mirror of
https://github.com/StarFleetCPTN/GoMFT.git
synced 2026-10-16 11:08:15 +02:00
feat: Enhance configuration form with validation and error handling
- Implemented validation for the configuration form to ensure required fields are filled before submission. - Added a dynamic error display for user feedback, including validation messages for configuration name and paths. - Ensured at least one concurrent transfer is set by default to improve user experience. - Updated form layout to include accessibility features and improved error handling for various input fields.
This commit is contained in:
1 parent
4b540faec4
commit
dc6209dbc1
2 files changed
+242
-9
No files matched your search
@@ -167,6 +167,9 @@ func getInitialData(config *db.TransferConfig) string {
|
|||||||
deleteAfterTransfer = config.GetDeleteAfterTransfer()
|
deleteAfterTransfer = config.GetDeleteAfterTransfer()
|
||||||
skipProcessedFiles = config.GetSkipProcessedFiles()
|
skipProcessedFiles = config.GetSkipProcessedFiles()
|
||||||
maxConcurrentTransfers = config.MaxConcurrentTransfers
|
maxConcurrentTransfers = config.MaxConcurrentTransfers
|
||||||
|
if maxConcurrentTransfers <= 0 {
|
||||||
|
maxConcurrentTransfers = 1 // Ensure at least 1 concurrent transfer
|
||||||
|
}
|
||||||
rcloneFlags = config.RcloneFlags
|
rcloneFlags = config.RcloneFlags
|
||||||
commandId = config.CommandID
|
commandId = config.CommandID
|
||||||
commandFlags = config.CommandFlags
|
commandFlags = config.CommandFlags
|
||||||
@@ -377,6 +380,7 @@ templ ConfigForm(ctx context.Context, data ConfigFormData) {
|
|||||||
|
|
||||||
<!-- Main Form -->
|
<!-- Main Form -->
|
||||||
<form
|
<form
|
||||||
|
id="config-form"
|
||||||
class="space-y-6"
|
class="space-y-6"
|
||||||
if data.IsNew {
|
if data.IsNew {
|
||||||
hx-post="/configs"
|
hx-post="/configs"
|
||||||
@@ -413,6 +417,11 @@ templ ConfigForm(ctx context.Context, data ConfigFormData) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ensure maxConcurrentTransfers is at least 1
|
||||||
|
if (!maxConcurrentTransfers || maxConcurrentTransfers < 1) {
|
||||||
|
maxConcurrentTransfers = 1;
|
||||||
|
}
|
||||||
|
|
||||||
// Initialize command requirements
|
// Initialize command requirements
|
||||||
updateCommandRequirements();
|
updateCommandRequirements();
|
||||||
})"
|
})"
|
||||||
@@ -435,8 +444,18 @@ templ ConfigForm(ctx context.Context, data ConfigFormData) {
|
|||||||
destPort = 23;
|
destPort = 23;
|
||||||
console.log('Updating destination port to 23 for Hetzner');
|
console.log('Updating destination port to 23 for Hetzner');
|
||||||
}"
|
}"
|
||||||
|
@formvalidation
|
||||||
>
|
>
|
||||||
|
|
||||||
|
<!-- Form Error Container -->
|
||||||
|
<div id="form-errors" class="hidden p-4 mb-6 text-sm text-red-800 rounded-lg bg-red-50 dark:bg-red-800/20 dark:text-red-400 border border-red-200 dark:border-red-900" role="alert">
|
||||||
|
<div class="flex items-center mb-2">
|
||||||
|
<i class="fas fa-exclamation-circle text-red-600 dark:text-red-500 mr-2"></i>
|
||||||
|
<h3 class="text-base font-medium text-red-800 dark:text-red-400">Please correct the following errors:</h3>
|
||||||
|
</div>
|
||||||
|
<ul id="error-list" class="ml-5 list-disc space-y-1"></ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Configuration Details Section -->
|
<!-- Configuration Details Section -->
|
||||||
<div class="p-4 mb-4 bg-blue-50 border border-blue-100 rounded-lg dark:bg-blue-900/20 dark:border-blue-900">
|
<div class="p-4 mb-4 bg-blue-50 border border-blue-100 rounded-lg dark:bg-blue-900/20 dark:border-blue-900">
|
||||||
<div class="flex items-center mb-2">
|
<div class="flex items-center mb-2">
|
||||||
@@ -450,13 +469,16 @@ templ ConfigForm(ctx context.Context, data ConfigFormData) {
|
|||||||
|
|
||||||
<!-- Name field -->
|
<!-- Name field -->
|
||||||
<div class="mb-2">
|
<div class="mb-2">
|
||||||
<label for="name" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Configuration Name</label>
|
<label for="name" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">
|
||||||
|
Configuration Name <span class="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
id="name"
|
id="name"
|
||||||
name="name"
|
name="name"
|
||||||
x-model="name"
|
x-model="name"
|
||||||
required
|
required
|
||||||
|
aria-required="true"
|
||||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||||
placeholder="My Transfer Configuration"
|
placeholder="My Transfer Configuration"
|
||||||
/>
|
/>
|
||||||
@@ -703,4 +725,215 @@ templ ConfigForm(ctx context.Context, data ConfigFormData) {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
templ formvalidation() {
|
||||||
|
<script>
|
||||||
|
document.addEventListener('htmx:beforeRequest', function(evt) {
|
||||||
|
if (evt.detail.elt.id === 'config-form') {
|
||||||
|
const formErrors = document.getElementById('form-errors');
|
||||||
|
const errorList = document.getElementById('error-list');
|
||||||
|
let errors = [];
|
||||||
|
let hasErrors = false;
|
||||||
|
|
||||||
|
// Clear previous errors
|
||||||
|
errorList.innerHTML = '';
|
||||||
|
formErrors.classList.add('hidden');
|
||||||
|
|
||||||
|
// Validate name
|
||||||
|
const name = document.getElementById('name').value;
|
||||||
|
if (!name || name.trim() === '') {
|
||||||
|
errors.push('Configuration name is required');
|
||||||
|
hasErrors = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Source path validation
|
||||||
|
// Note: Most source and destination forms already have HTML5 validation
|
||||||
|
// with the required attribute, but we do additional JS validation here
|
||||||
|
// to provide a better user experience with a centralized error display
|
||||||
|
const sourcePath = document.getElementById('source_path')?.value;
|
||||||
|
if (!sourcePath || sourcePath.trim() === '') {
|
||||||
|
errors.push('Source path is required');
|
||||||
|
hasErrors = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get source type
|
||||||
|
const sourceType = document.querySelector('input[name="source_type"]').value;
|
||||||
|
|
||||||
|
// For remote source, validate credentials
|
||||||
|
if (sourceType !== 'local') {
|
||||||
|
// Host validation for remote sources
|
||||||
|
const sourceHost = document.getElementById('source_host')?.value;
|
||||||
|
if (!sourceHost || sourceHost.trim() === '') {
|
||||||
|
errors.push('Source host is required for remote connections');
|
||||||
|
hasErrors = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Port validation
|
||||||
|
const sourcePort = document.getElementById('source_port')?.value;
|
||||||
|
if (!sourcePort || isNaN(parseInt(sourcePort)) || parseInt(sourcePort) <= 0 || parseInt(sourcePort) > 65535) {
|
||||||
|
errors.push('Source port must be a valid port number (1-65535)');
|
||||||
|
hasErrors = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Username validation for SFTP/FTP/etc.
|
||||||
|
if (['sftp', 'ftp', 'hetzner'].includes(sourceType)) {
|
||||||
|
const sourceUser = document.getElementById('source_username')?.value;
|
||||||
|
if (!sourceUser || sourceUser.trim() === '') {
|
||||||
|
errors.push('Source username is required');
|
||||||
|
hasErrors = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check auth type
|
||||||
|
const sourceAuthType = document.querySelector('input[name="source_auth_type"]:checked')?.value;
|
||||||
|
|
||||||
|
// Password validation if using password auth
|
||||||
|
if (sourceAuthType === 'password') {
|
||||||
|
const sourcePassword = document.getElementById('source_password')?.value;
|
||||||
|
if (!sourcePassword || sourcePassword.trim() === '') {
|
||||||
|
errors.push('Source password is required when using password authentication');
|
||||||
|
hasErrors = true;
|
||||||
|
}
|
||||||
|
} else if (sourceAuthType === 'key') {
|
||||||
|
// Key file validation if using key auth
|
||||||
|
const sourceKeyFile = document.getElementById('source_key_file')?.value;
|
||||||
|
if (!sourceKeyFile || sourceKeyFile.trim() === '') {
|
||||||
|
errors.push('Source key file path is required when using key authentication');
|
||||||
|
hasErrors = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// S3/B2/Wasabi specific validations
|
||||||
|
if (['s3', 'b2', 'wasabi', 'minio'].includes(sourceType)) {
|
||||||
|
const sourceAccessKey = document.getElementById('source_access_key')?.value;
|
||||||
|
const sourceSecretKey = document.getElementById('source_secret_key')?.value;
|
||||||
|
|
||||||
|
if (!sourceAccessKey || sourceAccessKey.trim() === '') {
|
||||||
|
errors.push('Source access key is required');
|
||||||
|
hasErrors = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!sourceSecretKey || sourceSecretKey.trim() === '') {
|
||||||
|
errors.push('Source secret key is required');
|
||||||
|
hasErrors = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bucket validation
|
||||||
|
const sourceBucket = document.getElementById('source_bucket')?.value;
|
||||||
|
if (!sourceBucket || sourceBucket.trim() === '') {
|
||||||
|
errors.push('Source bucket is required');
|
||||||
|
hasErrors = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate destination if it's a required field
|
||||||
|
const requiresDestination = document.querySelector('form').__x.$data.requiresDestination;
|
||||||
|
if (requiresDestination) {
|
||||||
|
// Destination path validation
|
||||||
|
const destPath = document.getElementById('destination_path')?.value;
|
||||||
|
if (!destPath || destPath.trim() === '') {
|
||||||
|
errors.push('Destination path is required');
|
||||||
|
hasErrors = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get destination type
|
||||||
|
const destType = document.querySelector('input[name="destination_type"]').value;
|
||||||
|
|
||||||
|
// For remote destination, validate credentials
|
||||||
|
if (destType !== 'local') {
|
||||||
|
// Host validation for remote destinations
|
||||||
|
const destHost = document.getElementById('destination_host')?.value;
|
||||||
|
if (!destHost || destHost.trim() === '') {
|
||||||
|
errors.push('Destination host is required for remote connections');
|
||||||
|
hasErrors = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Port validation
|
||||||
|
const destPort = document.getElementById('destination_port')?.value;
|
||||||
|
if (!destPort || isNaN(parseInt(destPort)) || parseInt(destPort) <= 0 || parseInt(destPort) > 65535) {
|
||||||
|
errors.push('Destination port must be a valid port number (1-65535)');
|
||||||
|
hasErrors = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Username validation for SFTP/FTP/etc.
|
||||||
|
if (['sftp', 'ftp', 'hetzner'].includes(destType)) {
|
||||||
|
const destUser = document.getElementById('destination_username')?.value;
|
||||||
|
if (!destUser || destUser.trim() === '') {
|
||||||
|
errors.push('Destination username is required');
|
||||||
|
hasErrors = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check auth type
|
||||||
|
const destAuthType = document.querySelector('input[name="destination_auth_type"]:checked')?.value;
|
||||||
|
|
||||||
|
// Password validation if using password auth
|
||||||
|
if (destAuthType === 'password') {
|
||||||
|
const destPassword = document.getElementById('destination_password')?.value;
|
||||||
|
if (!destPassword || destPassword.trim() === '') {
|
||||||
|
errors.push('Destination password is required when using password authentication');
|
||||||
|
hasErrors = true;
|
||||||
|
}
|
||||||
|
} else if (destAuthType === 'key') {
|
||||||
|
// Key file validation if using key auth
|
||||||
|
const destKeyFile = document.getElementById('destination_key_file')?.value;
|
||||||
|
if (!destKeyFile || destKeyFile.trim() === '') {
|
||||||
|
errors.push('Destination key file path is required when using key authentication');
|
||||||
|
hasErrors = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// S3/B2/Wasabi specific validations
|
||||||
|
if (['s3', 'b2', 'wasabi', 'minio'].includes(destType)) {
|
||||||
|
const destAccessKey = document.getElementById('destination_access_key')?.value;
|
||||||
|
const destSecretKey = document.getElementById('destination_secret_key')?.value;
|
||||||
|
|
||||||
|
if (!destAccessKey || destAccessKey.trim() === '') {
|
||||||
|
errors.push('Destination access key is required');
|
||||||
|
hasErrors = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!destSecretKey || destSecretKey.trim() === '') {
|
||||||
|
errors.push('Destination secret key is required');
|
||||||
|
hasErrors = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bucket validation
|
||||||
|
const destBucket = document.getElementById('destination_bucket')?.value;
|
||||||
|
if (!destBucket || destBucket.trim() === '') {
|
||||||
|
errors.push('Destination bucket is required');
|
||||||
|
hasErrors = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for concurrent transfers
|
||||||
|
const maxTransfers = document.getElementById('max_concurrent_transfers')?.value;
|
||||||
|
if (maxTransfers && (isNaN(parseInt(maxTransfers)) || parseInt(maxTransfers) < 1)) {
|
||||||
|
errors.push('Maximum concurrent transfers must be at least 1');
|
||||||
|
hasErrors = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If errors exist, prevent form submission and display errors
|
||||||
|
if (hasErrors) {
|
||||||
|
evt.preventDefault();
|
||||||
|
|
||||||
|
// Display errors
|
||||||
|
errors.forEach(error => {
|
||||||
|
const li = document.createElement('li');
|
||||||
|
li.textContent = error;
|
||||||
|
errorList.appendChild(li);
|
||||||
|
});
|
||||||
|
|
||||||
|
formErrors.classList.remove('hidden');
|
||||||
|
|
||||||
|
// Scroll to the errors
|
||||||
|
formErrors.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
}
|
}
|
||||||
@@ -59,7 +59,7 @@ func NewHandlers(database *db.DB, scheduler scheduler.SchedulerInterface, jwtSec
|
|||||||
// StartLogBroadcaster starts a goroutine that broadcasts logs to all connected WebSocket clients
|
// StartLogBroadcaster starts a goroutine that broadcasts logs to all connected WebSocket clients
|
||||||
func StartLogBroadcaster() {
|
func StartLogBroadcaster() {
|
||||||
go func() {
|
go func() {
|
||||||
fmt.Fprintln(os.Stderr, "[DEBUG-BROADCASTER-V4] Broadcaster goroutine started.")
|
fmt.Fprintln(os.Stderr, "[DEBUG-BROADCASTER] Broadcaster goroutine started.")
|
||||||
for {
|
for {
|
||||||
logEntry := <-LogChannel // Wait for a log entry
|
logEntry := <-LogChannel // Wait for a log entry
|
||||||
|
|
||||||
@@ -76,7 +76,7 @@ func StartLogBroadcaster() {
|
|||||||
continue // Skip if no clients
|
continue // Skip if no clients
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Fprintf(os.Stderr, "[DEBUG-BROADCASTER-V4] Received log. Broadcasting to %d clients. Level='%s', Src='%s'\n",
|
fmt.Fprintf(os.Stderr, "[DEBUG-BROADCASTER] Received log. Broadcasting to %d clients. Level='%s', Src='%s'\n",
|
||||||
len(clientsToSend), logEntry.Level, logEntry.Source)
|
len(clientsToSend), logEntry.Level, logEntry.Source)
|
||||||
|
|
||||||
var wg sync.WaitGroup
|
var wg sync.WaitGroup
|
||||||
@@ -85,7 +85,7 @@ func StartLogBroadcaster() {
|
|||||||
go func(c *websocket.Conn, m *sync.Mutex, entry components.LogEntry) {
|
go func(c *websocket.Conn, m *sync.Mutex, entry components.LogEntry) {
|
||||||
defer wg.Done()
|
defer wg.Done()
|
||||||
|
|
||||||
fmt.Fprintf(os.Stderr, "[DEBUG-BROADCASTER-V4] Attempting send to client %v\n", c.RemoteAddr())
|
fmt.Fprintf(os.Stderr, "[DEBUG-BROADCASTER] Attempting send to client %v\n", c.RemoteAddr())
|
||||||
|
|
||||||
// Lock only for this specific client's write
|
// Lock only for this specific client's write
|
||||||
m.Lock()
|
m.Lock()
|
||||||
@@ -93,7 +93,7 @@ func StartLogBroadcaster() {
|
|||||||
deadline := time.Now().Add(5 * time.Second) // 5-second deadline
|
deadline := time.Now().Add(5 * time.Second) // 5-second deadline
|
||||||
err := c.SetWriteDeadline(deadline)
|
err := c.SetWriteDeadline(deadline)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Fprintf(os.Stderr, "[DEBUG-BROADCASTER-V4] Error setting write deadline for client %v: %v\n", c.RemoteAddr(), err)
|
fmt.Fprintf(os.Stderr, "[DEBUG-BROADCASTER] Error setting write deadline for client %v: %v\n", c.RemoteAddr(), err)
|
||||||
// Don't unlock yet, proceed to cleanup
|
// Don't unlock yet, proceed to cleanup
|
||||||
} else {
|
} else {
|
||||||
err = c.WriteJSON(entry)
|
err = c.WriteJSON(entry)
|
||||||
@@ -101,19 +101,19 @@ func StartLogBroadcaster() {
|
|||||||
m.Unlock() // Unlock after write attempt (or deadline error)
|
m.Unlock() // Unlock after write attempt (or deadline error)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Fprintf(os.Stderr, "[DEBUG-BROADCASTER-V4] Error writing to client %v: %v. Initiating removal.\n", c.RemoteAddr(), err)
|
fmt.Fprintf(os.Stderr, "[DEBUG-BROADCASTER] Error writing to client %v: %v. Initiating removal.\n", c.RemoteAddr(), err)
|
||||||
WebSocketClientsMutex.Lock()
|
WebSocketClientsMutex.Lock()
|
||||||
if _, stillExists := WebSocketClients[c]; stillExists {
|
if _, stillExists := WebSocketClients[c]; stillExists {
|
||||||
delete(WebSocketClients, c)
|
delete(WebSocketClients, c)
|
||||||
delete(WebSocketClientWriteMutexes, c)
|
delete(WebSocketClientWriteMutexes, c)
|
||||||
fmt.Fprintf(os.Stderr, "[DEBUG-BROADCASTER-V4] Removed client %v from maps.\n", c.RemoteAddr())
|
fmt.Fprintf(os.Stderr, "[DEBUG-BROADCASTER] Removed client %v from maps.\n", c.RemoteAddr())
|
||||||
} else {
|
} else {
|
||||||
fmt.Fprintf(os.Stderr, "[DEBUG-BROADCASTER-V4] Client %v already removed by another process.\n", c.RemoteAddr())
|
fmt.Fprintf(os.Stderr, "[DEBUG-BROADCASTER] Client %v already removed by another process.\n", c.RemoteAddr())
|
||||||
}
|
}
|
||||||
WebSocketClientsMutex.Unlock()
|
WebSocketClientsMutex.Unlock()
|
||||||
c.Close() // Close the connection outside the lock
|
c.Close() // Close the connection outside the lock
|
||||||
} else {
|
} else {
|
||||||
fmt.Fprintf(os.Stderr, "[DEBUG-BROADCASTER-V4] Successfully sent to client %v\n", c.RemoteAddr())
|
fmt.Fprintf(os.Stderr, "[DEBUG-BROADCASTER] Successfully sent to client %v\n", c.RemoteAddr())
|
||||||
}
|
}
|
||||||
}(client, mutex, logEntry)
|
}(client, mutex, logEntry)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user