diff --git a/internal/fail2ban/connector_agent.go b/internal/fail2ban/connector_agent.go index b09b77f..e7301a5 100644 --- a/internal/fail2ban/connector_agent.go +++ b/internal/fail2ban/connector_agent.go @@ -321,6 +321,46 @@ func (ac *AgentConnector) TestLogpath(ctx context.Context, logpath string) ([]st return resp.Files, nil } +// TestLogpathWithResolution implements Connector. +// Agent server should handle variable resolution. +func (ac *AgentConnector) TestLogpathWithResolution(ctx context.Context, logpath string) (originalPath, resolvedPath string, files []string, err error) { + originalPath = strings.TrimSpace(logpath) + if originalPath == "" { + return originalPath, "", []string{}, nil + } + + payload := map[string]string{"logpath": originalPath} + var resp struct { + OriginalLogpath string `json:"original_logpath"` + ResolvedLogpath string `json:"resolved_logpath"` + Files []string `json:"files"` + Error string `json:"error,omitempty"` + } + + // Try new endpoint first, fallback to old endpoint + if err := ac.post(ctx, "/v1/jails/test-logpath-with-resolution", payload, &resp); err != nil { + // Fallback: use old endpoint and assume no resolution + files, err2 := ac.TestLogpath(ctx, originalPath) + if err2 != nil { + return originalPath, "", nil, fmt.Errorf("failed to test logpath: %w", err2) + } + return originalPath, originalPath, files, nil + } + + if resp.Error != "" { + return originalPath, "", nil, fmt.Errorf("agent error: %s", resp.Error) + } + + if resp.ResolvedLogpath == "" { + resp.ResolvedLogpath = resp.OriginalLogpath + } + if resp.OriginalLogpath == "" { + resp.OriginalLogpath = originalPath + } + + return resp.OriginalLogpath, resp.ResolvedLogpath, resp.Files, nil +} + // UpdateDefaultSettings implements Connector. func (ac *AgentConnector) UpdateDefaultSettings(ctx context.Context, settings config.AppSettings) error { // Convert IgnoreIPs array to space-separated string diff --git a/internal/fail2ban/connector_local.go b/internal/fail2ban/connector_local.go index e05d4fe..f51b3b7 100644 --- a/internal/fail2ban/connector_local.go +++ b/internal/fail2ban/connector_local.go @@ -52,7 +52,7 @@ func (lc *LocalConnector) GetJailInfos(ctx context.Context) ([]JailInfo, error) } oneHourAgo := time.Now().Add(-1 * time.Hour) - + // Use parallel execution for better performance type jailResult struct { jail JailInfo @@ -273,6 +273,11 @@ func (lc *LocalConnector) TestLogpath(ctx context.Context, logpath string) ([]st return TestLogpath(logpath) } +// TestLogpathWithResolution implements Connector. +func (lc *LocalConnector) TestLogpathWithResolution(ctx context.Context, logpath string) (originalPath, resolvedPath string, files []string, err error) { + return TestLogpathWithResolution(logpath) +} + // UpdateDefaultSettings implements Connector. func (lc *LocalConnector) UpdateDefaultSettings(ctx context.Context, settings config.AppSettings) error { return UpdateDefaultSettingsLocal(settings) diff --git a/internal/fail2ban/connector_ssh.go b/internal/fail2ban/connector_ssh.go index 1351d32..a541ffb 100644 --- a/internal/fail2ban/connector_ssh.go +++ b/internal/fail2ban/connector_ssh.go @@ -730,6 +730,150 @@ fi return matches, nil } +// TestLogpathWithResolution implements Connector. +// Resolves variables on remote system, then tests the resolved path. +func (sc *SSHConnector) TestLogpathWithResolution(ctx context.Context, logpath string) (originalPath, resolvedPath string, files []string, err error) { + originalPath = strings.TrimSpace(logpath) + if originalPath == "" { + return originalPath, "", []string{}, nil + } + + // Create Python script to resolve variables on remote system + resolveScript := fmt.Sprintf(`python3 - <<'PYEOF' +import os +import re +import glob +from pathlib import Path + +def extract_variables(s): + """Extract all variable names from a string.""" + pattern = r'%%\(([^)]+)\)s' + return re.findall(pattern, s) + +def find_variable_definition(var_name, fail2ban_path="/etc/fail2ban"): + """Search for variable definition in all .conf files.""" + var_name_lower = var_name.lower() + + for conf_file in Path(fail2ban_path).rglob("*.conf"): + try: + with open(conf_file, 'r') as f: + current_var = None + current_value = [] + in_multiline = False + + for line in f: + original_line = line + line = line.strip() + + if not in_multiline: + if '=' in line and not line.startswith('#'): + parts = line.split('=', 1) + key = parts[0].strip() + value = parts[1].strip() + + if key.lower() == var_name_lower: + current_var = key + current_value = [value] + in_multiline = True + continue + else: + # Check if continuation or new variable/section + if line.startswith('[') or (not line.startswith(' ') and '=' in line and not line.startswith('\t')): + # End of multi-line + return ' '.join(current_value) + else: + # Continuation + current_value.append(line) + + if in_multiline and current_var: + return ' '.join(current_value) + except: + continue + + return None + +def resolve_variable_recursive(var_name, visited=None): + """Resolve variable recursively.""" + if visited is None: + visited = set() + + if var_name in visited: + raise ValueError(f"Circular reference detected for variable '{var_name}'") + + visited.add(var_name) + + try: + value = find_variable_definition(var_name) + if value is None: + raise ValueError(f"Variable '{var_name}' not found") + + # Check for nested variables + nested_vars = extract_variables(value) + if not nested_vars: + return value + + # Resolve nested variables + resolved = value + for nested_var in nested_vars: + nested_value = resolve_variable_recursive(nested_var, visited.copy()) + pattern = f'%%({re.escape(nested_var)})s' + resolved = re.sub(pattern, nested_value, resolved) + + return resolved + finally: + visited.discard(var_name) + +def resolve_logpath(logpath): + """Resolve all variables in logpath.""" + variables = extract_variables(logpath) + if not variables: + return logpath + + resolved = logpath + for var_name in variables: + var_value = resolve_variable_recursive(var_name) + pattern = f'%%({re.escape(var_name)})s' + resolved = re.sub(pattern, var_value, resolved) + + return resolved + +# Main +logpath = %q +try: + resolved = resolve_logpath(logpath) + print(f"RESOLVED:{resolved}") +except Exception as e: + print(f"ERROR:{str(e)}") + exit(1) +PYEOF +`, originalPath) + + // Run resolution script + resolveOut, err := sc.runRemoteCommand(ctx, []string{"bash", "-lc", resolveScript}) + if err != nil { + return originalPath, "", nil, fmt.Errorf("failed to resolve variables: %w", err) + } + + resolveOut = strings.TrimSpace(resolveOut) + if strings.HasPrefix(resolveOut, "ERROR:") { + return originalPath, "", nil, fmt.Errorf(strings.TrimPrefix(resolveOut, "ERROR:")) + } + if strings.HasPrefix(resolveOut, "RESOLVED:") { + resolvedPath = strings.TrimPrefix(resolveOut, "RESOLVED:") + } else { + // Fallback: use original if resolution failed + resolvedPath = originalPath + } + + // Test the resolved path + files, err = sc.TestLogpath(ctx, resolvedPath) + if err != nil { + return originalPath, resolvedPath, nil, fmt.Errorf("failed to test logpath: %w", err) + } + + return originalPath, resolvedPath, files, nil +} + // UpdateDefaultSettings implements Connector. func (sc *SSHConnector) UpdateDefaultSettings(ctx context.Context, settings config.AppSettings) error { jailLocalPath := "/etc/fail2ban/jail.local" diff --git a/internal/fail2ban/jail_management.go b/internal/fail2ban/jail_management.go index b2d834e..79cc614 100644 --- a/internal/fail2ban/jail_management.go +++ b/internal/fail2ban/jail_management.go @@ -623,6 +623,7 @@ func SetJailConfig(jailName, content string) error { // TestLogpath tests a logpath pattern and returns matching files. // Supports wildcards/glob patterns (e.g., /var/log/*.log) and directory paths. +// This function tests the path as-is without variable resolution. func TestLogpath(logpath string) ([]string, error) { if logpath == "" { return []string{}, nil @@ -674,6 +675,34 @@ func TestLogpath(logpath string) ([]string, error) { return matches, nil } +// TestLogpathWithResolution resolves variables in logpath and tests the resolved path. +// Returns the original path, resolved path, matching files, and any error. +func TestLogpathWithResolution(logpath string) (originalPath, resolvedPath string, files []string, err error) { + originalPath = strings.TrimSpace(logpath) + if originalPath == "" { + return originalPath, "", []string{}, nil + } + + // Resolve variables + resolvedPath, err = ResolveLogpathVariables(originalPath) + if err != nil { + return originalPath, "", nil, fmt.Errorf("failed to resolve logpath variables: %w", err) + } + + // If resolution didn't change the path, resolvedPath will be the same + if resolvedPath == "" { + resolvedPath = originalPath + } + + // Test the resolved path + files, err = TestLogpath(resolvedPath) + if err != nil { + return originalPath, resolvedPath, nil, fmt.Errorf("failed to test logpath: %w", err) + } + + return originalPath, resolvedPath, files, nil +} + // ExtractLogpathFromJailConfig extracts the logpath value from jail configuration content. func ExtractLogpathFromJailConfig(jailContent string) string { scanner := bufio.NewScanner(strings.NewReader(jailContent)) diff --git a/internal/fail2ban/manager.go b/internal/fail2ban/manager.go index a00978e..186889e 100644 --- a/internal/fail2ban/manager.go +++ b/internal/fail2ban/manager.go @@ -34,6 +34,7 @@ type Connector interface { GetJailConfig(ctx context.Context, jail string) (string, error) SetJailConfig(ctx context.Context, jail, content string) error TestLogpath(ctx context.Context, logpath string) ([]string, error) + TestLogpathWithResolution(ctx context.Context, logpath string) (originalPath, resolvedPath string, files []string, err error) // Default settings operations UpdateDefaultSettings(ctx context.Context, settings config.AppSettings) error diff --git a/internal/fail2ban/variable_resolver.go b/internal/fail2ban/variable_resolver.go new file mode 100644 index 0000000..ecd588f --- /dev/null +++ b/internal/fail2ban/variable_resolver.go @@ -0,0 +1,385 @@ +// Fail2ban UI - A Swiss made, management interface for Fail2ban. +// +// Copyright (C) 2025 Swissmakers GmbH (https://swissmakers.ch) +// +// Licensed under the GNU General Public License, Version 3 (GPL-3.0) +// You may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.gnu.org/licenses/gpl-3.0.en.html +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package fail2ban + +import ( + "bufio" + "fmt" + "os" + "path/filepath" + "regexp" + "strings" + + "github.com/swissmakers/fail2ban-ui/internal/config" +) + +var ( + // Variable pattern: %(variable_name)s + variablePattern = regexp.MustCompile(`%\(([^)]+)\)s`) +) + +// extractVariablesFromString extracts all variable names from a string. +// Returns a list of variable names found in the pattern %(name)s. +func extractVariablesFromString(s string) []string { + matches := variablePattern.FindAllStringSubmatch(s, -1) + if len(matches) == 0 { + return nil + } + + var variables []string + for _, match := range matches { + if len(match) > 1 { + variables = append(variables, match[1]) + } + } + return variables +} + +// searchVariableInFile searches for a variable definition in a single file. +// Returns the value if found, empty string if not found, and error on file read error. +func searchVariableInFile(filePath, varName string) (string, error) { + file, err := os.Open(filePath) + if err != nil { + return "", err + } + defer file.Close() + + scanner := bufio.NewScanner(file) + var currentVar string + var currentValue strings.Builder + var inMultiLine bool + var pendingLine string + var pendingLineOriginal string + + for { + var originalLine string + var line string + + if pendingLine != "" { + originalLine = pendingLineOriginal + line = pendingLine + pendingLine = "" + pendingLineOriginal = "" + } else { + if !scanner.Scan() { + break + } + originalLine = scanner.Text() + line = strings.TrimSpace(originalLine) + } + + if !inMultiLine && (strings.HasPrefix(line, "#") || line == "") { + continue + } + + if !inMultiLine { + parts := strings.SplitN(line, "=", 2) + if len(parts) == 2 { + key := strings.TrimSpace(parts[0]) + value := strings.TrimSpace(parts[1]) + + if strings.EqualFold(key, varName) { + config.DebugLog("findVariableDefinition: found variable '%s' = '%s' in file %s", key, value, filePath) + currentVar = key + currentValue.WriteString(value) + + if scanner.Scan() { + nextLineOriginal := scanner.Text() + nextLineTrimmed := strings.TrimSpace(nextLineOriginal) + + isContinuation := nextLineTrimmed != "" && + !strings.HasPrefix(nextLineTrimmed, "#") && + !strings.HasPrefix(nextLineTrimmed, "[") && + (strings.HasPrefix(nextLineOriginal, " ") || strings.HasPrefix(nextLineOriginal, "\t") || + (!strings.Contains(nextLineTrimmed, "="))) + + if isContinuation { + inMultiLine = true + pendingLine = nextLineTrimmed + pendingLineOriginal = nextLineOriginal + continue + } else { + return strings.TrimSpace(currentValue.String()), nil + } + } else { + return strings.TrimSpace(currentValue.String()), nil + } + } + } + } else { + trimmedLine := strings.TrimSpace(originalLine) + + if strings.HasPrefix(trimmedLine, "[") { + return strings.TrimSpace(currentValue.String()), nil + } + + if strings.Contains(trimmedLine, "=") && !strings.HasPrefix(originalLine, " ") && !strings.HasPrefix(originalLine, "\t") { + return strings.TrimSpace(currentValue.String()), nil + } + + if currentValue.Len() > 0 { + currentValue.WriteString(" ") + } + currentValue.WriteString(trimmedLine) + + if scanner.Scan() { + nextLineOriginal := scanner.Text() + nextLineTrimmed := strings.TrimSpace(nextLineOriginal) + + if nextLineTrimmed == "" || + strings.HasPrefix(nextLineTrimmed, "#") || + strings.HasPrefix(nextLineTrimmed, "[") || + (strings.Contains(nextLineTrimmed, "=") && !strings.HasPrefix(nextLineOriginal, " ") && !strings.HasPrefix(nextLineOriginal, "\t")) { + return strings.TrimSpace(currentValue.String()), nil + } + pendingLine = nextLineTrimmed + pendingLineOriginal = nextLineOriginal + continue + } else { + return strings.TrimSpace(currentValue.String()), nil + } + } + } + + if inMultiLine && currentVar != "" { + return strings.TrimSpace(currentValue.String()), nil + } + + return "", nil +} + +// findVariableDefinition searches for a variable definition in all .local files first, +// then .conf files under /etc/fail2ban/ and subdirectories. +// Returns the FIRST value found (prioritizing .local over .conf). +func findVariableDefinition(varName string) (string, error) { + fail2banPath := "/etc/fail2ban" + + config.DebugLog("findVariableDefinition: searching for variable '%s'", varName) + + if _, err := os.Stat(fail2banPath); os.IsNotExist(err) { + return "", fmt.Errorf("variable '%s' not found: /etc/fail2ban directory does not exist", varName) + } + + // First pass: search .local files (higher priority) + var foundValue string + err := filepath.Walk(fail2banPath, func(path string, info os.FileInfo, err error) error { + if err != nil { + return nil + } + + if info.IsDir() || !strings.HasSuffix(strings.ToLower(path), ".local") { + return nil + } + + value, err := searchVariableInFile(path, varName) + if err != nil { + return nil // Skip files we can't read + } + + if value != "" { + foundValue = value + return filepath.SkipAll // Stop walking when found + } + + return nil + }) + + if foundValue != "" { + config.DebugLog("findVariableDefinition: returning value '%s' for variable '%s' (from .local file)", foundValue, varName) + return foundValue, nil + } + + if err != nil && err != filepath.SkipAll { + return "", err + } + + // Second pass: search .conf files (only if not found in .local) + config.DebugLog("findVariableDefinition: variable '%s' not found in .local files, searching .conf files", varName) + err = filepath.Walk(fail2banPath, func(path string, info os.FileInfo, err error) error { + if err != nil { + return nil + } + + if info.IsDir() || !strings.HasSuffix(strings.ToLower(path), ".conf") { + return nil + } + + value, err := searchVariableInFile(path, varName) + if err != nil { + return nil + } + + if value != "" { + foundValue = value + return filepath.SkipAll // Stop walking when found + } + + return nil + }) + + if foundValue != "" { + config.DebugLog("findVariableDefinition: returning value '%s' for variable '%s' (from .conf file)", foundValue, varName) + return foundValue, nil + } + + if err != nil && err != filepath.SkipAll { + return "", err + } + + config.DebugLog("findVariableDefinition: variable '%s' not found", varName) + return "", fmt.Errorf("variable '%s' not found in Fail2Ban configuration files", varName) +} + +// resolveVariableRecursive resolves a variable recursively, handling nested variables. +// visited map tracks visited variables to detect circular references. +// This function fully resolves all nested variables until no variables remain. +func resolveVariableRecursive(varName string, visited map[string]bool) (string, error) { + if visited[varName] { + return "", fmt.Errorf("circular reference detected for variable '%s'", varName) + } + + visited[varName] = true + defer delete(visited, varName) + + value, err := findVariableDefinition(varName) + if err != nil { + return "", err + } + + // Keep resolving until no more variables are found + resolved := value + maxIterations := 10 + iteration := 0 + + for iteration < maxIterations { + variables := extractVariablesFromString(resolved) + if len(variables) == 0 { + // No more variables, fully resolved + config.DebugLog("resolveVariableRecursive: '%s' fully resolved to '%s'", varName, resolved) + break + } + + config.DebugLog("resolveVariableRecursive: iteration %d for '%s', found %d variables in '%s': %v", iteration+1, varName, len(variables), resolved, variables) + + // Resolve all nested variables + for _, nestedVar := range variables { + // Check for circular reference + if visited[nestedVar] { + return "", fmt.Errorf("circular reference detected: '%s' -> '%s'", varName, nestedVar) + } + + config.DebugLog("resolveVariableRecursive: resolving nested variable '%s' for '%s'", nestedVar, varName) + nestedValue, err := resolveVariableRecursive(nestedVar, visited) + if err != nil { + return "", fmt.Errorf("failed to resolve variable '%s' in '%s': %w", nestedVar, varName, err) + } + + config.DebugLog("resolveVariableRecursive: resolved '%s' to '%s' for '%s'", nestedVar, nestedValue, varName) + + // Replace ALL occurrences of the nested variable + // Pattern: %(varName)s - need to escape parentheses for regex + // The pattern %(varName)s needs to be escaped as %\(varName\)s in regex + pattern := fmt.Sprintf("%%\\(%s\\)s", regexp.QuoteMeta(nestedVar)) + re := regexp.MustCompile(pattern) + beforeReplace := resolved + resolved = re.ReplaceAllString(resolved, nestedValue) + config.DebugLog("resolveVariableRecursive: replaced pattern '%s' in '%s' with '%s', result: '%s'", pattern, beforeReplace, nestedValue, resolved) + + // Verify the replacement actually happened + if beforeReplace == resolved { + config.DebugLog("resolveVariableRecursive: WARNING - replacement did not change string! Pattern: '%s', Before: '%s', After: '%s'", pattern, beforeReplace, resolved) + // If replacement didn't work, this is a critical error + return "", fmt.Errorf("failed to replace variable '%s' in '%s': pattern '%s' did not match", nestedVar, beforeReplace, pattern) + } + } + + // After replacing all variables in this iteration, check if we're done + // Verify no variables remain before continuing + remainingVars := extractVariablesFromString(resolved) + if len(remainingVars) == 0 { + // No more variables, fully resolved + config.DebugLog("resolveVariableRecursive: '%s' fully resolved to '%s' after replacements", varName, resolved) + break + } + + // If we still have variables after replacement, continue to next iteration + // But check if we made progress (resolved should be different from before) + iteration++ + } + + if iteration >= maxIterations { + return "", fmt.Errorf("maximum resolution iterations reached for variable '%s', possible circular reference. Last resolved value: '%s'", varName, resolved) + } + + return resolved, nil +} + +// ResolveLogpathVariables resolves all variables in a logpath string. +// Returns the fully resolved path. If no variables are present, returns the original path. +// Keeps resolving until no more variables are found (handles nested variables). +func ResolveLogpathVariables(logpath string) (string, error) { + if logpath == "" { + return "", nil + } + + logpath = strings.TrimSpace(logpath) + + // Keep resolving until no more variables are found + resolved := logpath + maxIterations := 10 // Prevent infinite loops + iteration := 0 + + for iteration < maxIterations { + variables := extractVariablesFromString(resolved) + if len(variables) == 0 { + // No more variables, we're done + break + } + + config.DebugLog("ResolveLogpathVariables: iteration %d, found %d variables in '%s'", iteration+1, len(variables), resolved) + + // Resolve all variables found in the current string + visited := make(map[string]bool) + for _, varName := range variables { + config.DebugLog("ResolveLogpathVariables: resolving variable '%s' from string '%s'", varName, resolved) + varValue, err := resolveVariableRecursive(varName, visited) + if err != nil { + return "", fmt.Errorf("failed to resolve variable '%s': %w", varName, err) + } + + config.DebugLog("ResolveLogpathVariables: resolved variable '%s' to '%s'", varName, varValue) + + // Replace ALL occurrences of the variable in the resolved string + // Pattern: %(varName)s - need to escape parentheses for regex + // The pattern %(varName)s needs to be escaped as %\(varName\)s in regex + pattern := fmt.Sprintf("%%\\(%s\\)s", regexp.QuoteMeta(varName)) + re := regexp.MustCompile(pattern) + beforeReplace := resolved + resolved = re.ReplaceAllString(resolved, varValue) + config.DebugLog("ResolveLogpathVariables: replaced pattern '%s' in '%s' with '%s', result: '%s'", pattern, beforeReplace, varValue, resolved) + } + + iteration++ + } + + if iteration >= maxIterations { + return "", fmt.Errorf("maximum resolution iterations reached, possible circular reference in logpath '%s'", logpath) + } + + config.DebugLog("Resolved logpath: '%s' -> '%s'", logpath, resolved) + return resolved, nil +} diff --git a/pkg/web/handlers.go b/pkg/web/handlers.go index d4a2b7a..4eec0f9 100644 --- a/pkg/web/handlers.go +++ b/pkg/web/handlers.go @@ -860,6 +860,7 @@ func equalStringSlices(a, b []string) bool { } // TestLogpathHandler tests a logpath and returns matching files +// Resolves Fail2Ban variables before testing func TestLogpathHandler(c *gin.Context) { config.DebugLog("----------------------------") config.DebugLog("TestLogpathHandler called (handlers.go)") // entry point @@ -878,22 +879,28 @@ func TestLogpathHandler(c *gin.Context) { } // Extract logpath from jail config - logpath := fail2ban.ExtractLogpathFromJailConfig(jailCfg) - if logpath == "" { - c.JSON(http.StatusOK, gin.H{"files": []string{}, "message": "No logpath configured for this jail"}) + originalLogpath := fail2ban.ExtractLogpathFromJailConfig(jailCfg) + if originalLogpath == "" { + c.JSON(http.StatusOK, gin.H{ + "original_logpath": "", + "resolved_logpath": "", + "files": []string{}, + "message": "No logpath configured for this jail", + }) return } - // Test the logpath - files, err := conn.TestLogpath(c.Request.Context(), logpath) + // Test the logpath with variable resolution + originalPath, resolvedPath, files, err := conn.TestLogpathWithResolution(c.Request.Context(), originalLogpath) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to test logpath: " + err.Error()}) return } c.JSON(http.StatusOK, gin.H{ - "logpath": logpath, - "files": files, + "original_logpath": originalPath, + "resolved_logpath": resolvedPath, + "files": files, }) } diff --git a/pkg/web/static/js/jails.js b/pkg/web/static/js/jails.js index 9aa0fe5..f9a310a 100644 --- a/pkg/web/static/js/jails.js +++ b/pkg/web/static/js/jails.js @@ -152,23 +152,54 @@ function testLogpath() { if (data.error) { resultsDiv.textContent = 'Error: ' + data.error; resultsDiv.classList.add('text-red-600'); + // Auto-scroll to results + setTimeout(function() { + resultsDiv.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); + }, 100); return; } + var originalLogpath = data.original_logpath || ''; + var resolvedLogpath = data.resolved_logpath || ''; var files = data.files || []; + + // Build output message + var output = ''; + + // Show resolved logpath if different from original + if (resolvedLogpath && resolvedLogpath !== originalLogpath) { + output += 'Resolved logpath: ' + resolvedLogpath + '\n\n'; + } else if (resolvedLogpath) { + output += 'Logpath: ' + resolvedLogpath + '\n\n'; + } else if (originalLogpath) { + output += 'Logpath: ' + originalLogpath + '\n\n'; + } + + // Show files found if (files.length === 0) { - resultsDiv.textContent = 'No files found for logpath: ' + (data.logpath || 'N/A'); + output += 'No files found matching the logpath pattern.'; resultsDiv.classList.remove('text-red-600'); resultsDiv.classList.add('text-yellow-600'); } else { - resultsDiv.textContent = 'Found ' + files.length + ' file(s):\n' + files.join('\n'); + output += 'Found ' + files.length + ' file(s):\n' + files.join('\n'); resultsDiv.classList.remove('text-red-600', 'text-yellow-600'); } + + resultsDiv.textContent = output; + + // Auto-scroll to results + setTimeout(function() { + resultsDiv.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); + }, 100); }) .catch(function(err) { showLoading(false); resultsDiv.textContent = 'Error: ' + err; resultsDiv.classList.add('text-red-600'); + // Auto-scroll to results + setTimeout(function() { + resultsDiv.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); + }, 100); }); }