Merge pull request #66 from StarFleetCPTN/development

Refactoring and improvements
This commit is contained in:
StarFleetCPTN
2025-03-30 12:44:24 -07:00
committed by GitHub
104 changed files with 15773 additions and 5402 deletions
+1 -14
View File
@@ -43,20 +43,7 @@ node_modules/
Thumbs.db Thumbs.db
# Ignore all Go files in the components directory # Ignore all Go files in the components directory
components/*.go *_templ.go
!components/components.go
components/providers/*.go
!components/providers/providers.go
components/providers/source/*.go
!components/providers/source/source.go
components/providers/destination/*.go
!components/providers/destination/destination.go
components/providers/common/*.go
!components/providers/common/common.go
# Ignore the data directory # Ignore the data directory
data/ data/
+4
View File
@@ -311,6 +311,10 @@ PGID=1000
- If longer than 32 bytes, it will be truncated to 32 bytes - If longer than 32 bytes, it will be truncated to 32 bytes
- Example: `TOTP_ENCRYPTION_KEY=abcdefghijklmnopqrstuvwxyz123456` - 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 ### Logging Configuration
GoMFT provides configurable logging with rotation support through the following environment variables: GoMFT provides configurable logging with rotation support through the following environment variables:
+1 -1
View File
@@ -275,7 +275,7 @@ templ formContent(provider *db.AuthProvider, isNew bool) {
<!-- Enabled --> <!-- Enabled -->
<div> <div>
<div class="flex items-center"> <div class="flex items-center">
if provider == nil || provider.Enabled { if provider == nil || provider.GetEnabled() {
<input <input
type="checkbox" type="checkbox"
id="enabled" id="enabled"
+1 -1
View File
@@ -80,7 +80,7 @@ templ AuthProviders(ctx context.Context, providers []db.AuthProvider) {
{ string(provider.Type) } { string(provider.Type) }
</td> </td>
<td class="px-6 py-4"> <td class="px-6 py-4">
if provider.Enabled { if provider.GetEnabled() {
<span class="px-2 py-1 text-xs rounded-full bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200"> <span class="px-2 py-1 text-xs rounded-full bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200">
Active Active
</span> </span>
+3 -3
View File
@@ -35,9 +35,9 @@ templ AuthProviderButtons(providers []db.AuthProvider) {
} else { } else {
<div class="space-y-2 w-full"> <div class="space-y-2 w-full">
for _, provider := range providers { for _, provider := range providers {
if provider.Enabled { if provider.GetEnabled() {
<a <a
href={ templ.SafeURL(fmt.Sprintf("/auth/provider/%d", provider.ID)) } href={ templ.SafeURL(fmt.Sprintf("/auth/provider/%d", provider.ID)) }
class="w-full inline-flex items-center justify-center px-4 py-2.5 bg-gray-100 border border-gray-300 rounded-lg font-medium text-gray-700 hover:bg-gray-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 dark:bg-gray-700 dark:border-gray-600 dark:text-gray-200 dark:hover:bg-gray-600" class="w-full inline-flex items-center justify-center px-4 py-2.5 bg-gray-100 border border-gray-300 rounded-lg font-medium text-gray-700 hover:bg-gray-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 dark:bg-gray-700 dark:border-gray-600 dark:text-gray-200 dark:hover:bg-gray-600"
> >
<span class="flex-shrink-0 w-5 h-5 mr-2.5"> <span class="flex-shrink-0 w-5 h-5 mr-2.5">
+71 -28
View File
@@ -12,6 +12,10 @@ import (
type ConfigFormData struct { type ConfigFormData struct {
Config *db.TransferConfig Config *db.TransferConfig
IsNew bool IsNew bool
// Fields for pre-rendering flags on edit
InitialCommand *db.RcloneCommand
SelectedFlagsMap map[uint]bool
SelectedFlagValues map[uint]string
} }
func getConfigFormTitle(isNew bool) string { func getConfigFormTitle(isNew bool) string {
@@ -286,33 +290,34 @@ func getInitialData(config *db.TransferConfig) string {
// This will need coordination with your backend to ensure the IDs match the commands // This will need coordination with your backend to ensure the IDs match the commands
let commandName = ''; let commandName = '';
switch(parseInt(this.commandId)) { switch(parseInt(this.commandId)) {
// Map command IDs to command names - adjust these based on your actual command IDs // Correct mapping based on internal/db/migrations/009_add_rclone_tables.go
case 1: commandName = 'copy'; break; case 1: commandName = 'copy'; break;
case 2: commandName = 'move'; break; case 2: commandName = 'sync'; break;
case 3: commandName = 'sync'; break; case 3: commandName = 'bisync'; break;
case 4: commandName = 'ls'; break; case 4: commandName = 'move'; break;
case 5: commandName = 'lsd'; break; case 5: commandName = 'delete'; break;
case 6: commandName = 'lsl'; break; case 6: commandName = 'purge'; break;
case 7: commandName = 'lsf'; break; case 7: commandName = 'mkdir'; break;
case 8: commandName = 'lsjson'; break; case 8: commandName = 'rmdir'; break;
case 9: commandName = 'md5sum'; break; case 9: commandName = 'rmdirs'; break;
case 10: commandName = 'sha1sum'; break; case 10: commandName = 'check'; break;
case 11: commandName = 'size'; break; case 11: commandName = 'ls'; break;
case 12: commandName = 'delete'; break; case 12: commandName = 'lsd'; break;
case 13: commandName = 'purge'; break; case 13: commandName = 'lsl'; break;
case 14: commandName = 'mkdir'; break; case 14: commandName = 'lsf'; break;
case 15: commandName = 'rmdir'; break; case 15: commandName = 'lsjson'; break;
case 16: commandName = 'rmdirs'; break; case 16: commandName = 'md5sum'; break;
case 17: commandName = 'check'; break; case 17: commandName = 'sha1sum'; break;
case 18: commandName = 'cleanup'; break; case 18: commandName = 'size'; break;
case 19: commandName = 'dedupe'; break; case 19: commandName = 'version'; break;
case 20: commandName = 'version'; break; case 20: commandName = 'cleanup'; break;
case 21: commandName = 'listremotes'; break; case 21: commandName = 'dedupe'; break;
case 22: commandName = 'cryptcheck'; break; case 22: commandName = 'copyto'; break;
case 23: commandName = 'bisync'; break; case 23: commandName = 'moveto'; break;
case 24: commandName = 'copyto'; break; case 24: commandName = 'listremotes'; break;
case 25: commandName = 'moveto'; break; case 25: commandName = 'obscure'; break;
default: commandName = 'copy'; // Default to copy case 26: commandName = 'cryptcheck'; break;
default: commandName = 'copy'; // Default to copy if ID is unknown
} }
console.log('Command ID:', this.commandId, 'Command Name:', commandName); console.log('Command ID:', this.commandId, 'Command Name:', commandName);
@@ -455,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 <i class="fas fa-terminal mr-2 text-blue-500 dark:text-blue-400"></i>Command Configuration
</h3> </h3>
<!-- Use the RcloneFlags component from common package --> <!-- Additonal Rclone Flags -->
@common.RcloneFlags() @common.RcloneFlags(data.Config.CommandID) // Pass current command ID
<!-- Container for flags, pre-rendered on edit, loaded via HTMX on new/change -->
<div id="command-flags-container" class="mt-4">
if !data.IsNew && data.InitialCommand != nil {
// Pre-render flags if editing and command data is available
@common.RcloneCommandFlagsContent(data.InitialCommand, data.SelectedFlagsMap, data.SelectedFlagValues)
}
</div>
</div> </div>
<!-- Source Configuration Section --> <!-- Source Configuration Section -->
@@ -468,6 +483,20 @@ templ ConfigForm(ctx context.Context, data ConfigFormData) {
<!-- Source selection --> <!-- Source selection -->
@common.SourceSelection() @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 --> <!-- Source type specific forms -->
<template x-if="sourceType === 'local'"> <template x-if="sourceType === 'local'">
@source.LocalSourceForm() @source.LocalSourceForm()
@@ -529,6 +558,20 @@ templ ConfigForm(ctx context.Context, data ConfigFormData) {
<!-- Destination selection --> <!-- Destination selection -->
@common.DestinationSelection() @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 --> <!-- Destination type specific forms -->
<template x-if="destinationType === 'local'"> <template x-if="destinationType === 'local'">
@destination.LocalDestinationForm() @destination.LocalDestinationForm()
@@ -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,376 @@
// Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.833
package details
//lint:file-ignore SA4006 This context is only used if a nested component is present.
import "github.com/a-h/templ"
import templruntime "github.com/a-h/templ/runtime"
import (
"context"
"fmt"
"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
"strconv"
)
// FileMetadataDetails renders the details view for a file metadata, matching original structure
func FileMetadataDetails(ctx context.Context, data file_metadata.FileMetadataDetailsData) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var1 := templ.GetChildren(ctx)
if templ_7745c5c3_Var1 == nil {
templ_7745c5c3_Var1 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Var2 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<!-- Status and Error Messages --> <div id=\"toast-container\" class=\"fixed top-5 right-5 z-50 flex flex-col gap-2\"></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = utils.FileMetadataJS().Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, " <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: ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(data.File.FileName)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `components/file_metadata/details/file_metadata_details.templ`, Line: 26, Col: 40}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "</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\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(data.File.FileName)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `components/file_metadata/details/file_metadata_details.templ`, Line: 37, Col: 27}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "</h5><p class=\"mt-1 text-sm text-gray-500 dark:text-gray-400\">File ID: ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(strconv.FormatUint(uint64(data.File.ID), 10))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `components/file_metadata/details/file_metadata_details.templ`, Line: 40, Col: 62}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "</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\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(data.File.FileName)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `components/file_metadata/details/file_metadata_details.templ`, Line: 60, Col: 33}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "</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\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var7 string
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(utils.FormatFileSize(data.File.FileSize))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `components/file_metadata/details/file_metadata_details.templ`, Line: 68, Col: 55}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "</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 templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if data.File.FileHash != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "<span class=\"font-mono\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var8 string
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(data.File.FileHash)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `components/file_metadata/details/file_metadata_details.templ`, Line: 77, Col: 58}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "</span>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "<span class=\"text-gray-400 dark:text-gray-500 italic\">Not available</span>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "</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\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var9 = []any{"text-xs font-medium px-2.5 py-0.5 rounded", utils.GetStatusBadgeClass(data.File.Status)}
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var9...)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "<span class=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var10 string
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var9).String())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `components/file_metadata/details/file_metadata_details.templ`, Line: 1, Col: 0}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var11 string
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(data.File.Status)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `components/file_metadata/details/file_metadata_details.templ`, Line: 89, Col: 32}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "</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>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var12 string
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(data.File.OriginalPath)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `components/file_metadata/details/file_metadata_details.templ`, Line: 100, Col: 44}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "</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>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var13 string
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(data.File.DestinationPath)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `components/file_metadata/details/file_metadata_details.templ`, Line: 111, Col: 47}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "</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=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var14 templ.SafeURL = templ.SafeURL(fmt.Sprintf("/files/job/%d", data.File.JobID))
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(string(templ_7745c5c3_Var14)))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "\" class=\"font-medium text-blue-600 dark:text-blue-500 hover:underline\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var15 string
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(data.File.Job.Name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `components/file_metadata/details/file_metadata_details.templ`, Line: 134, Col: 34}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "</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>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var16 string
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(data.File.ProcessedTime.Format("2006-01-02 15:04:05"))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `components/file_metadata/details/file_metadata_details.templ`, Line: 145, Col: 75}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "</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>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var17 string
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(data.File.CreationTime.Format("2006-01-02 15:04:05"))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `components/file_metadata/details/file_metadata_details.templ`, Line: 156, Col: 74}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "</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>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var18 string
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(data.File.ModTime.Format("2006-01-02 15:04:05"))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `components/file_metadata/details/file_metadata_details.templ`, Line: 167, Col: 69}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "</span></div></td></tr>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if data.File.Status == "error" && data.File.ErrorMessage != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "<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\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var19 string
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(data.File.ErrorMessage)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `components/file_metadata/details/file_metadata_details.templ`, Line: 179, Col: 84}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "</span></div></td></tr>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "<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\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var20 string
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinStringErrs(data.File.CreatedAt.Format("2006-01-02 15:04:05"))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `components/file_metadata/details/file_metadata_details.templ`, Line: 189, Col: 64}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "</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\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var21 string
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinStringErrs(data.File.UpdatedAt.Format("2006-01-02 15:04:05"))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `components/file_metadata/details/file_metadata_details.templ`, Line: 197, Col: 64}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var21))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "</td></tr></tbody></table></div></div></div><!-- Delete dialog component call -->")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = 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
).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "<!-- 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> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templ.RenderScriptItems(ctx, templ_7745c5c3_Buffer, templ.ComponentScript{Call: fmt.Sprintf("showModal('delete-file-dialog-%d')", data.File.ID)})
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "<button type=\"button\" onclick=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var22 templ.ComponentScript = templ.ComponentScript{Call: fmt.Sprintf("showModal('delete-file-dialog-%d')", data.File.ID)}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var22.Call)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "\" 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>\n\t\t\t\t// Set dark background color if in dark mode\n\t\t\t\tif (document.documentElement.classList.contains('dark')) {\n\t\t\t\t\tdocument.getElementById('file-details-container').style.backgroundColor = '#111827';\n\t\t\t\t}\n\n\t\t\t\t// Add event listener for theme changes\n\t\t\t\tdocument.addEventListener('DOMContentLoaded', function() {\n\t\t\t\t\tconst themeToggle = document.getElementById('theme-toggle');\n\t\t\t\t\tif (themeToggle) {\n\t\t\t\t\t\tthemeToggle.addEventListener('click', function() {\n\t\t\t\t\t\t\tsetTimeout(function() {\n\t\t\t\t\t\t\t\tconst isDark = document.documentElement.classList.contains('dark');\n\t\t\t\t\t\t\t\tdocument.getElementById('file-details-container').style.backgroundColor = isDark ? '#111827' : 'rgb(249, 250, 251)';\n\t\t\t\t\t\t\t}, 50);\n\t\t\t\t\t\t});\n\t\t\t\t\t}\n\t\t\t\t});\n\t\t\t</script></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
templ_7745c5c3_Err = components.LayoutWithContext("File Details", ctx).Render(templ.WithChildren(ctx, templ_7745c5c3_Var2), templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
var _ = templruntime.GeneratedTemplate
@@ -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,301 @@
// Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.833
package dialog
//lint:file-ignore SA4006 This context is only used if a nested component is present.
import "github.com/a-h/templ"
import templruntime "github.com/a-h/templ/runtime"
import (
"fmt"
"github.com/starfleetcptn/gomft/components/file_metadata/utils"
)
// FileMetadataDialog renders a confirmation dialog for file metadata actions
func FileMetadataDialog(id string, title string, message string, confirmClass string, confirmText string, action string, fileID uint, fileName string, section string) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var1 := templ.GetChildren(ctx)
if templ_7745c5c3_Var1 == nil {
templ_7745c5c3_Var1 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = utils.FileMetadataJS().Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<div id=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var2 string
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(id)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `components/file_metadata/dialog/file_metadata_dialog.templ`, Line: 11, Col: 13}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "\" 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=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%s-backdrop", id))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `components/file_metadata/dialog/file_metadata_dialog.templ`, Line: 13, Col: 42}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "\" 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 templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if action == "delete" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "<i class=\"fas fa-trash-alt text-red-400 text-3xl mb-4\"></i>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "<i class=\"fas fa-exclamation-triangle text-yellow-400 text-3xl mb-4\"></i>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "<h3 class=\"mb-5 text-lg font-normal text-gray-500 dark:text-gray-400\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(message)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `components/file_metadata/dialog/file_metadata_dialog.templ`, Line: 23, Col: 84}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "</h3>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if section == "list" {
templ_7745c5c3_Err = templ.RenderScriptItems(ctx, templ_7745c5c3_Buffer, templ.ComponentScript{Call: fmt.Sprintf("triggerFileDelete('%s', %d, '%s')", id, fileID, fileName)})
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "<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=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("/files/%d", fileID))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `components/file_metadata/dialog/file_metadata_dialog.templ`, Line: 28, Col: 51}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "\" hx-target=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("#file-row-%d", fileID))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `components/file_metadata/dialog/file_metadata_dialog.templ`, Line: 29, Col: 54}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "\" hx-swap=\"delete\" data-file-name=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var7 string
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(fileName)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `components/file_metadata/dialog/file_metadata_dialog.templ`, Line: 31, Col: 32}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "\" data-file-id=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var8 string
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprint(fileID))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `components/file_metadata/dialog/file_metadata_dialog.templ`, Line: 32, Col: 40}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "\" id=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var9 string
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("delete-file-btn-%d", fileID))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `components/file_metadata/dialog/file_metadata_dialog.templ`, Line: 33, Col: 53}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "\" onclick=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var10 templ.ComponentScript = templ.ComponentScript{Call: fmt.Sprintf("triggerFileDelete('%s', %d, '%s')", id, fileID, fileName)}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var10.Call)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var11 string
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(confirmText)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `components/file_metadata/dialog/file_metadata_dialog.templ`, Line: 35, Col: 20}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "</button> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templ.RenderScriptItems(ctx, templ_7745c5c3_Buffer, templ.ComponentScript{Call: fmt.Sprintf("triggerFileDelete('%s', %d, '%s')", id, fileID, fileName)})
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "<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=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var12 string
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("/files/%d", fileID))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `components/file_metadata/dialog/file_metadata_dialog.templ`, Line: 41, Col: 51}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "\" hx-redirect=\"/files\" data-file-name=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var13 string
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(fileName)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `components/file_metadata/dialog/file_metadata_dialog.templ`, Line: 43, Col: 32}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "\" data-file-id=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var14 string
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprint(fileID))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `components/file_metadata/dialog/file_metadata_dialog.templ`, Line: 44, Col: 40}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "\" id=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var15 string
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("delete-file-btn-%d", fileID))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `components/file_metadata/dialog/file_metadata_dialog.templ`, Line: 45, Col: 53}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "\" onclick=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var16 templ.ComponentScript = templ.ComponentScript{Call: fmt.Sprintf("triggerFileDelete('%s', %d, '%s')", id, fileID, fileName)}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var16.Call)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var17 string
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(confirmText)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `components/file_metadata/dialog/file_metadata_dialog.templ`, Line: 47, Col: 20}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "</button> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templ.RenderScriptItems(ctx, templ_7745c5c3_Buffer, templ.ComponentScript{Call: fmt.Sprintf("closeModal('%s')", id)})
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "<button type=\"button\" onclick=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var18 templ.ComponentScript = templ.ComponentScript{Call: fmt.Sprintf("closeModal('%s')", id)}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var18.Call)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "\" 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>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
var _ = templruntime.GeneratedTemplate
@@ -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,802 @@
// Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.833
package list
//lint:file-ignore SA4006 This context is only used if a nested component is present.
import "github.com/a-h/templ"
import templruntime "github.com/a-h/templ/runtime"
import (
"context"
"fmt"
"github.com/starfleetcptn/gomft/components/file_metadata"
"github.com/starfleetcptn/gomft/components/file_metadata/dialog"
"github.com/starfleetcptn/gomft/components/file_metadata/utils"
"net/url"
"strconv"
)
// 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
func FileMetadataListPartial(ctx context.Context, data file_metadata.FileMetadataListData, basePath string, targetContainerID string) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var1 := templ.GetChildren(ctx)
if templ_7745c5c3_Var1 == nil {
templ_7745c5c3_Var1 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<!-- Container for dynamically generated dialogs --><div id=\"dialog-container\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
for _, file := range data.Files {
templ_7745c5c3_Err = 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",
).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "</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=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var2 string
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(sortLink(data.SortBy, data.SortDir, "id", basePath, data.Filter, data.Limit))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `components/file_metadata/list/file_metadata_list_partial.templ`, Line: 87, Col: 92}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "\" hx-target=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(targetContainerID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `components/file_metadata/list/file_metadata_list_partial.templ`, Line: 88, Col: 36}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "\" hx-swap=\"innerHTML\" class=\"flex items-center hover:text-blue-600 dark:hover:text-blue-400\">ID ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var4 = []any{sortIconClass(data.SortBy, data.SortDir, "id")}
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var4...)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "<i class=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var4).String())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `components/file_metadata/list/file_metadata_list_partial.templ`, Line: 1, Col: 0}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "\"></i></a></th><th scope=\"col\" class=\"px-6 py-3\"><a href=\"#\" hx-get=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(sortLink(data.SortBy, data.SortDir, "filename", basePath, data.Filter, data.Limit))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `components/file_metadata/list/file_metadata_list_partial.templ`, Line: 98, Col: 98}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "\" hx-target=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var7 string
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(targetContainerID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `components/file_metadata/list/file_metadata_list_partial.templ`, Line: 99, Col: 36}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "\" hx-swap=\"innerHTML\" class=\"flex items-center hover:text-blue-600 dark:hover:text-blue-400\">Filename ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var8 = []any{sortIconClass(data.SortBy, data.SortDir, "filename")}
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var8...)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "<i class=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var9 string
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var8).String())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `components/file_metadata/list/file_metadata_list_partial.templ`, Line: 1, Col: 0}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "\"></i></a></th><th scope=\"col\" class=\"px-6 py-3\"><a href=\"#\" hx-get=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var10 string
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(sortLink(data.SortBy, data.SortDir, "size", basePath, data.Filter, data.Limit))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `components/file_metadata/list/file_metadata_list_partial.templ`, Line: 109, Col: 94}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "\" hx-target=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var11 string
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(targetContainerID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `components/file_metadata/list/file_metadata_list_partial.templ`, Line: 110, Col: 36}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "\" hx-swap=\"innerHTML\" class=\"flex items-center hover:text-blue-600 dark:hover:text-blue-400\">Size ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var12 = []any{sortIconClass(data.SortBy, data.SortDir, "size")}
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var12...)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "<i class=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var13 string
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var12).String())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `components/file_metadata/list/file_metadata_list_partial.templ`, Line: 1, Col: 0}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "\"></i></a></th><th scope=\"col\" class=\"px-6 py-3\"><a href=\"#\" hx-get=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var14 string
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(sortLink(data.SortBy, data.SortDir, "processed_time", basePath, data.Filter, data.Limit))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `components/file_metadata/list/file_metadata_list_partial.templ`, Line: 120, Col: 104}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "\" hx-target=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var15 string
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(targetContainerID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `components/file_metadata/list/file_metadata_list_partial.templ`, Line: 121, Col: 36}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "\" hx-swap=\"innerHTML\" class=\"flex items-center hover:text-blue-600 dark:hover:text-blue-400\">Processed time ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var16 = []any{sortIconClass(data.SortBy, data.SortDir, "processed_time")}
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var16...)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "<i class=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var17 string
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var16).String())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `components/file_metadata/list/file_metadata_list_partial.templ`, Line: 1, Col: 0}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "\"></i></a></th><th scope=\"col\" class=\"px-6 py-3\"><a href=\"#\" hx-get=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var18 string
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(sortLink(data.SortBy, data.SortDir, "status", basePath, data.Filter, data.Limit))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `components/file_metadata/list/file_metadata_list_partial.templ`, Line: 131, Col: 96}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "\" hx-target=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var19 string
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(targetContainerID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `components/file_metadata/list/file_metadata_list_partial.templ`, Line: 132, Col: 36}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "\" hx-swap=\"innerHTML\" class=\"flex items-center hover:text-blue-600 dark:hover:text-blue-400\">Status ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var20 = []any{sortIconClass(data.SortBy, data.SortDir, "status")}
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var20...)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "<i class=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var21 string
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var20).String())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `components/file_metadata/list/file_metadata_list_partial.templ`, Line: 1, Col: 0}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var21))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "\"></i></a></th>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if data.Job == nil {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "<th scope=\"col\" class=\"px-6 py-3\">Job</th>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "<th scope=\"col\" class=\"px-6 py-3\">Actions</th></tr></thead> <tbody>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
for _, file := range data.Files {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "<tr id=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var22 string
templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("file-row-%d", file.ID))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `components/file_metadata/list/file_metadata_list_partial.templ`, Line: 147, Col: 49}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "\" 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\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var23 string
templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.JoinStringErrs(strconv.FormatUint(uint64(file.ID), 10))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `components/file_metadata/list/file_metadata_list_partial.templ`, Line: 149, Col: 48}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var23))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "</td><td class=\"px-6 py-4 font-medium text-gray-900 whitespace-nowrap dark:text-white\"><a href=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var24 templ.SafeURL = templ.SafeURL(fmt.Sprintf("/files/%d", file.ID))
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(string(templ_7745c5c3_Var24)))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "\" class=\"hover:underline\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var25 string
templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.JoinStringErrs(file.FileName)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `components/file_metadata/list/file_metadata_list_partial.templ`, Line: 153, Col: 23}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var25))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "</a></td><td class=\"px-6 py-4\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var26 string
templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.JoinStringErrs(utils.FormatFileSize(file.FileSize))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `components/file_metadata/list/file_metadata_list_partial.templ`, Line: 157, Col: 44}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var26))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "</td><td class=\"px-6 py-4\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var27 string
templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.JoinStringErrs(file.ProcessedTime.Format("2006-01-02 15:04:05"))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `components/file_metadata/list/file_metadata_list_partial.templ`, Line: 160, Col: 57}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var27))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "</td><td class=\"px-6 py-4\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var28 = []any{"text-xs font-medium px-2.5 py-0.5 rounded", utils.GetStatusBadgeClass(file.Status)}
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var28...)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "<span class=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var29 string
templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var28).String())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `components/file_metadata/list/file_metadata_list_partial.templ`, Line: 1, Col: 0}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var29))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var30 string
templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.JoinStringErrs(file.Status)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `components/file_metadata/list/file_metadata_list_partial.templ`, Line: 164, Col: 21}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var30))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "</span></td>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if data.Job == nil {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "<td class=\"px-6 py-4\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if file.Job.ID > 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "<a href=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var31 templ.SafeURL = templ.SafeURL(fmt.Sprintf("/files/job/%d", file.Job.ID))
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(string(templ_7745c5c3_Var31)))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "\" class=\"font-medium text-blue-600 dark:text-blue-500 hover:underline\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var32 string
templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.JoinStringErrs(file.Job.Name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `components/file_metadata/list/file_metadata_list_partial.templ`, Line: 171, Col: 25}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var32))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "</a>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "<span class=\"text-gray-400 dark:text-gray-500 italic\">N/A</span>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "</td>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "<td class=\"px-6 py-4\"><div class=\"flex space-x-3\"><a href=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var33 templ.SafeURL = templ.SafeURL(fmt.Sprintf("/files/%d", file.ID))
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(string(templ_7745c5c3_Var33)))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "\" class=\"font-medium text-blue-600 dark:text-blue-500 hover:underline\"><i class=\"fas fa-eye\"></i></a> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templ.RenderScriptItems(ctx, templ_7745c5c3_Buffer, templ.ComponentScript{Call: fmt.Sprintf("showModal('delete-file-dialog-%d')", file.ID)})
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "<button type=\"button\" onclick=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var34 templ.ComponentScript = templ.ComponentScript{Call: fmt.Sprintf("showModal('delete-file-dialog-%d')", file.ID)}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var34.Call)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "\" data-file-id=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var35 string
templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.JoinStringErrs(strconv.FormatUint(uint64(file.ID), 10))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `components/file_metadata/list/file_metadata_list_partial.templ`, Line: 186, Col: 63}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var35))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "\" data-file-name=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var36 string
templ_7745c5c3_Var36, templ_7745c5c3_Err = templ.JoinStringErrs(file.FileName)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `components/file_metadata/list/file_metadata_list_partial.templ`, Line: 187, Col: 39}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var36))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "\" class=\"font-medium text-red-600 dark:text-red-500 hover:underline\"><i class=\"fas fa-trash\"></i></button></div></td></tr>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "</tbody></table></div><!-- Pagination with HTMX (Update links to include sorting and targetContainerID) -->")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if data.TotalPages > 1 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "<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\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var37 string
templ_7745c5c3_Var37, templ_7745c5c3_Err = templ.JoinStringErrs(strconv.Itoa((data.Page-1)*data.Limit + 1))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `components/file_metadata/list/file_metadata_list_partial.templ`, Line: 203, Col: 112}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var37))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "-")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var38 string
templ_7745c5c3_Var38, templ_7745c5c3_Err = templ.JoinStringErrs(strconv.Itoa(func() int {
end := data.Page * data.Limit
if int64(end) > data.TotalCount {
return int(data.TotalCount)
}
return end
}()))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `components/file_metadata/list/file_metadata_list_partial.templ`, Line: 209, Col: 8}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var38))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "</span> of <span class=\"font-semibold text-gray-900 dark:text-white\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var39 string
templ_7745c5c3_Var39, templ_7745c5c3_Err = templ.JoinStringErrs(strconv.FormatInt(data.TotalCount, 10))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `components/file_metadata/list/file_metadata_list_partial.templ`, Line: 209, Col: 119}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var39))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "</span></span><ul class=\"inline-flex -space-x-px rtl:space-x-reverse text-sm h-8\"><li>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if data.Page == 1 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "<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>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "<a hx-get=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var40 string
templ_7745c5c3_Var40, templ_7745c5c3_Err = templ.JoinStringErrs(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))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `components/file_metadata/list/file_metadata_list_partial.templ`, Line: 218, Col: 232}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var40))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "\" hx-target=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var41 string
templ_7745c5c3_Var41, templ_7745c5c3_Err = templ.JoinStringErrs(targetContainerID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `components/file_metadata/list/file_metadata_list_partial.templ`, Line: 219, Col: 38}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var41))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "\" 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>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "</li>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
for i := 1; i <= data.TotalPages; i++ {
if i == 1 || i == data.TotalPages || (i >= data.Page-2 && i <= data.Page+2) {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "<li>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if i == data.Page {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "<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\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var42 string
templ_7745c5c3_Var42, templ_7745c5c3_Err = templ.JoinStringErrs(strconv.Itoa(i))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `components/file_metadata/list/file_metadata_list_partial.templ`, Line: 232, Col: 26}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var42))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "</span>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "<a hx-get=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var43 string
templ_7745c5c3_Var43, templ_7745c5c3_Err = templ.JoinStringErrs(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))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `components/file_metadata/list/file_metadata_list_partial.templ`, Line: 235, Col: 222}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var43))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, "\" hx-target=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var44 string
templ_7745c5c3_Var44, templ_7745c5c3_Err = templ.JoinStringErrs(targetContainerID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `components/file_metadata/list/file_metadata_list_partial.templ`, Line: 236, Col: 40}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var44))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, "\" 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\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var45 string
templ_7745c5c3_Var45, templ_7745c5c3_Err = templ.JoinStringErrs(strconv.Itoa(i))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `components/file_metadata/list/file_metadata_list_partial.templ`, Line: 239, Col: 26}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var45))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "</a>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "</li>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else if (i == 2 && data.Page > 4) || (i == data.TotalPages-1 && data.Page < data.TotalPages-3) {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, "<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>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, "<li>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if data.Page == data.TotalPages {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, "<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>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, "<a hx-get=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var46 string
templ_7745c5c3_Var46, templ_7745c5c3_Err = templ.JoinStringErrs(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))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `components/file_metadata/list/file_metadata_list_partial.templ`, Line: 258, Col: 232}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var46))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, "\" hx-target=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var47 string
templ_7745c5c3_Var47, templ_7745c5c3_Err = templ.JoinStringErrs(targetContainerID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `components/file_metadata/list/file_metadata_list_partial.templ`, Line: 259, Col: 38}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var47))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 70, "\" 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>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, "</li></ul></nav>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
return nil
})
}
var _ = templruntime.GeneratedTemplate
@@ -0,0 +1,208 @@
// Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.833
package list
//lint:file-ignore SA4006 This context is only used if a nested component is present.
import "github.com/a-h/templ"
import templruntime "github.com/a-h/templ/runtime"
import (
"context"
"github.com/starfleetcptn/gomft/components" // Import the main components package
"github.com/starfleetcptn/gomft/components/file_metadata"
"github.com/starfleetcptn/gomft/components/file_metadata/utils"
"strconv"
)
// FileMetadataList renders the list of file metadata
func FileMetadataList(ctx context.Context, data file_metadata.FileMetadataListData) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var1 := templ.GetChildren(ctx)
if templ_7745c5c3_Var1 == nil {
templ_7745c5c3_Var1 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Var2 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, " <!-- Status and Error Messages --> <div id=\"toast-container\" class=\"fixed top-5 right-5 z-50 flex flex-col gap-2\"></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = utils.FileMetadataJS().Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, " <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=\"{&#34;X-HX-Request&#34;: &#34;true&#34;}\" class=\"grid grid-cols-1 md:grid-cols-3 gap-4\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if data.Job == nil {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "<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=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(data.Filter.JobID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `components/file_metadata/list/file_metadata_list.templ`, Line: 46, Col: 78}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "\" 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>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "<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\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if data.Filter.Status == "processed" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, ">Processed</option> <option value=\"archived\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if data.Filter.Status == "archived" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, ">Archived</option> <option value=\"deleted\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if data.Filter.Status == "deleted" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, ">Deleted</option> <option value=\"archived_and_deleted\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if data.Filter.Status == "archived_and_deleted" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, ">Archived & Deleted</option> <option value=\"error\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if data.Filter.Status == "error" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, ">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=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(data.Filter.FileName)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `components/file_metadata/list/file_metadata_list.templ`, Line: 62, Col: 84}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "\" 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 ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(strconv.FormatInt(int64((data.Page-1)*data.Limit+1), 10))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `components/file_metadata/list/file_metadata_list.templ`, Line: 84, Col: 79}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, " to ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(strconv.FormatInt(int64(min(data.Page*data.Limit, int(data.TotalCount))), 10))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `components/file_metadata/list/file_metadata_list.templ`, Line: 84, Col: 166}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, " of ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var7 string
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(strconv.FormatInt(data.TotalCount, 10))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `components/file_metadata/list/file_metadata_list.templ`, Line: 84, Col: 212}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, " files</p></div><!-- Card content --><div id=\"file-list-container\" class=\"p-4 md:p-5\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = FileMetadataListPartial(ctx, data, "/files/partial", "#file-list-container").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "</div></div></div><script>\n\t\t\t\t// Set dark background color if in dark mode\n\t\t\t\tif (document.documentElement.classList.contains('dark')) {\n\t\t\t\t\tdocument.getElementById('list-container').style.backgroundColor = '#111827';\n\t\t\t\t}\n\t\t\t\t\n\t\t\t\t// Add event listener for theme changes\n\t\t\t\tdocument.addEventListener('DOMContentLoaded', function() {\n\t\t\t\t\tconst themeToggle = document.getElementById('theme-toggle');\n\t\t\t\t\tif (themeToggle) {\n\t\t\t\t\t\tthemeToggle.addEventListener('click', function() {\n\t\t\t\t\t\t\tsetTimeout(function() {\n\t\t\t\t\t\t\t\tconst isDark = document.documentElement.classList.contains('dark');\n\t\t\t\t\t\t\t\tdocument.getElementById('list-container').style.backgroundColor = isDark ? '#111827' : 'rgb(249, 250, 251)';\n\t\t\t\t\t\t\t}, 50);\n\t\t\t\t\t\t});\n\t\t\t\t\t}\n\t\t\t\t});\n\t\t\t</script></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
templ_7745c5c3_Err = components.LayoutWithContext("Files", ctx).Render(templ.WithChildren(ctx, templ_7745c5c3_Var2), templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
var _ = templruntime.GeneratedTemplate
@@ -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,75 @@
// Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.833
package search
//lint:file-ignore SA4006 This context is only used if a nested component is present.
import "github.com/a-h/templ"
import templruntime "github.com/a-h/templ/runtime"
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
func FileMetadataSearchContent(ctx context.Context, data file_metadata.FileMetadataSearchData) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var1 := templ.GetChildren(ctx)
if templ_7745c5c3_Var1 == nil {
templ_7745c5c3_Var1 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<!-- Search Results --><div id=\"search-results\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if len(data.Files) > 0 {
templ_7745c5c3_Err = 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").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, " ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "<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\"></path></svg><p>No files found matching your search criteria.</p></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "</div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
var _ = templruntime.GeneratedTemplate
@@ -0,0 +1,189 @@
// Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.833
package search
//lint:file-ignore SA4006 This context is only used if a nested component is present.
import "github.com/a-h/templ"
import templruntime "github.com/a-h/templ/runtime"
import (
"context"
"github.com/starfleetcptn/gomft/components" // Import the main components package
"github.com/starfleetcptn/gomft/components/file_metadata"
"github.com/starfleetcptn/gomft/components/file_metadata/utils"
)
// FileMetadataSearch renders the search interface for file metadata
func FileMetadataSearch(ctx context.Context, data file_metadata.FileMetadataSearchData) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var1 := templ.GetChildren(ctx)
if templ_7745c5c3_Var1 == nil {
templ_7745c5c3_Var1 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Var2 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<!-- Status and Error Messages --> <div id=\"toast-container\" class=\"fixed top-5 right-5 z-50 flex flex-col gap-2\"></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = utils.FileMetadataJS().Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, " <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=\"{&#34;X-HX-Request&#34;: &#34;true&#34;}\" 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=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(data.Filter.JobID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `components/file_metadata/search/file_metadata_search.templ`, Line: 45, Col: 77}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "\" 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\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if data.Filter.Status == "processed" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, ">Processed</option> <option value=\"archived\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if data.Filter.Status == "archived" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, ">Archived</option> <option value=\"deleted\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if data.Filter.Status == "deleted" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, ">Deleted</option> <option value=\"archived_and_deleted\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if data.Filter.Status == "archived_and_deleted" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, ">Archived & Deleted</option> <option value=\"error\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if data.Filter.Status == "error" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, ">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=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(data.Filter.FileName)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `components/file_metadata/search/file_metadata_search.templ`, Line: 61, Col: 84}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "\" 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=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(data.Filter.Hash)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `components/file_metadata/search/file_metadata_search.templ`, Line: 66, Col: 72}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "\" 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=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(data.Filter.StartDate)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `components/file_metadata/search/file_metadata_search.templ`, Line: 71, Col: 89}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "\" class=\"bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark: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=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var7 string
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(data.Filter.EndDate)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `components/file_metadata/search/file_metadata_search.templ`, Line: 76, Col: 83}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "\" class=\"bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark: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>\n\t\t\t\t// Set dark background color if in dark mode\n\t\t\t\tif (document.documentElement.classList.contains('dark')) {\n\t\t\t\t\tdocument.getElementById('search-container').style.backgroundColor = '#111827';\n\t\t\t\t}\n\n\t\t\t\t// Add event listener for theme changes\n\t\t\t\tdocument.addEventListener('DOMContentLoaded', function() {\n\t\t\t\t\tconst themeToggle = document.getElementById('theme-toggle');\n\t\t\t\t\tif (themeToggle) {\n\t\t\t\t\t\tthemeToggle.addEventListener('click', function() {\n\t\t\t\t\t\t\tsetTimeout(function() {\n\t\t\t\t\t\t\t\tconst isDark = document.documentElement.classList.contains('dark');\n\t\t\t\t\t\t\t\tdocument.getElementById('search-container').style.backgroundColor = isDark ? '#111827' : 'rgb(249, 250, 251)';\n\t\t\t\t\t\t\t}, 50);\n\t\t\t\t\t\t});\n\t\t\t\t\t}\n\t\t\t\t});\n\t\t\t</script></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
templ_7745c5c3_Err = components.LayoutWithContext("Search Files", ctx).Render(templ.WithChildren(ctx, templ_7745c5c3_Var2), templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
var _ = templruntime.GeneratedTemplate
+43
View File
@@ -0,0 +1,43 @@
package file_metadata
import "github.com/starfleetcptn/gomft/internal/db"
// FileMetadataFilter represents filter parameters for file metadata queries
type FileMetadataFilter struct {
Status string
JobID string
FileName string
Hash string
StartDate string
EndDate string
}
// FileMetadataListData contains data for the file metadata list template
type FileMetadataListData struct {
Files []db.FileMetadata
TotalCount int64
Page int
Limit int
TotalPages int
Job *db.Job // Optional: if viewing files for a specific job
Filter FileMetadataFilter
SortBy string // Added for sorting
SortDir string // Added for sorting ("asc" or "desc")
}
// FileMetadataDetailsData contains data for the file metadata details template
type FileMetadataDetailsData struct {
File db.FileMetadata
}
// FileMetadataSearchData contains data for the file metadata search template
type FileMetadataSearchData struct {
Files []db.FileMetadata
TotalCount int64
Page int
Limit int
TotalPages int
Filter FileMetadataFilter
SortBy string // Added for sorting
SortDir string // Added for sorting ("asc" or "desc")
}
@@ -0,0 +1,114 @@
package utils
// FileMetadataJS provides common JavaScript functions for file metadata components,
// including HTMX event listeners for delete toasts.
templ FileMetadataJS() {
<script>
// Function to create and show a toast (Defined locally for guaranteed availability)
function showToast(message, type = 'info') {
const toastContainer = document.getElementById('toast-container');
if (!toastContainer) {
console.error("Toast container not found!"); // Keep this error log
return;
}
// Create toast element
const toast = document.createElement('div');
toast.id = 'toast-' + type + '-' + Date.now();
// Use classes similar to the original file_metadata.templ for consistency
toast.className = 'flex items-center w-full max-w-xs p-4 mb-4 rounded-lg shadow text-gray-500 bg-white dark:text-gray-400 dark:bg-gray-800 transform translate-y-16 opacity-0 transition-all duration-300 ease-out';
toast.role = 'alert';
// Set toast content based on type
let iconClass;
if (type === 'success') {
iconClass = 'text-green-500 bg-green-100 dark:bg-green-800 dark:text-green-200';
} else if (type === 'error') {
iconClass = 'text-red-500 bg-red-100 dark:bg-red-800 dark:text-red-200';
} else { // Default to info
iconClass = 'text-blue-500 bg-blue-100 dark:bg-blue-800 dark:text-blue-200';
}
// Set inner HTML with appropriate icon and message
toast.innerHTML = `
<div class="inline-flex items-center justify-center flex-shrink-0 w-8 h-8 rounded-lg ${iconClass}">
${type === 'success'
? '<i class="fas fa-check"></i>'
: type === 'error'
? '<i class="fas fa-exclamation-circle"></i>'
: '<i class="fas fa-info-circle"></i>'}
</div>
<div class="ml-3 text-sm font-normal">${message}</div>
<button type="button" class="ml-auto -mx-1.5 -my-1.5 bg-white text-gray-400 hover:text-gray-900 rounded-lg focus:ring-2 focus:ring-gray-300 p-1.5 hover:bg-gray-100 inline-flex h-8 w-8 dark:text-gray-500 dark:hover:text-white dark:bg-gray-800 dark:hover:bg-gray-700" data-dismiss-target="#${toast.id}" aria-label="Close">
<span class="sr-only">Close</span>
<i class="fas fa-times"></i>
</button>
`;
// Add toast to container
toastContainer.appendChild(toast);
// Trigger animation after a small delay
setTimeout(() => {
toast.classList.remove('translate-y-16', 'opacity-0');
toast.classList.add('translate-y-0', 'opacity-100');
}, 10);
// Add event listener to close button
const closeButton = toast.querySelector('button[data-dismiss-target]');
closeButton.addEventListener('click', function() {
toast.classList.add('opacity-0', 'translate-y-4');
setTimeout(() => { toast.remove(); }, 300);
});
// Auto-remove toast after 5 seconds
setTimeout(() => {
toast.classList.add('opacity-0', 'translate-y-4');
setTimeout(() => { toast.remove(); }, 300);
}, 5000);
}
// --- HTMX Event Listener for Delete Toasts ---
// Ensure listener is attached only once using a flag
if (!window._gomft_fileMetadataListenerAttached) {
document.body.addEventListener('htmx:afterRequest', function(event) {
const triggerElement = event.detail.elt;
// Check if the element that triggered this request was the file delete button from the dialog
if (triggerElement && triggerElement.id && triggerElement.id.startsWith('delete-file-btn-')) {
// Get the path directly from the element's hx-delete attribute
const path = triggerElement.getAttribute('hx-delete');
// Ensure requestConfig exists before accessing verb (robustness)
const method = event.detail.requestConfig ? event.detail.requestConfig.verb : null;
// Check if the method was delete and the path from the attribute matches the expected pattern
if (method === 'delete' && path && path.match(/^\/files\/\d+$/)) {
const fileName = triggerElement.getAttribute('data-file-name') || "Unknown"; // Get filename from the button
// Call the locally defined showToast function
if (event.detail.successful) {
showToast(`File "${fileName}" metadata deleted successfully`, 'success');
} else {
let errorMsg = `Failed to delete file "${fileName}" metadata`;
if (event.detail.xhr && event.detail.xhr.responseText) {
try {
const responseJson = JSON.parse(event.detail.xhr.responseText);
errorMsg = responseJson.error ? `Error: ${responseJson.error}` : `Error: ${event.detail.xhr.responseText}`;
} catch (e) {
errorMsg = `Error: ${event.detail.xhr.responseText}`;
}
}
showToast(errorMsg, 'error');
}
}
}
});
// Set the flag to true after attaching the listener
window._gomft_fileMetadataListenerAttached = true;
console.log("[FileMetadataJS] HTMX afterRequest listener attached."); // Log attachment once
}
</script>
}
File diff suppressed because one or more lines are too long
@@ -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))
}
}
+6 -1
View File
@@ -6,6 +6,7 @@ import (
"strings" "strings"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"time" "time"
"github.com/starfleetcptn/gomft/components/shared/toast"
) )
// AppVersion will be set at build time using ldflags // AppVersion will be set at build time using ldflags
@@ -141,7 +142,7 @@ templ LayoutWithContext(title string, ctx context.Context) {
} }
</style> </style>
</head> </head>
<body class="min-h-full bg-gray-50 dark:bg-gray-900" style="min-height: 100vh; display: flex; flex-direction: column;"> <body class="min-h-full bg-gray-50 dark:bg-gray-900" style="min-height: 100vh; display: flex; flex-direction: column;" hx-on::after-swap="initFlowbite()">
if isLoggedIn(ctx) { if isLoggedIn(ctx) {
<!-- Application Shell --> <!-- Application Shell -->
<div class="flex min-h-screen bg-gray-50 dark:bg-gray-900"> <div class="flex min-h-screen bg-gray-50 dark:bg-gray-900">
@@ -444,6 +445,8 @@ templ LayoutWithContext(title string, ctx context.Context) {
</div> </div>
</div> </div>
</header> </header>
<!-- Toast Container -->
@toast.Container()
<!-- Page Content --> <!-- Page Content -->
<main class="flex-1 bg-gray-50 dark:bg-gray-900"> <main class="flex-1 bg-gray-50 dark:bg-gray-900">
<div class="py-6 bg-gray-50 dark:bg-gray-900"> <div class="py-6 bg-gray-50 dark:bg-gray-900">
@@ -498,6 +501,8 @@ templ LayoutWithContext(title string, ctx context.Context) {
<!-- Scripts --> <!-- Scripts -->
<!-- Alpine.js and dependencies --> <!-- Alpine.js and dependencies -->
<script defer src="/static/dist/vendor.js"></script> <script defer src="/static/dist/vendor.js"></script>
<!-- Shared Scripts -->
@toast.ShowToastJS()
<!-- Application scripts --> <!-- Application scripts -->
<script defer src="/static/dist/app.js"></script> <script defer src="/static/dist/app.js"></script>
<script defer src="/static/dist/init.js"></script> <script defer src="/static/dist/init.js"></script>
@@ -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,41 @@
// Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.833
package dialog
//lint:file-ignore SA4006 This context is only used if a nested component is present.
import "github.com/a-h/templ"
import templruntime "github.com/a-h/templ/runtime"
// DialogScripts provides the JavaScript function specific to the notification delete confirmation dialog.
func DialogScripts() templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var1 := templ.GetChildren(ctx)
if templ_7745c5c3_Var1 == nil {
templ_7745c5c3_Var1 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<script type=\"text/javascript\">\n\t\t// Called when the delete confirmation button is clicked.\n\t\t// Primarily closes the modal; the actual delete is handled by hx-delete.\n\t\tfunction triggerServiceDelete(dialogId, serviceId, serviceName) {\n\t\t\tconsole.log(`Confirmed delete for service: ${serviceName} (ID: ${serviceId}). Closing modal: ${dialogId}`);\n\t\t\t// Call the global closeModal function defined elsewhere (e.g., app.js)\n\t\t\tif (typeof closeModal === 'function') {\n\t\t\t\tcloseModal(dialogId);\n\t\t\t} else {\n\t\t\t\tconsole.error('Global closeModal function not found.');\n\t\t\t}\n\t\t\t// Optional: Show a \"Deleting...\" toast here if desired.\n\t\t\t// The hx-delete attribute on the button will trigger the actual backend request.\n\t\t}\n\t</script>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
var _ = templruntime.GeneratedTemplate
@@ -0,0 +1,218 @@
// Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.833
package dialog
//lint:file-ignore SA4006 This context is only used if a nested component is present.
import "github.com/a-h/templ"
import templruntime "github.com/a-h/templ/runtime"
import (
"fmt"
// "strconv" // No longer needed here
)
// NotificationDialog component for confirmation dialogs using Flowbite modal
func NotificationDialog(id string, title string, message string, confirmClass string, confirmText string, action string, serviceID uint, serviceName string) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var1 := templ.GetChildren(ctx)
if templ_7745c5c3_Var1 == nil {
templ_7745c5c3_Var1 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<div id=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var2 string
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(id)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `components/notifications/dialog/dialog.templ`, Line: 10, Col: 13}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "\" 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=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%s-backdrop", id))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `components/notifications/dialog/dialog.templ`, Line: 12, Col: 42}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "\" 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 templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if action == "delete" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "<i class=\"fas fa-trash-alt text-red-400 text-3xl mb-4\"></i>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "<i class=\"fas fa-exclamation-triangle text-yellow-400 text-3xl mb-4\"></i>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "<h3 class=\"mb-5 text-lg font-normal text-gray-500 dark:text-gray-400\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(message)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `components/notifications/dialog/dialog.templ`, Line: 22, Col: 84}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "</h3>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var5 = []any{confirmClass}
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var5...)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templ.RenderScriptItems(ctx, templ_7745c5c3_Buffer, templ.ComponentScript{Call: fmt.Sprintf("triggerServiceDelete('%s', %d, '%s')", id, serviceID, serviceName)})
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "<button type=\"button\" class=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var5).String())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `components/notifications/dialog/dialog.templ`, Line: 1, Col: 0}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "\" hx-delete=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var7 string
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("/admin/settings/notifications/%d", serviceID))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `components/notifications/dialog/dialog.templ`, Line: 26, Col: 76}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "\" hx-target=\"body\" data-service-name=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var8 string
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(serviceName)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `components/notifications/dialog/dialog.templ`, Line: 28, Col: 37}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "\" data-service-id=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var9 string
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprint(serviceID))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `components/notifications/dialog/dialog.templ`, Line: 29, Col: 45}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "\" id=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var10 string
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("delete-btn-%d", serviceID))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `components/notifications/dialog/dialog.templ`, Line: 30, Col: 50}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "\" onclick=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var11 templ.ComponentScript = templ.ComponentScript{Call: fmt.Sprintf("triggerServiceDelete('%s', %d, '%s')", id, serviceID, serviceName)}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var11.Call)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var12 string
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(confirmText)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `components/notifications/dialog/dialog.templ`, Line: 32, Col: 19}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "</button> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templ.RenderScriptItems(ctx, templ_7745c5c3_Buffer, templ.ComponentScript{Call: fmt.Sprintf("closeModal('%s')", id)})
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "<button type=\"button\" onclick=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var13 templ.ComponentScript = templ.ComponentScript{Call: fmt.Sprintf("closeModal('%s')", id)}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var13.Call)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "\" 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>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
// Scripts (triggerServiceDelete, closeModal, showModal) are now expected to be defined globally or in the calling template (e.g., list.templ).
var _ = templruntime.GeneratedTemplate
@@ -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,45 @@
// Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.833
package fields
//lint:file-ignore SA4006 This context is only used if a nested component is present.
import "github.com/a-h/templ"
import templruntime "github.com/a-h/templ/runtime"
import (
"github.com/starfleetcptn/gomft/components/notifications/types"
// No utils needed for this specific template yet
)
func EmailFields(data types.NotificationFormData) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var1 := templ.GetChildren(ctx)
if templ_7745c5c3_Var1 == nil {
templ_7745c5c3_Var1 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<!-- 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>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
var _ = templruntime.GeneratedTemplate
@@ -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,154 @@
// Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.833
package fields
//lint:file-ignore SA4006 This context is only used if a nested component is present.
import "github.com/a-h/templ"
import templruntime "github.com/a-h/templ/runtime"
import (
"github.com/starfleetcptn/gomft/components/notifications/types"
// "github.com/starfleetcptn/gomft/components/notifications/form/utils" // Removed as unused
)
func GotifyFields(data types.NotificationFormData) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var1 := templ.GetChildren(ctx)
if templ_7745c5c3_Var1 == nil {
templ_7745c5c3_Var1 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<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 templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if data.NotificationService.GotifyURL != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<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=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var2 string
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(data.NotificationService.GotifyURL)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `components/notifications/form/fields/gotify.templ`, Line: 13, Col: 407}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "<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=\"\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "<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 templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if data.NotificationService.GotifyToken != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "<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=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(data.NotificationService.GotifyToken)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `components/notifications/form/fields/gotify.templ`, Line: 22, Col: 402}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "<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=\"\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "<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 templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if data.NotificationService.GotifyPriority != "" && data.NotificationService.GotifyPriority == "5" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "<option value=\"5\" selected=\"selected\">Normal (5)</option> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "<option value=\"5\">Normal (5)</option> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "<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 templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if data.NotificationService.GotifyTitleTemplate != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "<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=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(data.NotificationService.GotifyTitleTemplate)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `components/notifications/form/fields/gotify.templ`, Line: 43, Col: 399}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "<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=\"\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "</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 &#39;{{job.name}}&#39; {{job.status}} at {{job.completed_at}}. {{job.file_count}} files transferred ({{job.transfer_bytes}} bytes).\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if data.NotificationService.GotifyMessageTemplate != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "data.NotificationService.GotifyMessageTemplate")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "</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><!-- 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>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
var _ = templruntime.GeneratedTemplate
@@ -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,210 @@
// Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.833
package fields
//lint:file-ignore SA4006 This context is only used if a nested component is present.
import "github.com/a-h/templ"
import templruntime "github.com/a-h/templ/runtime"
import (
"github.com/starfleetcptn/gomft/components/notifications/types"
// "github.com/starfleetcptn/gomft/components/notifications/form/utils" // Removed as unused
)
func NtfyFields(data types.NotificationFormData) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var1 := templ.GetChildren(ctx)
if templ_7745c5c3_Var1 == nil {
templ_7745c5c3_Var1 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<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 templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if data.NotificationService.NtfyServer != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<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=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var2 string
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(data.NotificationService.NtfyServer)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `components/notifications/form/fields/ntfy.templ`, Line: 13, Col: 399}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "<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\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "<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 templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if data.NotificationService.NtfyTopic != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "<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=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(data.NotificationService.NtfyTopic)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `components/notifications/form/fields/ntfy.templ`, Line: 22, Col: 399}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "<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\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "<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 templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if data.NotificationService.NtfyPriority == "3" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "<option value=\"3\" selected=\"selected\">Default (3)</option> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "<option value=\"3\">Default (3)</option> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "<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 templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if data.NotificationService.NtfyUsername != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "<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=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(data.NotificationService.NtfyUsername)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `components/notifications/form/fields/ntfy.templ`, Line: 44, Col: 420}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "<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=\"\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "</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 templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if data.NotificationService.NtfyPassword != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "<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=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(data.NotificationService.NtfyPassword)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `components/notifications/form/fields/ntfy.templ`, Line: 52, Col: 424}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "<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=\"\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "</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 templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if data.NotificationService.NtfyTitleTemplate != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "<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=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(data.NotificationService.NtfyTitleTemplate)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `components/notifications/form/fields/ntfy.templ`, Line: 60, Col: 393}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "<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=\"\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "</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 &#39;{{job.name}}&#39; {{job.status}} at {{job.completed_at}}. {{job.file_count}} files transferred ({{job.transfer_bytes}} bytes).\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if data.NotificationService.NtfyMessageTemplate != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "data.NotificationService.NtfyMessageTemplate")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "</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><!-- 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>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
var _ = templruntime.GeneratedTemplate
@@ -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,139 @@
// Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.833
package fields
//lint:file-ignore SA4006 This context is only used if a nested component is present.
import "github.com/a-h/templ"
import templruntime "github.com/a-h/templ/runtime"
import (
"github.com/starfleetcptn/gomft/components/notifications/types"
// "github.com/starfleetcptn/gomft/components/notifications/form/utils" // Removed as unused
)
func PushbulletFields(data types.NotificationFormData) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var1 := templ.GetChildren(ctx)
if templ_7745c5c3_Var1 == nil {
templ_7745c5c3_Var1 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<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 templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if data.NotificationService.PushbulletAPIKey != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<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=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var2 string
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(data.NotificationService.PushbulletAPIKey)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `components/notifications/form/fields/pushbullet.templ`, Line: 13, Col: 430}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "<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=\"\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "<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 templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if data.NotificationService.PushbulletDeviceID != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "<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=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(data.NotificationService.PushbulletDeviceID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `components/notifications/form/fields/pushbullet.templ`, Line: 22, Col: 449}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "<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=\"\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "</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 templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if data.NotificationService.PushbulletTitleTemplate != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "<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=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(data.NotificationService.PushbulletTitleTemplate)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `components/notifications/form/fields/pushbullet.templ`, Line: 30, Col: 411}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "<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=\"\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "</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 &#39;{{job.name}}&#39; {{job.status}} at {{job.completed_at}}. {{job.file_count}} files transferred ({{job.transfer_bytes}} bytes).\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if data.NotificationService.PushbulletBodyTemplate != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "data.NotificationService.PushbulletBodyTemplate")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "</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><!-- 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>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
var _ = templruntime.GeneratedTemplate
@@ -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,182 @@
// Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.833
package fields
//lint:file-ignore SA4006 This context is only used if a nested component is present.
import "github.com/a-h/templ"
import templruntime "github.com/a-h/templ/runtime"
import (
"github.com/starfleetcptn/gomft/components/notifications/types"
// "github.com/starfleetcptn/gomft/components/notifications/form/utils" // Removed as unused
)
func PushoverFields(data types.NotificationFormData) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var1 := templ.GetChildren(ctx)
if templ_7745c5c3_Var1 == nil {
templ_7745c5c3_Var1 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<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 templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if data.NotificationService.PushoverAPIToken != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<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=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var2 string
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(data.NotificationService.PushoverAPIToken)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `components/notifications/form/fields/pushover.templ`, Line: 13, Col: 435}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "<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=\"\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "<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 templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if data.NotificationService.PushoverUserKey != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "<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=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(data.NotificationService.PushoverUserKey)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `components/notifications/form/fields/pushover.templ`, Line: 22, Col: 432}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "<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=\"\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "<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 templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if data.NotificationService.PushoverDevice != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "<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=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(data.NotificationService.PushoverDevice)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `components/notifications/form/fields/pushover.templ`, Line: 31, Col: 431}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "<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=\"\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "</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 templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if data.NotificationService.PushoverPriority != "" && data.NotificationService.PushoverPriority == "0" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "<option value=\"0\" selected=\"selected\">Normal (0)</option> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "<option value=\"0\">Normal (0)</option> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "<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 templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if data.NotificationService.PushoverTitleTemplate != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "<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=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(data.NotificationService.PushoverTitleTemplate)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `components/notifications/form/fields/pushover.templ`, Line: 81, Col: 405}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "<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=\"&#39;{{job.name}}&#39; {{job.status}}\" value=\"\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "</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 &#39;{{job.name}}&#39; {{job.status}} at {{job.completed_at}}. {{job.file_count}} files transferred ({{job.transfer_bytes}} bytes).\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if data.NotificationService.PushoverMessageTemplate != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "data.NotificationService.PushoverMessageTemplate")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "</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><!-- 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>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
var _ = templruntime.GeneratedTemplate
@@ -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,223 @@
// Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.833
package fields
//lint:file-ignore SA4006 This context is only used if a nested component is present.
import "github.com/a-h/templ"
import templruntime "github.com/a-h/templ/runtime"
import (
"github.com/starfleetcptn/gomft/components/notifications/types"
// "github.com/starfleetcptn/gomft/components/notifications/form/utils" // Removed as unused
)
func WebhookFields(data types.NotificationFormData) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var1 := templ.GetChildren(ctx)
if templ_7745c5c3_Var1 == nil {
templ_7745c5c3_Var1 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<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 templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if data.NotificationService.WebhookURL != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<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=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var2 string
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(data.NotificationService.WebhookURL)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `components/notifications/form/fields/webhook.templ`, Line: 13, Col: 415}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "<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=\"\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "</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 templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if data.NotificationService.Method != "" {
if data.NotificationService.Method == "POST" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "<option value=\"POST\" selected=\"selected\">POST</option>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "<option value=\"POST\">POST</option>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, " ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if data.NotificationService.Method == "PUT" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "<option value=\"PUT\" selected=\"selected\">PUT</option>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "<option value=\"PUT\">PUT</option>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "<option value=\"POST\">POST</option> <option value=\"PUT\">PUT</option>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "</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 templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if data.NotificationService.Headers != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "<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=\"{&#34;Content-Type&#34;: &#34;application/json&#34;, &#34;Authorization&#34;: &#34;Bearer token&#34;}\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(data.NotificationService.Headers)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `components/notifications/form/fields/webhook.templ`, Line: 41, Col: 437}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "</textarea>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "<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=\"{&#34;Content-Type&#34;: &#34;application/json&#34;, &#34;Authorization&#34;: &#34;Bearer token&#34;}\"></textarea>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "</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=\"{\n\t&#34;event&#34;: &#34;{{job.event}}&#34;,\n\t&#34;job&#34;: {\n\t\t\t&#34;id&#34;: &#34;{{job.id}}&#34;,\n\t\t\t&#34;name&#34;: &#34;{{job.name}}&#34;,\n\t\t\t&#34;status&#34;: &#34;{{job.status}}&#34;,\n\t\t\t&#34;message&#34;: &#34;{{job.message}}&#34;,\n\t\t\t&#34;started_at&#34;: &#34;{{job.started_at}}&#34;,\n\t\t\t&#34;completed_at&#34;: &#34;{{job.completed_at}}&#34;,\n\t\t\t&#34;duration_seconds&#34;: {{job.duration_seconds}},\n\t\t\t&#34;config_id&#34;: &#34;{{job.config_id}}&#34;,\n\t\t\t&#34;config_name&#34;: &#34;{{job.config_name}}&#34;,\n\t\t\t&#34;transfer_bytes&#34;: {{job.transfer_bytes}},\n\t\t\t&#34;file_count&#34;: {{job.file_count}}\n\t},\n\t&#34;instance&#34;: {\n\t\t\t&#34;id&#34;: &#34;{{instance.id}}&#34;,\n\t\t\t&#34;name&#34;: &#34;{{instance.name}}&#34;,\n\t\t\t&#34;version&#34;: &#34;{{instance.version}}&#34;,\n\t\t\t&#34;environment&#34;: &#34;{{instance.environment}}&#34;\n\t},\n\t&#34;timestamp&#34;: &#34;{{timestamp}}&#34;,\n\t&#34;notification_id&#34;: &#34;{{notification.id}}&#34;\n}\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if data.NotificationService.PayloadTemplate != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "data.NotificationService.PayloadTemplate")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "</textarea><p class=\"mt-1 text-sm text-gray-500 dark:text-gray-400\">Use placeholders for dynamic values. Available variables: job.*, instance.*, timestamp, notification.*</p></div><div class=\"mb-6\"><label for=\"secret_key\" class=\"block mb-2 text-sm font-medium text-gray-900 dark:text-white\">Secret Key (for signature verification)</label> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if data.NotificationService.SecretKey != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "<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=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(data.NotificationService.SecretKey)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `components/notifications/form/fields/webhook.templ`, Line: 87, Col: 417}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "<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 templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if data.NotificationService.RetryPolicy != "" {
if data.NotificationService.RetryPolicy == "none" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "<option value=\"none\" selected=\"selected\">No retries</option>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "<option value=\"none\">No retries</option>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, " ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if data.NotificationService.RetryPolicy == "simple" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "<option value=\"simple\" selected=\"selected\">Simple (3 retries)</option>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "<option value=\"simple\">Simple (3 retries)</option>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, " ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if data.NotificationService.RetryPolicy == "exponential" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "<option value=\"exponential\" selected=\"selected\">Exponential backoff</option>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "<option value=\"exponential\">Exponential backoff</option>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "<option value=\"none\">No retries</option> <option value=\"simple\">Simple (3 retries)</option> <option value=\"exponential\">Exponential backoff</option>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "</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>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
var _ = templruntime.GeneratedTemplate
+177
View File
@@ -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,41 @@
// Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.833
package form
//lint:file-ignore SA4006 This context is only used if a nested component is present.
import "github.com/a-h/templ"
import templruntime "github.com/a-h/templ/runtime"
// FormScripts contains JavaScript specific to the notification form page.
func FormScripts() templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var1 := templ.GetChildren(ctx)
if templ_7745c5c3_Var1 == nil {
templ_7745c5c3_Var1 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<script>\n\t\t// Toggle notification fields based on selection\n\t\tdocument.addEventListener('DOMContentLoaded', function() {\n\t\t\tconst typeSelector = document.getElementById('notification_type');\n\t\t\t// Ensure typeSelector exists before adding listener\n\t\t\tif (!typeSelector) {\n\t\t\t\tconsole.warn(\"Notification type selector not found.\");\n\t\t\t\treturn; \n\t\t\t}\n\n\t\t\tconst allFields = document.querySelectorAll('.notification-fields');\n\t\t\tconst commonFields = document.querySelectorAll('.common-fields');\n\n\t\t\tfunction toggleFields() {\n\t\t\t\t// Hide all specific fields first\n\t\t\t\tallFields.forEach(field => field.classList.add('hidden'));\n\n\t\t\t\t// Show/hide common fields based on selection\n\t\t\t\tconst selectedType = typeSelector.value;\n\t\t\t\tif (selectedType) {\n\t\t\t\t\t// Show common fields (name, description, is_enabled, submit)\n\t\t\t\t\tcommonFields.forEach(field => field.classList.remove('hidden'));\n\n\t\t\t\t\t// Show the selected type's specific fields\n\t\t\t\t\tconst fieldsToShow = document.getElementById(`${selectedType}_fields`);\n\t\t\t\t\tif (fieldsToShow) {\n\t\t\t\t\t\tfieldsToShow.classList.remove('hidden');\n\t\t\t\t\t}\n\t\t\t\t} else {\n\t\t\t\t\t// Hide common fields if no type selected\n\t\t\t\t\tcommonFields.forEach(field => field.classList.add('hidden'));\n\t\t\t\t}\n\t\t\t}\n\n\t\t\ttypeSelector.addEventListener('change', toggleFields);\n\n\t\t\t// Initialize form state on load (if editing or if a type is pre-selected)\n\t\t\ttoggleFields(); \n\t\t});\n\t</script>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
var _ = templruntime.GeneratedTemplate
+398
View File
@@ -0,0 +1,398 @@
// Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.833
package form
//lint:file-ignore SA4006 This context is only used if a nested component is present.
import "github.com/a-h/templ"
import templruntime "github.com/a-h/templ/runtime"
import (
"context"
"fmt"
"github.com/starfleetcptn/gomft/components" // For LayoutWithContext
"github.com/starfleetcptn/gomft/components/notifications/form/fields" // Import fields
"github.com/starfleetcptn/gomft/components/notifications/form/utils"
"github.com/starfleetcptn/gomft/components/notifications/types"
)
func NotificationForm(ctx context.Context, data types.NotificationFormData) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var1 := templ.GetChildren(ctx)
if templ_7745c5c3_Var1 == nil {
templ_7745c5c3_Var1 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = FormScripts().Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Var2 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<!-- 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 templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if data.SuccessMessage != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<div class=\"hidden success-message\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(data.SuccessMessage)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `components/notifications/form/form.templ`, Line: 21, Col: 62}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "</div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "<!-- Error Message (hidden, used for HTMX responses) -->")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if data.ErrorMessage != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "<div class=\"hidden error-message\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(data.ErrorMessage)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `components/notifications/form/form.templ`, Line: 25, Col: 58}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "</div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "<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> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(utils.GetNotificationFormTitle(data.IsNew))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `components/notifications/form/form.templ`, Line: 31, Col: 50}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "</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 templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if data.IsNew {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, " hx-post=\"/admin/settings/notifications\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, " hx-put=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("/admin/settings/notifications/%d", data.NotificationService.ID))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `components/notifications/form/form.templ`, Line: 45, Col: 92}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, " 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 templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if data.NotificationService != nil && data.NotificationService.Type == "webhook" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "<option value=\"webhook\" selected=\"selected\">Webhook</option> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "<option value=\"webhook\">Webhook</option> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
if data.NotificationService != nil && data.NotificationService.Type == "pushbullet" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "<option value=\"pushbullet\" selected=\"selected\">Pushbullet</option> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "<option value=\"pushbullet\">Pushbullet</option> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
if data.NotificationService != nil && data.NotificationService.Type == "ntfy" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "<option value=\"ntfy\" selected=\"selected\">Ntfy</option> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "<option value=\"ntfy\">Ntfy</option> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
if data.NotificationService != nil && data.NotificationService.Type == "gotify" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "<option value=\"gotify\" selected=\"selected\">Gotify</option> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "<option value=\"gotify\">Gotify</option> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
if data.NotificationService != nil && data.NotificationService.Type == "pushover" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "<option value=\"pushover\" selected=\"selected\">Pushover</option> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "<option value=\"pushover\">Pushover</option> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "<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 templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if data.NotificationService.Name != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "<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=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var7 string
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(data.NotificationService.Name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `components/notifications/form/form.templ`, Line: 83, Col: 414}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "<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=\"\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "</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 templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if data.NotificationService.Description != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "<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\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var8 string
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(data.NotificationService.Description)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `components/notifications/form/form.templ`, Line: 91, Col: 438}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "</textarea>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "<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>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "</div><!-- Dynamic fields based on notification type -->")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = fields.EmailFields(data).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = fields.WebhookFields(data).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = fields.PushbulletFields(data).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = fields.NtfyFields(data).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = fields.GotifyFields(data).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = fields.PushoverFields(data).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "<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\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
for _, event := range []string{"job_start", "job_complete", "job_error"} {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "<div class=\"flex items-center\"><input id=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var9 string
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs("trigger_" + event)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `components/notifications/form/form.templ`, Line: 113, Col: 34}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "\" name=\"event_triggers[]\" type=\"checkbox\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var10 string
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(event)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `components/notifications/form/form.templ`, Line: 116, Col: 24}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "\" 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 templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if utils.IsEventTriggerSelected(data.NotificationService, event, data.IsNew) {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, " checked")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "> <label for=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var11 string
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs("trigger_" + event)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `components/notifications/form/form.templ`, Line: 122, Col: 41}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "\" class=\"ml-2 text-sm font-medium text-gray-900 dark:text-gray-300\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var12 string
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(utils.FormatEventTriggerName(event))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `components/notifications/form/form.templ`, Line: 122, Col: 147}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "</label></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "</div></div><div class=\"flex items-start mb-6 hidden common-fields\"><div class=\"flex items-center h-5\"><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 templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if data.NotificationService != nil && data.NotificationService.IsEnabled {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, " checked")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "></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 templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if data.IsNew {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "Add Service")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "Save Changes")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "</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 -->")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
templ_7745c5c3_Err = components.LayoutWithContext(utils.GetNotificationFormTitle(data.IsNew), ctx).Render(templ.WithChildren(ctx, templ_7745c5c3_Var2), templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
var _ = templruntime.GeneratedTemplate
@@ -0,0 +1,88 @@
package utils
import (
"strings"
)
// GetNotificationFormTitle returns the title for the notification form page.
func GetNotificationFormTitle(isNew bool) string {
if isNew {
return "Add Notification Service"
}
return "Edit Notification Service"
}
// Contains checks if a string slice contains a specific string.
func Contains(slice []string, item string) bool {
for _, a := range slice {
if a == item {
return true
}
}
return false
}
// BoolToString converts a boolean to its string representation "true" or "false".
// Useful for setting HTML attributes that expect string values.
func BoolToString(b bool) string {
if b {
return "true"
}
return "false"
}
// IsEventTriggerSelected checks if a specific event trigger should be pre-selected.
// It now accepts the anonymous struct type defined in types.NotificationFormData.
// Defaults to checking 'job_complete' and 'job_error' when creating a new service.
func IsEventTriggerSelected(service *struct {
ID uint
Name string
Description string
Type string
IsEnabled bool
EventTriggers []string
RetryPolicy string
WebhookURL string
Method string
Headers string
PayloadTemplate string
SecretKey string
PushbulletAPIKey string
PushbulletDeviceID string
PushbulletTitleTemplate string
PushbulletBodyTemplate string
NtfyServer string
NtfyTopic string
NtfyPriority string
NtfyUsername string
NtfyPassword string
NtfyTitleTemplate string
NtfyMessageTemplate string
GotifyURL string
GotifyToken string
GotifyPriority string
GotifyTitleTemplate string
GotifyMessageTemplate string
PushoverAPIToken string
PushoverUserKey string
PushoverDevice string
PushoverPriority string
PushoverSound string
PushoverTitleTemplate string
PushoverMessageTemplate string
}, event string, isNew bool) bool {
if !isNew && service != nil {
// Use the Contains helper function
return Contains(service.EventTriggers, event)
}
// Default for new services: check complete and error
return isNew && (event == "job_complete" || event == "job_error")
}
// FormatEventTriggerName converts event trigger keys to human-readable names.
func FormatEventTriggerName(event string) string {
// Replace underscores with spaces and capitalize words
name := strings.ReplaceAll(event, "_", " ")
name = strings.Title(name) // Use strings.Title for capitalization
return name
}
+207
View File
@@ -0,0 +1,207 @@
package list
import (
"context"
"fmt"
"github.com/starfleetcptn/gomft/components"
"github.com/starfleetcptn/gomft/components/notifications/dialog"
"github.com/starfleetcptn/gomft/components/notifications/types"
)
// List renders the notification services list page.
templ List(ctx context.Context, data types.SettingsNotificationsData) {
@components.LayoutWithContext("Notification Services", ctx) {
<!-- Status and Error Messages (Handled by shared toast component in layout) -->
<div id="notifications-container" class="notifications-page bg-gray-50 dark:bg-gray-900 min-h-screen">
<div class="pb-8 w-full">
<!-- Success Message (hidden, used for HTMX responses/toast trigger) -->
if data.SuccessMessage != "" {
<div class="hidden success-message">{ data.SuccessMessage }</div>
}
<!-- Error Message (hidden, used for HTMX responses/toast trigger) -->
if data.ErrorMessage != "" {
<div class="hidden error-message">{ data.ErrorMessage }</div>
}
<div class="mb-6 flex flex-col md:flex-row md:items-center md:justify-between gap-4">
<h1 class="text-2xl font-bold text-gray-900 dark:text-white flex items-center">
<i class="fas fa-bell w-6 h-6 mr-2 text-blue-500 dark:text-blue-400"></i>
Notification Services
</h1>
<a href="/admin/settings/notifications/new" class="flex items-center justify-center text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 font-medium rounded-lg px-5 py-2.5 dark:bg-blue-600 dark:hover:bg-blue-700 focus:outline-none dark:focus:ring-blue-800">
<i class="fas fa-plus w-4 h-4 mr-2"></i>
Add Notification Service
</a>
</div>
<!-- List of Notification Services -->
if len(data.NotificationServices) == 0 {
<div class="text-center py-8 bg-white dark:bg-gray-800 shadow-md rounded-lg">
<div class="inline-flex items-center justify-center w-16 h-16 rounded-full bg-blue-100 dark:bg-blue-900 mb-4">
<i class="fas fa-bell text-2xl text-blue-600 dark:text-blue-400"></i>
</div>
<h3 class="mb-2 text-lg font-semibold text-gray-900 dark:text-white">No notification services configured</h3>
<p class="text-gray-500 dark:text-gray-400 mb-4">Add a notification service to receive alerts for job events.</p>
<a href="/admin/settings/notifications/new" class="inline-flex items-center px-3 py-2 text-sm font-medium text-center text-white bg-blue-700 rounded-lg hover:bg-blue-800 focus:ring-4 focus:outline-none focus:ring-blue-300 dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800">
<i class="fas fa-plus w-4 h-4 mr-2"></i>
Add First Notification Service
</a>
</div>
} else {
<div class="bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 dark:bg-gray-800 overflow-hidden">
<ul class="divide-y divide-gray-200 dark:divide-gray-700">
for _, service := range data.NotificationServices {
<li>
<div class="block hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors">
<div class="px-4 py-4 sm:px-6">
<div class="flex items-center justify-between">
<div class="flex items-center">
if service.Type == "email" {
<div class="w-10 h-10 rounded-full bg-blue-100 flex items-center justify-center text-blue-600 dark:bg-blue-900 dark:text-blue-400 mr-3">
<i class="fas fa-envelope"></i>
</div>
} else if service.Type == "webhook" {
<div class="w-10 h-10 rounded-full bg-green-100 flex items-center justify-center text-green-600 dark:bg-green-900 dark:text-green-400 mr-3">
<i class="fas fa-code"></i>
</div>
} else { // Default icon
<div class="w-10 h-10 rounded-full bg-gray-100 flex items-center justify-center text-gray-600 dark:bg-gray-700 dark:text-gray-400 mr-3">
<i class="fas fa-bell"></i>
</div>
}
<div>
<p class="text-sm font-medium text-blue-600 dark:text-blue-400 truncate">
{ service.Name }
</p>
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">
{ service.Description }
</p>
</div>
</div>
<div class="ml-2 flex-shrink-0 flex space-x-2">
<a
href={ templ.SafeURL(fmt.Sprintf("/admin/settings/notifications/%d/edit", service.ID)) }
class="text-gray-500 bg-white focus:outline-none hover:bg-gray-100 focus:ring-4 focus:ring-gray-200 rounded-lg text-sm p-2 mr-1 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-white dark:focus:ring-gray-700"
>
<i class="fas fa-edit"></i>
</a>
<!-- Add notification delete dialog -->
@dialog.NotificationDialog(
fmt.Sprintf("delete-notification-dialog-%d", service.ID),
"Delete Notification Service",
fmt.Sprintf("Are you sure you want to delete the notification service '%s'? This cannot be undone.", service.Name),
"text-white bg-red-700 hover:bg-red-800 focus:ring-4 focus:ring-red-300 font-medium rounded-lg text-sm px-5 py-2.5 dark:bg-red-600 dark:hover:bg-red-700 focus:outline-none dark:focus:ring-red-800",
"Delete",
"delete",
service.ID,
service.Name,
)
<button
type="button"
onclick={ templ.ComponentScript{Call: fmt.Sprintf("showModal('delete-notification-dialog-%d')", service.ID)} }
class="text-red-500 bg-white focus:outline-none hover:bg-gray-100 focus:ring-4 focus:ring-gray-200 rounded-lg text-sm p-2 dark:bg-gray-800 dark:text-red-400 dark:hover:bg-gray-700 dark:hover:text-white dark:focus:ring-gray-700"
>
<i class="fas fa-trash-alt"></i>
</button>
</div>
</div>
<div class="mt-3 sm:flex sm:justify-between">
<div class="sm:flex flex-col md:flex-row gap-2 md:gap-6">
<div class="flex items-center">
<span
class={ "px-2 py-1 text-xs font-medium rounded-full",
templ.KV("bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300", service.IsEnabled),
templ.KV("bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300", !service.IsEnabled) }
>
if service.IsEnabled {
Active
} else {
Disabled
}
</span>
<span class="ml-2 px-2 py-1 text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-300 rounded-full">
{ service.Type }
</span>
if len(service.EventTriggers) > 0 && service.Type == "webhook" {
<span class="ml-2 px-2 py-1 text-xs font-medium bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-300 rounded-full">
{ fmt.Sprintf("%d triggers", len(service.EventTriggers)) }
</span>
}
if service.SuccessCount > 0 || service.FailureCount > 0 {
<span class="ml-2 px-2 py-1 text-xs font-medium bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300 rounded-full">
{ fmt.Sprintf("%d/%d", service.SuccessCount, service.SuccessCount + service.FailureCount) }
</span>
}
</div>
</div>
if service.Type == "webhook" {
<div class="mt-2 md:mt-0 flex items-center space-x-4">
<div class="text-xs">
<span class="text-gray-500 dark:text-gray-400">Events:</span>
<span class="ml-1 text-gray-900 dark:text-gray-300">
if len(service.EventTriggers) == 0 {
None
} else {
for i, trigger := range service.EventTriggers {
if i > 0 {
<span>, </span>
}
{ trigger }
}
}
</span>
</div>
<div class="text-xs">
<span class="text-gray-500 dark:text-gray-400">Retry:</span>
<span class="ml-1 text-gray-900 dark:text-gray-300">
if service.RetryPolicy == "" {
Default
} else {
{ service.RetryPolicy }
}
</span>
</div>
</div>
} else {
<div class="mt-2 md:mt-0 flex items-center text-sm text-gray-500 dark:text-gray-400">
<i class="far fa-clock w-4 h-4 mr-1.5 text-gray-400 dark:text-gray-500"></i>
<p>
Last sent:
if service.SuccessCount > 0 {
"Recently"
} else {
"Never"
}
</p>
</div>
}
</div>
</div>
</div>
</li>
}
</ul>
</div>
}
<!-- Help Notice Placeholder -->
<div class="mt-8 p-4 bg-gray-50 border border-gray-200 rounded-lg dark:bg-gray-800 dark:border-gray-700">
<div class="flex">
<div class="flex-shrink-0">
<i class="fas fa-info-circle text-blue-400 dark:text-blue-400"></i>
</div>
<div class="ml-3">
<p class="text-sm text-blue-700 dark:text-blue-400">
Notification services allow the system to send alerts for job events such as completion, errors, or when jobs start.
</p>
</div>
</div>
</div>
</div>
</div>
@dialog.DialogScripts()
}
// Script call removed for now
}
+221
View File
@@ -0,0 +1,221 @@
package list
// ListScripts contains JavaScript specific to the notification list page.
templ ListScripts() {
<script>
// Notification system (showToast function is now in shared/toast/toast_js.templ)
// Track all HTMX events for debugging
document.addEventListener('htmx:beforeRequest', function(event) {
// Check if this is a DELETE request for a notification service
const path = event.detail.path;
const method = event.detail.verb;
console.log(`Request path: ${path}, method: ${method}`);
// Pattern match for notification service deletions (e.g., /admin/settings/notifications/123)
if (path && method === 'DELETE' && path.match(/^\/admin\/settings\/notifications\/\d+$/)) {
console.log("Detected notification service deletion request via URL pattern");
// This is definitely a delete request - store this information
window.isServiceDeleteRequest = true;
}
});
document.addEventListener('htmx:afterRequest', function(event) {
// Check for notification service deletion multiple ways
const isDeleteRequest =
// Check global flag from the triggerServiceDelete function
window.currentlyDeletingService ||
// Check flag from beforeRequest handler
window.isServiceDeleteRequest ||
// Check URL pattern directly from this event
(event.detail.pathInfo &&
event.detail.pathInfo.requestPath &&
event.detail.pathInfo.requestPath.match(/^\/admin\/settings\/notifications\/\d+$/) &&
event.detail.verb === 'DELETE');
console.log(`Is delete request: ${isDeleteRequest}`);
// If this is a successful delete request, show notification
if (isDeleteRequest && event.detail.successful) {
console.log("Delete request was successful");
let serviceName = "Unknown";
// Try multiple sources for service name
if (event.detail.elt && event.detail.elt.getAttribute) {
serviceName = event.detail.elt.getAttribute('data-service-name') || serviceName;
}
if (serviceName === "Unknown" && window.lastDeletedService) {
// Fallback to our stored service info
serviceName = window.lastDeletedService.name;
}
console.log(`Showing success notification for deleted service: ${serviceName}`);
// Ensure showToast is globally available
if (typeof showToast === 'function') {
showToast(`Notification service "${serviceName}" deleted successfully`, 'success');
} else {
console.error("showToast function not found!");
}
// Clear flags
window.currentlyDeletingService = false;
window.isServiceDeleteRequest = false;
window.lastDeletedService = null;
}
});
document.addEventListener('htmx:responseError', function(event) {
console.log("HTMX response error:", event.detail);
// Similar logic as success but for errors
const isDeleteRequest =
window.currentlyDeletingService ||
window.isServiceDeleteRequest ||
(event.detail.pathInfo &&
event.detail.pathInfo.requestPath &&
event.detail.pathInfo.requestPath.match(/^\/admin\/settings\/notifications\/\d+$/) &&
event.detail.verb === 'DELETE');
let errorMsg = 'An error occurred';
if (event.detail.xhr && event.detail.xhr.responseText) {
errorMsg = event.detail.xhr.responseText;
}
if (isDeleteRequest) {
console.log("Delete request failed");
let serviceName = "Unknown";
// Try multiple sources for service name
if (event.detail.elt && event.detail.elt.getAttribute) {
serviceName = event.detail.elt.getAttribute('data-service-name') || serviceName;
}
if (serviceName === "Unknown" && window.lastDeletedService) {
// Fallback to our stored service info
serviceName = window.lastDeletedService.name;
}
let specificErrorMsg = `Failed to delete notification service "${serviceName}"`;
if (event.detail.xhr && event.detail.xhr.responseText) {
// Try to provide a more specific error from the response
specificErrorMsg = `Error deleting "${serviceName}": ${event.detail.xhr.responseText}`;
}
console.log(`Showing error notification: ${specificErrorMsg}`);
if (typeof showToast === 'function') {
showToast(specificErrorMsg, 'error');
} else {
console.error("showToast function not found!");
}
// Clear flags
window.currentlyDeletingService = false;
window.isServiceDeleteRequest = false;
window.lastDeletedService = null;
} else {
// General error toast
if (typeof showToast === 'function') {
showToast(errorMsg, 'error');
} else {
console.error("showToast function not found!");
}
}
});
// Handle modal hide buttons (This might be better placed globally or in layout if modals are used elsewhere)
document.addEventListener('DOMContentLoaded', function() {
// This listener handles closing modals via data-modal-hide attribute
// It might conflict or be redundant if Flowbite's JS handles this already.
// Consider removing if Flowbite is initialized globally.
const hideButtons = document.querySelectorAll('[data-modal-hide]');
hideButtons.forEach(button => {
button.addEventListener('click', function() {
const modalId = this.getAttribute('data-modal-hide');
const modal = document.getElementById(modalId);
if (modal) {
modal.classList.add('hidden');
modal.classList.remove('flex');
}
const backdrop = document.getElementById(modalId + "-backdrop");
if (backdrop) {
backdrop.classList.add("hidden");
}
});
});
// Show any success or error messages passed via data struct as toasts
const successDiv = document.querySelector('.success-message');
if (successDiv) {
const successMsg = successDiv.textContent.trim();
if (successMsg && typeof showToast === 'function') {
showToast(successMsg, 'success');
} else if (successMsg) {
console.error("showToast function not found, cannot display success message:", successMsg);
}
}
const errorDiv = document.querySelector('.error-message');
if (errorDiv) {
const errorMsg = errorDiv.textContent.trim();
if (errorMsg && typeof showToast === 'function') {
showToast(errorMsg, 'error');
} else if (errorMsg) {
console.error("showToast function not found, cannot display error message:", errorMsg);
}
}
});
// Script for handling the service deletion trigger
function triggerServiceDelete(dialogId, serviceID, serviceName) {
// Hide the dialog first
closeModal(dialogId); // Reuse closeModal logic
// Add debugging info
console.log(`Notification service deletion triggered for: ${serviceName} (ID: ${serviceID})`);
// Store data in a way that's accessible to event handlers
window.lastDeletedService = {
id: serviceID,
name: serviceName
};
// Add custom marker to track this deletion
window.currentlyDeletingService = true;
}
// Script for closing the modal
function closeModal(id) {
const dialog = document.getElementById(id);
if (dialog) {
dialog.classList.add("hidden");
dialog.classList.remove("flex");
}
const backdrop = document.getElementById(id + "-backdrop");
if (backdrop) {
// Instead of removing, hide it to potentially reuse
backdrop.classList.add("hidden");
}
}
// Script for showing the modal
function showModal(id) {
const dialog = document.getElementById(id);
if (dialog) {
dialog.classList.remove("hidden");
dialog.classList.add("flex"); // Use flex to center content
}
const backdrop = document.getElementById(id + "-backdrop");
if (backdrop) {
backdrop.classList.remove("hidden");
}
}
</script>
}
File diff suppressed because one or more lines are too long
+403
View File
@@ -0,0 +1,403 @@
// Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.833
package list
//lint:file-ignore SA4006 This context is only used if a nested component is present.
import "github.com/a-h/templ"
import templruntime "github.com/a-h/templ/runtime"
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.
func List(ctx context.Context, data types.SettingsNotificationsData) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var1 := templ.GetChildren(ctx)
if templ_7745c5c3_Var1 == nil {
templ_7745c5c3_Var1 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Var2 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<!-- 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 templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if data.SuccessMessage != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<div class=\"hidden success-message\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(data.SuccessMessage)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `components/notifications/list/list.templ`, Line: 20, Col: 62}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "</div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "<!-- Error Message (hidden, used for HTMX responses/toast trigger) -->")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if data.ErrorMessage != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "<div class=\"hidden error-message\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(data.ErrorMessage)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `components/notifications/list/list.templ`, Line: 24, Col: 58}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "</div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "<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 templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if len(data.NotificationServices) == 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "<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>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "<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\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
for _, service := range data.NotificationServices {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "<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 templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if service.Type == "email" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "<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>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else if service.Type == "webhook" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "<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>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, " <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>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "<div><p class=\"text-sm font-medium text-blue-600 dark:text-blue-400 truncate\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(service.Name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `components/notifications/list/list.templ`, Line: 75, Col: 29}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "</p><p class=\"text-sm text-gray-500 dark:text-gray-400 mt-1\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(service.Description)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `components/notifications/list/list.templ`, Line: 78, Col: 36}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "</p></div></div><div class=\"ml-2 flex-shrink-0 flex space-x-2\"><a href=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var7 templ.SafeURL = templ.SafeURL(fmt.Sprintf("/admin/settings/notifications/%d/edit", service.ID))
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(string(templ_7745c5c3_Var7)))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "\" 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 -->")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = 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,
).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templ.RenderScriptItems(ctx, templ_7745c5c3_Buffer, templ.ComponentScript{Call: fmt.Sprintf("showModal('delete-notification-dialog-%d')", service.ID)})
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "<button type=\"button\" onclick=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var8 templ.ComponentScript = templ.ComponentScript{Call: fmt.Sprintf("showModal('delete-notification-dialog-%d')", service.ID)}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var8.Call)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "\" 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\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var9 = []any{"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)}
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var9...)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "<span class=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var10 string
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var9).String())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `components/notifications/list/list.templ`, Line: 1, Col: 0}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if service.IsEnabled {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "Active")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "Disabled")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "</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\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var11 string
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(service.Type)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `components/notifications/list/list.templ`, Line: 124, Col: 29}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "</span> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if len(service.EventTriggers) > 0 && service.Type == "webhook" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "<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\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var12 string
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d triggers", len(service.EventTriggers)))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `components/notifications/list/list.templ`, Line: 128, Col: 72}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "</span> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
if service.SuccessCount > 0 || service.FailureCount > 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "<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\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var13 string
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d/%d", service.SuccessCount, service.SuccessCount+service.FailureCount))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `components/notifications/list/list.templ`, Line: 133, Col: 105}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "</span>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "</div></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if service.Type == "webhook" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "<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 templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if len(service.EventTriggers) == 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "None")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
for i, trigger := range service.EventTriggers {
if i > 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "<span>, </span>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, " ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var14 string
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(trigger)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `components/notifications/list/list.templ`, Line: 150, Col: 27}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "</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 templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if service.RetryPolicy == "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "Default")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
var templ_7745c5c3_Var15 string
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(service.RetryPolicy)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `components/notifications/list/list.templ`, Line: 161, Col: 38}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "</span></div></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "<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 templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if service.SuccessCount > 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "\"Recently\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "\"Never\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "</p></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "</div></div></div></li>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "</ul></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "<!-- 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>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = dialog.DialogScripts().Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
templ_7745c5c3_Err = components.LayoutWithContext("Notification Services", ctx).Render(templ.WithChildren(ctx, templ_7745c5c3_Var2), templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
var _ = templruntime.GeneratedTemplate
+69
View File
@@ -0,0 +1,69 @@
package types
// SettingsNotificationsData defines the data needed for the notifications list page
type SettingsNotificationsData struct {
NotificationServices []NotificationServiceData
SuccessMessage string
ErrorMessage string
}
// NotificationServiceData defines the data for a single service in the list
type NotificationServiceData struct {
ID uint
Name string
Type string
IsEnabled bool
Config map[string]string // Keep for now, might refine later
Description string
EventTriggers []string
PayloadTemplate string
SecretKey string
RetryPolicy string
SuccessCount int
FailureCount int
}
// NotificationFormData defines the data needed for the notification add/edit form
// TODO: This will be moved from notification_form.templ later
type NotificationFormData struct {
NotificationService *struct {
ID uint
Name string
Description string
Type string
IsEnabled bool
EventTriggers []string
RetryPolicy string
WebhookURL string
Method string
Headers string
PayloadTemplate string
SecretKey string
PushbulletAPIKey string
PushbulletDeviceID string
PushbulletTitleTemplate string
PushbulletBodyTemplate string
NtfyServer string
NtfyTopic string
NtfyPriority string
NtfyUsername string
NtfyPassword string
NtfyTitleTemplate string
NtfyMessageTemplate string
GotifyURL string
GotifyToken string
GotifyPriority string
GotifyTitleTemplate string
GotifyMessageTemplate string
PushoverAPIToken string
PushoverUserKey string
PushoverDevice string
PushoverPriority string
PushoverSound string
PushoverTitleTemplate string
PushoverMessageTemplate string
}
IsNew bool
SuccessMessage string
ErrorMessage string
}
+55 -50
View File
@@ -138,21 +138,20 @@ templ ArchiveOptions() {
</div> </div>
} }
templ RcloneFlags() { templ RcloneFlags(currentCommandID uint) {
<div class="mb-6"> <div class="mb-6">
<label for="command_id" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Rclone Command</label> <label for="command_id" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Rclone Command</label>
<div class="relative"> <div class="relative">
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none"> <div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-terminal text-gray-400 dark:text-gray-500"></i> <i class="fas fa-terminal text-gray-400 dark:text-gray-500"></i>
</div> </div>
@RcloneCommandOptions() @RcloneCommandOptions(currentCommandID) // Pass it down
</div> </div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400"> <p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
Select the rclone command to use for this configuration. Select the rclone command to use for this configuration.
</p> </p>
<!-- Command flags container - will be populated via HTMX --> <!-- Flag container is now rendered directly in ConfigForm -->
<div id="command-flags-container" class="mt-4"></div>
<label for="rclone_flags" class="block mb-2 mt-6 text-sm font-medium text-gray-900 dark:text-white">Additional Rclone Flags</label> <label for="rclone_flags" class="block mb-2 mt-6 text-sm font-medium text-gray-900 dark:text-white">Additional Rclone Flags</label>
<div class="relative"> <div class="relative">
@@ -171,12 +170,13 @@ templ RcloneFlags() {
} }
// New placeholder templ for rclone command options // New placeholder templ for rclone command options
templ RcloneCommandOptions() { templ RcloneCommandOptions(currentCommandID uint) { // Accept currentCommandID
<div <div
hx-get="/api/rclone/commands" hx-get="/api/rclone/commands"
hx-trigger="load" hx-trigger="load"
hx-target="this" hx-target="this"
hx-swap="outerHTML"> hx-swap="outerHTML"
hx-vals={ fmt.Sprintf(`{"commandId": %d}`, currentCommandID) }>
<!-- Loading placeholder --> <!-- Loading placeholder -->
<option value="">Loading commands...</option> <option value="">Loading commands...</option>
</div> </div>
@@ -231,12 +231,13 @@ templ DestinationSelection() {
} }
// RcloneCommandOptionsContent renders the command options organized by category // RcloneCommandOptionsContent renders the command options organized by category
templ RcloneCommandOptionsContent(categoryMap map[string][]db.RcloneCommand, categories []string) { templ RcloneCommandOptionsContent(categoryMap map[string][]db.RcloneCommand, categories []string, currentCommandID uint, commandFlagsJSON string, commandFlagValuesJSON string) { // Add flag JSON strings
<select id="command_id" name="command_id" x-model="commandId" <select id="command_id" name="command_id" x-model="commandId"
hx-get="/api/rclone/command-flags" hx-get="/api/rclone/command-flags"
hx-target="#command-flags-container" hx-target="#command-flags-container"
hx-trigger="change" hx-trigger="change"
hx-include="[name='command_id']" hx-include="[name='command_id']"
hx-vals={ fmt.Sprintf(`{"commandFlags": %s, "commandFlagValues": %s}`, commandFlagsJSON, commandFlagValuesJSON) }
@change="updateCommandRequirements()" @change="updateCommandRequirements()"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"> class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500">
<option value="">Select command...</option> <option value="">Select command...</option>
@@ -244,7 +245,11 @@ templ RcloneCommandOptionsContent(categoryMap map[string][]db.RcloneCommand, cat
if commands, ok := categoryMap[category]; ok && len(commands) > 0 { if commands, ok := categoryMap[category]; ok && len(commands) > 0 {
<optgroup label={ category }> <optgroup label={ category }>
for _, cmd := range commands { for _, cmd := range commands {
<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> </optgroup>
} }
@@ -253,7 +258,7 @@ templ RcloneCommandOptionsContent(categoryMap map[string][]db.RcloneCommand, cat
} }
// RcloneCommandFlagsContent renders the command flags for a selected command // RcloneCommandFlagsContent renders the command flags for a selected command
templ RcloneCommandFlagsContent(command *db.RcloneCommand) { templ RcloneCommandFlagsContent(command *db.RcloneCommand, selectedFlagsMap map[uint]bool, selectedFlagValues map[uint]string) {
if command == nil { if command == nil {
<div class="p-4 text-red-500">Command not found</div> <div class="p-4 text-red-500">Command not found</div>
return return
@@ -277,7 +282,9 @@ templ RcloneCommandFlagsContent(command *db.RcloneCommand) {
name="command_flags" name="command_flags"
value={ fmt.Sprintf("%d", flag.ID) } value={ fmt.Sprintf("%d", flag.ID) }
class="mt-0.5 rounded border-gray-300 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700 dark:focus:ring-blue-600" class="mt-0.5 rounded border-gray-300 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700 dark:focus:ring-blue-600"
/> if selectedFlagsMap[flag.ID] {
checked
} />
<div class="ml-3"> <div class="ml-3">
<label for={ fmt.Sprintf("flag_%d", flag.ID) } class="font-medium text-gray-900 dark:text-white"> <label for={ fmt.Sprintf("flag_%d", flag.ID) } class="font-medium text-gray-900 dark:text-white">
{ flag.Name } - { flag.Description } { flag.Name } - { flag.Description }
@@ -299,14 +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" class="mr-2 rounded border-gray-300 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700 dark:focus:ring-blue-600"
data-input-id={ fmt.Sprintf("flag_value_%d", flag.ID) } data-input-id={ fmt.Sprintf("flag_value_%d", flag.ID) }
onclick="toggleFlagValue(this)" onclick="toggleFlagValue(this)"
/> if selectedFlagsMap[flag.ID] {
checked
} />
<label for={ fmt.Sprintf("flag_enable_%d", flag.ID) } class="font-medium text-gray-900 dark:text-white"> <label for={ fmt.Sprintf("flag_enable_%d", flag.ID) } class="font-medium text-gray-900 dark:text-white">
{ flag.Name } - { flag.Description } { flag.Name } - { flag.Description }
</label> </label>
</div> </div>
<div class="w-full mt-2"> <div class="w-full mt-2">
@renderFlagInput(flag) @renderFlagInput(flag, selectedFlagValues[flag.ID], selectedFlagsMap[flag.ID]) // Pass value and enabled status
if flag.DefaultValue != "" { if flag.DefaultValue != "" {
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">Default: { flag.DefaultValue }</p> <p class="text-xs text-gray-500 dark:text-gray-400 mt-1">Default: { flag.DefaultValue }</p>
} }
@@ -345,23 +354,7 @@ templ RcloneCommandFlagsContent(command *db.RcloneCommand) {
} }
// Initialize all flag inputs on page load // Initialize all flag inputs on page load
document.addEventListener('DOMContentLoaded', function() { // Initialization is now handled by server-side rendering
const checkboxes = document.querySelectorAll('input[id^="flag_enable_"]');
checkboxes.forEach(function(checkbox) {
const inputId = checkbox.getAttribute('data-input-id');
const input = document.getElementById(inputId);
if (input) {
input.disabled = !checkbox.checked;
// Also initialize the hidden input
const hiddenId = inputId.replace('flag_value_', 'flag_hidden_');
const hiddenInput = document.getElementById(hiddenId);
if (hiddenInput) {
hiddenInput.disabled = !checkbox.checked;
}
}
});
});
</script> </script>
<div class="mt-4 p-3 bg-blue-50 text-blue-800 rounded-lg border border-blue-100 dark:bg-blue-900/20 dark:text-blue-300 dark:border-blue-900 text-sm"> <div class="mt-4 p-3 bg-blue-50 text-blue-800 rounded-lg border border-blue-100 dark:bg-blue-900/20 dark:text-blue-300 dark:border-blue-900 text-sm">
@@ -402,7 +395,7 @@ templ RcloneCommandFlagsContent(command *db.RcloneCommand) {
} }
// Helper function to render appropriate input based on flag data type // Helper function to render appropriate input based on flag data type
templ renderFlagInput(flag db.RcloneCommandFlag) { templ renderFlagInput(flag db.RcloneCommandFlag, value string, enabled bool) {
if flag.DataType == "int" { if flag.DataType == "int" {
<input <input
type="number" type="number"
@@ -410,14 +403,18 @@ templ renderFlagInput(flag db.RcloneCommandFlag) {
name={ fmt.Sprintf("flag_value_%d", flag.ID) } name={ fmt.Sprintf("flag_value_%d", flag.ID) }
class="w-full bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 p-2 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" class="w-full bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 p-2 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder={ flag.DefaultValue } placeholder={ flag.DefaultValue }
disabled value={ value }
/> if !enabled {
disabled
} />
<!-- Hidden input to include this flag ID when checked --> <!-- Hidden input to include this flag ID when checked -->
<input <input
type="hidden" type="hidden"
name="command_flags" name="command_flags"
value={ fmt.Sprintf("%d", flag.ID) } value={ fmt.Sprintf("%d", flag.ID) }
disabled if !enabled {
disabled
}
id={ fmt.Sprintf("flag_hidden_%d", flag.ID) } id={ fmt.Sprintf("flag_hidden_%d", flag.ID) }
data-enable-with={ fmt.Sprintf("flag_enable_%d", flag.ID) } data-enable-with={ fmt.Sprintf("flag_enable_%d", flag.ID) }
/> />
@@ -429,14 +426,18 @@ templ renderFlagInput(flag db.RcloneCommandFlag) {
step="0.01" step="0.01"
class="w-full bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 p-2 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" class="w-full bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 p-2 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder={ flag.DefaultValue } placeholder={ flag.DefaultValue }
disabled value={ value }
/> if !enabled {
disabled
} />
<!-- Hidden input to include this flag ID when checked --> <!-- Hidden input to include this flag ID when checked -->
<input <input
type="hidden" type="hidden"
name="command_flags" name="command_flags"
value={ fmt.Sprintf("%d", flag.ID) } value={ fmt.Sprintf("%d", flag.ID) }
disabled if !enabled {
disabled
}
id={ fmt.Sprintf("flag_hidden_%d", flag.ID) } id={ fmt.Sprintf("flag_hidden_%d", flag.ID) }
data-enable-with={ fmt.Sprintf("flag_enable_%d", flag.ID) } data-enable-with={ fmt.Sprintf("flag_enable_%d", flag.ID) }
/> />
@@ -448,14 +449,18 @@ templ renderFlagInput(flag db.RcloneCommandFlag) {
name={ fmt.Sprintf("flag_value_%d", flag.ID) } name={ fmt.Sprintf("flag_value_%d", flag.ID) }
class="w-full bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 p-2 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" class="w-full bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 p-2 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder={ flag.DefaultValue } placeholder={ flag.DefaultValue }
disabled value={ value }
/> if !enabled {
disabled
} />
<!-- Hidden input to include this flag ID when checked --> <!-- Hidden input to include this flag ID when checked -->
<input <input
type="hidden" type="hidden"
name="command_flags" name="command_flags"
value={ fmt.Sprintf("%d", flag.ID) } value={ fmt.Sprintf("%d", flag.ID) }
disabled if !enabled {
disabled
}
id={ fmt.Sprintf("flag_hidden_%d", flag.ID) } id={ fmt.Sprintf("flag_hidden_%d", flag.ID) }
data-enable-with={ fmt.Sprintf("flag_enable_%d", flag.ID) } data-enable-with={ fmt.Sprintf("flag_enable_%d", flag.ID) }
/> />
@@ -39,6 +39,22 @@ templ MinIODestinationForm() {
</p> </p>
</div> </div>
<div class="mb-6">
<label for="dest_region" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Region (Optional)</label>
<div class="relative">
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-globe-americas text-gray-400 dark:text-gray-500"></i>
</div>
<input type="text" id="dest_region" name="dest_region" x-model="destRegion"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="us-east-1" />
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
Optional: Specify the region if your MinIO setup requires it.
</p>
</div>
<div class="mb-6"> <div class="mb-6">
<label for="dest_access_key" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Access Key</label> <label for="dest_access_key" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Access Key</label>
<div class="relative"> <div class="relative">
+16
View File
@@ -39,6 +39,22 @@ templ MinIOSourceForm() {
</p> </p>
</div> </div>
<div class="mb-6">
<label for="source_region" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Region (Optional)</label>
<div class="relative">
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-globe-americas text-gray-400 dark:text-gray-500"></i>
</div>
<input type="text" id="source_region" name="source_region" x-model="sourceRegion"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="us-east-1" />
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
Optional: Specify the region if your MinIO setup requires it.
</p>
</div>
<div class="mb-6"> <div class="mb-6">
<label for="source_access_key" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Access Key</label> <label for="source_access_key" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Access Key</label>
<div class="relative"> <div class="relative">
+5
View File
@@ -0,0 +1,5 @@
package toast
templ Container() {
<div id="toast-container" class="fixed top-5 right-5 z-50 flex flex-col gap-2"></div>
}
+80
View File
@@ -0,0 +1,80 @@
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';
}
// 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() {
// 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>
}
+40
View File
@@ -0,0 +1,40 @@
// Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.833
package toast
//lint:file-ignore SA4006 This context is only used if a nested component is present.
import "github.com/a-h/templ"
import templruntime "github.com/a-h/templ/runtime"
func ShowToastJS() templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var1 := templ.GetChildren(ctx)
if templ_7745c5c3_Var1 == nil {
templ_7745c5c3_Var1 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<script>\n\t\t// Notification system\n\t\tfunction showToast(message, type) {\n\t\t\tconst toastContainer = document.getElementById('toast-container');\n\t\t\tif (!toastContainer) {\n\t\t\t\tconsole.error(\"Toast container not found!\");\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\t// Create toast element\n\t\t\tconst toast = document.createElement('div');\n\t\t\ttoast.id = 'toast-' + type + '-' + Date.now();\n\t\t\ttoast.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';\n\t\t\ttoast.role = 'alert';\n\n\t\t\t// Set toast content based on type\n\t\t\tlet iconClass, bgColorClass, textColorClass;\n\n\t\t\tif (type === 'success') {\n\t\t\t\ticonClass = 'text-green-500 bg-green-100 dark:bg-green-800 dark:text-green-200';\n\t\t\t\tbgColorClass = 'text-green-500 dark:text-green-200';\n\t\t\t\ttextColorClass = 'text-green-500 dark:text-green-200';\n\t\t\t} else if (type === 'error') {\n\t\t\t\ticonClass = 'text-red-500 bg-red-100 dark:bg-red-800 dark:text-red-200';\n\t\t\t\tbgColorClass = 'text-red-500 dark:text-red-200';\n\t\t\t\ttextColorClass = 'text-red-500 dark:text-red-200';\n\t\t\t} else { // Default to info\n\t\t\t\ticonClass = 'text-blue-500 bg-blue-100 dark:bg-blue-800 dark:text-blue-200';\n\t\t\t\tbgColorClass = 'text-blue-500 dark:text-blue-200';\n\t\t\t\ttextColorClass = 'text-blue-500 dark:text-blue-200';\n\t\t\t}\n\n\t\t\t// Set inner HTML with appropriate icon and message\n\t\t\ttoast.innerHTML = `\n\t\t\t\t<div class=\"inline-flex items-center justify-center flex-shrink-0 w-8 h-8 rounded-lg ${iconClass}\">\n\t\t\t\t\t${type === 'success'\n\t\t\t\t\t\t? '<i class=\"fas fa-check\"></i>'\n\t\t\t\t\t\t: type === 'error'\n\t\t\t\t\t\t? '<i class=\"fas fa-exclamation-circle\"></i>'\n\t\t\t\t\t\t: '<i class=\"fas fa-info-circle\"></i>'}\n\t\t\t\t</div>\n\t\t\t\t<div class=\"ml-3 text-sm font-normal\">${message}</div>\n\t\t\t\t<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\">\n\t\t\t\t\t<span class=\"sr-only\">Close</span>\n\t\t\t\t\t<i class=\"fas fa-times\"></i>\n\t\t\t\t</button>\n\t\t\t`;\n\n\t\t\t// Add toast to container\n\t\t\ttoastContainer.appendChild(toast);\n\n\t\t\t// Trigger animation after a small delay\n\t\t\tsetTimeout(() => {\n\t\t\t\ttoast.classList.remove('translate-y-16', 'opacity-0');\n\t\t\t\ttoast.classList.add('translate-y-0', 'opacity-100');\n\t\t\t}, 10);\n\n\t\t\t// Add event listener to close button\n\t\t\tconst closeButton = toast.querySelector('button[data-dismiss-target]');\n\t\t\tcloseButton.addEventListener('click', function() {\n\t\t\t\t// Animate out before removing\n\t\t\t\ttoast.classList.add('opacity-0', 'translate-y-4');\n\t\t\t\tsetTimeout(() => {\n\t\t\t\t\ttoast.remove();\n\t\t\t\t}, 300);\n\t\t\t});\n\n\t\t\t// Auto-remove toast after 5 seconds\n\t\t\tsetTimeout(() => {\n\t\t\t\ttoast.classList.add('opacity-0', 'translate-y-4');\n\t\t\t\tsetTimeout(() => {\n\t\t\t\t\ttoast.remove();\n\t\t\t\t}, 300);\n\t\t\t}, 5000);\n\t\t}\n\t</script>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
var _ = templruntime.GeneratedTemplate
+40
View File
@@ -0,0 +1,40 @@
// Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.833
package toast
//lint:file-ignore SA4006 This context is only used if a nested component is present.
import "github.com/a-h/templ"
import templruntime "github.com/a-h/templ/runtime"
func Container() templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var1 := templ.GetChildren(ctx)
if templ_7745c5c3_Var1 == nil {
templ_7745c5c3_Var1 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<div id=\"toast-container\" class=\"fixed top-5 right-5 z-50 flex flex-col gap-2\"></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
var _ = templruntime.GeneratedTemplate
+15
View File
@@ -0,0 +1,15 @@
package components
templ TestResult(success bool, message string) {
if success {
<div class="text-green-600 dark:text-green-400 flex items-center">
<i class="fas fa-check-circle mr-2"></i>
<span>{ message }</span>
</div>
} else {
<div class="text-red-600 dark:text-red-400 flex items-center">
<i class="fas fa-exclamation-triangle mr-2"></i>
<span>{ message }</span>
</div>
}
}
+3
View File
@@ -12,6 +12,7 @@ require (
github.com/joho/godotenv v1.5.1 github.com/joho/godotenv v1.5.1
github.com/pquerna/otp v1.4.0 github.com/pquerna/otp v1.4.0
github.com/robfig/cron/v3 v3.0.1 github.com/robfig/cron/v3 v3.0.1
github.com/stretchr/testify v1.10.0
golang.org/x/crypto v0.36.0 golang.org/x/crypto v0.36.0
gopkg.in/natefinch/lumberjack.v2 v2.2.1 gopkg.in/natefinch/lumberjack.v2 v2.2.1
gorm.io/gorm v1.25.12 gorm.io/gorm v1.25.12
@@ -22,6 +23,7 @@ require (
github.com/bytedance/sonic v1.12.9 // indirect github.com/bytedance/sonic v1.12.9 // indirect
github.com/bytedance/sonic/loader v0.2.3 // indirect github.com/bytedance/sonic/loader v0.2.3 // indirect
github.com/cloudwego/base64x v0.1.5 // 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/dustin/go-humanize v1.0.1 // indirect
github.com/gabriel-vasile/mimetype v1.4.8 // indirect github.com/gabriel-vasile/mimetype v1.4.8 // indirect
github.com/gin-contrib/sse v1.0.0 // 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/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.2.3 // 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/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.12 // indirect github.com/ugorji/go/codec v1.2.12 // indirect
+14
View File
@@ -16,6 +16,7 @@ type Config struct {
Email EmailConfig `json:"email"` Email EmailConfig `json:"email"`
BaseURL string `json:"base_url"` // Base URL for generating links in emails BaseURL string `json:"base_url"` // Base URL for generating links in emails
TOTPEncryptKey string `json:"totp_encrypt_key"` // Encryption key for TOTP secrets 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 { type EmailConfig struct {
@@ -40,6 +41,7 @@ func Load() (*Config, error) {
JWTSecret: "change_this_to_a_secure_random_string", JWTSecret: "change_this_to_a_secure_random_string",
BaseURL: "http://localhost:8080", BaseURL: "http://localhost:8080",
TOTPEncryptKey: "this-is-a-dev-key-not-for-production!", // Default development key TOTPEncryptKey: "this-is-a-dev-key-not-for-production!", // Default development key
SkipSSLVerify: false, // Default to verifying SSL
Email: EmailConfig{ Email: EmailConfig{
Enabled: false, Enabled: false,
Host: "smtp.example.com", Host: "smtp.example.com",
@@ -119,6 +121,13 @@ func Load() (*Config, error) {
if emailRequireAuth := os.Getenv("EMAIL_REQUIRE_AUTH"); emailRequireAuth != "" { if emailRequireAuth := os.Getenv("EMAIL_REQUIRE_AUTH"); emailRequireAuth != "" {
cfg.Email.RequireAuth = strings.ToLower(emailRequireAuth) == "true" 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) { } else if !os.IsNotExist(err) {
return nil, err return nil, err
} else { } else {
@@ -148,6 +157,11 @@ func Load() (*Config, error) {
"EMAIL_REQUIRE_AUTH=" + strconv.FormatBool(cfg.Email.RequireAuth), "EMAIL_REQUIRE_AUTH=" + strconv.FormatBool(cfg.Email.RequireAuth),
"EMAIL_USERNAME=" + cfg.Email.Username, "EMAIL_USERNAME=" + cfg.Email.Username,
"EMAIL_PASSWORD=" + cfg.Email.Password, "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 { if err := os.WriteFile(envPath, []byte(strings.Join(envContent, "\n")), 0644); err != nil {
+16 -1
View File
@@ -28,7 +28,7 @@ type AuthProvider struct {
ID uint `gorm:"primarykey" json:"id"` ID uint `gorm:"primarykey" json:"id"`
Name string `gorm:"not null" json:"name"` Name string `gorm:"not null" json:"name"`
Type ProviderType `gorm:"not null" json:"type"` 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"` Description string `json:"description"`
ProviderURL string `json:"provider_url"` ProviderURL string `json:"provider_url"`
ClientID string `json:"client_id"` ClientID string `json:"client_id"`
@@ -75,6 +75,21 @@ func (p *AuthProvider) SetConfig(data map[string]interface{}) error {
return nil 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 // ExternalUserIdentity represents a user identity from an external authentication provider
type ExternalUserIdentity struct { type ExternalUserIdentity struct {
ID uint `gorm:"primarykey" json:"id"` ID uint `gorm:"primarykey" json:"id"`
-2050
View File
File diff suppressed because it is too large Load Diff
+25
View File
@@ -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
}
+33
View File
@@ -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
}
+161
View File
@@ -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
}
+154
View File
@@ -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,281 @@
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)
// 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
},
}
}
+1
View File
@@ -22,6 +22,7 @@ func GetMigrations(db *gorm.DB) *gormigrate.Gormigrate {
AddRcloneTables(), // 009 AddRcloneTables(), // 009
AddRcloneCommandToConfig(), // 010 AddRcloneCommandToConfig(), // 010
AddAuthProviders(), // 011 AddAuthProviders(), // 011
AlterBooleanDefaults(), // 012
) )
return gormigrate.New(db, gormigrate.DefaultOptions, migrations) return gormigrate.New(db, gormigrate.DefaultOptions, migrations)
+21 -1
View File
@@ -12,7 +12,7 @@ type NotificationService struct {
ID uint `json:"id" gorm:"primaryKey"` ID uint `json:"id" gorm:"primaryKey"`
Name string `json:"name" gorm:"not null"` Name string `json:"name" gorm:"not null"`
Type string `json:"type" gorm:"not null"` // email, webhook 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:"-"` Config map[string]string `json:"config" gorm:"-"`
ConfigJSON string `json:"-" gorm:"column:config"` ConfigJSON string `json:"-" gorm:"column:config"`
Description string `json:"description"` Description string `json:"description"`
@@ -69,6 +69,8 @@ func (db *DB) GetNotificationServices(onlyEnabled bool) ([]NotificationService,
query := db.DB query := db.DB
if onlyEnabled { 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) query = query.Where("is_enabled = ?", true)
} }
@@ -102,3 +104,21 @@ func (db *DB) UpdateNotificationService(service *NotificationService) error {
func (db *DB) DeleteNotificationService(id uint) error { func (db *DB) DeleteNotificationService(id uint) error {
return db.Delete(&NotificationService{}, id).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
}
+87
View File
@@ -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
}
+333
View File
@@ -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
}
+143
View File
@@ -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
}
+200
View File
@@ -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
}
+505
View File
@@ -0,0 +1,505 @@
package db
import (
"fmt"
"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)
}
// ... (Add cases for other source types: b2, smb, ftp, webdav, nextcloud, onedrive, gdrive, gphotos) ...
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)
}
// ... (Add cases for other destination types: b2, smb, ftp, webdav, nextcloud, onedrive, gdrive, gphotos) ...
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 "", ""
}
+166
View File
@@ -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
}
+63
View File
@@ -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
}
+201
View File
@@ -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("&copy; %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("&copy; %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).
}
+278
View File
@@ -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)
+206
View File
@@ -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
}
+526
View File
@@ -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()
}
+184
View File
@@ -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
}
+206
View File
@@ -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).
+75
View File
@@ -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)
}
+226
View File
@@ -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
+308
View File
@@ -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: &notifySuccess,
NotifyOnFailure: &notifyFailure,
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)
File diff suppressed because it is too large Load Diff
+525
View File
@@ -0,0 +1,525 @@
package scheduler
import (
"context"
"fmt"
"reflect" // Added import
"strings"
"sync"
"testing"
"time"
"github.com/robfig/cron/v3"
"github.com/starfleetcptn/gomft/internal/db"
)
// --- Mock Implementations ---
// Mock SchedulerDB
var _ SchedulerDB = (*mockSchedulerDB)(nil)
type mockSchedulerDB struct {
mu sync.Mutex
GetActiveJobsFunc func() ([]db.Job, error)
UpdateJobStatusFunc func(job *db.Job) error
// Store calls/data
getActiveJobsCalls int
updatedJobStatus *db.Job
}
func (m *mockSchedulerDB) GetActiveJobs() ([]db.Job, error) {
m.mu.Lock()
m.getActiveJobsCalls++
m.mu.Unlock()
if m.GetActiveJobsFunc != nil {
return m.GetActiveJobsFunc()
}
// Default: return an empty list
return []db.Job{}, nil
}
func (m *mockSchedulerDB) UpdateJobStatus(job *db.Job) error {
m.mu.Lock()
m.updatedJobStatus = job
m.mu.Unlock()
if m.UpdateJobStatusFunc != nil {
return m.UpdateJobStatusFunc(job)
}
return nil // Default success
}
func (m *mockSchedulerDB) Reset() {
m.mu.Lock()
defer m.mu.Unlock()
m.getActiveJobsCalls = 0
m.updatedJobStatus = nil
}
// Mock SchedulerCron
var _ SchedulerCron = (*mockSchedulerCron)(nil)
type mockSchedulerCron struct {
mu sync.Mutex
addFuncMock func(spec string, cmd func()) (cron.EntryID, error) // Renamed field
removeFuncMock func(id cron.EntryID) // Renamed field
entryFuncMock func(id cron.EntryID) cron.Entry // Renamed field
stopFuncMock func() context.Context // Renamed field
// Store calls/data
addedJobs map[string]func() // spec -> cmd
removedIDs []cron.EntryID
entryCalled cron.EntryID
stopCalled bool
}
func (m *mockSchedulerCron) AddFunc(spec string, cmd func()) (cron.EntryID, error) {
m.mu.Lock()
defer m.mu.Unlock()
if m.addedJobs == nil {
m.addedJobs = make(map[string]func())
}
m.addedJobs[spec] = cmd
// Use reflect to check if the mock function is set, avoiding the warning
if reflect.ValueOf(m.addFuncMock).IsValid() && !reflect.ValueOf(m.addFuncMock).IsNil() {
return m.addFuncMock(spec, cmd)
}
// Default: return a mock ID
return cron.EntryID(len(m.addedJobs)), nil
}
func (m *mockSchedulerCron) Remove(id cron.EntryID) {
m.mu.Lock()
defer m.mu.Unlock()
m.removedIDs = append(m.removedIDs, id)
// Removed unused loop for spec, addedCmd
if m.removeFuncMock != nil { // Use renamed field
m.removeFuncMock(id)
}
}
func (m *mockSchedulerCron) Entry(id cron.EntryID) cron.Entry {
m.mu.Lock()
m.entryCalled = id
m.mu.Unlock()
if m.entryFuncMock != nil { // Use renamed field
return m.entryFuncMock(id)
}
// Default: return entry with future time
return cron.Entry{ID: id, Next: time.Now().Add(time.Hour)}
}
func (m *mockSchedulerCron) Stop() context.Context {
m.mu.Lock()
m.stopCalled = true
m.mu.Unlock()
if m.stopFuncMock != nil { // Use renamed field
return m.stopFuncMock()
}
return context.Background() // Default context
}
func (m *mockSchedulerCron) Reset() {
m.mu.Lock()
defer m.mu.Unlock()
m.addedJobs = nil
m.removedIDs = nil
m.entryCalled = 0
m.stopCalled = false
}
// Mock SchedulerLogger
var _ SchedulerLogger = (*mockSchedulerLogger)(nil)
type mockSchedulerLogger struct {
mu sync.Mutex
LogInfoFunc func(format string, v ...interface{})
LogErrorFunc func(format string, v ...interface{})
LogDebugFunc func(format string, v ...interface{})
CloseFunc func()
RotateLogsFunc func() error
// PrintlnFunc removed
// Store calls/data
infoLogs []string
errorLogs []string
debugLogs []string
closeCalled bool
rotateCalled bool
// printlnLogs removed
}
func (m *mockSchedulerLogger) LogInfo(format string, v ...interface{}) {
m.mu.Lock()
m.infoLogs = append(m.infoLogs, fmt.Sprintf(format, v...))
m.mu.Unlock()
if m.LogInfoFunc != nil {
m.LogInfoFunc(format, v...)
}
}
func (m *mockSchedulerLogger) LogError(format string, v ...interface{}) {
m.mu.Lock()
m.errorLogs = append(m.errorLogs, fmt.Sprintf(format, v...))
m.mu.Unlock()
if m.LogErrorFunc != nil {
m.LogErrorFunc(format, v...)
}
}
func (m *mockSchedulerLogger) LogDebug(format string, v ...interface{}) {
m.mu.Lock()
m.debugLogs = append(m.debugLogs, fmt.Sprintf(format, v...))
m.mu.Unlock()
if m.LogDebugFunc != nil {
m.LogDebugFunc(format, v...)
}
}
func (m *mockSchedulerLogger) Close() {
m.mu.Lock()
m.closeCalled = true
m.mu.Unlock()
if m.CloseFunc != nil {
m.CloseFunc()
}
}
func (m *mockSchedulerLogger) RotateLogs() error {
m.mu.Lock()
m.rotateCalled = true
m.mu.Unlock()
if m.RotateLogsFunc != nil {
return m.RotateLogsFunc()
}
return nil // Default success
}
// Println method removed
func (m *mockSchedulerLogger) Reset() {
m.mu.Lock()
defer m.mu.Unlock()
m.infoLogs = nil
m.errorLogs = nil
m.debugLogs = nil
m.closeCalled = false
m.rotateCalled = false
// printlnLogs removed from Reset
}
// Mock SchedulerJobExecutor
var _ SchedulerJobExecutor = (*mockSchedulerJobExecutor)(nil)
type mockSchedulerJobExecutor struct {
mu sync.Mutex
ExecuteJobFunc func(jobID uint)
// Store calls
executeJobCalls []uint
}
func (m *mockSchedulerJobExecutor) executeJob(jobID uint) {
m.mu.Lock()
m.executeJobCalls = append(m.executeJobCalls, jobID)
m.mu.Unlock()
if m.ExecuteJobFunc != nil {
m.ExecuteJobFunc(jobID)
}
}
func (m *mockSchedulerJobExecutor) Reset() {
m.mu.Lock()
defer m.mu.Unlock()
m.executeJobCalls = nil
}
// --- Test Setup ---
type testSchedulerComponents struct {
db *mockSchedulerDB
cron *mockSchedulerCron
logger *mockSchedulerLogger
executor *mockSchedulerJobExecutor
jobsMap map[uint]cron.EntryID
jobMutex *sync.Mutex
scheduler *Scheduler
}
func setupTestScheduler() testSchedulerComponents {
dbMock := &mockSchedulerDB{}
cronMock := &mockSchedulerCron{}
loggerMock := &mockSchedulerLogger{}
executorMock := &mockSchedulerJobExecutor{}
jobsMap := make(map[uint]cron.EntryID)
var jobMutex sync.Mutex
// Create scheduler with mocks
scheduler := New(
dbMock,
cronMock,
loggerMock,
executorMock,
jobsMap,
&jobMutex,
)
return testSchedulerComponents{
db: dbMock,
cron: cronMock,
logger: loggerMock,
executor: executorMock,
jobsMap: jobsMap,
jobMutex: &jobMutex,
scheduler: scheduler,
}
}
// --- Tests ---
func TestNewScheduler_LoadJobs(t *testing.T) {
dbMock := &mockSchedulerDB{}
cronMock := &mockSchedulerCron{}
loggerMock := &mockSchedulerLogger{}
executorMock := &mockSchedulerJobExecutor{}
jobsMap := make(map[uint]cron.EntryID)
var jobMutex sync.Mutex
// Configure DB mock to return jobs
enabled := true
disabled := false
dbMock.GetActiveJobsFunc = func() ([]db.Job, error) {
return []db.Job{
{ID: 1, Name: "Job 1", Schedule: "* * * * *", Enabled: &enabled},
{ID: 2, Name: "Job 2", Schedule: "0 * * * *", Enabled: &enabled},
{ID: 3, Name: "Job 3", Schedule: "*/5 * * * *", Enabled: &disabled}, // Disabled job
}, nil
}
// Create scheduler - this calls loadJobs internally
_ = New(dbMock, cronMock, loggerMock, executorMock, jobsMap, &jobMutex)
// Assertions
// 1. DB GetActiveJobs called once
dbMock.mu.Lock()
if dbMock.getActiveJobsCalls != 1 {
t.Errorf("Expected GetActiveJobs to be called once, got %d", dbMock.getActiveJobsCalls)
}
dbMock.mu.Unlock()
// 2. Cron AddFunc called twice (for enabled jobs)
cronMock.mu.Lock()
if len(cronMock.addedJobs) != 2 {
t.Errorf("Expected 2 jobs to be added to cron, got %d", len(cronMock.addedJobs))
}
// Job 1 schedule is 5 fields, should be converted to 6 by ScheduleJob logic.
if _, ok := cronMock.addedJobs["0 * * * * *"]; !ok { // Check for 6-field version
t.Errorf("Expected Job 1 schedule '0 * * * * *' to be added, got map: %v", cronMock.addedJobs)
}
// Job 2 schedule is 5 fields, should be converted to 6 by ScheduleJob logic.
if _, ok := cronMock.addedJobs["0 0 * * * *"]; !ok { // Check for 6-field version
t.Errorf("Expected Job 2 schedule '0 0 * * * *' to be added, got map: %v", cronMock.addedJobs)
}
cronMock.mu.Unlock()
// 3. Check logs
loggerMock.mu.Lock()
foundLoadLog := false
foundDisabledLog := false
foundLoadedCountLog := false
for _, log := range loggerMock.infoLogs {
if strings.Contains(log, "Loading scheduled jobs") {
foundLoadLog = true
}
if strings.Contains(log, "Job 3 (Job 3) is disabled") {
foundDisabledLog = true
}
if strings.Contains(log, "Loaded 2 jobs") {
foundLoadedCountLog = true
}
}
if !foundLoadLog {
t.Error("Expected 'Loading scheduled jobs' log")
}
if !foundDisabledLog {
t.Error("Expected 'Job 3 ... disabled' log")
}
if !foundLoadedCountLog {
t.Error("Expected 'Loaded 2 jobs' log")
}
loggerMock.mu.Unlock()
}
func TestScheduleJob_Success(t *testing.T) {
comps := setupTestScheduler()
enabled := true
job := db.Job{ID: 5, Name: "Test Sched", Schedule: "10 * * * *", Enabled: &enabled}
err := comps.scheduler.ScheduleJob(&job)
if err != nil {
t.Fatalf("ScheduleJob failed: %v", err)
}
// Assertions
// 1. Cron AddFunc called
comps.cron.mu.Lock()
if len(comps.cron.addedJobs) != 1 {
t.Fatalf("Expected 1 job added to cron, got %d", len(comps.cron.addedJobs))
}
// Job schedule is 5 fields, should be converted to 6 by ScheduleJob logic.
if _, ok := comps.cron.addedJobs["0 10 * * * *"]; !ok { // Check for 6-field version
t.Errorf("Expected schedule '0 10 * * * *' to be added, got map: %v", comps.cron.addedJobs)
}
comps.cron.mu.Unlock()
// 2. Job map updated
comps.jobMutex.Lock()
if _, ok := comps.jobsMap[job.ID]; !ok {
t.Errorf("Job ID %d not found in scheduler jobs map", job.ID)
}
comps.jobMutex.Unlock()
// 3. DB UpdateJobStatus called with NextRun set
comps.db.mu.Lock()
if comps.db.updatedJobStatus == nil {
t.Error("UpdateJobStatus was not called")
} else if comps.db.updatedJobStatus.ID != job.ID {
t.Errorf("UpdateJobStatus called with wrong job ID: got %d, want %d", comps.db.updatedJobStatus.ID, job.ID)
} else if comps.db.updatedJobStatus.NextRun == nil {
t.Error("UpdateJobStatus called but NextRun was not set")
}
comps.db.mu.Unlock()
}
func TestScheduleJob_Disabled(t *testing.T) {
comps := setupTestScheduler()
disabled := false
job := db.Job{ID: 6, Name: "Disabled Sched", Schedule: "* * * * *", Enabled: &disabled}
err := comps.scheduler.ScheduleJob(&job)
if err != nil {
t.Fatalf("ScheduleJob failed for disabled job: %v", err)
}
// Assertions
comps.cron.mu.Lock()
if len(comps.cron.addedJobs) != 0 {
t.Errorf("Expected 0 jobs added to cron for disabled job, got %d", len(comps.cron.addedJobs))
}
comps.cron.mu.Unlock()
comps.jobMutex.Lock()
if _, ok := comps.jobsMap[job.ID]; ok {
t.Errorf("Disabled Job ID %d should not be in scheduler jobs map", job.ID)
}
comps.jobMutex.Unlock()
comps.db.mu.Lock()
if comps.db.updatedJobStatus != nil {
t.Error("UpdateJobStatus should not be called for disabled job")
}
comps.db.mu.Unlock()
comps.logger.mu.Lock()
foundDisabledLog := false
for _, log := range comps.logger.infoLogs {
if strings.Contains(log, fmt.Sprintf("Job %d is disabled, skipping scheduling", job.ID)) {
foundDisabledLog = true
break
}
}
if !foundDisabledLog {
t.Error("Expected 'disabled, skipping' log message")
}
comps.logger.mu.Unlock()
}
func TestScheduleJob_InvalidCron(t *testing.T) {
comps := setupTestScheduler()
enabled := true
job := db.Job{ID: 7, Name: "Invalid Sched", Schedule: "invalid cron string", Enabled: &enabled}
err := comps.scheduler.ScheduleJob(&job)
if err == nil {
t.Fatal("ScheduleJob succeeded with invalid cron, expected error")
}
if !strings.Contains(err.Error(), "invalid cron expression") {
t.Errorf("Expected error containing 'invalid cron expression', got: %v", err)
}
// Assertions
comps.cron.mu.Lock()
if len(comps.cron.addedJobs) != 0 {
t.Errorf("Expected 0 jobs added to cron for invalid schedule, got %d", len(comps.cron.addedJobs))
}
comps.cron.mu.Unlock()
}
func TestUnscheduleJob(t *testing.T) {
comps := setupTestScheduler()
testJobID := uint(8)
testEntryID := cron.EntryID(88)
// Pre-populate the map
comps.jobsMap[testJobID] = testEntryID
comps.scheduler.UnscheduleJob(testJobID)
// Assertions
// 1. Cron Remove called
comps.cron.mu.Lock()
foundRemoved := false
for _, id := range comps.cron.removedIDs {
if id == testEntryID {
foundRemoved = true
break
}
}
if !foundRemoved {
t.Errorf("Expected cron Remove to be called with EntryID %d", testEntryID)
}
comps.cron.mu.Unlock()
// 2. Job removed from map
comps.jobMutex.Lock()
if _, ok := comps.jobsMap[testJobID]; ok {
t.Errorf("Job ID %d should have been removed from scheduler jobs map", testJobID)
}
comps.jobMutex.Unlock()
}
func TestStop(t *testing.T) {
comps := setupTestScheduler()
comps.scheduler.Stop()
// Assertions
comps.cron.mu.Lock()
if !comps.cron.stopCalled {
t.Error("Expected cron Stop to be called")
}
comps.cron.mu.Unlock()
comps.logger.mu.Lock()
if !comps.logger.closeCalled {
t.Error("Expected logger Close to be called")
}
comps.logger.mu.Unlock()
}
func TestRunJobNow(t *testing.T) {
comps := setupTestScheduler()
testJobID := uint(9)
err := comps.scheduler.RunJobNow(testJobID)
if err != nil {
t.Fatalf("RunJobNow failed: %v", err)
}
// Allow time for goroutine to potentially start
time.Sleep(10 * time.Millisecond)
// Assertions
comps.executor.mu.Lock()
foundCall := false
for _, id := range comps.executor.executeJobCalls {
if id == testJobID {
foundCall = true
break
}
}
if !foundCall {
t.Errorf("Expected executor executeJob to be called with JobID %d", testJobID)
}
comps.executor.mu.Unlock()
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,367 @@
package scheduler
import (
"bytes"
"context"
"errors"
"fmt"
"os" // Added import
"os/exec"
"strings"
"sync"
"testing"
"github.com/starfleetcptn/gomft/internal/db"
// Removed unused: encoding/json, path/filepath, reflect, time, gorm.io/gorm
)
// --- Mock Implementations ---
// Mock TransferDB
var _ TransferDB = (*mockTransferDB)(nil)
type mockTransferDB struct {
mu sync.Mutex
GetConfigRclonePathFunc func(config *db.TransferConfig) string
GetRcloneCommandFunc func(id uint) (*db.RcloneCommand, error)
UpdateJobHistoryFunc func(history *db.JobHistory) error
CreateFileMetadataFunc func(metadata *db.FileMetadata) error
GetRcloneCommandFlagsMapFunc func(commandID uint) (map[uint]db.RcloneCommandFlag, error)
// Store calls/data for verification
updatedHistory *db.JobHistory
createdMetadata []*db.FileMetadata
rcloneConfigPath string
}
func (m *mockTransferDB) GetConfigRclonePath(config *db.TransferConfig) string {
if m.GetConfigRclonePathFunc != nil {
return m.GetConfigRclonePathFunc(config)
}
m.rcloneConfigPath = "/tmp/mock_rclone.conf" // Default mock path
return m.rcloneConfigPath
}
func (m *mockTransferDB) GetRcloneCommand(id uint) (*db.RcloneCommand, error) {
if m.GetRcloneCommandFunc != nil {
return m.GetRcloneCommandFunc(id)
}
// Default: return a basic command if ID > 0
if id > 0 {
return &db.RcloneCommand{ID: id, Name: "copy"}, nil
}
return nil, errors.New("mock GetRcloneCommand not found")
}
func (m *mockTransferDB) UpdateJobHistory(history *db.JobHistory) error {
m.mu.Lock()
defer m.mu.Unlock()
m.updatedHistory = history // Store last updated history
if m.UpdateJobHistoryFunc != nil {
return m.UpdateJobHistoryFunc(history)
}
return nil // Default success
}
func (m *mockTransferDB) CreateFileMetadata(metadata *db.FileMetadata) error {
m.mu.Lock()
defer m.mu.Unlock()
m.createdMetadata = append(m.createdMetadata, metadata) // Store created metadata
if m.CreateFileMetadataFunc != nil {
return m.CreateFileMetadataFunc(metadata)
}
metadata.ID = uint(len(m.createdMetadata)) // Assign a mock ID
return nil // Default success
}
func (m *mockTransferDB) GetRcloneCommandFlagsMap(commandID uint) (map[uint]db.RcloneCommandFlag, error) {
if m.GetRcloneCommandFlagsMapFunc != nil {
return m.GetRcloneCommandFlagsMapFunc(commandID)
}
// Corrected type name
return make(map[uint]db.RcloneCommandFlag), nil // Default empty map
}
func (m *mockTransferDB) Reset() {
m.mu.Lock()
defer m.mu.Unlock()
m.updatedHistory = nil
m.createdMetadata = nil
m.rcloneConfigPath = ""
}
// Mock TransferNotifier
var _ TransferNotifier = (*mockTransferNotifier)(nil)
type mockTransferNotifier struct {
mu sync.Mutex
SendNotificationsFunc func(job *db.Job, history *db.JobHistory, config *db.TransferConfig)
CreateJobNotificationFunc func(job *db.Job, history *db.JobHistory) error
// Store calls/data for verification
sendNotificationsCalls []map[string]interface{}
createNotificationCalls []map[string]interface{}
}
func (m *mockTransferNotifier) SendNotifications(job *db.Job, history *db.JobHistory, config *db.TransferConfig) {
m.mu.Lock()
defer m.mu.Unlock()
m.sendNotificationsCalls = append(m.sendNotificationsCalls, map[string]interface{}{
"job": job, "history": history, "config": config,
})
if m.SendNotificationsFunc != nil {
m.SendNotificationsFunc(job, history, config)
}
}
func (m *mockTransferNotifier) createJobNotification(job *db.Job, history *db.JobHistory) error {
m.mu.Lock()
defer m.mu.Unlock()
m.createNotificationCalls = append(m.createNotificationCalls, map[string]interface{}{
"job": job, "history": history,
})
if m.CreateJobNotificationFunc != nil {
return m.CreateJobNotificationFunc(job, history)
}
return nil // Default success
}
func (m *mockTransferNotifier) Reset() {
m.mu.Lock()
defer m.mu.Unlock()
m.sendNotificationsCalls = nil
m.createNotificationCalls = nil
}
// Mock TransferMetadataHandler
var _ TransferMetadataHandler = (*mockTransferMetadataHandler)(nil)
type mockTransferMetadataHandler struct {
mu sync.Mutex
HasFileBeenProcessedFunc func(jobID uint, fileHash string) (bool, *db.FileMetadata, error)
CheckFileProcessingHistoryFunc func(jobID uint, fileName string) (*db.FileMetadata, error)
}
func (m *mockTransferMetadataHandler) hasFileBeenProcessed(jobID uint, fileHash string) (bool, *db.FileMetadata, error) {
if m.HasFileBeenProcessedFunc != nil {
return m.HasFileBeenProcessedFunc(jobID, fileHash)
}
return false, nil, nil // Default: not processed
}
func (m *mockTransferMetadataHandler) checkFileProcessingHistory(jobID uint, fileName string) (*db.FileMetadata, error) {
if m.CheckFileProcessingHistoryFunc != nil {
return m.CheckFileProcessingHistoryFunc(jobID, fileName)
}
return nil, fmt.Errorf("mock history not found for %s", fileName) // Default: not found
}
func (m *mockTransferMetadataHandler) Reset() {
m.mu.Lock()
defer m.mu.Unlock()
// Reset any stored state if needed in the future
}
// --- Mock os/exec ---
// MockExecCommand replaces the package-level execCommandContext variable (defined in transfer_executor.go)
// with a function provided by the test and returns a function to restore the original.
// Note: This helper replaces the *command creation*. The provided mockFunc
// needs to return an *exec.Cmd. To mock the *execution result* (like CombinedOutput),
// use the TestHelperProcess approach below or a similar technique.
func MockExecCommand(mockFunc func(ctx context.Context, command string, args ...string) *exec.Cmd) (restore func()) {
original := execCommandContext
execCommandContext = mockFunc
return func() { execCommandContext = original }
}
// TestHelperProcess isn't a real test, but a helper process function.
// It's triggered when tests run this binary with specific arguments and env vars.
// Based on env vars like GO_TEST_HELPER_PROCESS_WANT_ERROR, it prints to stdout/stderr
// and exits with 0 or 1.
func TestHelperProcess(t *testing.T) {
// Check if this invocation is intended to be the helper process
if os.Getenv("GO_TEST_HELPER_PROCESS") != "1" {
return
}
// Simulate command execution based on environment variables
mockOutput := os.Getenv("GO_TEST_HELPER_PROCESS_OUTPUT")
mockStderr := os.Getenv("GO_TEST_HELPER_PROCESS_STDERR")
wantError := os.Getenv("GO_TEST_HELPER_PROCESS_WANT_ERROR") == "1"
// Print the mock output/stderr
fmt.Fprint(os.Stdout, mockOutput)
fmt.Fprint(os.Stderr, mockStderr)
// Exit with appropriate code
if wantError {
os.Exit(1)
}
os.Exit(0)
}
// --- Test Setup ---
type testExecutorComponents struct {
db *mockTransferDB
logger *Logger
logBuf *bytes.Buffer
metadata *mockTransferMetadataHandler
notifier *mockTransferNotifier
executor *TransferExecutor
}
func setupTestExecutor() testExecutorComponents {
dbMock := &mockTransferDB{}
logger, logBuf := newTestLogger(LogLevelDebug)
metadataMock := &mockTransferMetadataHandler{}
notifierMock := &mockTransferNotifier{}
executor := NewTransferExecutor(dbMock, logger, metadataMock, notifierMock)
return testExecutorComponents{
db: dbMock,
logger: logger,
logBuf: logBuf,
metadata: metadataMock,
notifier: notifierMock,
executor: executor,
}
}
// --- Tests ---
func TestExecuteSimpleCommand_Success(t *testing.T) {
comps := setupTestExecutor()
defer comps.logger.Close()
job := db.Job{ID: 1, Name: "Simple Job"}
config := db.TransferConfig{ID: 10, SourceType: "local", SourcePath: "/src", DestinationType: "local", DestinationPath: "/dst"}
history := &db.JobHistory{ID: 100, JobID: 1, ConfigID: 10}
configPath := "/tmp/test_rclone.conf"
cmdName := "ls"
cmdType := "listing"
expectedOutput := "file1.txt\nfile2.txt\n"
// Mock exec.CommandContext using the TestHelperProcess strategy
restoreExec := MockExecCommand(func(ctx context.Context, command string, args ...string) *exec.Cmd {
cs := []string{"-test.run=TestHelperProcess", "--"} // Args for test binary
cmd := exec.CommandContext(ctx, os.Args[0], cs...) // Run the test binary itself
cmd.Env = []string{ // Set env vars for the helper process
"GO_TEST_HELPER_PROCESS=1",
fmt.Sprintf("GO_TEST_HELPER_PROCESS_OUTPUT=%s", expectedOutput),
"GO_TEST_HELPER_PROCESS_WANT_ERROR=0",
}
return cmd
})
defer restoreExec()
// Replace the direct call to exec.Command with our context-aware version
// This requires modifying the code under test slightly, or ensuring execCommandContext is used.
// Assuming TransferExecutor uses execCommandContext internally (needs verification/refactor)
// For now, we proceed assuming the mock intercepts correctly.
// If TransferExecutor directly calls exec.Command, this mock won't work without refactoring TransferExecutor.
// Let's assume TransferExecutor needs refactoring to use execCommandContext.
// We'll add a TODO in the original code and proceed with the test logic.
// TODO: Refactor TransferExecutor to use execCommandContext instead of exec.Command
comps.executor.executeSimpleCommand(cmdName, cmdType, job, config, history, configPath)
// Assertions
comps.db.mu.Lock()
if comps.db.updatedHistory == nil {
t.Fatal("Expected UpdateJobHistory to be called, but it wasn't")
}
if comps.db.updatedHistory.Status != "completed" {
t.Errorf("Expected history status 'completed', got %q", comps.db.updatedHistory.Status)
}
// Note: FilesTransferred calculation based on output lines happens *after* CombinedOutput
// in the original code. Our mock simulates CombinedOutput directly.
// The test needs to align with how the code under test processes the output.
// For "listing", it counts lines.
if comps.db.updatedHistory.FilesTransferred != 2 { // Based on lines in expectedOutput
t.Errorf("Expected FilesTransferred 2, got %d", comps.db.updatedHistory.FilesTransferred)
}
if !strings.Contains(comps.db.updatedHistory.ErrorMessage, expectedOutput) {
t.Errorf("Expected history ErrorMessage to contain command output %q, got %q", expectedOutput, comps.db.updatedHistory.ErrorMessage)
}
comps.db.mu.Unlock()
comps.notifier.mu.Lock()
if len(comps.notifier.sendNotificationsCalls) != 1 {
t.Errorf("Expected 1 call to SendNotifications, got %d", len(comps.notifier.sendNotificationsCalls))
}
comps.notifier.mu.Unlock()
logOutput := comps.logBuf.String()
if !strings.Contains(logOutput, "Successfully executed command 'ls'") {
t.Errorf("Expected success log message, but got:\n%s", logOutput)
}
}
func TestExecuteSimpleCommand_Failure(t *testing.T) {
comps := setupTestExecutor()
defer comps.logger.Close()
job := db.Job{ID: 2, Name: "Fail Job"}
config := db.TransferConfig{ID: 20, SourceType: "local", SourcePath: "/src", DestinationType: "local", DestinationPath: "/dst"}
history := &db.JobHistory{ID: 200, JobID: 2, ConfigID: 20}
configPath := "/tmp/test_rclone.conf"
cmdName := "copy"
cmdType := "transfer"
expectedStderr := "Some rclone error"
// Mock exec.CommandContext using the TestHelperProcess strategy for failure
restoreExec := MockExecCommand(func(ctx context.Context, command string, args ...string) *exec.Cmd {
cs := []string{"-test.run=TestHelperProcess", "--"}
cmd := exec.CommandContext(ctx, os.Args[0], cs...)
cmd.Env = []string{
"GO_TEST_HELPER_PROCESS=1",
fmt.Sprintf("GO_TEST_HELPER_PROCESS_STDERR=%s", expectedStderr),
"GO_TEST_HELPER_PROCESS_WANT_ERROR=1", // Indicate failure
}
return cmd
})
defer restoreExec()
// TODO: Refactor TransferExecutor to use execCommandContext instead of exec.Command
comps.executor.executeSimpleCommand(cmdName, cmdType, job, config, history, configPath)
// Assertions
comps.db.mu.Lock()
if comps.db.updatedHistory == nil {
t.Fatal("Expected UpdateJobHistory to be called, but it wasn't")
}
if comps.db.updatedHistory.Status != "failed" {
t.Errorf("Expected history status 'failed', got %q", comps.db.updatedHistory.Status)
}
// The error message should contain the stderr output captured by CombinedOutput
if !strings.Contains(comps.db.updatedHistory.ErrorMessage, "Command Error:") || !strings.Contains(comps.db.updatedHistory.ErrorMessage, expectedStderr) {
t.Errorf("Expected history ErrorMessage to contain 'Command Error:' and stderr %q, got %q", expectedStderr, comps.db.updatedHistory.ErrorMessage)
}
comps.db.mu.Unlock()
comps.notifier.mu.Lock()
if len(comps.notifier.sendNotificationsCalls) != 1 {
t.Errorf("Expected 1 call to SendNotifications, got %d", len(comps.notifier.sendNotificationsCalls))
}
comps.notifier.mu.Unlock()
logOutput := comps.logBuf.String()
if !strings.Contains(logOutput, "Error executing command 'copy'") {
t.Errorf("Expected error log message, but got:\n%s", logOutput)
}
if !strings.Contains(logOutput, expectedStderr) {
t.Errorf("Expected stderr %q in log output, but got:\n%s", expectedStderr, logOutput)
}
}
// TODO: Add tests for executeConfigTransfer (file-by-file)
// - Success case
// - Error during lsjson
// - Error parsing lsjson
// - No files found
// - Error during individual file transfer
// - Skipping processed files (hash match)
// - Skipping processed files (name match, skip enabled)
// - Re-processing file (skip disabled)
// - Archiving success
// - Archiving failure
// - Deleting success
// - Deleting failure
// - Concurrent transfers limit
// - Output pattern usage
// - Filter usage
+77
View File
@@ -0,0 +1,77 @@
package scheduler
import (
"fmt"
"io/ioutil"
"path/filepath"
"regexp"
"strings"
"time"
)
// ProcessOutputPattern processes an output pattern with variables and returns the result
// This function is useful for testing pattern processing in isolation
func ProcessOutputPattern(pattern string, originalFilename string) string {
// Process date variables
dateRegex := regexp.MustCompile(`\${date:([^}]+)}`)
processedPattern := dateRegex.ReplaceAllStringFunc(pattern, func(match string) string {
format := dateRegex.FindStringSubmatch(match)[1]
return time.Now().Format(format)
})
// Split the filename and extension
ext := filepath.Ext(originalFilename)
filename := strings.TrimSuffix(originalFilename, ext)
// Replace filename and extension variables
processedPattern = strings.ReplaceAll(processedPattern, "${filename}", filename)
// Remove leading dot from ext before replacing
processedPattern = strings.ReplaceAll(processedPattern, "${ext}", strings.TrimPrefix(ext, "."))
return processedPattern
}
// createRcloneFilterFile creates a temporary filter file for rclone with rename rules
func createRcloneFilterFile(pattern string) (string, error) {
// Create a temporary file
tmpFile, err := ioutil.TempFile("", "rclone-filter-*.txt")
if err != nil {
return "", fmt.Errorf("failed to create temporary filter file: %v", err)
}
defer tmpFile.Close()
// Process the pattern to create a rclone filter rule
// First, replace date variables with current date in the specified format
dateRegex := regexp.MustCompile(`\${date:([^}]+)}`)
processedPattern := dateRegex.ReplaceAllStringFunc(pattern, func(match string) string {
format := dateRegex.FindStringSubmatch(match)[1]
return time.Now().Format(format)
})
// Replace filename and extension variables with rclone's capture group references
// For rclone rename filters, we need to use {1} for the first capture group, not $1
// See: https://rclone.org/filtering/#rename
// Extract filename without extension
processedPattern = strings.ReplaceAll(processedPattern, "${filename}", "{1}")
// Extract extension (with the dot)
processedPattern = strings.ReplaceAll(processedPattern, "${ext}", "{2}")
// Create a rename rule for rclone using the correct syntax:
// - The format for rename filters is: "-- SourceRegexp ReplacementPattern"
// - For files with extension: capture the name and extension separately
rule := fmt.Sprintf("-- (.*)(\\..+)$ %s\n", processedPattern) // Correct escaping for dot
// Add a fallback rule for files without extension
// Keep [^.] as it correctly excludes literal dot in character class
fallbackRule := fmt.Sprintf("-- ([^.]+)$ %s\n",
strings.ReplaceAll(processedPattern, "{2}", ""))
// Removed duplicate declaration below
// Write the rules to the file
if _, err := tmpFile.WriteString(rule + fallbackRule); err != nil {
return "", fmt.Errorf("failed to write to filter file: %v", err)
}
return tmpFile.Name(), nil
}
+165
View File
@@ -0,0 +1,165 @@
package scheduler
import (
"fmt"
"os"
"regexp"
"strings"
"testing"
"time"
)
func TestProcessOutputPattern(t *testing.T) {
now := time.Now()
tests := []struct {
name string
pattern string
originalFilename string
wantPatternRegex string // Use regex for date matching
}{
{
name: "Simple filename and extension",
pattern: "${filename}_processed.${ext}",
originalFilename: "myfile.txt",
wantPatternRegex: `^myfile_processed\.txt$`,
},
{
name: "Filename without extension",
pattern: "${filename}_backup",
originalFilename: "important_data",
wantPatternRegex: `^important_data_backup$`,
},
{
name: "Date formatting YYYYMMDD",
pattern: "${filename}_${date:20060102}.${ext}",
originalFilename: "report.csv",
wantPatternRegex: fmt.Sprintf(`^report_%s\.csv$`, now.Format("20060102")),
},
{
name: "Date formatting with time",
pattern: "${date:2006-01-02_150405}_${filename}.${ext}",
originalFilename: "image.jpg",
wantPatternRegex: fmt.Sprintf(`^%s_image\.jpg$`, now.Format("2006-01-02_150405")),
},
{
name: "Combined date, filename, extension",
pattern: "archive/${date:2006/01}/${filename}_${date:1504}.${ext}",
originalFilename: "document.pdf",
wantPatternRegex: fmt.Sprintf(`^archive/%s/document_%s\.pdf$`, now.Format("2006/01"), now.Format("1504")),
},
{
name: "No variables",
pattern: "fixed_output.dat",
originalFilename: "input.bin",
wantPatternRegex: `^fixed_output\.dat$`,
},
{
name: "Filename with multiple dots",
pattern: "${filename}.${ext}",
originalFilename: "archive.tar.gz",
wantPatternRegex: `^archive\.tar\.gz$`, // Ext should be .gz
},
{
name: "Pattern with only date",
pattern: "${date:2006}_backup",
originalFilename: "data.zip",
wantPatternRegex: fmt.Sprintf(`^%s_backup$`, now.Format("2006")),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Ensure the fix in utils.go (TrimPrefix) is present for this test to pass
got := ProcessOutputPattern(tt.pattern, tt.originalFilename)
matched, err := regexp.MatchString(tt.wantPatternRegex, got)
if err != nil {
t.Fatalf("Invalid regex pattern %q: %v", tt.wantPatternRegex, err)
}
if !matched {
t.Errorf("ProcessOutputPattern(%q, %q) = %q, want match for regex %q", tt.pattern, tt.originalFilename, got, tt.wantPatternRegex)
}
})
}
}
// Rewritten TestCreateRcloneFilterFile using parsing instead of regex matching
func TestCreateRcloneFilterFile(t *testing.T) {
now := time.Now()
tests := []struct {
name string
pattern string
wantReplacementRule string // Expected replacement part for the main rule
wantFallbackRule string // Expected replacement part for the fallback rule
wantErr bool
}{
{
name: "Simple rename with date",
pattern: "${date:20060102}_${filename}.${ext}",
wantReplacementRule: fmt.Sprintf("%s_{1}.{2}", now.Format("20060102")),
wantFallbackRule: fmt.Sprintf("%s_{1}.", now.Format("20060102")),
wantErr: false,
},
{
name: "Filename only",
pattern: "prefix_${filename}",
wantReplacementRule: "prefix_{1}",
wantFallbackRule: "prefix_{1}",
wantErr: false,
},
{
name: "Extension only (unlikely but test)",
pattern: "file.${ext}",
wantReplacementRule: "file.{2}",
wantFallbackRule: "file.", // Fallback has no {2}
wantErr: false,
},
{
name: "Complex pattern with slashes",
pattern: "processed/${date:2006/01}/${filename}_backup.${ext}",
wantReplacementRule: fmt.Sprintf("processed/%s/{1}_backup.{2}", now.Format("2006/01")),
wantFallbackRule: fmt.Sprintf("processed/%s/{1}_backup.", now.Format("2006/01")),
wantErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
filePath, err := createRcloneFilterFile(tt.pattern)
if (err != nil) != tt.wantErr {
t.Fatalf("createRcloneFilterFile() error = %v, wantErr %v", err, tt.wantErr)
return
}
if err != nil {
return // Expected error, test passed
}
defer os.Remove(filePath) // Clean up the temp file
contentBytes, readErr := os.ReadFile(filePath)
if readErr != nil {
t.Fatalf("Failed to read created filter file %q: %v", filePath, readErr)
}
content := string(contentBytes)
lines := strings.Split(strings.TrimSpace(content), "\n")
if len(lines) != 2 {
t.Fatalf("Expected 2 lines in filter file, got %d. Content:\n%s", len(lines), content)
}
// Define the expected source patterns literally
expectedSourcePattern1 := `(.*)(\..+)$`
expectedSourcePattern2 := `([^.]+)$`
// Validate first rule (with extension)
parts1 := strings.Fields(lines[0])
if len(parts1) != 3 || parts1[0] != "--" || parts1[1] != expectedSourcePattern1 || parts1[2] != tt.wantReplacementRule {
t.Errorf("Rule 1 mismatch:\n Got: %q\n Want: -- %s %s", lines[0], expectedSourcePattern1, tt.wantReplacementRule)
}
// Validate second rule (fallback without extension)
parts2 := strings.Fields(lines[1])
if len(parts2) != 3 || parts2[0] != "--" || parts2[1] != expectedSourcePattern2 || parts2[2] != tt.wantFallbackRule {
t.Errorf("Rule 2 (fallback) mismatch:\n Got: %q\n Want: -- %s %s", lines[1], expectedSourcePattern2, tt.wantFallbackRule)
}
})
}
}
+2 -2
View File
@@ -698,7 +698,7 @@ func (h *Handlers) HandleAuthProviderInit(c *gin.Context) {
// Get the auth provider // Get the auth provider
provider, err := h.DB.GetAuthProviderByID(c.Request.Context(), uint(providerID)) provider, err := h.DB.GetAuthProviderByID(c.Request.Context(), uint(providerID))
if err != nil || !provider.Enabled { if err != nil || !provider.GetEnabled() { // Use getter
h.HandleBadRequest(c, "Provider Not Available", "The authentication provider is not available") h.HandleBadRequest(c, "Provider Not Available", "The authentication provider is not available")
return return
} }
@@ -862,7 +862,7 @@ func (h *Handlers) HandleAuthProviderCallback(c *gin.Context) {
// Get the auth provider // Get the auth provider
provider, err := h.DB.GetAuthProviderByID(c.Request.Context(), uint(providerID)) provider, err := h.DB.GetAuthProviderByID(c.Request.Context(), uint(providerID))
if err != nil || !provider.Enabled { if err != nil || !provider.GetEnabled() { // Use getter
h.HandleBadRequest(c, "Provider Not Available", "The authentication provider is not available") h.HandleBadRequest(c, "Provider Not Available", "The authentication provider is not available")
return return
} }
@@ -99,8 +99,9 @@ func (h *Handlers) HandleCreateAuthProvider(c *gin.Context) {
Scopes: c.PostForm("scopes"), Scopes: c.PostForm("scopes"),
Description: c.PostForm("description"), Description: c.PostForm("description"),
IconURL: c.PostForm("icon_url"), IconURL: c.PostForm("icon_url"),
Enabled: c.PostForm("enabled") == "on", // Enabled will be set using the helper method below
} }
provider.SetEnabled(c.PostForm("enabled") == "on") // Use helper method
// Process config values based on provider type // Process config values based on provider type
config := make(map[string]interface{}) config := make(map[string]interface{})
@@ -211,7 +212,7 @@ func (h *Handlers) HandleUpdateAuthProvider(c *gin.Context) {
existingProvider.RedirectURL = c.PostForm("redirect_url") existingProvider.RedirectURL = c.PostForm("redirect_url")
existingProvider.Scopes = c.PostForm("scopes") existingProvider.Scopes = c.PostForm("scopes")
existingProvider.Description = c.PostForm("description") existingProvider.Description = c.PostForm("description")
existingProvider.Enabled = c.PostForm("enabled") == "on" existingProvider.SetEnabled(c.PostForm("enabled") == "on") // Use setter
// Update the type if changed // Update the type if changed
if providerType := c.PostForm("type"); providerType != "" { if providerType := c.PostForm("type"); providerType != "" {
+124 -2
View File
@@ -11,7 +11,9 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/starfleetcptn/gomft/components" "github.com/starfleetcptn/gomft/components"
"github.com/starfleetcptn/gomft/internal/db" "github.com/starfleetcptn/gomft/internal/db"
"github.com/starfleetcptn/gomft/internal/rclone_service" // Assuming we create this package
) )
// HandleConfigs handles the GET /configs route // HandleConfigs handles the GET /configs route
@@ -65,9 +67,43 @@ func (h *Handlers) HandleEditConfig(c *gin.Context) {
} }
} }
// Fetch the initial command details for pre-rendering flags
initialCommand, err := h.DB.GetRcloneCommandWithFlags(config.CommandID)
if err != nil {
// Log the error but proceed, the form might still be usable without pre-rendered flags
log.Printf("Warning: Failed to get initial command flags for config %d: %v", config.ID, err)
initialCommand = nil // Ensure it's nil if fetching failed
}
// Parse the selected flags and values from the config
selectedFlagsMap := make(map[uint]bool)
if config.CommandFlags != "" {
var selectedFlagIDs []uint
// Use json.Unmarshal directly as CommandFlags should be a JSON array string
if err := json.Unmarshal([]byte(config.CommandFlags), &selectedFlagIDs); err == nil {
for _, id := range selectedFlagIDs {
selectedFlagsMap[id] = true
}
} else {
log.Printf("Warning: Failed to unmarshal CommandFlags for config %d: %v. JSON: %s", config.ID, err, config.CommandFlags)
}
}
selectedFlagValues := make(map[uint]string)
if config.CommandFlagValues != "" {
// Use json.Unmarshal directly as CommandFlagValues should be a JSON object string
if err := json.Unmarshal([]byte(config.CommandFlagValues), &selectedFlagValues); err != nil {
log.Printf("Warning: Failed to unmarshal CommandFlagValues for config %d: %v. JSON: %s", config.ID, err, config.CommandFlagValues)
selectedFlagValues = make(map[uint]string) // Reset on error
}
}
data := components.ConfigFormData{ data := components.ConfigFormData{
Config: &config, Config: &config,
IsNew: false, IsNew: false,
InitialCommand: initialCommand,
SelectedFlagsMap: selectedFlagsMap,
SelectedFlagValues: selectedFlagValues,
} }
components.ConfigForm(c.Request.Context(), data).Render(c, c.Writer) components.ConfigForm(c.Request.Context(), data).Render(c, c.Writer)
} }
@@ -652,3 +688,89 @@ func (h *Handlers) HandleDuplicateConfig(c *gin.Context) {
c.Header("HX-Refresh", "true") c.Header("HX-Refresh", "true")
c.JSON(http.StatusOK, gin.H{"message": "Config duplicated successfully"}) c.JSON(http.StatusOK, gin.H{"message": "Config duplicated successfully"})
} }
// HandleTestProviderConnection handles the POST /configs/test-connection route
func (h *Handlers) HandleTestProviderConnection(c *gin.Context) {
var config db.TransferConfig
providerType := c.PostForm("providerType") // "source" or "destination"
// Bind all form data into a temporary config struct
// We don't save this, just use it to gather the necessary fields
if err := c.ShouldBind(&config); err != nil {
log.Printf("Error binding test connection form: %v", err)
// Render error using the TestResult component
components.TestResult(false, fmt.Sprintf("Invalid form data: %v", err)).Render(c, c.Writer)
return
}
// Process boolean fields manually as ShouldBind might not handle 'on' correctly for pointers
if providerType == "source" {
sourcePassiveModeVal := c.Request.FormValue("source_passive_mode")
sourcePassiveModeValue := sourcePassiveModeVal == "on" || sourcePassiveModeVal == "true"
config.SourcePassiveMode = &sourcePassiveModeValue
sourceReadOnlyVal := c.Request.FormValue("source_read_only")
sourceReadOnlyValue := sourceReadOnlyVal == "on" || sourceReadOnlyVal == "true"
config.SourceReadOnly = &sourceReadOnlyValue
sourceIncludeArchivedVal := c.Request.FormValue("source_include_archived")
sourceIncludeArchivedValue := sourceIncludeArchivedVal == "on" || sourceIncludeArchivedVal == "true"
config.SourceIncludeArchived = &sourceIncludeArchivedValue
useBuiltinAuthSourceVal := c.Request.FormValue("use_builtin_auth_source")
useBuiltinAuthSourceValue := useBuiltinAuthSourceVal == "on" || useBuiltinAuthSourceVal == "true"
config.UseBuiltinAuthSource = &useBuiltinAuthSourceValue
} else if providerType == "destination" {
destPassiveModeVal := c.Request.FormValue("dest_passive_mode")
destPassiveModeValue := destPassiveModeVal == "on" || destPassiveModeVal == "true"
config.DestPassiveMode = &destPassiveModeValue
destReadOnlyVal := c.Request.FormValue("dest_read_only")
destReadOnlyValue := destReadOnlyVal == "on" || destReadOnlyVal == "true"
config.DestReadOnly = &destReadOnlyValue
destIncludeArchivedVal := c.Request.FormValue("dest_include_archived")
destIncludeArchivedValue := destIncludeArchivedVal == "on" || destIncludeArchivedVal == "true"
config.DestIncludeArchived = &destIncludeArchivedValue
useBuiltinAuthDestVal := c.Request.FormValue("use_builtin_auth_dest")
useBuiltinAuthDestValue := useBuiltinAuthDestVal == "on" || useBuiltinAuthDestVal == "true"
config.UseBuiltinAuthDest = &useBuiltinAuthDestValue
}
// Call the rclone test function (to be implemented)
success, message, err := rclone_service.TestRcloneConnection(config, providerType, h.DB) // Pass DB if needed for built-in auth
toastType := "info" // Default type
if err != nil {
log.Printf("Error testing rclone connection: %v. Message: %s", err, message) // Log both err and message
toastType = "error"
// Use the message from TestRcloneConnection for the toast
} else if success {
toastType = "success"
} else {
// If no error but not success, treat as error/warning
toastType = "error"
}
// Prepare data for HX-Trigger
toastData := map[string]interface{}{
"showToast": map[string]string{
"message": message,
"type": toastType,
},
}
// Marshal data to JSON for the header
jsonData, err := json.Marshal(toastData)
if err != nil {
// Log the error, but maybe still try to send a basic trigger? Or just fail?
log.Printf("Error marshaling toast data for HX-Trigger: %v", err)
// Fallback or error handling - for now, just proceed without trigger maybe?
c.Status(http.StatusInternalServerError)
return
}
// Trigger toast notification on the frontend via HX-Trigger header
c.Header("HX-Trigger", string(jsonData))
c.Status(http.StatusOK) // Return 200 OK, but with no body swap intended
}
+139 -25
View File
@@ -7,6 +7,10 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/starfleetcptn/gomft/components" "github.com/starfleetcptn/gomft/components"
"github.com/starfleetcptn/gomft/components/file_metadata"
"github.com/starfleetcptn/gomft/components/file_metadata/details"
"github.com/starfleetcptn/gomft/components/file_metadata/list"
"github.com/starfleetcptn/gomft/components/file_metadata/search"
"github.com/starfleetcptn/gomft/internal/db" "github.com/starfleetcptn/gomft/internal/db"
) )
@@ -37,6 +41,26 @@ func (h *FileMetadataHandler) ListFileMetadata(c *gin.Context) {
status := c.Query("status") status := c.Query("status")
jobIDStr := c.Query("job_id") jobIDStr := c.Query("job_id")
fileName := c.Query("filename") fileName := c.Query("filename")
sortBy := c.DefaultQuery("sort_by", "processed_time")
sortDir := c.DefaultQuery("sort_dir", "desc")
// Validate sort parameters
allowedSortColumns := map[string]string{
"id": "file_metadata.id",
"filename": "file_metadata.file_name",
"size": "file_metadata.file_size",
"processed_time": "file_metadata.processed_time",
"status": "file_metadata.status",
}
dbColumn, ok := allowedSortColumns[sortBy]
if !ok {
sortBy = "processed_time" // Default sort column
dbColumn = allowedSortColumns[sortBy]
}
if sortDir != "asc" && sortDir != "desc" {
sortDir = "desc" // Default sort direction
}
orderClause := fmt.Sprintf("%s %s", dbColumn, sortDir)
// Base query // Base query
query := h.DB.DB.Model(&db.FileMetadata{}).Joins("JOIN jobs ON file_metadata.job_id = jobs.id") query := h.DB.DB.Model(&db.FileMetadata{}).Joins("JOIN jobs ON file_metadata.job_id = jobs.id")
@@ -66,7 +90,7 @@ func (h *FileMetadataHandler) ListFileMetadata(c *gin.Context) {
var fileMetadata []db.FileMetadata var fileMetadata []db.FileMetadata
offset := (page - 1) * limit offset := (page - 1) * limit
err := query.Preload("Job").Preload("Job.Config"). err := query.Preload("Job").Preload("Job.Config").
Order("file_metadata.processed_time DESC"). Order(orderClause). // Use dynamic order clause
Offset(offset).Limit(limit). Offset(offset).Limit(limit).
Find(&fileMetadata).Error Find(&fileMetadata).Error
@@ -79,17 +103,19 @@ func (h *FileMetadataHandler) ListFileMetadata(c *gin.Context) {
ctx := components.CreateTemplateContext(c) ctx := components.CreateTemplateContext(c)
// Render the file metadata list template // Render the file metadata list template
data := components.FileMetadataListData{ data := file_metadata.FileMetadataListData{
Files: fileMetadata, Files: fileMetadata,
TotalCount: totalCount, TotalCount: totalCount,
Page: page, Page: page,
Limit: limit, Limit: limit,
TotalPages: int(totalCount) / limit, TotalPages: int(totalCount) / limit,
Filter: components.FileMetadataFilter{ Filter: file_metadata.FileMetadataFilter{
Status: status, Status: status,
JobID: jobIDStr, JobID: jobIDStr,
FileName: fileName, FileName: fileName,
}, },
SortBy: sortBy, // Pass sorting info
SortDir: sortDir, // Pass sorting info
} }
// If total count is not exactly divisible by limit, add one more page // If total count is not exactly divisible by limit, add one more page
@@ -104,10 +130,10 @@ func (h *FileMetadataHandler) ListFileMetadata(c *gin.Context) {
if isHtmxRequest { if isHtmxRequest {
// For HTMX requests, render just the partial template // For HTMX requests, render just the partial template
components.FileMetadataListPartial(data).Render(ctx, c.Writer) list.FileMetadataListPartial(ctx, data, "/files/partial", "#file-list-container").Render(ctx, c.Writer)
} else { } else {
// For full page requests, render the complete template // For full page requests, render the complete template
components.FileMetadataList(ctx, data).Render(ctx, c.Writer) list.FileMetadataList(ctx, data).Render(ctx, c.Writer)
} }
} }
@@ -142,12 +168,12 @@ func (h *FileMetadataHandler) GetFileMetadataDetails(c *gin.Context) {
ctx := components.CreateTemplateContext(c) ctx := components.CreateTemplateContext(c)
// Render the file metadata details template // Render the file metadata details template
data := components.FileMetadataDetailsData{ data := file_metadata.FileMetadataDetailsData{
File: fileMetadata, File: fileMetadata,
} }
c.Header("Content-Type", "text/html") c.Header("Content-Type", "text/html")
components.FileMetadataDetails(ctx, data).Render(ctx, c.Writer) details.FileMetadataDetails(ctx, data).Render(ctx, c.Writer)
} }
// GetFileMetadataForJob displays file metadata for a specific job // GetFileMetadataForJob displays file metadata for a specific job
@@ -187,6 +213,26 @@ func (h *FileMetadataHandler) GetFileMetadataForJob(c *gin.Context) {
status := c.Query("status") status := c.Query("status")
fileName := c.Query("filename") fileName := c.Query("filename")
sortBy := c.DefaultQuery("sort_by", "processed_time")
sortDir := c.DefaultQuery("sort_dir", "desc")
// Validate sort parameters
allowedSortColumns := map[string]string{
"id": "file_metadata.id",
"filename": "file_metadata.file_name",
"size": "file_metadata.file_size",
"processed_time": "file_metadata.processed_time",
"status": "file_metadata.status",
}
dbColumn, ok := allowedSortColumns[sortBy]
if !ok {
sortBy = "processed_time" // Default sort column
dbColumn = allowedSortColumns[sortBy]
}
if sortDir != "asc" && sortDir != "desc" {
sortDir = "desc" // Default sort direction
}
orderClause := fmt.Sprintf("%s %s", dbColumn, sortDir)
// Base query // Base query
query := h.DB.DB.Model(&db.FileMetadata{}).Where("job_id = ?", jobID) query := h.DB.DB.Model(&db.FileMetadata{}).Where("job_id = ?", jobID)
@@ -208,7 +254,7 @@ func (h *FileMetadataHandler) GetFileMetadataForJob(c *gin.Context) {
var fileMetadata []db.FileMetadata var fileMetadata []db.FileMetadata
offset := (page - 1) * limit offset := (page - 1) * limit
err = query.Preload("Job").Preload("Job.Config"). err = query.Preload("Job").Preload("Job.Config").
Order("processed_time DESC"). Order(orderClause). // Use dynamic order clause
Offset(offset).Limit(limit). Offset(offset).Limit(limit).
Find(&fileMetadata).Error Find(&fileMetadata).Error
@@ -221,18 +267,20 @@ func (h *FileMetadataHandler) GetFileMetadataForJob(c *gin.Context) {
ctx := components.CreateTemplateContext(c) ctx := components.CreateTemplateContext(c)
// Render the file metadata list template // Render the file metadata list template
data := components.FileMetadataListData{ data := file_metadata.FileMetadataListData{
Files: fileMetadata, Files: fileMetadata,
TotalCount: totalCount, TotalCount: totalCount,
Page: page, Page: page,
Limit: limit, Limit: limit,
TotalPages: int(totalCount) / limit, TotalPages: int(totalCount) / limit,
Job: &job, Job: &job,
Filter: components.FileMetadataFilter{ Filter: file_metadata.FileMetadataFilter{
Status: status, Status: status,
JobID: strconv.FormatUint(uint64(job.ID), 10), JobID: strconv.FormatUint(uint64(job.ID), 10),
FileName: fileName, FileName: fileName,
}, },
SortBy: sortBy, // Pass sorting info
SortDir: sortDir, // Pass sorting info
} }
// If total count is not exactly divisible by limit, add one more page // If total count is not exactly divisible by limit, add one more page
@@ -247,10 +295,10 @@ func (h *FileMetadataHandler) GetFileMetadataForJob(c *gin.Context) {
if isHtmxRequest { if isHtmxRequest {
// For HTMX requests, render just the partial template // For HTMX requests, render just the partial template
components.FileMetadataListPartial(data).Render(ctx, c.Writer) list.FileMetadataListPartial(ctx, data, "/files/partial", "#file-list-container").Render(ctx, c.Writer)
} else { } else {
// For full page requests, render the complete template // For full page requests, render the complete template
components.FileMetadataList(ctx, data).Render(ctx, c.Writer) list.FileMetadataList(ctx, data).Render(ctx, c.Writer)
} }
} }
@@ -275,6 +323,26 @@ func (h *FileMetadataHandler) SearchFileMetadata(c *gin.Context) {
hash := c.Query("hash") hash := c.Query("hash")
startDate := c.Query("start_date") startDate := c.Query("start_date")
endDate := c.Query("end_date") endDate := c.Query("end_date")
sortBy := c.DefaultQuery("sort_by", "processed_time")
sortDir := c.DefaultQuery("sort_dir", "desc")
// Validate sort parameters
allowedSortColumns := map[string]string{
"id": "file_metadata.id",
"filename": "file_metadata.file_name",
"size": "file_metadata.file_size",
"processed_time": "file_metadata.processed_time",
"status": "file_metadata.status",
}
dbColumn, ok := allowedSortColumns[sortBy]
if !ok {
sortBy = "processed_time" // Default sort column
dbColumn = allowedSortColumns[sortBy]
}
if sortDir != "asc" && sortDir != "desc" {
sortDir = "desc" // Default sort direction
}
orderClause := fmt.Sprintf("%s %s", dbColumn, sortDir)
// Base query // Base query
query := h.DB.DB.Model(&db.FileMetadata{}).Joins("JOIN jobs ON file_metadata.job_id = jobs.id") query := h.DB.DB.Model(&db.FileMetadata{}).Joins("JOIN jobs ON file_metadata.job_id = jobs.id")
@@ -316,7 +384,7 @@ func (h *FileMetadataHandler) SearchFileMetadata(c *gin.Context) {
var fileMetadata []db.FileMetadata var fileMetadata []db.FileMetadata
offset := (page - 1) * limit offset := (page - 1) * limit
err := query.Preload("Job").Preload("Job.Config"). err := query.Preload("Job").Preload("Job.Config").
Order("file_metadata.processed_time DESC"). Order(orderClause). // Use dynamic order clause
Offset(offset).Limit(limit). Offset(offset).Limit(limit).
Find(&fileMetadata).Error Find(&fileMetadata).Error
@@ -326,13 +394,13 @@ func (h *FileMetadataHandler) SearchFileMetadata(c *gin.Context) {
} }
// Render the file metadata search template // Render the file metadata search template
data := components.FileMetadataSearchData{ data := file_metadata.FileMetadataSearchData{
Files: fileMetadata, Files: fileMetadata,
TotalCount: totalCount, TotalCount: totalCount,
Page: page, Page: page,
Limit: limit, Limit: limit,
TotalPages: int(totalCount) / limit, TotalPages: int(totalCount) / limit,
Filter: components.FileMetadataFilter{ Filter: file_metadata.FileMetadataFilter{
Status: status, Status: status,
JobID: jobIDStr, JobID: jobIDStr,
FileName: fileName, FileName: fileName,
@@ -340,6 +408,8 @@ func (h *FileMetadataHandler) SearchFileMetadata(c *gin.Context) {
StartDate: startDate, StartDate: startDate,
EndDate: endDate, EndDate: endDate,
}, },
SortBy: sortBy, // Pass sorting info
SortDir: sortDir, // Pass sorting info
} }
// If total count is not exactly divisible by limit, add one more page // If total count is not exactly divisible by limit, add one more page
@@ -357,10 +427,10 @@ func (h *FileMetadataHandler) SearchFileMetadata(c *gin.Context) {
if isHtmxRequest { if isHtmxRequest {
// For HTMX requests, render just the partial template // For HTMX requests, render just the partial template
components.FileMetadataSearchContent(data).Render(ctx, c.Writer) search.FileMetadataSearchContent(ctx, data).Render(ctx, c.Writer)
} else { } else {
// For full page requests, render the complete template // For full page requests, render the complete template
components.FileMetadataSearch(ctx, data).Render(ctx, c.Writer) search.FileMetadataSearch(ctx, data).Render(ctx, c.Writer)
} }
} }
@@ -444,6 +514,26 @@ func (h *FileMetadataHandler) HandleFileMetadataPartial(c *gin.Context) {
status := c.Query("status") status := c.Query("status")
jobIDStr := c.Query("job_id") jobIDStr := c.Query("job_id")
fileName := c.Query("filename") fileName := c.Query("filename")
sortBy := c.DefaultQuery("sort_by", "processed_time")
sortDir := c.DefaultQuery("sort_dir", "desc")
// Validate sort parameters
allowedSortColumns := map[string]string{
"id": "file_metadata.id",
"filename": "file_metadata.file_name",
"size": "file_metadata.file_size",
"processed_time": "file_metadata.processed_time",
"status": "file_metadata.status",
}
dbColumn, ok := allowedSortColumns[sortBy]
if !ok {
sortBy = "processed_time" // Default sort column
dbColumn = allowedSortColumns[sortBy]
}
if sortDir != "asc" && sortDir != "desc" {
sortDir = "desc" // Default sort direction
}
orderClause := fmt.Sprintf("%s %s", dbColumn, sortDir)
// Base query // Base query
query := h.DB.DB.Model(&db.FileMetadata{}).Joins("JOIN jobs ON file_metadata.job_id = jobs.id") query := h.DB.DB.Model(&db.FileMetadata{}).Joins("JOIN jobs ON file_metadata.job_id = jobs.id")
@@ -473,7 +563,7 @@ func (h *FileMetadataHandler) HandleFileMetadataPartial(c *gin.Context) {
var fileMetadata []db.FileMetadata var fileMetadata []db.FileMetadata
offset := (page - 1) * limit offset := (page - 1) * limit
err := query.Preload("Job").Preload("Job.Config"). err := query.Preload("Job").Preload("Job.Config").
Order("file_metadata.processed_time DESC"). Order(orderClause). // Use dynamic order clause
Offset(offset).Limit(limit). Offset(offset).Limit(limit).
Find(&fileMetadata).Error Find(&fileMetadata).Error
@@ -496,18 +586,20 @@ func (h *FileMetadataHandler) HandleFileMetadataPartial(c *gin.Context) {
} }
// Render the file metadata list template // Render the file metadata list template
data := components.FileMetadataListData{ data := file_metadata.FileMetadataListData{
Files: fileMetadata, Files: fileMetadata,
TotalCount: totalCount, TotalCount: totalCount,
Page: page, Page: page,
Limit: limit, Limit: limit,
TotalPages: int(totalCount) / limit, TotalPages: int(totalCount) / limit,
Job: job, Job: job,
Filter: components.FileMetadataFilter{ Filter: file_metadata.FileMetadataFilter{
Status: status, Status: status,
JobID: jobIDStr, JobID: jobIDStr,
FileName: fileName, FileName: fileName,
}, },
SortBy: sortBy, // Pass sorting info
SortDir: sortDir, // Pass sorting info
} }
// If total count is not exactly divisible by limit, add one more page // If total count is not exactly divisible by limit, add one more page
@@ -516,7 +608,7 @@ func (h *FileMetadataHandler) HandleFileMetadataPartial(c *gin.Context) {
} }
c.Header("Content-Type", "text/html") c.Header("Content-Type", "text/html")
components.FileMetadataListPartial(data).Render(ctx, c.Writer) list.FileMetadataListPartial(ctx, data, "/files/partial", "#file-list-container").Render(ctx, c.Writer)
} }
// HandleFileMetadataSearchPartial handles partial updates for search results // HandleFileMetadataSearchPartial handles partial updates for search results
@@ -553,6 +645,26 @@ func (h *FileMetadataHandler) HandleFileMetadataSearchPartial(c *gin.Context) {
hash := c.Query("hash") hash := c.Query("hash")
startDate := c.Query("start_date") startDate := c.Query("start_date")
endDate := c.Query("end_date") endDate := c.Query("end_date")
sortBy := c.DefaultQuery("sort_by", "processed_time")
sortDir := c.DefaultQuery("sort_dir", "desc")
// Validate sort parameters
allowedSortColumns := map[string]string{
"id": "file_metadata.id",
"filename": "file_metadata.file_name",
"size": "file_metadata.file_size",
"processed_time": "file_metadata.processed_time",
"status": "file_metadata.status",
}
dbColumn, ok := allowedSortColumns[sortBy]
if !ok {
sortBy = "processed_time" // Default sort column
dbColumn = allowedSortColumns[sortBy]
}
if sortDir != "asc" && sortDir != "desc" {
sortDir = "desc" // Default sort direction
}
orderClause := fmt.Sprintf("%s %s", dbColumn, sortDir)
// Execute the search query // Execute the search query
query := h.DB.DB.Model(&db.FileMetadata{}).Joins("JOIN jobs ON file_metadata.job_id = jobs.id") query := h.DB.DB.Model(&db.FileMetadata{}).Joins("JOIN jobs ON file_metadata.job_id = jobs.id")
@@ -597,7 +709,7 @@ func (h *FileMetadataHandler) HandleFileMetadataSearchPartial(c *gin.Context) {
var files []db.FileMetadata var files []db.FileMetadata
if err := query. if err := query.
Preload("Job"). Preload("Job").
Order("file_metadata.processed_time DESC"). Order(orderClause). // Use dynamic order clause
Limit(limit). Limit(limit).
Offset((page - 1) * limit). Offset((page - 1) * limit).
Find(&files).Error; err != nil { Find(&files).Error; err != nil {
@@ -606,13 +718,13 @@ func (h *FileMetadataHandler) HandleFileMetadataSearchPartial(c *gin.Context) {
} }
// Build the template data // Build the template data
data := components.FileMetadataSearchData{ data := file_metadata.FileMetadataSearchData{
Files: files, Files: files,
TotalCount: totalCount, TotalCount: totalCount,
Page: page, Page: page,
Limit: limit, Limit: limit,
TotalPages: int(totalCount) / limit, TotalPages: int(totalCount) / limit,
Filter: components.FileMetadataFilter{ Filter: file_metadata.FileMetadataFilter{
Status: status, Status: status,
JobID: jobIDStr, JobID: jobIDStr,
FileName: fileName, FileName: fileName,
@@ -620,6 +732,8 @@ func (h *FileMetadataHandler) HandleFileMetadataSearchPartial(c *gin.Context) {
StartDate: startDate, StartDate: startDate,
EndDate: endDate, EndDate: endDate,
}, },
SortBy: sortBy, // Pass sorting info
SortDir: sortDir, // Pass sorting info
} }
if int(totalCount)%limit > 0 { if int(totalCount)%limit > 0 {
@@ -630,5 +744,5 @@ func (h *FileMetadataHandler) HandleFileMetadataSearchPartial(c *gin.Context) {
ctx := components.CreateTemplateContext(c) ctx := components.CreateTemplateContext(c)
c.Header("Content-Type", "text/html") c.Header("Content-Type", "text/html")
components.FileMetadataSearchContent(data).Render(ctx, c.Writer) search.FileMetadataSearchContent(ctx, data).Render(ctx, c.Writer)
} }
+39 -2
View File
@@ -1,6 +1,7 @@
package handlers package handlers
import ( import (
"encoding/json"
"html/template" "html/template"
"log" "log"
"net/http" "net/http"
@@ -26,6 +27,14 @@ func NewRcloneHandler(db *db.DB) *RcloneHandler {
// RcloneCommandOptions renders the rclone command options for the config form // RcloneCommandOptions renders the rclone command options for the config form
func (h *RcloneHandler) RcloneCommandOptions(c *gin.Context) { func (h *RcloneHandler) RcloneCommandOptions(c *gin.Context) {
// Get the current command ID if provided (for pre-selection)
currentCommandIDStr := c.DefaultQuery("commandId", "1") // Default to 1 (copy)
currentCommandID, err := strconv.ParseUint(currentCommandIDStr, 10, 64)
if err != nil {
log.Printf("Error parsing current command ID: %v, using default 1", err)
currentCommandID = 1
}
commands, err := h.DB.GetRcloneCommands() commands, err := h.DB.GetRcloneCommands()
if err != nil { if err != nil {
log.Printf("Error getting rclone commands: %v", err) log.Printf("Error getting rclone commands: %v", err)
@@ -47,12 +56,20 @@ func (h *RcloneHandler) RcloneCommandOptions(c *gin.Context) {
categoryMap[cmd.Category] = append(categoryMap[cmd.Category], cmd) categoryMap[cmd.Category] = append(categoryMap[cmd.Category], cmd)
} }
_ = common.RcloneCommandOptionsContent(categoryMap, categories).Render(c.Request.Context(), c.Writer) // Get the initial flag JSON strings passed from the placeholder's hx-vals
commandFlagsJSON := c.DefaultQuery("commandFlags", "[]")
commandFlagValuesJSON := c.DefaultQuery("commandFlagValues", "{}")
// Pass the current command ID and flag JSON strings to the template
_ = common.RcloneCommandOptionsContent(categoryMap, categories, uint(currentCommandID), commandFlagsJSON, commandFlagValuesJSON).Render(c.Request.Context(), c.Writer)
} }
// RcloneCommandFlags renders the rclone command flags for the selected command // RcloneCommandFlags renders the rclone command flags for the selected command
func (h *RcloneHandler) RcloneCommandFlags(c *gin.Context) { func (h *RcloneHandler) RcloneCommandFlags(c *gin.Context) {
commandIDStr := c.DefaultQuery("command_id", "") commandIDStr := c.DefaultQuery("command_id", "")
commandFlagsJSON := c.DefaultQuery("commandFlags", "[]") // Get selected flag IDs JSON
commandFlagValuesJSON := c.DefaultQuery("commandFlagValues", "{}") // Get selected flag values JSON
if commandIDStr == "" { if commandIDStr == "" {
c.String(http.StatusBadRequest, "Command ID is required") c.String(http.StatusBadRequest, "Command ID is required")
return return
@@ -72,6 +89,25 @@ func (h *RcloneHandler) RcloneCommandFlags(c *gin.Context) {
return return
} }
// Parse the selected flags and values
var selectedFlagIDs []uint
if err := json.Unmarshal([]byte(commandFlagsJSON), &selectedFlagIDs); err != nil {
log.Printf("Error unmarshaling commandFlags JSON: %v, JSON: %s", err, commandFlagsJSON)
// Don't fail, just proceed with no flags selected
selectedFlagIDs = []uint{}
}
selectedFlagsMap := make(map[uint]bool)
for _, id := range selectedFlagIDs {
selectedFlagsMap[id] = true
}
var selectedFlagValues map[uint]string
if err := json.Unmarshal([]byte(commandFlagValuesJSON), &selectedFlagValues); err != nil {
log.Printf("Error unmarshaling commandFlagValues JSON: %v, JSON: %s", err, commandFlagValuesJSON)
// Don't fail, just proceed with no values
selectedFlagValues = make(map[uint]string)
}
if command == nil { if command == nil {
c.String(http.StatusNotFound, "Command not found") c.String(http.StatusNotFound, "Command not found")
return return
@@ -82,7 +118,8 @@ func (h *RcloneHandler) RcloneCommandFlags(c *gin.Context) {
return command.Flags[i].Name < command.Flags[j].Name return command.Flags[i].Name < command.Flags[j].Name
}) })
_ = common.RcloneCommandFlagsContent(command).Render(c.Request.Context(), c.Writer) // Pass the parsed selected flags and values to the template
_ = common.RcloneCommandFlagsContent(command, selectedFlagsMap, selectedFlagValues).Render(c.Request.Context(), c.Writer)
} }
// RcloneCommandUsage renders the usage information for a command // RcloneCommandUsage renders the usage information for a command
+1
View File
@@ -57,6 +57,7 @@ func (h *Handlers) RegisterRoutes(router *gin.Engine) {
authorized.POST("/configs/:id", h.HandleUpdateConfig) authorized.POST("/configs/:id", h.HandleUpdateConfig)
authorized.DELETE("/configs/:id", h.HandleDeleteConfig) authorized.DELETE("/configs/:id", h.HandleDeleteConfig)
authorized.POST("/configs/:id/duplicate", h.HandleDuplicateConfig) authorized.POST("/configs/:id/duplicate", h.HandleDuplicateConfig)
authorized.POST("/configs/test-connection", h.HandleTestProviderConnection)
// Path validation endpoint // Path validation endpoint
authorized.GET("/check-path", h.HandleCheckPath) authorized.GET("/check-path", h.HandleCheckPath)
+105 -163
View File
@@ -17,6 +17,9 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/starfleetcptn/gomft/components" "github.com/starfleetcptn/gomft/components"
"github.com/starfleetcptn/gomft/components/notifications/form"
"github.com/starfleetcptn/gomft/components/notifications/list"
"github.com/starfleetcptn/gomft/components/notifications/types"
"github.com/starfleetcptn/gomft/internal/db" "github.com/starfleetcptn/gomft/internal/db"
) )
@@ -40,7 +43,7 @@ func (h *Handlers) HandleSettings(c *gin.Context) {
ID: service.ID, ID: service.ID,
Name: service.Name, Name: service.Name,
Type: service.Type, Type: service.Type,
IsEnabled: service.IsEnabled, IsEnabled: service.GetIsEnabled(), // Use getter
Config: service.Config, Config: service.Config,
Description: service.Description, Description: service.Description,
EventTriggers: service.EventTriggers, EventTriggers: service.EventTriggers,
@@ -73,6 +76,8 @@ func (h *Handlers) HandleCreateNotificationService(c *gin.Context) {
serviceType := c.PostForm("type") serviceType := c.PostForm("type")
description := c.PostForm("description") description := c.PostForm("description")
isEnabled := c.PostForm("is_enabled") == "on" isEnabled := c.PostForm("is_enabled") == "on"
// Read event triggers directly from the form array
eventTriggers := c.PostFormArray("event_triggers[]")
// Validate required fields // Validate required fields
if name == "" || serviceType == "" { if name == "" || serviceType == "" {
@@ -83,7 +88,7 @@ func (h *Handlers) HandleCreateNotificationService(c *gin.Context) {
// Create config map based on service type // Create config map based on service type
config := make(map[string]string) config := make(map[string]string)
eventTriggers := make([]string, 0) // eventTriggers slice is now populated above
fmt.Println("serviceType", serviceType) fmt.Println("serviceType", serviceType)
switch serviceType { switch serviceType {
case "email": case "email":
@@ -99,16 +104,7 @@ func (h *Handlers) HandleCreateNotificationService(c *gin.Context) {
config["title_template"] = c.PostForm("pushbullet_title_template") config["title_template"] = c.PostForm("pushbullet_title_template")
config["body_template"] = c.PostForm("pushbullet_body_template") config["body_template"] = c.PostForm("pushbullet_body_template")
// Build event triggers // Event triggers are handled above
if c.PostForm("trigger_job_start") == "on" {
eventTriggers = append(eventTriggers, "job_start")
}
if c.PostForm("trigger_job_complete") == "on" {
eventTriggers = append(eventTriggers, "job_complete")
}
if c.PostForm("trigger_job_error") == "on" {
eventTriggers = append(eventTriggers, "job_error")
}
// Validate required fields // Validate required fields
if config["api_key"] == "" { if config["api_key"] == "" {
@@ -118,14 +114,15 @@ func (h *Handlers) HandleCreateNotificationService(c *gin.Context) {
// Create new notification service // Create new notification service
service := db.NotificationService{ service := db.NotificationService{
Name: name, Name: name,
Type: serviceType, Type: serviceType,
IsEnabled: isEnabled, // IsEnabled will be set using the helper method below
Config: config, Config: config,
Description: description, Description: description,
EventTriggers: eventTriggers, EventTriggers: eventTriggers,
CreatedBy: c.GetUint("userID"), CreatedBy: c.GetUint("userID"),
} }
service.SetIsEnabled(isEnabled) // Use helper method
// Save to database // Save to database
if err := h.DB.Create(&service).Error; err != nil { if err := h.DB.Create(&service).Error; err != nil {
@@ -138,7 +135,7 @@ func (h *Handlers) HandleCreateNotificationService(c *gin.Context) {
auditDetails := map[string]interface{}{ auditDetails := map[string]interface{}{
"name": service.Name, "name": service.Name,
"type": service.Type, "type": service.Type,
"is_enabled": service.IsEnabled, "is_enabled": service.GetIsEnabled(), // Use getter
"description": service.Description, "description": service.Description,
"event_triggers": eventTriggers, "event_triggers": eventTriggers,
} }
@@ -168,16 +165,7 @@ func (h *Handlers) HandleCreateNotificationService(c *gin.Context) {
config["title_template"] = c.PostForm("ntfy_title_template") config["title_template"] = c.PostForm("ntfy_title_template")
config["message_template"] = c.PostForm("ntfy_message_template") config["message_template"] = c.PostForm("ntfy_message_template")
// Build event triggers // Event triggers are handled above
if c.PostForm("trigger_job_start") == "on" {
eventTriggers = append(eventTriggers, "job_start")
}
if c.PostForm("trigger_job_complete") == "on" {
eventTriggers = append(eventTriggers, "job_complete")
}
if c.PostForm("trigger_job_error") == "on" {
eventTriggers = append(eventTriggers, "job_error")
}
// Validate required fields // Validate required fields
if config["server"] == "" || config["topic"] == "" { if config["server"] == "" || config["topic"] == "" {
@@ -187,14 +175,15 @@ func (h *Handlers) HandleCreateNotificationService(c *gin.Context) {
// Create new notification service // Create new notification service
service := db.NotificationService{ service := db.NotificationService{
Name: name, Name: name,
Type: serviceType, Type: serviceType,
IsEnabled: isEnabled, // IsEnabled will be set using the helper method below
Config: config, Config: config,
Description: description, Description: description,
EventTriggers: eventTriggers, EventTriggers: eventTriggers,
CreatedBy: c.GetUint("userID"), CreatedBy: c.GetUint("userID"),
} }
service.SetIsEnabled(isEnabled) // Use helper method
// Save to database // Save to database
if err := h.DB.Create(&service).Error; err != nil { if err := h.DB.Create(&service).Error; err != nil {
@@ -207,7 +196,7 @@ func (h *Handlers) HandleCreateNotificationService(c *gin.Context) {
auditDetails := map[string]interface{}{ auditDetails := map[string]interface{}{
"name": service.Name, "name": service.Name,
"type": service.Type, "type": service.Type,
"is_enabled": service.IsEnabled, "is_enabled": service.GetIsEnabled(), // Use getter
"description": service.Description, "description": service.Description,
"event_triggers": eventTriggers, "event_triggers": eventTriggers,
} }
@@ -235,16 +224,7 @@ func (h *Handlers) HandleCreateNotificationService(c *gin.Context) {
config["title_template"] = c.PostForm("gotify_title_template") config["title_template"] = c.PostForm("gotify_title_template")
config["message_template"] = c.PostForm("gotify_message_template") config["message_template"] = c.PostForm("gotify_message_template")
// Build event triggers // Event triggers are handled above
if c.PostForm("trigger_job_start") == "on" {
eventTriggers = append(eventTriggers, "job_start")
}
if c.PostForm("trigger_job_complete") == "on" {
eventTriggers = append(eventTriggers, "job_complete")
}
if c.PostForm("trigger_job_error") == "on" {
eventTriggers = append(eventTriggers, "job_error")
}
// Validate required fields // Validate required fields
if config["url"] == "" || config["token"] == "" { if config["url"] == "" || config["token"] == "" {
@@ -254,14 +234,15 @@ func (h *Handlers) HandleCreateNotificationService(c *gin.Context) {
// Create new notification service // Create new notification service
service := db.NotificationService{ service := db.NotificationService{
Name: name, Name: name,
Type: serviceType, Type: serviceType,
IsEnabled: isEnabled, // IsEnabled will be set using the helper method below
Config: config, Config: config,
Description: description, Description: description,
EventTriggers: eventTriggers, EventTriggers: eventTriggers,
CreatedBy: c.GetUint("userID"), CreatedBy: c.GetUint("userID"),
} }
service.SetIsEnabled(isEnabled) // Use helper method
// Save to database // Save to database
if err := h.DB.Create(&service).Error; err != nil { if err := h.DB.Create(&service).Error; err != nil {
@@ -274,7 +255,7 @@ func (h *Handlers) HandleCreateNotificationService(c *gin.Context) {
auditDetails := map[string]interface{}{ auditDetails := map[string]interface{}{
"name": service.Name, "name": service.Name,
"type": service.Type, "type": service.Type,
"is_enabled": service.IsEnabled, "is_enabled": service.GetIsEnabled(), // Use getter
"description": service.Description, "description": service.Description,
"event_triggers": eventTriggers, "event_triggers": eventTriggers,
} }
@@ -304,16 +285,7 @@ func (h *Handlers) HandleCreateNotificationService(c *gin.Context) {
config["title_template"] = c.PostForm("pushover_title_template") config["title_template"] = c.PostForm("pushover_title_template")
config["message_template"] = c.PostForm("pushover_message_template") config["message_template"] = c.PostForm("pushover_message_template")
// Build event triggers // Event triggers are handled above
if c.PostForm("trigger_job_start") == "on" {
eventTriggers = append(eventTriggers, "job_start")
}
if c.PostForm("trigger_job_complete") == "on" {
eventTriggers = append(eventTriggers, "job_complete")
}
if c.PostForm("trigger_job_error") == "on" {
eventTriggers = append(eventTriggers, "job_error")
}
// Validate required fields // Validate required fields
if config["app_token"] == "" || config["user_key"] == "" { if config["app_token"] == "" || config["user_key"] == "" {
@@ -323,14 +295,15 @@ func (h *Handlers) HandleCreateNotificationService(c *gin.Context) {
// Create new notification service // Create new notification service
service := db.NotificationService{ service := db.NotificationService{
Name: name, Name: name,
Type: serviceType, Type: serviceType,
IsEnabled: isEnabled, // IsEnabled will be set using the helper method below
Config: config, Config: config,
Description: description, Description: description,
EventTriggers: eventTriggers, EventTriggers: eventTriggers,
CreatedBy: c.GetUint("userID"), CreatedBy: c.GetUint("userID"),
} }
service.SetIsEnabled(isEnabled) // Use helper method
// Save to database // Save to database
if err := h.DB.Create(&service).Error; err != nil { if err := h.DB.Create(&service).Error; err != nil {
@@ -343,7 +316,7 @@ func (h *Handlers) HandleCreateNotificationService(c *gin.Context) {
auditDetails := map[string]interface{}{ auditDetails := map[string]interface{}{
"name": service.Name, "name": service.Name,
"type": service.Type, "type": service.Type,
"is_enabled": service.IsEnabled, "is_enabled": service.GetIsEnabled(), // Use getter
"description": service.Description, "description": service.Description,
"event_triggers": eventTriggers, "event_triggers": eventTriggers,
} }
@@ -370,26 +343,13 @@ func (h *Handlers) HandleCreateNotificationService(c *gin.Context) {
config["headers"] = c.PostForm("headers") config["headers"] = c.PostForm("headers")
// Add the new webhook fields // Add the new webhook fields
// Create event triggers array // Event triggers are handled above
// print all event triggers
log.Printf("Event triggers: %v", c.PostForm("trigger_job_start"))
log.Printf("Event triggers: %v", c.PostForm("trigger_job_complete"))
log.Printf("Event triggers: %v", c.PostForm("trigger_job_error"))
if c.PostForm("trigger_job_start") == "on" {
eventTriggers = append(eventTriggers, "job_start")
}
if c.PostForm("trigger_job_complete") == "on" {
eventTriggers = append(eventTriggers, "job_complete")
}
if c.PostForm("trigger_job_error") == "on" {
eventTriggers = append(eventTriggers, "job_error")
}
// Create new notification service with additional fields // Create new notification service with additional fields
service := db.NotificationService{ service := db.NotificationService{
Name: name, Name: name,
Type: serviceType, Type: serviceType,
IsEnabled: isEnabled, // IsEnabled will be set using the helper method below
Config: config, Config: config,
Description: description, Description: description,
EventTriggers: eventTriggers, EventTriggers: eventTriggers,
@@ -398,6 +358,7 @@ func (h *Handlers) HandleCreateNotificationService(c *gin.Context) {
RetryPolicy: c.PostForm("retry_policy"), RetryPolicy: c.PostForm("retry_policy"),
CreatedBy: c.GetUint("userID"), CreatedBy: c.GetUint("userID"),
} }
service.SetIsEnabled(isEnabled) // Use helper method
// Save to database // Save to database
if err := h.DB.Create(&service).Error; err != nil { if err := h.DB.Create(&service).Error; err != nil {
@@ -410,7 +371,7 @@ func (h *Handlers) HandleCreateNotificationService(c *gin.Context) {
auditDetails := map[string]interface{}{ auditDetails := map[string]interface{}{
"name": service.Name, "name": service.Name,
"type": service.Type, "type": service.Type,
"is_enabled": service.IsEnabled, "is_enabled": service.GetIsEnabled(), // Use getter
"description": service.Description, "description": service.Description,
"event_triggers": eventTriggers, "event_triggers": eventTriggers,
"retry_policy": service.RetryPolicy, "retry_policy": service.RetryPolicy,
@@ -439,13 +400,14 @@ func (h *Handlers) HandleCreateNotificationService(c *gin.Context) {
// Create new notification service // Create new notification service
service := db.NotificationService{ service := db.NotificationService{
Name: name, Name: name,
Type: serviceType, Type: serviceType,
IsEnabled: isEnabled, // IsEnabled will be set using the helper method below
Config: config, Config: config,
Description: description, Description: description,
CreatedBy: c.GetUint("userID"), CreatedBy: c.GetUint("userID"),
} }
service.SetIsEnabled(isEnabled) // Use helper method
// Save to database // Save to database
if err := h.DB.Create(&service).Error; err != nil { if err := h.DB.Create(&service).Error; err != nil {
@@ -458,7 +420,7 @@ func (h *Handlers) HandleCreateNotificationService(c *gin.Context) {
auditDetails := map[string]interface{}{ auditDetails := map[string]interface{}{
"name": service.Name, "name": service.Name,
"type": service.Type, "type": service.Type,
"is_enabled": service.IsEnabled, "is_enabled": service.GetIsEnabled(), // Use getter
"description": service.Description, "description": service.Description,
} }
@@ -1133,13 +1095,13 @@ func (h *Handlers) HandleNotificationsPage(c *gin.Context) {
} }
// Convert to components.NotificationService // Convert to components.NotificationService
var componentServices []components.NotificationService var componentServices []types.NotificationServiceData // Use types.NotificationServiceData
for _, service := range notificationServices { for _, service := range notificationServices {
componentServices = append(componentServices, components.NotificationService{ componentServices = append(componentServices, types.NotificationServiceData{ // Use types.NotificationServiceData
ID: service.ID, ID: service.ID,
Name: service.Name, Name: service.Name,
Type: service.Type, Type: service.Type,
IsEnabled: service.IsEnabled, IsEnabled: service.GetIsEnabled(), // Use getter
Config: service.Config, Config: service.Config,
Description: service.Description, Description: service.Description,
EventTriggers: service.EventTriggers, EventTriggers: service.EventTriggers,
@@ -1151,12 +1113,12 @@ func (h *Handlers) HandleNotificationsPage(c *gin.Context) {
}) })
} }
data := components.SettingsNotificationsData{ data := types.SettingsNotificationsData{ // Use types.SettingsNotificationsData
NotificationServices: componentServices, NotificationServices: componentServices,
} }
ctx := h.CreateTemplateContext(c) ctx := h.CreateTemplateContext(c)
components.Notifications(ctx, data).Render(ctx, c.Writer) list.List(ctx, data).Render(ctx, c.Writer) // Use list.List
} }
// HandleNewNotificationPage handles GET /admin/settings/notifications/new // HandleNewNotificationPage handles GET /admin/settings/notifications/new
@@ -1167,7 +1129,7 @@ func (h *Handlers) HandleNewNotificationPage(c *gin.Context) {
return return
} }
data := components.NotificationFormData{ data := types.NotificationFormData{ // Use types.NotificationFormData
IsNew: true, IsNew: true,
NotificationService: &struct { NotificationService: &struct {
ID uint ID uint
@@ -1209,7 +1171,7 @@ func (h *Handlers) HandleNewNotificationPage(c *gin.Context) {
} }
ctx := h.CreateTemplateContext(c) ctx := h.CreateTemplateContext(c)
components.NotificationForm(ctx, data).Render(ctx, c.Writer) form.NotificationForm(ctx, data).Render(ctx, c.Writer) // Use form.NotificationForm
} }
// HandleEditNotificationPage handles GET /admin/settings/notifications/:id/edit // HandleEditNotificationPage handles GET /admin/settings/notifications/:id/edit
@@ -1235,7 +1197,7 @@ func (h *Handlers) HandleEditNotificationPage(c *gin.Context) {
} }
// Convert to NotificationFormData // Convert to NotificationFormData
data := components.NotificationFormData{ data := types.NotificationFormData{ // Use types.NotificationFormData
IsNew: false, IsNew: false,
NotificationService: &struct { NotificationService: &struct {
ID uint ID uint
@@ -1278,7 +1240,7 @@ func (h *Handlers) HandleEditNotificationPage(c *gin.Context) {
Name: service.Name, Name: service.Name,
Description: service.Description, Description: service.Description,
Type: service.Type, Type: service.Type,
IsEnabled: service.IsEnabled, IsEnabled: service.GetIsEnabled(), // Use getter
EventTriggers: service.EventTriggers, EventTriggers: service.EventTriggers,
RetryPolicy: service.RetryPolicy, RetryPolicy: service.RetryPolicy,
PayloadTemplate: service.PayloadTemplate, PayloadTemplate: service.PayloadTemplate,
@@ -1320,7 +1282,7 @@ func (h *Handlers) HandleEditNotificationPage(c *gin.Context) {
} }
ctx := h.CreateTemplateContext(c) ctx := h.CreateTemplateContext(c)
components.NotificationForm(ctx, data).Render(ctx, c.Writer) form.NotificationForm(ctx, data).Render(ctx, c.Writer) // Use form.NotificationForm
} }
// HandleUpdateNotificationService handles PUT /admin/settings/notifications/:id // HandleUpdateNotificationService handles PUT /admin/settings/notifications/:id
@@ -1345,11 +1307,45 @@ func (h *Handlers) HandleUpdateNotificationService(c *gin.Context) {
return return
} }
// --- DEBUG: Print received form data ---
log.Println("--- Received POST Form Data (Update Notification) ---")
if err := c.Request.ParseForm(); err == nil {
postData := c.Request.PostForm
if len(postData) > 0 {
for key, values := range postData {
log.Printf(" %s: %v\n", key, values)
}
} else {
log.Println(" (No POST form data found or parsed)")
}
} else {
log.Printf(" Error parsing form: %v\n", err)
}
log.Println("-------------------------------------------------")
// --- END DEBUG ---
// Parse form data // Parse form data
name := c.PostForm("name") name := c.PostForm("name")
serviceType := c.PostForm("type") serviceType := c.PostForm("type")
description := c.PostForm("description") description := c.PostForm("description")
isEnabled := c.PostForm("is_enabled") == "on"
// Correctly handle boolean checkbox with hidden input
isEnabled := false // Default to false
if err := c.Request.ParseForm(); err == nil { // Ensure form is parsed
isEnabledValues := c.Request.PostForm["is_enabled"] // Get slice of values
for _, v := range isEnabledValues {
if v == "true" {
isEnabled = true // Checkbox was checked if "true" is present
break
}
}
} else {
log.Printf("Error parsing form during update: %v", err)
// Decide if this is a fatal error or if we can proceed assuming false
// For now, we proceed with isEnabled = false
}
eventTriggers := c.PostFormArray("event_triggers[]")
// Validate required fields // Validate required fields
if name == "" || serviceType == "" { if name == "" || serviceType == "" {
@@ -1361,7 +1357,8 @@ func (h *Handlers) HandleUpdateNotificationService(c *gin.Context) {
service.Name = name service.Name = name
service.Type = serviceType service.Type = serviceType
service.Description = description service.Description = description
service.IsEnabled = isEnabled service.EventTriggers = eventTriggers
service.SetIsEnabled(isEnabled) // Use the correctly determined boolean
// Update type-specific fields based on service type // Update type-specific fields based on service type
switch serviceType { switch serviceType {
@@ -1374,18 +1371,7 @@ func (h *Handlers) HandleUpdateNotificationService(c *gin.Context) {
service.SecretKey = c.PostForm("secret_key") service.SecretKey = c.PostForm("secret_key")
service.RetryPolicy = c.PostForm("retry_policy") service.RetryPolicy = c.PostForm("retry_policy")
// Update event triggers // Event triggers are handled above
eventTriggers := make([]string, 0)
if c.PostForm("trigger_job_start") == "on" {
eventTriggers = append(eventTriggers, "job_start")
}
if c.PostForm("trigger_job_complete") == "on" {
eventTriggers = append(eventTriggers, "job_complete")
}
if c.PostForm("trigger_job_error") == "on" {
eventTriggers = append(eventTriggers, "job_error")
}
service.EventTriggers = eventTriggers
case "pushbullet": case "pushbullet":
// Update Pushbullet-specific fields // Update Pushbullet-specific fields
@@ -1394,18 +1380,7 @@ func (h *Handlers) HandleUpdateNotificationService(c *gin.Context) {
service.Config["title_template"] = c.PostForm("pushbullet_title_template") service.Config["title_template"] = c.PostForm("pushbullet_title_template")
service.Config["body_template"] = c.PostForm("pushbullet_body_template") service.Config["body_template"] = c.PostForm("pushbullet_body_template")
// Update event triggers // Event triggers are handled above
eventTriggers := make([]string, 0)
if c.PostForm("trigger_job_start") == "on" {
eventTriggers = append(eventTriggers, "job_start")
}
if c.PostForm("trigger_job_complete") == "on" {
eventTriggers = append(eventTriggers, "job_complete")
}
if c.PostForm("trigger_job_error") == "on" {
eventTriggers = append(eventTriggers, "job_error")
}
service.EventTriggers = eventTriggers
case "ntfy": case "ntfy":
// Update Ntfy-specific fields // Update Ntfy-specific fields
@@ -1417,18 +1392,7 @@ func (h *Handlers) HandleUpdateNotificationService(c *gin.Context) {
service.Config["title_template"] = c.PostForm("ntfy_title_template") service.Config["title_template"] = c.PostForm("ntfy_title_template")
service.Config["message_template"] = c.PostForm("ntfy_message_template") service.Config["message_template"] = c.PostForm("ntfy_message_template")
// Update event triggers // Event triggers are handled above
eventTriggers := make([]string, 0)
if c.PostForm("trigger_job_start") == "on" {
eventTriggers = append(eventTriggers, "job_start")
}
if c.PostForm("trigger_job_complete") == "on" {
eventTriggers = append(eventTriggers, "job_complete")
}
if c.PostForm("trigger_job_error") == "on" {
eventTriggers = append(eventTriggers, "job_error")
}
service.EventTriggers = eventTriggers
case "gotify": case "gotify":
// Update Gotify-specific fields // Update Gotify-specific fields
@@ -1438,18 +1402,7 @@ func (h *Handlers) HandleUpdateNotificationService(c *gin.Context) {
service.Config["title_template"] = c.PostForm("gotify_title_template") service.Config["title_template"] = c.PostForm("gotify_title_template")
service.Config["message_template"] = c.PostForm("gotify_message_template") service.Config["message_template"] = c.PostForm("gotify_message_template")
// Update event triggers // Event triggers are handled above
eventTriggers := make([]string, 0)
if c.PostForm("trigger_job_start") == "on" {
eventTriggers = append(eventTriggers, "job_start")
}
if c.PostForm("trigger_job_complete") == "on" {
eventTriggers = append(eventTriggers, "job_complete")
}
if c.PostForm("trigger_job_error") == "on" {
eventTriggers = append(eventTriggers, "job_error")
}
service.EventTriggers = eventTriggers
case "pushover": case "pushover":
// Update Pushover-specific fields // Update Pushover-specific fields
@@ -1461,18 +1414,7 @@ func (h *Handlers) HandleUpdateNotificationService(c *gin.Context) {
service.Config["title_template"] = c.PostForm("pushover_title_template") service.Config["title_template"] = c.PostForm("pushover_title_template")
service.Config["message_template"] = c.PostForm("pushover_message_template") service.Config["message_template"] = c.PostForm("pushover_message_template")
// Update event triggers // Event triggers are handled above
eventTriggers := make([]string, 0)
if c.PostForm("trigger_job_start") == "on" {
eventTriggers = append(eventTriggers, "job_start")
}
if c.PostForm("trigger_job_complete") == "on" {
eventTriggers = append(eventTriggers, "job_complete")
}
if c.PostForm("trigger_job_error") == "on" {
eventTriggers = append(eventTriggers, "job_error")
}
service.EventTriggers = eventTriggers
} }
// Save to database // Save to database
@@ -1486,7 +1428,7 @@ func (h *Handlers) HandleUpdateNotificationService(c *gin.Context) {
auditDetails := map[string]interface{}{ auditDetails := map[string]interface{}{
"name": service.Name, "name": service.Name,
"type": service.Type, "type": service.Type,
"is_enabled": service.IsEnabled, "is_enabled": service.GetIsEnabled(), // Use getter
"description": service.Description, "description": service.Description,
"event_triggers": service.EventTriggers, "event_triggers": service.EventTriggers,
} }
@@ -1515,13 +1457,13 @@ func (h *Handlers) handleNotificationsWithError(c *gin.Context, errorMessage str
} }
// Convert to components.NotificationService // Convert to components.NotificationService
var componentServices []components.NotificationService var componentServices []types.NotificationServiceData // Use types.NotificationServiceData
for _, service := range notificationServices { for _, service := range notificationServices {
componentServices = append(componentServices, components.NotificationService{ componentServices = append(componentServices, types.NotificationServiceData{ // Use types.NotificationServiceData
ID: service.ID, ID: service.ID,
Name: service.Name, Name: service.Name,
Type: service.Type, Type: service.Type,
IsEnabled: service.IsEnabled, IsEnabled: service.GetIsEnabled(), // Use getter
Config: service.Config, Config: service.Config,
Description: service.Description, Description: service.Description,
EventTriggers: service.EventTriggers, EventTriggers: service.EventTriggers,
@@ -1533,13 +1475,13 @@ func (h *Handlers) handleNotificationsWithError(c *gin.Context, errorMessage str
}) })
} }
data := components.SettingsNotificationsData{ data := types.SettingsNotificationsData{ // Use types.SettingsNotificationsData
NotificationServices: componentServices, NotificationServices: componentServices,
ErrorMessage: errorMessage, ErrorMessage: errorMessage,
} }
ctx := h.CreateTemplateContext(c) ctx := h.CreateTemplateContext(c)
components.Notifications(ctx, data).Render(ctx, c.Writer) list.List(ctx, data).Render(ctx, c.Writer) // Use list.List
} }
// handleNotificationsWithSuccess renders the notifications page with a success message // handleNotificationsWithSuccess renders the notifications page with a success message
@@ -1550,13 +1492,13 @@ func (h *Handlers) handleNotificationsWithSuccess(c *gin.Context, successMessage
} }
// Convert to components.NotificationService // Convert to components.NotificationService
var componentServices []components.NotificationService var componentServices []types.NotificationServiceData // Use types.NotificationServiceData
for _, service := range notificationServices { for _, service := range notificationServices {
componentServices = append(componentServices, components.NotificationService{ componentServices = append(componentServices, types.NotificationServiceData{ // Use types.NotificationServiceData
ID: service.ID, ID: service.ID,
Name: service.Name, Name: service.Name,
Type: service.Type, Type: service.Type,
IsEnabled: service.IsEnabled, IsEnabled: service.GetIsEnabled(), // Use getter
Config: service.Config, Config: service.Config,
Description: service.Description, Description: service.Description,
EventTriggers: service.EventTriggers, EventTriggers: service.EventTriggers,
@@ -1568,11 +1510,11 @@ func (h *Handlers) handleNotificationsWithSuccess(c *gin.Context, successMessage
}) })
} }
data := components.SettingsNotificationsData{ data := types.SettingsNotificationsData{ // Use types.SettingsNotificationsData
NotificationServices: componentServices, NotificationServices: componentServices,
SuccessMessage: successMessage, SuccessMessage: successMessage,
} }
ctx := h.CreateTemplateContext(c) ctx := h.CreateTemplateContext(c)
components.Notifications(ctx, data).Render(ctx, c.Writer) list.List(ctx, data).Render(ctx, c.Writer) // Use list.List
} }

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