mirror of
https://github.com/StarFleetCPTN/GoMFT.git
synced 2026-09-11 00:50:47 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
dd7426a01f | ||
|
|
fde60b7d68 | ||
|
|
297e5ca92b | ||
|
|
405b2e605e |
@@ -29,6 +29,23 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 0 # Needed to get all tags for versioning
|
||||
|
||||
# Set up Node.js for frontend build
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: 'npm'
|
||||
|
||||
# Install dependencies
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
# Build frontend assets
|
||||
- name: Build frontend assets
|
||||
run: |
|
||||
node build.js
|
||||
ls -la static/dist/
|
||||
|
||||
# Set version information
|
||||
- name: Set Version
|
||||
id: version
|
||||
|
||||
@@ -30,6 +30,23 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 0 # Needed to get all tags for versioning
|
||||
|
||||
# Set up Node.js for frontend build
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: 'npm'
|
||||
|
||||
# Install dependencies
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
# Build frontend assets
|
||||
- name: Build frontend assets
|
||||
run: |
|
||||
node build.js
|
||||
ls -la static/dist/
|
||||
|
||||
# Set version information
|
||||
- name: Set Version
|
||||
id: version
|
||||
|
||||
@@ -27,6 +27,26 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install Node.js dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Build frontend assets
|
||||
run: |
|
||||
# Build JavaScript and CSS assets
|
||||
node build.js
|
||||
|
||||
# Ensure the dist directory exists
|
||||
mkdir -p static/dist
|
||||
|
||||
# Verify the build output
|
||||
ls -la static/dist
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
|
||||
+3
-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/
|
||||
@@ -79,6 +66,8 @@ backups/
|
||||
/destination/
|
||||
/archive/
|
||||
|
||||
# Ignore the dist directory
|
||||
static/dist/
|
||||
|
||||
# Ignore binaries
|
||||
gomft
|
||||
+44
-3
@@ -1,3 +1,32 @@
|
||||
# Build frontend assets
|
||||
FROM node:20-alpine AS frontend-builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy all files needed for the build first
|
||||
COPY package.json package-lock.json ./
|
||||
COPY build.js ./
|
||||
COPY static/ ./static/
|
||||
COPY tailwind.config.js ./
|
||||
|
||||
# Debug: Show the contents of build.js
|
||||
RUN echo "Contents of build.js:" && cat build.js
|
||||
|
||||
# Remove the build script from postinstall
|
||||
RUN sed -i 's/"postinstall": "npm run build",//' package.json
|
||||
|
||||
# Install dependencies and build with verbose output
|
||||
RUN npm ci && \
|
||||
echo "Building frontend assets..." && \
|
||||
node build.js && \
|
||||
echo "Build complete. Contents of dist:" && \
|
||||
ls -la static/dist/ && \
|
||||
echo "Sample of app.js:" && \
|
||||
head -n 10 static/dist/app.js && \
|
||||
echo "Sample of app.css:" && \
|
||||
head -n 10 static/dist/app.css
|
||||
|
||||
# Go build stage
|
||||
FROM golang:1.24-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
@@ -17,7 +46,20 @@ RUN go install github.com/a-h/templ/cmd/templ@latest
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
|
||||
# Copy the rest of the source code
|
||||
# Create static directory structure
|
||||
RUN mkdir -p /app/static/dist
|
||||
|
||||
# Copy built frontend assets from frontend-builder BEFORE copying Go source
|
||||
COPY --from=frontend-builder /app/static/dist/ /app/static/dist/
|
||||
|
||||
# Copy the rest of the static files
|
||||
COPY static/ /app/static/
|
||||
|
||||
# Verify static files are in place before Go build
|
||||
RUN echo "Verifying static files before Go build:" && \
|
||||
ls -la /app/static/dist/
|
||||
|
||||
# Now copy the rest of the source code
|
||||
COPY . .
|
||||
|
||||
# Generate template files from .templ files
|
||||
@@ -61,8 +103,7 @@ RUN addgroup -g ${GID} ${USERNAME} && \
|
||||
COPY --from=builder /app/gomft /app/
|
||||
COPY --from=builder /usr/local/bin/rclone /usr/local/bin/rclone
|
||||
|
||||
# Copy static files and configurations
|
||||
COPY static/ /app/static/
|
||||
# Copy components
|
||||
COPY components/ /app/components/
|
||||
|
||||
# Copy entrypoint script
|
||||
|
||||
@@ -311,6 +311,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:
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
import * as esbuild from 'esbuild';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { execSync } from 'child_process';
|
||||
import fs from 'fs';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
const isWatch = process.argv.includes('--watch');
|
||||
|
||||
// Ensure the dist directory exists
|
||||
const distDir = path.join(__dirname, 'static', 'dist');
|
||||
if (!fs.existsSync(distDir)) {
|
||||
fs.mkdirSync(distDir, { recursive: true });
|
||||
}
|
||||
|
||||
// Copy Font Awesome files
|
||||
const fontAwesomeSrcDir = path.join(__dirname, 'node_modules', '@fortawesome', 'fontawesome-free');
|
||||
const fontAwesomeDestDir = path.join(distDir, 'fontawesome');
|
||||
|
||||
// Copy CSS files
|
||||
const cssFiles = [
|
||||
'css/all.min.css',
|
||||
'css/fontawesome.min.css',
|
||||
'css/solid.min.css',
|
||||
'css/regular.min.css',
|
||||
'css/brands.min.css'
|
||||
];
|
||||
|
||||
cssFiles.forEach(file => {
|
||||
const srcFile = path.join(fontAwesomeSrcDir, file);
|
||||
const destFile = path.join(fontAwesomeDestDir, file);
|
||||
const destDir = path.dirname(destFile);
|
||||
|
||||
if (!fs.existsSync(destDir)) {
|
||||
fs.mkdirSync(destDir, { recursive: true });
|
||||
}
|
||||
|
||||
if (fs.existsSync(srcFile)) {
|
||||
fs.copyFileSync(srcFile, destFile);
|
||||
}
|
||||
});
|
||||
|
||||
// Copy webfonts
|
||||
const webfontsSrcDir = path.join(fontAwesomeSrcDir, 'webfonts');
|
||||
const webfontsDestDir = path.join(fontAwesomeDestDir, 'webfonts');
|
||||
|
||||
if (!fs.existsSync(webfontsDestDir)) {
|
||||
fs.mkdirSync(webfontsDestDir, { recursive: true });
|
||||
}
|
||||
|
||||
fs.readdirSync(webfontsSrcDir).forEach(file => {
|
||||
fs.copyFileSync(
|
||||
path.join(webfontsSrcDir, file),
|
||||
path.join(webfontsDestDir, file)
|
||||
);
|
||||
});
|
||||
|
||||
const commonConfig = {
|
||||
sourcemap: true,
|
||||
minify: true,
|
||||
bundle: true,
|
||||
platform: 'browser',
|
||||
target: ['es2020'],
|
||||
};
|
||||
|
||||
async function buildTailwind() {
|
||||
console.log('Building Tailwind CSS...');
|
||||
execSync('npx tailwindcss -i ./static/css/app.css -o ./static/dist/app.css --minify');
|
||||
}
|
||||
|
||||
async function build() {
|
||||
try {
|
||||
// Build vendor JavaScript bundle (CDN dependencies)
|
||||
await esbuild.build({
|
||||
...commonConfig,
|
||||
entryPoints: ['static/js/vendor.js'],
|
||||
outfile: 'static/dist/vendor.js',
|
||||
format: 'iife',
|
||||
});
|
||||
|
||||
// Build application JavaScript
|
||||
await esbuild.build({
|
||||
...commonConfig,
|
||||
entryPoints: ['static/js/app.js'],
|
||||
outfile: 'static/dist/app.js',
|
||||
format: 'iife',
|
||||
});
|
||||
|
||||
// Build initialization JavaScript
|
||||
await esbuild.build({
|
||||
...commonConfig,
|
||||
entryPoints: ['static/js/init.js'],
|
||||
outfile: 'static/dist/init.js',
|
||||
format: 'iife',
|
||||
});
|
||||
|
||||
// Build CSS with Tailwind
|
||||
await buildTailwind();
|
||||
|
||||
console.log('Build completed successfully!');
|
||||
} catch (error) {
|
||||
console.error('Build failed:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
if (isWatch) {
|
||||
// Watch mode
|
||||
console.log('Starting watch mode...');
|
||||
const ctx = await esbuild.context(commonConfig);
|
||||
await ctx.watch();
|
||||
|
||||
// Watch Tailwind CSS
|
||||
execSync('npx tailwindcss -i ./static/css/app.css -o ./static/dist/app.css --watch');
|
||||
|
||||
console.log('Watching for changes...');
|
||||
} else {
|
||||
// Single build
|
||||
build();
|
||||
}
|
||||
@@ -87,7 +87,7 @@ templ AdminRoles(ctx context.Context, data RolesData) {
|
||||
<button
|
||||
data-role-id={ fmt.Sprint(role.ID) }
|
||||
data-role-name={ role.Name }
|
||||
onclick={ showRoleDialog(fmt.Sprintf("delete-role-dialog-%d", role.ID)) }
|
||||
onclick={ showModal(fmt.Sprintf("delete-role-dialog-%d", role.ID)) }
|
||||
class="text-red-600 hover:text-red-900 dark:text-red-400 dark:hover:text-red-300">
|
||||
<i class="fas fa-trash"></i>
|
||||
</button>
|
||||
@@ -269,7 +269,10 @@ templ AdminRoles(ctx context.Context, data RolesData) {
|
||||
|
||||
// RoleDialog for confirmation actions
|
||||
templ RoleDialog(id string, title string, message string, confirmClass string, confirmText string, action string, roleID uint, roleName string) {
|
||||
<div id={ id } tabindex="-1" aria-hidden="true" class="hidden overflow-y-auto overflow-x-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 bg-gray-900/50 dark:bg-gray-900/80 backdrop-blur-sm">
|
||||
<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">
|
||||
@@ -288,7 +291,7 @@ templ RoleDialog(id string, title string, message string, confirmClass string, c
|
||||
onclick={ triggerRoleDelete(id, roleID, roleName) }>
|
||||
{ confirmText }
|
||||
</button>
|
||||
<button type="button" data-modal-hide={ 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">
|
||||
<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>
|
||||
@@ -297,16 +300,6 @@ templ RoleDialog(id string, title string, message string, confirmClass string, c
|
||||
</div>
|
||||
}
|
||||
|
||||
script hideRoleDialog(id string) {
|
||||
document.getElementById(id).classList.add("hidden");
|
||||
document.getElementById(id).classList.remove("flex");
|
||||
}
|
||||
|
||||
script showRoleDialog(id string) {
|
||||
document.getElementById(id).classList.remove("hidden");
|
||||
document.getElementById(id).classList.add("flex");
|
||||
}
|
||||
|
||||
script triggerRoleDelete(dialogId string, roleID uint, roleName string) {
|
||||
// Hide the dialog
|
||||
document.getElementById(dialogId).classList.add("hidden");
|
||||
|
||||
@@ -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">
|
||||
|
||||
+105
-32
@@ -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 := ""
|
||||
@@ -286,33 +290,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);
|
||||
@@ -386,11 +391,41 @@ 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;
|
||||
}
|
||||
}
|
||||
|
||||
if (destPort === 0 || !destPort) {
|
||||
if (destinationType === 'sftp') {
|
||||
destPort = 22;
|
||||
} else if (destinationType === 'ftp') {
|
||||
destPort = 21;
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize command requirements
|
||||
updateCommandRequirements();
|
||||
})"
|
||||
x-effect="if (sourceType === 'sftp' && (sourcePort === 0 || sourcePort === 21)) {
|
||||
sourcePort = 22;
|
||||
console.log('Updating source port to 22 for SFTP');
|
||||
} else if (sourceType === 'ftp' && (sourcePort === 0 || sourcePort === 22)) {
|
||||
sourcePort = 21;
|
||||
console.log('Updating source port to 21 for FTP');
|
||||
}"
|
||||
x-effect="if (destinationType === 'sftp' && (destPort === 0 || destPort === 21)) {
|
||||
destPort = 22;
|
||||
console.log('Updating destination port to 22 for SFTP');
|
||||
} else if (destinationType === 'ftp' && (destPort === 0 || destPort === 22)) {
|
||||
destPort = 21;
|
||||
console.log('Updating destination port to 21 for FTP');
|
||||
}"
|
||||
>
|
||||
|
||||
<!-- Configuration Details Section -->
|
||||
@@ -425,8 +460,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 +483,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()
|
||||
@@ -499,6 +558,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()
|
||||
|
||||
@@ -8,7 +8,10 @@ import (
|
||||
|
||||
// Dialog component for confirmation dialogs using Flowbite modal
|
||||
templ ConfigDialog(id string, title string, message string, confirmClass string, confirmText string, action string, configID uint, configName string) {
|
||||
<div id={ id } tabindex="-1" aria-hidden="true" class="hidden overflow-y-auto overflow-x-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 bg-gray-900/50 dark:bg-gray-900/80 backdrop-blur-sm">
|
||||
<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">
|
||||
@@ -30,7 +33,7 @@ templ ConfigDialog(id string, title string, message string, confirmClass string,
|
||||
onclick={ triggerConfigDelete(id, configID, configName) }>
|
||||
{ confirmText }
|
||||
</button>
|
||||
<button type="button" data-modal-hide={ 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">
|
||||
<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>
|
||||
@@ -39,14 +42,26 @@ templ ConfigDialog(id string, title string, message string, confirmClass string,
|
||||
</div>
|
||||
}
|
||||
|
||||
script hideConfigDialog(id string) {
|
||||
document.getElementById(id).classList.add("hidden");
|
||||
document.getElementById(id).classList.remove("flex");
|
||||
script closeModal(id string) {
|
||||
const modal = document.getElementById(id);
|
||||
const backdrop = document.getElementById(id + '-backdrop');
|
||||
if (modal) {
|
||||
modal.classList.add('hidden');
|
||||
modal.classList.remove('flex');
|
||||
}
|
||||
if (backdrop) {
|
||||
backdrop.remove();
|
||||
}
|
||||
document.body.style.overflow = '';
|
||||
}
|
||||
|
||||
script showConfigDialog(id string) {
|
||||
document.getElementById(id).classList.remove("hidden");
|
||||
document.getElementById(id).classList.add("flex");
|
||||
script showModal(id string) {
|
||||
const modal = document.getElementById(id);
|
||||
if (modal) {
|
||||
modal.classList.remove('hidden');
|
||||
modal.classList.add('flex');
|
||||
document.body.style.overflow = 'hidden';
|
||||
}
|
||||
}
|
||||
|
||||
script triggerConfigDelete(dialogId string, configID uint, configName string) {
|
||||
@@ -429,7 +444,7 @@ templ Configs(ctx context.Context, data ConfigsData) {
|
||||
)
|
||||
<button
|
||||
type="button"
|
||||
onclick={ showConfigDialog(fmt.Sprintf("delete-config-dialog-%d", config.ID)) }
|
||||
onclick={ showModal(fmt.Sprintf("delete-config-dialog-%d", config.ID)) }
|
||||
class="text-white bg-red-700 hover:bg-red-800 focus:ring-4 focus:outline-none focus:ring-red-300 font-medium rounded-lg text-sm px-3 py-1.5 text-center inline-flex items-center dark:bg-red-600 dark:hover:bg-red-700 dark:focus:ring-red-800">
|
||||
<i class="fas fa-trash-alt w-3.5 h-3.5 mr-1.5"></i>
|
||||
Delete
|
||||
|
||||
@@ -76,7 +76,10 @@ func formatFileSize(size int64) string {
|
||||
|
||||
// Updated Dialog component using Flowbite
|
||||
templ FileMetadataDialog(id string, title string, message string, confirmClass string, confirmText string, action string, fileID uint, fileName string, section string) {
|
||||
<div id={ id } tabindex="-1" aria-hidden="true" class="hidden overflow-y-auto overflow-x-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 bg-gray-900/50 dark:bg-gray-900/80 backdrop-blur-sm">
|
||||
<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">
|
||||
@@ -112,7 +115,7 @@ templ FileMetadataDialog(id string, title string, message string, confirmClass s
|
||||
{ confirmText }
|
||||
</button>
|
||||
}
|
||||
<button type="button" data-modal-hide={ 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">
|
||||
<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>
|
||||
@@ -121,16 +124,6 @@ templ FileMetadataDialog(id string, title string, message string, confirmClass s
|
||||
</div>
|
||||
}
|
||||
|
||||
script hideFileDialog(id string) {
|
||||
document.getElementById(id).classList.add("hidden");
|
||||
document.getElementById(id).classList.remove("flex");
|
||||
}
|
||||
|
||||
script showFileDialog(id string) {
|
||||
document.getElementById(id).classList.remove("hidden");
|
||||
document.getElementById(id).classList.add("flex");
|
||||
}
|
||||
|
||||
script triggerFileDelete(dialogId string, fileID uint, fileName string) {
|
||||
// Hide the dialog
|
||||
document.getElementById(dialogId).classList.add("hidden");
|
||||
@@ -211,7 +204,7 @@ templ FileMetadataListPartial(data FileMetadataListData) {
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
onclick={ showFileDialog(fmt.Sprintf("delete-file-dialog-%d", file.ID)) }
|
||||
onclick={ showModal(fmt.Sprintf("delete-file-dialog-%d", file.ID)) }
|
||||
class="font-medium text-red-600 dark:text-red-500 hover:underline">
|
||||
<i class="fas fa-trash"></i>
|
||||
</button>
|
||||
@@ -392,9 +385,7 @@ templ FileMetadataList(ctx context.Context, data FileMetadataListData) {
|
||||
});
|
||||
|
||||
// Track all HTMX events for debugging
|
||||
document.addEventListener('htmx:beforeRequest', function(event) {
|
||||
console.log("HTMX before request:", event.detail);
|
||||
|
||||
document.addEventListener('htmx:beforeRequest', function(event) {
|
||||
// Check if this is a DELETE request by examining the URL and method
|
||||
const path = event.detail.path;
|
||||
const method = event.detail.verb;
|
||||
@@ -411,9 +402,7 @@ templ FileMetadataList(ctx context.Context, data FileMetadataListData) {
|
||||
});
|
||||
|
||||
// Track HTMX after-request events for file deletion
|
||||
document.addEventListener('htmx:afterRequest', function(event) {
|
||||
console.log("HTMX after request:", event.detail);
|
||||
|
||||
document.addEventListener('htmx:afterRequest', function(event) {
|
||||
// Check for file deletion multiple ways
|
||||
const isDeleteRequest =
|
||||
// Check global flag from the triggerFileDelete function
|
||||
@@ -970,7 +959,7 @@ templ FileMetadataDetails(ctx context.Context, data FileMetadataDetailsData) {
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
onclick={ showFileDialog(fmt.Sprintf("delete-file-dialog-%d", data.File.ID)) }
|
||||
onclick={ showModal(fmt.Sprintf("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>
|
||||
@@ -1336,7 +1325,7 @@ templ FileMetadataSearchContent(data FileMetadataSearchData) {
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
onclick={ showFileDialog(fmt.Sprintf("delete-file-dialog-%d", file.ID)) }
|
||||
onclick={ showModal(fmt.Sprintf("delete-file-dialog-%d", file.ID)) }
|
||||
class="font-medium text-red-600 dark:text-red-500 hover:underline">
|
||||
<i class="fas fa-trash"></i>
|
||||
</button>
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
+8
-19
@@ -8,7 +8,10 @@ import (
|
||||
|
||||
// Dialog component for confirmation dialogs using Flowbite modal
|
||||
templ JobDialog(id string, title string, message string, confirmClass string, confirmText string, action string, jobID uint, jobName string) {
|
||||
<div id={ id } tabindex="-1" aria-hidden="true" class="hidden overflow-y-auto overflow-x-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 bg-gray-900/50 dark:bg-gray-900/80 backdrop-blur-sm">
|
||||
<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">
|
||||
@@ -30,7 +33,7 @@ templ JobDialog(id string, title string, message string, confirmClass string, co
|
||||
onclick={ triggerJobDelete(id, jobID, jobName) }>
|
||||
{ confirmText }
|
||||
</button>
|
||||
<button type="button" data-modal-hide={ 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">
|
||||
<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>
|
||||
@@ -39,16 +42,6 @@ templ JobDialog(id string, title string, message string, confirmClass string, co
|
||||
</div>
|
||||
}
|
||||
|
||||
script hideJobDialog(id string) {
|
||||
document.getElementById(id).classList.add("hidden");
|
||||
document.getElementById(id).classList.remove("flex");
|
||||
}
|
||||
|
||||
script showJobDialog(id string) {
|
||||
document.getElementById(id).classList.remove("hidden");
|
||||
document.getElementById(id).classList.add("flex");
|
||||
}
|
||||
|
||||
script triggerJobDelete(dialogId string, jobID uint, jobName string) {
|
||||
// Hide the dialog
|
||||
document.getElementById(dialogId).classList.add("hidden");
|
||||
@@ -196,9 +189,7 @@ templ Jobs(ctx context.Context, data JobsData) {
|
||||
});
|
||||
|
||||
// Track all HTMX events for debugging
|
||||
document.addEventListener('htmx:beforeRequest', function(event) {
|
||||
console.log("HTMX before request:", event.detail);
|
||||
|
||||
document.addEventListener('htmx:beforeRequest', function(event) {
|
||||
// Check if this is a DELETE request by examining the URL and method
|
||||
const path = event.detail.path;
|
||||
const method = event.detail.verb;
|
||||
@@ -215,9 +206,7 @@ templ Jobs(ctx context.Context, data JobsData) {
|
||||
});
|
||||
|
||||
// Track HTMX after-request events for job deletion
|
||||
document.addEventListener('htmx:afterRequest', function(event) {
|
||||
console.log("HTMX after request:", event.detail);
|
||||
|
||||
document.addEventListener('htmx:afterRequest', function(event) {
|
||||
// Check for job deletion multiple ways
|
||||
const isDeleteRequest =
|
||||
// Check global flag from the triggerJobDelete function
|
||||
@@ -395,7 +384,7 @@ templ Jobs(ctx context.Context, data JobsData) {
|
||||
)
|
||||
<button
|
||||
type="button"
|
||||
onclick={ showJobDialog(fmt.Sprintf("delete-job-dialog-%d", job.ID)) }
|
||||
onclick={ showModal(fmt.Sprintf("delete-job-dialog-%d", job.ID)) }
|
||||
class="focus:outline-none text-white bg-red-700 hover:bg-red-800 focus:ring-4 focus:ring-red-300 font-medium rounded-lg text-xs px-3 py-1.5 dark:bg-red-600 dark:hover:bg-red-700 dark:focus:ring-red-900">
|
||||
<i class="fas fa-trash mr-1"></i>
|
||||
Delete
|
||||
|
||||
+122
-97
@@ -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
|
||||
@@ -54,42 +55,36 @@ templ LayoutWithContext(title string, ctx context.Context) {
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/static/favicon-16x16.png"/>
|
||||
<link rel="manifest" href="/static/site.webmanifest"/>
|
||||
<title>{ title } - GoMFT</title>
|
||||
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
|
||||
<script src="https://unpkg.com/alpinejs@3.13.5/dist/cdn.min.js" defer></script>
|
||||
<!-- Tailwind CSS with Flowbite -->
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<link href="https://cdnjs.cloudflare.com/ajax/libs/flowbite/2.2.1/flowbite.min.css" rel="stylesheet"/>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/flowbite/2.2.1/flowbite.min.js"></script>
|
||||
<script src="/static/js/app.js"></script>
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css"/>
|
||||
<link rel="stylesheet" href="/static/css/app.css"/>
|
||||
<!-- Font Awesome -->
|
||||
<link rel="stylesheet" href="/static/dist/fontawesome/css/all.min.css"/>
|
||||
<!-- Application assets -->
|
||||
<link rel="stylesheet" href="/static/dist/app.css"/>
|
||||
<link rel="stylesheet" href="https://rsms.me/inter/inter.css"/>
|
||||
|
||||
<script>
|
||||
tailwind.config = {
|
||||
darkMode: 'class',
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
primary: {
|
||||
50: '#f0f9ff',
|
||||
100: '#e0f2fe',
|
||||
200: '#bae6fd',
|
||||
300: '#7dd3fc',
|
||||
400: '#38bdf8',
|
||||
500: '#0ea5e9',
|
||||
600: '#0284c7',
|
||||
700: '#0369a1',
|
||||
800: '#075985',
|
||||
900: '#0c4a6e',
|
||||
950: '#082f49',
|
||||
// Immediate theme application
|
||||
// This script runs before the DOM is fully loaded
|
||||
(function() {
|
||||
const isDark = localStorage.theme === 'dark' ||
|
||||
(!('theme' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches);
|
||||
|
||||
if (isDark) {
|
||||
document.documentElement.classList.add('dark');
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
document.body.classList.add('dark');
|
||||
|
||||
// Apply dark theme to containers
|
||||
const containers = ['jobs-container', 'configs-container'];
|
||||
containers.forEach(function(id) {
|
||||
const container = document.getElementById(id);
|
||||
if (container) {
|
||||
container.classList.add('dark');
|
||||
container.style.backgroundColor = '#111827';
|
||||
}
|
||||
},
|
||||
fontFamily: {
|
||||
sans: ['Inter var', 'ui-sans-serif', 'system-ui', '-apple-system', 'BlinkMacSystemFont', 'Segoe UI', 'Roboto', 'Helvetica Neue', 'Arial', 'sans-serif'],
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
<style>
|
||||
html, body {
|
||||
@@ -146,34 +141,8 @@ templ LayoutWithContext(title string, ctx context.Context) {
|
||||
background-color: #1f2937 !important;
|
||||
}
|
||||
</style>
|
||||
<link rel="stylesheet" href="https://rsms.me/inter/inter.css"/>
|
||||
<script>
|
||||
// Immediate theme application
|
||||
// This script runs before the DOM is fully loaded
|
||||
(function() {
|
||||
const isDark = localStorage.theme === 'dark' ||
|
||||
(!('theme' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches);
|
||||
|
||||
if (isDark) {
|
||||
document.documentElement.classList.add('dark');
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
document.body.classList.add('dark');
|
||||
|
||||
// Apply dark theme to containers
|
||||
const containers = ['jobs-container', 'configs-container'];
|
||||
containers.forEach(function(id) {
|
||||
const container = document.getElementById(id);
|
||||
if (container) {
|
||||
container.classList.add('dark');
|
||||
container.style.backgroundColor = '#111827';
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</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">
|
||||
@@ -269,20 +238,100 @@ templ LayoutWithContext(title string, ctx context.Context) {
|
||||
<button data-drawer-target="mobile-menu" data-drawer-toggle="mobile-menu" class="p-2 text-gray-500 rounded-lg hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-700">
|
||||
<i class="fas fa-bars text-lg"></i>
|
||||
</button>
|
||||
<!-- User menu (same as desktop) -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Add this drawer element for mobile menu -->
|
||||
<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
|
||||
</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>
|
||||
</button>
|
||||
</div>
|
||||
<!-- Copy the same navigation items from the desktop sidebar -->
|
||||
<nav class="mt-5">
|
||||
<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>
|
||||
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
|
||||
</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
|
||||
</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
|
||||
</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>
|
||||
Files
|
||||
</a>
|
||||
if isAdmin(ctx) {
|
||||
<p class="px-3 text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Administration
|
||||
</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
|
||||
</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
|
||||
</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/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">
|
||||
<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">
|
||||
// <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>
|
||||
<li>
|
||||
<a href="/admin/settings/auth-providers" class="group flex items-center px-2 py-2 text-sm font-medium rounded-md text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700">
|
||||
<i class="fas fa-user-lock w-4 h-4 mr-2 text-gray-500 dark:text-gray-400"></i>
|
||||
Authentication Providers
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/admin/settings/notifications" 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-bell w-4 h-4 mr-2 text-gray-500 dark:text-gray-400"></i>
|
||||
Notification Services
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
}
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
<!-- Main Content -->
|
||||
<div class="md:pl-64 flex flex-col flex-1 w-full bg-gray-50 dark:bg-gray-900">
|
||||
<!-- Main header -->
|
||||
<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">
|
||||
<button data-drawer-target="desktop-menu" data-drawer-toggle="desktop-menu" class="md:hidden p-2 text-gray-500 rounded-lg hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-700">
|
||||
<i class="fas fa-bars text-lg"></i>
|
||||
</button>
|
||||
<h1 class="text-lg font-semibold text-gray-900 dark:text-white">{ title }</h1>
|
||||
</div>
|
||||
<!-- Theme toggle and user menu -->
|
||||
@@ -396,6 +445,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">
|
||||
@@ -447,40 +498,14 @@ templ LayoutWithContext(title string, ctx context.Context) {
|
||||
</footer>
|
||||
</div>
|
||||
}
|
||||
<script>
|
||||
// Add script to ensure dark mode is properly applied
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Apply body dark class when theme changes
|
||||
const isDark = document.documentElement.classList.contains('dark');
|
||||
if (isDark) {
|
||||
document.body.classList.add('dark');
|
||||
|
||||
// Also apply to containers
|
||||
const jobsContainer = document.getElementById('jobs-container');
|
||||
const configsContainer = document.getElementById('configs-container');
|
||||
|
||||
if (jobsContainer) jobsContainer.classList.add('dark');
|
||||
if (configsContainer) configsContainer.classList.add('dark');
|
||||
}
|
||||
|
||||
// Initialize admin dropdown toggle if available
|
||||
const adminDropdownToggle = document.querySelector('[data-collapse-toggle="dropdown-settings"]');
|
||||
const adminDropdown = document.getElementById('dropdown-settings');
|
||||
|
||||
if (adminDropdownToggle && adminDropdown) {
|
||||
// Check if we should show the dropdown (if current page is under admin section)
|
||||
const currentPath = window.location.pathname;
|
||||
if (currentPath.startsWith('/admin')) {
|
||||
adminDropdown.classList.remove('hidden');
|
||||
}
|
||||
|
||||
// Add click event listener
|
||||
adminDropdownToggle.addEventListener('click', function() {
|
||||
adminDropdown.classList.toggle('hidden');
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
<!-- 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>
|
||||
</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>
|
||||
|
||||
|
||||
@@ -7,7 +7,10 @@ import (
|
||||
|
||||
// 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 overflow-y-auto overflow-x-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 bg-gray-900/50 dark:bg-gray-900/80 backdrop-blur-sm">
|
||||
<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">
|
||||
@@ -28,7 +31,7 @@ templ NotificationDialog(id string, title string, message string, confirmClass s
|
||||
onclick={ triggerServiceDelete(id, serviceID, serviceName) }>
|
||||
{ confirmText }
|
||||
</button>
|
||||
<button type="button" data-modal-hide={ 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">
|
||||
<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>
|
||||
@@ -37,16 +40,6 @@ templ NotificationDialog(id string, title string, message string, confirmClass s
|
||||
</div>
|
||||
}
|
||||
|
||||
script hideNotificationDialog(id string) {
|
||||
document.getElementById(id).classList.add("hidden");
|
||||
document.getElementById(id).classList.remove("flex");
|
||||
}
|
||||
|
||||
script showNotificationDialog(id string) {
|
||||
document.getElementById(id).classList.remove("hidden");
|
||||
document.getElementById(id).classList.add("flex");
|
||||
}
|
||||
|
||||
script triggerServiceDelete(dialogId string, serviceID uint, serviceName string) {
|
||||
// Hide the dialog
|
||||
document.getElementById(dialogId).classList.add("hidden");
|
||||
@@ -143,9 +136,7 @@ templ Notifications(ctx context.Context, data SettingsNotificationsData) {
|
||||
}
|
||||
|
||||
// Track all HTMX events for debugging
|
||||
document.addEventListener('htmx:beforeRequest', function(event) {
|
||||
console.log("HTMX before request:", event.detail);
|
||||
|
||||
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;
|
||||
@@ -161,9 +152,7 @@ templ Notifications(ctx context.Context, data SettingsNotificationsData) {
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('htmx:afterRequest', function(event) {
|
||||
console.log("HTMX after request:", event.detail);
|
||||
|
||||
document.addEventListener('htmx:afterRequest', function(event) {
|
||||
// Check for notification service deletion multiple ways
|
||||
const isDeleteRequest =
|
||||
// Check global flag from the triggerServiceDelete function
|
||||
@@ -369,7 +358,7 @@ templ Notifications(ctx context.Context, data SettingsNotificationsData) {
|
||||
)
|
||||
<button
|
||||
type="button"
|
||||
onclick={ showNotificationDialog(fmt.Sprintf("delete-notification-dialog-%d", service.ID)) }
|
||||
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>
|
||||
|
||||
@@ -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,177 @@
|
||||
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) {
|
||||
@FormScripts() // Include the form-specific scripts
|
||||
@components.LayoutWithContext(utils.GetNotificationFormTitle(data.IsNew), ctx) {
|
||||
<!-- 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"
|
||||
} else {
|
||||
hx-put={ fmt.Sprintf("/admin/settings/notifications/%d", data.NotificationService.ID) }
|
||||
}
|
||||
hx-target="#notification-form-container">
|
||||
<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>
|
||||
@@ -231,12 +231,13 @@ templ DestinationSelection() {
|
||||
}
|
||||
|
||||
// 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 +245,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 +258,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 +282,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 +306,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 +354,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 +395,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 +403,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 +426,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 +449,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) }
|
||||
/>
|
||||
|
||||
@@ -8,7 +8,7 @@ templ FTPDestinationForm() {
|
||||
<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="destinationHost" 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="ftp.example.com" />
|
||||
</div>
|
||||
@@ -21,9 +21,11 @@ templ FTPDestinationForm() {
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||
<i class="fas fa-network-wired text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<input type="number" id="dest_port" name="dest_port" x-model="destinationPort"
|
||||
<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>
|
||||
@@ -34,7 +36,7 @@ templ FTPDestinationForm() {
|
||||
<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="destinationUser" x-bind:required="requiresDestination"
|
||||
<input type="text" id="dest_user" name="dest_user" x-model="destUser" x-bind:required="requiresDestination"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="username" />
|
||||
</div>
|
||||
@@ -47,7 +49,7 @@ templ FTPDestinationForm() {
|
||||
<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="destinationPassword" x-bind:required="requiresDestination"
|
||||
<input type="password" id="dest_password" name="dest_password" x-model="destPassword" x-bind:required="requiresDestination"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="Enter password" />
|
||||
</div>
|
||||
|
||||
@@ -23,7 +23,7 @@ templ GoogleDriveDestinationForm() {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div x-show="!useBuiltinAuthDestination">
|
||||
<div x-show="!useBuiltinAuthDest">
|
||||
<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-exclamation-triangle mr-2 flex-shrink-0"></i>
|
||||
@@ -37,9 +37,9 @@ templ GoogleDriveDestinationForm() {
|
||||
<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_client_id" name="dest_client_id" x-model="destinationClientId"
|
||||
<input type="text" id="dest_client_id" name="dest_client_id" x-model="destClientId"
|
||||
class="bg-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 Google API Client ID" x-bind:required="!useBuiltinAuthDestination" />
|
||||
placeholder="Your Google API Client ID" x-bind:required="!useBuiltinAuthDest" />
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">OAuth 2.0 Client ID from Google Cloud Console</p>
|
||||
</div>
|
||||
@@ -50,9 +50,9 @@ templ GoogleDriveDestinationForm() {
|
||||
<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="password" id="dest_client_secret" name="dest_client_secret" x-model="destinationClientSecret"
|
||||
<input type="password" id="dest_client_secret" name="dest_client_secret" x-model="destClientSecret"
|
||||
class="bg-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 Google API Client Secret" x-bind:required="!useBuiltinAuthDestination" />
|
||||
placeholder="Your Google API Client Secret" x-bind:required="!useBuiltinAuthDest" />
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">OAuth 2.0 Client Secret from Google Cloud Console</p>
|
||||
</div>
|
||||
@@ -79,7 +79,7 @@ templ GoogleDriveDestinationForm() {
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||
<i class="fas fa-fingerprint text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<input type="text" id="dest_drive_id" name="dest_drive_id" x-model="destinationDriveId"
|
||||
<input type="text" id="dest_drive_id" name="dest_drive_id" x-model="destDriveId"
|
||||
class="bg-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="Specific Google Drive ID" />
|
||||
</div>
|
||||
@@ -94,7 +94,7 @@ templ GoogleDriveDestinationForm() {
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||
<i class="fas fa-users text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<input type="text" id="dest_team_drive" name="dest_team_drive" x-model="destinationTeamDrive"
|
||||
<input type="text" id="dest_team_drive" name="dest_team_drive" x-model="destTeamDrive"
|
||||
class="bg-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="Team Drive ID" />
|
||||
</div>
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
package destination
|
||||
|
||||
templ GooglePhotosDestinationForm() {
|
||||
<div class="space-y-6 mt-4" x-init="$watch('useBuiltinAuthDestination', value => {
|
||||
<div class="space-y-6 mt-4" x-init="$watch('useBuiltinAuthDest', value => {
|
||||
if(value) {
|
||||
destinationClientId = '';
|
||||
destinationClientSecret = '';
|
||||
destClientId = '';
|
||||
destClientSecret = '';
|
||||
}
|
||||
})">
|
||||
<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">
|
||||
@@ -38,7 +38,7 @@ templ GooglePhotosDestinationForm() {
|
||||
<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_client_id" name="dest_client_id" x-model="destinationClientId"
|
||||
<input type="text" id="dest_client_id" name="dest_client_id" x-model="destClientId"
|
||||
class="bg-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"
|
||||
x-bind:disabled="useBuiltinAuthDest"
|
||||
placeholder="Google Photos OAuth Client ID" />
|
||||
@@ -57,7 +57,7 @@ templ GooglePhotosDestinationForm() {
|
||||
<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="password" id="dest_client_secret" name="dest_client_secret" x-model="destinationClientSecret"
|
||||
<input type="password" id="dest_client_secret" name="dest_client_secret" x-model="destClientSecret"
|
||||
class="bg-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"
|
||||
x-bind:disabled="useBuiltinAuthDest"
|
||||
placeholder="Google Photos OAuth Client Secret" />
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ templ MinIODestinationForm() {
|
||||
<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="destinationEndpoint" x-bind:required="requiresDestination"
|
||||
<input type="text" id="dest_endpoint" name="dest_endpoint" x-model="destEndpoint" 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://minio.example.com" />
|
||||
</div>
|
||||
@@ -30,7 +30,7 @@ templ MinIODestinationForm() {
|
||||
<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="destinationBucket" x-bind:required="requiresDestination"
|
||||
<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-bucket" />
|
||||
</div>
|
||||
@@ -39,13 +39,29 @@ 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">
|
||||
<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="destinationAccessKey" x-bind:required="requiresDestination"
|
||||
<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="minioadmin" />
|
||||
</div>
|
||||
@@ -60,7 +76,7 @@ templ MinIODestinationForm() {
|
||||
<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="destinationSecretKey" x-bind:required="requiresDestination"
|
||||
<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="minioadmin" />
|
||||
</div>
|
||||
|
||||
@@ -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="destinationEndpoint" 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>
|
||||
@@ -30,9 +30,9 @@ templ NextCloudDestinationForm() {
|
||||
<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="destinationUser" x-bind:required="requiresDestination"
|
||||
<input type="text" id="dest_user" name="dest_user" x-model="destUser" x-bind:required="requiresDestination"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="nextcloud_username" />
|
||||
placeholder="username" />
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
Your NextCloud username
|
||||
@@ -45,9 +45,9 @@ templ NextCloudDestinationForm() {
|
||||
<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="destinationPassword" x-bind:required="requiresDestination"
|
||||
<input type="password" id="dest_password" name="dest_password" x-model="destPassword" x-bind:required="requiresDestination"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
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>
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ templ S3DestinationForm() {
|
||||
<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="destinationRegion" x-bind:required="requiresDestination"
|
||||
<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>
|
||||
@@ -30,7 +30,7 @@ templ S3DestinationForm() {
|
||||
<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="destinationBucket" x-bind:required="requiresDestination"
|
||||
<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-bucket" />
|
||||
</div>
|
||||
@@ -45,7 +45,7 @@ templ S3DestinationForm() {
|
||||
<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="destinationAccessKey" x-bind:required="requiresDestination"
|
||||
<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="AKIAIOSFODNN7EXAMPLE" />
|
||||
</div>
|
||||
@@ -60,7 +60,7 @@ templ S3DestinationForm() {
|
||||
<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="destinationSecretKey" x-bind:required="requiresDestination"
|
||||
<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="wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY" />
|
||||
</div>
|
||||
|
||||
@@ -8,7 +8,7 @@ templ SFTPDestinationForm() {
|
||||
<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="destinationHost" 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="sftp.example.com" />
|
||||
</div>
|
||||
@@ -21,9 +21,9 @@ templ SFTPDestinationForm() {
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||
<i class="fas fa-network-wired text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<input type="number" id="dest_port" name="dest_port" x-model="destinationPort"
|
||||
<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>
|
||||
@@ -34,7 +34,7 @@ templ SFTPDestinationForm() {
|
||||
<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="destinationUser" x-bind:required="requiresDestination"
|
||||
<input type="text" id="dest_user" name="dest_user" x-model="destUser" x-bind:required="requiresDestination"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="username" />
|
||||
</div>
|
||||
@@ -61,7 +61,7 @@ templ SFTPDestinationForm() {
|
||||
<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="destinationPassword"
|
||||
<input type="password" id="dest_password" name="dest_password" x-model="destPassword"
|
||||
class="bg-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="Enter password" x-bind:required="destAuthType === 'password'" />
|
||||
</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>
|
||||
|
||||
@@ -15,7 +15,7 @@ templ SMBDestinationForm() {
|
||||
<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="destinationHost" 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="192.168.1.100 or server.example.com" />
|
||||
</div>
|
||||
@@ -30,7 +30,7 @@ templ SMBDestinationForm() {
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||
<i class="fas fa-share-alt text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<input type="text" id="dest_share" name="dest_share" x-model="destinationShare" x-bind:required="requiresDestination"
|
||||
<input type="text" id="dest_share" name="dest_share" x-model="destShare" 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="Documents" />
|
||||
</div>
|
||||
@@ -45,7 +45,7 @@ templ SMBDestinationForm() {
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||
<i class="fas fa-sitemap text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<input type="text" id="dest_domain" name="dest_domain" x-model="destinationDomain"
|
||||
<input type="text" id="dest_domain" name="dest_domain" x-model="destDomain"
|
||||
class="bg-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="WORKGROUP or DOMAIN" />
|
||||
</div>
|
||||
@@ -60,7 +60,7 @@ templ SMBDestinationForm() {
|
||||
<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="destinationUser" x-bind:required="requiresDestination"
|
||||
<input type="text" id="dest_user" name="dest_user" x-model="destUser" x-bind:required="requiresDestination"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="smb_username" />
|
||||
</div>
|
||||
@@ -75,7 +75,7 @@ templ SMBDestinationForm() {
|
||||
<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="destinationPassword" x-bind:required="requiresDestination"
|
||||
<input type="password" id="dest_password" name="dest_password" x-model="destPassword" x-bind:required="requiresDestination"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="SMB password" />
|
||||
</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="destinationEndpoint" 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>
|
||||
@@ -30,9 +30,9 @@ templ WebDAVDestinationForm() {
|
||||
<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="destinationUser" x-bind:required="requiresDestination"
|
||||
<input type="text" id="dest_user" name="dest_user" x-model="destUser" x-bind:required="requiresDestination"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="webdav_username" />
|
||||
placeholder="username" />
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
Your WebDAV username
|
||||
@@ -45,9 +45,9 @@ templ WebDAVDestinationForm() {
|
||||
<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="destinationPassword" x-bind:required="requiresDestination"
|
||||
<input type="password" id="dest_password" name="dest_password" x-model="destPassword" x-bind:required="requiresDestination"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="WebDAV password" />
|
||||
placeholder="password" />
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
Your WebDAV account password
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
}
|
||||
@@ -117,9 +117,7 @@ templ UserManagementContent(data UserManagementData) {
|
||||
});
|
||||
|
||||
// Track user operations in HTMX events
|
||||
document.addEventListener('htmx:beforeRequest', function(event) {
|
||||
console.log("HTMX before request:", event.detail);
|
||||
|
||||
document.addEventListener('htmx:beforeRequest', function(event) {
|
||||
// Check if this is a DELETE request for a user
|
||||
const path = event.detail.path;
|
||||
const method = event.detail.verb;
|
||||
@@ -421,7 +419,7 @@ templ userList(users []db.User) {
|
||||
class="text-white bg-red-700 hover:bg-red-800 focus:ring-4 focus:outline-none focus:ring-red-300 font-medium rounded-lg text-sm px-3 py-1.5 text-center inline-flex items-center dark:bg-red-600 dark:hover:bg-red-700 dark:focus:ring-red-800"
|
||||
data-user-email={ user.Email }
|
||||
data-user-id={ strconv.Itoa(int(user.ID)) }
|
||||
onclick={ showUserDialog(fmt.Sprintf("delete-user-dialog-%d", user.ID)) }
|
||||
onclick={ showModal(fmt.Sprintf("delete-user-dialog-%d", user.ID)) }
|
||||
>
|
||||
<i class="fas fa-trash-alt w-3.5 h-3.5 mr-1.5"></i> Delete
|
||||
</button>
|
||||
@@ -634,7 +632,10 @@ func hasRole(user *db.User, roleID uint) bool {
|
||||
|
||||
// UserDialog for confirmation actions
|
||||
templ UserDialog(id string, title string, message string, confirmClass string, confirmText string, action string, userID uint, userEmail string) {
|
||||
<div id={ id } tabindex="-1" aria-hidden="true" class="hidden overflow-y-auto overflow-x-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 bg-gray-900/50 dark:bg-gray-900/80 backdrop-blur-sm">
|
||||
<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">
|
||||
@@ -654,7 +655,7 @@ templ UserDialog(id string, title string, message string, confirmClass string, c
|
||||
onclick={ triggerUserDelete(id, userID, userEmail) }>
|
||||
{ confirmText }
|
||||
</button>
|
||||
<button type="button" data-modal-hide={ 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">
|
||||
<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>
|
||||
@@ -663,16 +664,6 @@ templ UserDialog(id string, title string, message string, confirmClass string, c
|
||||
</div>
|
||||
}
|
||||
|
||||
script hideUserDialog(id string) {
|
||||
document.getElementById(id).classList.add("hidden");
|
||||
document.getElementById(id).classList.remove("flex");
|
||||
}
|
||||
|
||||
script showUserDialog(id string) {
|
||||
document.getElementById(id).classList.remove("hidden");
|
||||
document.getElementById(id).classList.add("flex");
|
||||
}
|
||||
|
||||
script triggerUserDelete(dialogId string, userID uint, userEmail string) {
|
||||
// Hide the dialog
|
||||
document.getElementById(dialogId).classList.add("hidden");
|
||||
|
||||
+43
-9
@@ -16,15 +16,49 @@ 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
|
||||
|
||||
# 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
|
||||
|
||||
# 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}"
|
||||
if ! adduser -u ${PUID} -G ${USERNAME} -h /app -s /bin/sh -D ${USERNAME}; then
|
||||
echo "⚠️ Failed to add user ${USERNAME} with UID ${PUID} and group ${USERNAME}."
|
||||
# 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=$(getent group ${USERNAME} | cut -d: -f3 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..."
|
||||
|
||||
@@ -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
|
||||
@@ -43,6 +45,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
|
||||
|
||||
@@ -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"`
|
||||
|
||||
-2045
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,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: "011a_recover_transfer_configs_rename",
|
||||
Migrate: func(tx *gorm.DB) error {
|
||||
fmt.Println("Running migration 011a: 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 011a_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: "011b_recover_notification_services_rename",
|
||||
Migrate: func(tx *gorm.DB) error {
|
||||
fmt.Println("Running migration 011b: 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 011b_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: "011c_recover_auth_providers_rename",
|
||||
Migrate: func(tx *gorm.DB) error {
|
||||
fmt.Println("Running migration 011c: 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 011c_recover_auth_providers_rename is not applicable.")
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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,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
|
||||
RecoverTransferConfigsRename(), // 011a
|
||||
RecoverNotificationServicesRename(), // 011b
|
||||
RecoverAuthProvidersRename(), // 011c
|
||||
AlterBooleanDefaults(), // 012
|
||||
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,588 @@
|
||||
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":
|
||||
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 "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":
|
||||
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 "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,278 @@
|
||||
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"
|
||||
}
|
||||
|
||||
createArgs := []string{
|
||||
"config", "create", remoteName, provider,
|
||||
"--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":
|
||||
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 "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 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", endpoint, "vendor", "other", "user", user)
|
||||
if pass != "" {
|
||||
createArgs = append(createArgs, "pass", pass)
|
||||
}
|
||||
case "nextcloud":
|
||||
createArgs = append(createArgs, "url", endpoint, "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,184 @@
|
||||
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
|
||||
)
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// LogInfo logs an info message if the log level allows it
|
||||
func (l *Logger) LogInfo(format string, v ...interface{}) {
|
||||
if l.logLevel >= LogLevelInfo {
|
||||
l.Info.Printf(format, v...)
|
||||
}
|
||||
}
|
||||
|
||||
// LogError logs an error message if the log level allows it
|
||||
func (l *Logger) LogError(format string, v ...interface{}) {
|
||||
if l.logLevel >= LogLevelError {
|
||||
l.Error.Printf(format, v...)
|
||||
}
|
||||
}
|
||||
|
||||
// LogDebug logs a debug message if the log level allows it
|
||||
func (l *Logger) LogDebug(format string, v ...interface{}) {
|
||||
if l.logLevel >= LogLevelDebug {
|
||||
l.Debug.Printf(format, v...)
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
// 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,
|
||||
}
|
||||
|
||||
// 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",
|
||||
filepath.Join(logsDir, "scheduler.log"), maxSize, maxBackups, maxAge, compress, logLevel.String())
|
||||
}
|
||||
|
||||
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
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user