feat: Add connection testing functionality for Rclone providers

- Introduced a new endpoint to test connections for source and destination providers.
- Implemented a TestResult component to display success or error messages based on connection outcomes.
- Enhanced the configuration form with buttons to initiate connection tests, improving user experience.
- Updated backend logic to handle connection testing and return appropriate feedback to the frontend.
- Refactored existing handlers and routes to accommodate the new testing functionality.
This commit is contained in:
StarFleetCPTN
2025-03-29 11:27:48 -07:00
parent f95720758e
commit 68dd9fc173
7 changed files with 452 additions and 4 deletions
+32 -3
View File
@@ -459,6 +459,9 @@ templ ConfigForm(ctx context.Context, data ConfigFormData) {
<i class="fas fa-terminal mr-2 text-blue-500 dark:text-blue-400"></i>Command Configuration
</h3>
<!-- Additonal Rclone Flags -->
@common.RcloneFlags(data.Config.CommandID) // Pass current command ID
<!-- Container for flags, pre-rendered on edit, loaded via HTMX on new/change -->
<div id="command-flags-container" class="mt-4">
if !data.IsNew && data.InitialCommand != nil {
@@ -467,9 +470,7 @@ templ ConfigForm(ctx context.Context, data ConfigFormData) {
}
</div>
<!-- Additonal Rclone Flags -->
@common.RcloneFlags(data.Config.CommandID) // Pass current command ID
</div>
<!-- Source Configuration Section -->
@@ -481,6 +482,20 @@ templ ConfigForm(ctx context.Context, data ConfigFormData) {
<!-- Source selection -->
@common.SourceSelection()
<div class="mt-4">
<button type="button"
class="text-white bg-green-600 hover:bg-green-700 focus:ring-4 focus:ring-green-300 font-medium rounded-lg text-sm px-4 py-2 text-center dark:bg-green-500 dark:hover:bg-green-600 dark:focus:ring-green-800"
hx-post="/configs/test-connection"
hx-include="closest form"
hx-vals='{"providerType": "source"}'
hx-swap="none"
hx-indicator="#source-test-spinner">
<i class="fas fa-plug mr-1"></i> Test Source
<span id="source-test-spinner" class="htmx-indicator ml-2"><i class="fas fa-spinner fa-spin"></i></span>
</button>
<!-- Removed target div, result shown via toast -->
</div>
<!-- Source type specific forms -->
<template x-if="sourceType === 'local'">
@source.LocalSourceForm()
@@ -542,6 +557,20 @@ templ ConfigForm(ctx context.Context, data ConfigFormData) {
<!-- Destination selection -->
@common.DestinationSelection()
<div class="mt-4">
<button type="button"
class="text-white bg-green-600 hover:bg-green-700 focus:ring-4 focus:ring-green-300 font-medium rounded-lg text-sm px-4 py-2 text-center dark:bg-green-500 dark:hover:bg-green-600 dark:focus:ring-green-800"
hx-post="/configs/test-connection"
hx-include="closest form"
hx-vals='{"providerType": "destination"}'
hx-swap="none"
hx-indicator="#dest-test-spinner">
<i class="fas fa-plug mr-1"></i> Test Destination
<span id="dest-test-spinner" class="htmx-indicator ml-2"><i class="fas fa-spinner fa-spin"></i></span>
</button>
<!-- Removed target div, result shown via toast -->
</div>
<!-- Destination type specific forms -->
<template x-if="destinationType === 'local'">
@destination.LocalDestinationForm()
+1 -1
View File
@@ -235,7 +235,7 @@ templ RcloneCommandOptionsContent(categoryMap map[string][]db.RcloneCommand, cat
<select id="command_id" name="command_id" x-model="commandId"
hx-get="/api/rclone/command-flags"
hx-target="#command-flags-container"
hx-trigger="load, change"
hx-trigger="change"
hx-include="[name='command_id']"
hx-vals={ fmt.Sprintf(`{"commandFlags": %s, "commandFlagValues": %s}`, commandFlagsJSON, commandFlagValuesJSON) }
@change="updateCommandRequirements()"
+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>
}
}
+298
View File
@@ -0,0 +1,298 @@
package rclone_service
import (
"bytes"
"context"
"fmt"
"log"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
"github.com/starfleetcptn/gomft/internal/db"
)
// 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
// Removed bucket, share as they are not used in current rclone config create args
var host, user, pass, keyFile, region, accessKey, secretKey, endpoint, domain, clientID, clientSecret, driveID, teamDrive string
var port int
// Add other necessary fields like passiveMode, readOnly etc. if needed by rclone config create for specific types
var err error
// Create a temporary directory for the config file
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) // Clean up the temp directory
tempConfigPath := filepath.Join(tempDir, "rclone_test.conf")
// Extract parameters based on providerType
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
// bucket = config.SourceBucket // Removed - Not used in create args yet
region = config.SourceRegion
accessKey = config.SourceAccessKey
secretKey = config.SourceSecretKey
endpoint = config.SourceEndpoint
// share = config.SourceShare // Removed - Not used in create args yet
domain = config.SourceDomain
clientID = config.SourceClientID
clientSecret = config.SourceClientSecret
driveID = config.SourceDriveID
teamDrive = config.SourceTeamDrive
// Extract other source fields as needed (e.g., passiveMode, readOnly)
} 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
// bucket = config.DestBucket // Removed - Not used in create args yet
region = config.DestRegion
accessKey = config.DestAccessKey
secretKey = config.DestSecretKey
endpoint = config.DestEndpoint
// share = config.DestShare // Removed - Not used in create args yet
domain = config.DestDomain
clientID = config.DestClientID
clientSecret = config.DestClientSecret
driveID = config.DestDriveID
teamDrive = config.DestTeamDrive
// Extract other dest fields as needed (e.g., passiveMode, readOnly)
} else {
return false, "Invalid provider type specified", fmt.Errorf("unknown provider type: %s", providerType)
}
// Get the rclone path from the environment variable or use the default path
rclonePath := os.Getenv("RCLONE_PATH")
if rclonePath == "" {
rclonePath = "rclone"
}
// --- Use `rclone config create` to generate the temporary config section ---
createArgs := []string{
"config", "create", remoteName, provider,
"--config", tempConfigPath,
"--non-interactive",
"--log-level", "DEBUG", // Use DEBUG for create to see details if it fails
}
// --- Declare variables for lsd command *before* the switch/goto ---
var ctx context.Context
var cancel context.CancelFunc
var lsdArgs []string
var stdout, stderr bytes.Buffer
var lsdCmd *exec.Cmd
var createCmd *exec.Cmd // Declare createCmd here as well
// Add provider-specific arguments
// Mirroring logic from db.GenerateRcloneConfig but passing args to CLI
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) // Pass directly
}
if keyFile != "" {
createArgs = append(createArgs, "key_file", keyFile)
// Note: Passphrase for keyfile might need 'pass' too, rclone handles this context.
}
// Add other SFTP args like ssh_agent, use_insecure_cipher etc. if needed
case "s3":
createArgs = append(createArgs, "provider", "AWS", "env_auth", "false") // Assume AWS for generic S3
if accessKey != "" {
createArgs = append(createArgs, "access_key_id", accessKey)
}
if secretKey != "" {
createArgs = append(createArgs, "secret_access_key", secretKey) // Pass directly
}
if region != "" {
createArgs = append(createArgs, "region", region)
}
if endpoint != "" {
createArgs = append(createArgs, "endpoint", endpoint)
}
// Add other S3 args like acl, storage_class if needed
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) // Pass directly
}
if endpoint != "" {
createArgs = append(createArgs, "endpoint", endpoint)
}
if region != "" { // Minio might ignore region, but add if present
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)
}
// Add explicit_tls, passive_mode based on config boolean pointers
if config.GetSourcePassiveMode() || config.GetDestPassiveMode() { // Check based on providerType
createArgs = append(createArgs, "passive_mode", "true")
} else {
createArgs = append(createArgs, "passive_mode", "false")
}
createArgs = append(createArgs, "explicit_tls", "true") // Defaulting to 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) // Default vendor
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)
}
// Add cases for gdrive, gphotos - these are complex due to token handling
// For testing, they might require pre-existing tokens or manual auth flow outside this scope.
// Passing client_id/secret might work for initial setup but not subsequent tests without a token.
case "gdrive":
createArgs = append(createArgs, "scope", "drive") // Default scope
if clientID != "" {
createArgs = append(createArgs, "client_id", clientID)
}
if clientSecret != "" {
createArgs = append(createArgs, "client_secret", clientSecret)
}
if driveID != "" { // Use root_folder_id for specific drive/folder
createArgs = append(createArgs, "root_folder_id", driveID)
}
if teamDrive != "" {
createArgs = append(createArgs, "team_drive", teamDrive)
}
// Cannot pass token directly via 'config create' easily for testing non-interactive flow.
log.Println("Warning: Google Drive test may require pre-existing token or manual auth.")
case "gphotos":
// Similar complexity to gdrive regarding tokens
if clientID != "" {
createArgs = append(createArgs, "client_id", clientID)
}
if clientSecret != "" {
createArgs = append(createArgs, "client_secret", clientSecret)
}
// Add read_only, start_year, include_archived based on config
// Cannot pass token directly via 'config create' easily for testing non-interactive flow.
log.Println("Warning: Google Photos test may require pre-existing token or manual auth.")
case "local":
// 'rclone config create' might not be needed or work well for 'local' type.
// Write a minimal config manually for 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 // Skip rclone config create for local
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 = exec.Command(rclonePath, createArgs...) // Assign value using =
if output, err := createCmd.CombinedOutput(); err != nil {
// Log the config file content on error for debugging
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)
// Declarations moved before the switch statement
RunLsd: // Label to jump to for local type
// --- Execute `rclone lsd` using the temporary config ---
ctx, cancel = context.WithTimeout(context.Background(), 30*time.Second) // Assign values
defer cancel()
lsdArgs = []string{ // Assign value
"--config", tempConfigPath,
"lsd",
fmt.Sprintf("%s:%s", remoteName, remotePath),
"--low-level-retries", "1",
"--retries", "1",
// Add -vv for verbose logging during test if needed
// "-vv",
}
log.Printf("Executing rclone lsd command: %s %s", rclonePath, strings.Join(lsdArgs, " "))
lsdCmd = exec.CommandContext(ctx, rclonePath, lsdArgs...) // Assign value
// var stdout, stderr bytes.Buffer // Moved declaration up
lsdCmd.Stdout = &stdout // Assign buffer
lsdCmd.Stderr = &stderr // Assign buffer
err = lsdCmd.Run() // Assign error
stdoutStr := stdout.String()
stderrStr := stderr.String()
log.Printf("Rclone lsd stdout:\n%s", stdoutStr)
log.Printf("Rclone lsd stderr:\n%s", stderrStr)
if ctx.Err() == context.DeadlineExceeded {
return false, "Connection test timed out after 30 seconds.", ctx.Err()
}
if err != nil {
// Try to provide a more specific error message based on lsd output
errMsg := fmt.Sprintf("Connection test failed: %v. Stderr: %s", err, stderrStr)
// Add specific error checks based on stderrStr if needed
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") { // Error from rclone config create
errMsg = "Connection test failed: Invalid parameters provided for provider type."
}
return false, errMsg, err
}
// If lsd runs without error, the connection is likely okay
return true, "Connection test successful!", nil
}
+88
View File
@@ -11,7 +11,9 @@ import (
"github.com/gin-gonic/gin"
"github.com/starfleetcptn/gomft/components"
"github.com/starfleetcptn/gomft/internal/db"
"github.com/starfleetcptn/gomft/internal/rclone_service" // Assuming we create this package
)
// HandleConfigs handles the GET /configs route
@@ -686,3 +688,89 @@ func (h *Handlers) HandleDuplicateConfig(c *gin.Context) {
c.Header("HX-Refresh", "true")
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
}
+1
View File
@@ -57,6 +57,7 @@ func (h *Handlers) RegisterRoutes(router *gin.Engine) {
authorized.POST("/configs/:id", h.HandleUpdateConfig)
authorized.DELETE("/configs/:id", h.HandleDeleteConfig)
authorized.POST("/configs/:id/duplicate", h.HandleDuplicateConfig)
authorized.POST("/configs/test-connection", h.HandleTestProviderConnection)
// Path validation endpoint
authorized.GET("/check-path", h.HandleCheckPath)
+17
View File
@@ -446,3 +446,20 @@ function enhanceMobileForms() {
});
});
}
// Listen for custom 'showToast' event triggered by HX-Trigger
document.body.addEventListener('showToast', function(event) {
// Debug logs removed
if (event.detail && event.detail.message && event.detail.type) {
// Call the globally defined showToast function from toast_js.templ
if (typeof showToast === 'function') {
showToast(event.detail.message, event.detail.type);
} else {
console.error('showToast function not found. Ensure toast_js.templ is loaded.');
}
} else {
console.warn('Received showToast event without expected detail:', event.detail);
}
});