mirror of
https://github.com/swissmakers/fail2ban-ui.git
synced 2026-04-11 13:47:05 +02:00
Enhanced Filter Debug page with inline editing and improved SSH filter testing
This commit is contained in:
@@ -308,11 +308,14 @@ func (ac *AgentConnector) GetFilters(ctx context.Context) ([]string, error) {
|
||||
}
|
||||
|
||||
// TestFilter implements Connector.
|
||||
func (ac *AgentConnector) TestFilter(ctx context.Context, filterName string, logLines []string) (string, string, error) {
|
||||
func (ac *AgentConnector) TestFilter(ctx context.Context, filterName string, logLines []string, filterContent string) (string, string, error) {
|
||||
payload := map[string]any{
|
||||
"filterName": filterName,
|
||||
"logLines": logLines,
|
||||
}
|
||||
if filterContent != "" {
|
||||
payload["filterContent"] = filterContent
|
||||
}
|
||||
var resp struct {
|
||||
Output string `json:"output"`
|
||||
FilterPath string `json:"filterPath"`
|
||||
|
||||
@@ -312,8 +312,8 @@ func (lc *LocalConnector) GetFilters(ctx context.Context) ([]string, error) {
|
||||
}
|
||||
|
||||
// TestFilter implements Connector.
|
||||
func (lc *LocalConnector) TestFilter(ctx context.Context, filterName string, logLines []string) (string, string, error) {
|
||||
return TestFilterLocal(filterName, logLines)
|
||||
func (lc *LocalConnector) TestFilter(ctx context.Context, filterName string, logLines []string, filterContent string) (string, string, error) {
|
||||
return TestFilterLocal(filterName, logLines, filterContent)
|
||||
}
|
||||
|
||||
// GetJailConfig implements Connector.
|
||||
|
||||
@@ -966,8 +966,163 @@ func (sc *SSHConnector) GetFilters(ctx context.Context) ([]string, error) {
|
||||
return filters, nil
|
||||
}
|
||||
|
||||
// resolveFilterIncludesRemote resolves filter includes by reading included files from remote server
|
||||
// This is similar to resolveFilterIncludes but reads files via SSH instead of local filesystem
|
||||
func (sc *SSHConnector) resolveFilterIncludesRemote(ctx context.Context, filterContent string, filterDPath string, currentFilterName string) (string, error) {
|
||||
lines := strings.Split(filterContent, "\n")
|
||||
var beforeFiles []string
|
||||
var afterFiles []string
|
||||
var inIncludesSection bool
|
||||
var mainContent strings.Builder
|
||||
|
||||
// Parse the filter content to find [INCLUDES] section
|
||||
for i, line := range lines {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
|
||||
// Check for [INCLUDES] section
|
||||
if strings.HasPrefix(trimmed, "[INCLUDES]") {
|
||||
inIncludesSection = true
|
||||
continue
|
||||
}
|
||||
|
||||
// Check for end of [INCLUDES] section (next section starts)
|
||||
if inIncludesSection && strings.HasPrefix(trimmed, "[") {
|
||||
inIncludesSection = false
|
||||
}
|
||||
|
||||
// Parse before and after directives
|
||||
if inIncludesSection {
|
||||
if strings.HasPrefix(strings.ToLower(trimmed), "before") {
|
||||
parts := strings.SplitN(trimmed, "=", 2)
|
||||
if len(parts) == 2 {
|
||||
file := strings.TrimSpace(parts[1])
|
||||
if file != "" {
|
||||
beforeFiles = append(beforeFiles, file)
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(strings.ToLower(trimmed), "after") {
|
||||
parts := strings.SplitN(trimmed, "=", 2)
|
||||
if len(parts) == 2 {
|
||||
file := strings.TrimSpace(parts[1])
|
||||
if file != "" {
|
||||
afterFiles = append(afterFiles, file)
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// Collect main content (everything except [INCLUDES] section)
|
||||
if !inIncludesSection {
|
||||
if i > 0 {
|
||||
mainContent.WriteString("\n")
|
||||
}
|
||||
mainContent.WriteString(line)
|
||||
}
|
||||
}
|
||||
|
||||
// Extract variables from main filter content first
|
||||
mainContentStr := mainContent.String()
|
||||
mainVariables := extractVariablesFromContent(mainContentStr)
|
||||
|
||||
// Build combined content: before files + main filter + after files
|
||||
var combined strings.Builder
|
||||
|
||||
// Helper function to read remote file
|
||||
readRemoteFilterFile := func(baseName string) (string, error) {
|
||||
localPath := filepath.Join(filterDPath, baseName+".local")
|
||||
confPath := filepath.Join(filterDPath, baseName+".conf")
|
||||
|
||||
// Try .local first
|
||||
content, err := sc.readRemoteFile(ctx, localPath)
|
||||
if err == nil {
|
||||
config.DebugLog("Loading included filter file from .local: %s", localPath)
|
||||
return content, nil
|
||||
}
|
||||
|
||||
// Fallback to .conf
|
||||
content, err = sc.readRemoteFile(ctx, confPath)
|
||||
if err == nil {
|
||||
config.DebugLog("Loading included filter file from .conf: %s", confPath)
|
||||
return content, nil
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("could not load included filter file '%s' or '%s'", localPath, confPath)
|
||||
}
|
||||
|
||||
// Load and append before files, removing duplicates that exist in main filter
|
||||
for _, fileName := range beforeFiles {
|
||||
// Remove any existing extension to get base name
|
||||
baseName := fileName
|
||||
if strings.HasSuffix(baseName, ".local") {
|
||||
baseName = strings.TrimSuffix(baseName, ".local")
|
||||
} else if strings.HasSuffix(baseName, ".conf") {
|
||||
baseName = strings.TrimSuffix(baseName, ".conf")
|
||||
}
|
||||
|
||||
// Skip if this is the same filter (avoid self-inclusion)
|
||||
if baseName == currentFilterName {
|
||||
config.DebugLog("Skipping self-inclusion of filter '%s' in before files", baseName)
|
||||
continue
|
||||
}
|
||||
|
||||
contentStr, err := readRemoteFilterFile(baseName)
|
||||
if err != nil {
|
||||
config.DebugLog("Warning: %v", err)
|
||||
continue // Skip if file doesn't exist
|
||||
}
|
||||
|
||||
// Remove variables from included file that are defined in main filter (main filter takes precedence)
|
||||
cleanedContent := removeDuplicateVariables(contentStr, mainVariables)
|
||||
combined.WriteString(cleanedContent)
|
||||
if !strings.HasSuffix(cleanedContent, "\n") {
|
||||
combined.WriteString("\n")
|
||||
}
|
||||
combined.WriteString("\n")
|
||||
}
|
||||
|
||||
// Append main filter content (unchanged - this is what the user is editing)
|
||||
combined.WriteString(mainContentStr)
|
||||
if !strings.HasSuffix(mainContentStr, "\n") {
|
||||
combined.WriteString("\n")
|
||||
}
|
||||
|
||||
// Load and append after files, also removing duplicates that exist in main filter
|
||||
for _, fileName := range afterFiles {
|
||||
// Remove any existing extension to get base name
|
||||
baseName := fileName
|
||||
if strings.HasSuffix(baseName, ".local") {
|
||||
baseName = strings.TrimSuffix(baseName, ".local")
|
||||
} else if strings.HasSuffix(baseName, ".conf") {
|
||||
baseName = strings.TrimSuffix(baseName, ".conf")
|
||||
}
|
||||
|
||||
// Note: Self-inclusion in "after" directive is intentional in fail2ban
|
||||
// (e.g., after = apache-common.local is standard pattern for .local files)
|
||||
// So we always load it, even if it's the same filter name
|
||||
|
||||
contentStr, err := readRemoteFilterFile(baseName)
|
||||
if err != nil {
|
||||
config.DebugLog("Warning: %v", err)
|
||||
continue // Skip if file doesn't exist
|
||||
}
|
||||
|
||||
// Remove variables from included file that are defined in main filter (main filter takes precedence)
|
||||
cleanedContent := removeDuplicateVariables(contentStr, mainVariables)
|
||||
combined.WriteString("\n")
|
||||
combined.WriteString(cleanedContent)
|
||||
if !strings.HasSuffix(cleanedContent, "\n") {
|
||||
combined.WriteString("\n")
|
||||
}
|
||||
}
|
||||
|
||||
return combined.String(), nil
|
||||
}
|
||||
|
||||
// TestFilter implements Connector.
|
||||
func (sc *SSHConnector) TestFilter(ctx context.Context, filterName string, logLines []string) (string, string, error) {
|
||||
func (sc *SSHConnector) TestFilter(ctx context.Context, filterName string, logLines []string, filterContent string) (string, string, error) {
|
||||
cleaned := normalizeLogLines(logLines)
|
||||
if len(cleaned) == 0 {
|
||||
return "No log lines provided.\n", "", nil
|
||||
@@ -989,9 +1144,52 @@ func (sc *SSHConnector) TestFilter(ctx context.Context, filterName string, logLi
|
||||
confPath := filepath.Join(fail2banPath, "filter.d", filterName+".conf")
|
||||
|
||||
const heredocMarker = "F2B_FILTER_TEST_LOG"
|
||||
const filterContentMarker = "F2B_FILTER_CONTENT"
|
||||
logContent := strings.Join(cleaned, "\n")
|
||||
|
||||
script := fmt.Sprintf(`
|
||||
var script string
|
||||
if filterContent != "" {
|
||||
// Resolve filter includes locally (same approach as local connector)
|
||||
// This avoids complex Python scripts and heredoc issues
|
||||
filterDPath := filepath.Join(fail2banPath, "filter.d")
|
||||
|
||||
// First, we need to create a remote-aware version of resolveFilterIncludes
|
||||
// that can read included files from the remote server
|
||||
resolvedContent, err := sc.resolveFilterIncludesRemote(ctx, filterContent, filterDPath, filterName)
|
||||
if err != nil {
|
||||
config.DebugLog("Warning: failed to resolve filter includes remotely, using original content: %v", err)
|
||||
resolvedContent = filterContent
|
||||
}
|
||||
|
||||
// Ensure it ends with a newline for proper parsing
|
||||
if !strings.HasSuffix(resolvedContent, "\n") {
|
||||
resolvedContent += "\n"
|
||||
}
|
||||
|
||||
// Base64 encode resolved filter content to avoid any heredoc/escaping issues
|
||||
resolvedContentB64 := base64.StdEncoding.EncodeToString([]byte(resolvedContent))
|
||||
|
||||
// Simple script: just write the resolved content to temp file and test
|
||||
script = fmt.Sprintf(`
|
||||
set -e
|
||||
TMPFILTER=$(mktemp /tmp/fail2ban-filter-XXXXXX.conf)
|
||||
trap 'rm -f "$TMPFILTER"' EXIT
|
||||
|
||||
# Write resolved filter content to temp file using base64 decode
|
||||
echo '%[1]s' | base64 -d > "$TMPFILTER"
|
||||
|
||||
FILTER_PATH="$TMPFILTER"
|
||||
echo "FILTER_PATH:$FILTER_PATH"
|
||||
TMPFILE=$(mktemp /tmp/fail2ban-test-XXXXXX.log)
|
||||
trap 'rm -f "$TMPFILE" "$TMPFILTER"' EXIT
|
||||
cat <<'%[2]s' > "$TMPFILE"
|
||||
%[3]s
|
||||
%[2]s
|
||||
fail2ban-regex "$TMPFILE" "$FILTER_PATH" || true
|
||||
`, resolvedContentB64, heredocMarker, logContent)
|
||||
} else {
|
||||
// Use existing filter file
|
||||
script = fmt.Sprintf(`
|
||||
set -e
|
||||
LOCAL_PATH=%[1]q
|
||||
CONF_PATH=%[2]q
|
||||
@@ -1012,6 +1210,7 @@ cat <<'%[3]s' > "$TMPFILE"
|
||||
%[3]s
|
||||
fail2ban-regex "$TMPFILE" "$FILTER_PATH" || true
|
||||
`, localPath, confPath, heredocMarker, logContent)
|
||||
}
|
||||
|
||||
out, err := sc.runRemoteCommand(ctx, []string{script})
|
||||
if err != nil {
|
||||
|
||||
@@ -90,6 +90,33 @@ func ensureFilterLocalFile(filterName string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// RemoveComments removes all lines that start with # (comments) from filter content
|
||||
// and trims leading/trailing empty newlines
|
||||
// This is exported for use in handlers that need to display filter content without comments
|
||||
func RemoveComments(content string) string {
|
||||
lines := strings.Split(content, "\n")
|
||||
var result []string
|
||||
for _, line := range lines {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
// Skip lines that start with # (comments)
|
||||
if !strings.HasPrefix(trimmed, "#") {
|
||||
result = append(result, line)
|
||||
}
|
||||
}
|
||||
|
||||
// Remove leading empty lines
|
||||
for len(result) > 0 && strings.TrimSpace(result[0]) == "" {
|
||||
result = result[1:]
|
||||
}
|
||||
|
||||
// Remove trailing empty lines
|
||||
for len(result) > 0 && strings.TrimSpace(result[len(result)-1]) == "" {
|
||||
result = result[:len(result)-1]
|
||||
}
|
||||
|
||||
return strings.Join(result, "\n")
|
||||
}
|
||||
|
||||
// readFilterConfigWithFallback reads filter config from .local first, then falls back to .conf.
|
||||
// Returns (content, filePath, error)
|
||||
func readFilterConfigWithFallback(filterName string) (string, string, error) {
|
||||
@@ -353,20 +380,313 @@ func normalizeLogLines(logLines []string) []string {
|
||||
return cleaned
|
||||
}
|
||||
|
||||
// extractVariablesFromContent extracts variable names from [DEFAULT] section of filter content
|
||||
func extractVariablesFromContent(content string) map[string]bool {
|
||||
variables := make(map[string]bool)
|
||||
lines := strings.Split(content, "\n")
|
||||
inDefaultSection := false
|
||||
|
||||
for _, line := range lines {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
|
||||
// Check for [DEFAULT] section
|
||||
if strings.HasPrefix(trimmed, "[DEFAULT]") {
|
||||
inDefaultSection = true
|
||||
continue
|
||||
}
|
||||
|
||||
// Check for end of [DEFAULT] section (next section starts)
|
||||
if inDefaultSection && strings.HasPrefix(trimmed, "[") {
|
||||
inDefaultSection = false
|
||||
continue
|
||||
}
|
||||
|
||||
// Extract variable name from [DEFAULT] section
|
||||
if inDefaultSection && !strings.HasPrefix(trimmed, "#") && strings.Contains(trimmed, "=") {
|
||||
parts := strings.SplitN(trimmed, "=", 2)
|
||||
if len(parts) == 2 {
|
||||
varName := strings.TrimSpace(parts[0])
|
||||
if varName != "" {
|
||||
variables[varName] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return variables
|
||||
}
|
||||
|
||||
// removeDuplicateVariables removes variable definitions from included content that already exist in main filter
|
||||
func removeDuplicateVariables(includedContent string, mainVariables map[string]bool) string {
|
||||
lines := strings.Split(includedContent, "\n")
|
||||
var result strings.Builder
|
||||
inDefaultSection := false
|
||||
removedCount := 0
|
||||
|
||||
for _, line := range lines {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
originalLine := line
|
||||
|
||||
// Check for [DEFAULT] section
|
||||
if strings.HasPrefix(trimmed, "[DEFAULT]") {
|
||||
inDefaultSection = true
|
||||
result.WriteString(originalLine)
|
||||
result.WriteString("\n")
|
||||
continue
|
||||
}
|
||||
|
||||
// Check for end of [DEFAULT] section (next section starts)
|
||||
if inDefaultSection && strings.HasPrefix(trimmed, "[") {
|
||||
inDefaultSection = false
|
||||
result.WriteString(originalLine)
|
||||
result.WriteString("\n")
|
||||
continue
|
||||
}
|
||||
|
||||
// In [DEFAULT] section, check if variable already exists in main filter
|
||||
if inDefaultSection && !strings.HasPrefix(trimmed, "#") && strings.Contains(trimmed, "=") {
|
||||
parts := strings.SplitN(trimmed, "=", 2)
|
||||
if len(parts) == 2 {
|
||||
varName := strings.TrimSpace(parts[0])
|
||||
if mainVariables[varName] {
|
||||
// Skip this line - variable will be defined in main filter (takes precedence)
|
||||
removedCount++
|
||||
config.DebugLog("Removing variable '%s' from included file (will be overridden by main filter)", varName)
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result.WriteString(originalLine)
|
||||
result.WriteString("\n")
|
||||
}
|
||||
|
||||
if removedCount > 0 {
|
||||
config.DebugLog("Removed %d variable definitions from included file (overridden by main filter)", removedCount)
|
||||
}
|
||||
|
||||
return result.String()
|
||||
}
|
||||
|
||||
// resolveFilterIncludes parses the filter content to find [INCLUDES] section
|
||||
// and loads the included files, combining them with the main filter content.
|
||||
// Returns: combined content with before files + main filter + after files
|
||||
// Duplicate variables in main filter are removed if they exist in included files
|
||||
// currentFilterName: name of the current filter being tested (to avoid self-inclusion)
|
||||
func resolveFilterIncludes(filterContent string, filterDPath string, currentFilterName string) (string, error) {
|
||||
lines := strings.Split(filterContent, "\n")
|
||||
var beforeFiles []string
|
||||
var afterFiles []string
|
||||
var inIncludesSection bool
|
||||
var mainContent strings.Builder
|
||||
|
||||
// Parse the filter content to find [INCLUDES] section
|
||||
for i, line := range lines {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
|
||||
// Check for [INCLUDES] section
|
||||
if strings.HasPrefix(trimmed, "[INCLUDES]") {
|
||||
inIncludesSection = true
|
||||
continue
|
||||
}
|
||||
|
||||
// Check for end of [INCLUDES] section (next section starts)
|
||||
if inIncludesSection && strings.HasPrefix(trimmed, "[") {
|
||||
inIncludesSection = false
|
||||
}
|
||||
|
||||
// Parse before and after directives
|
||||
if inIncludesSection {
|
||||
if strings.HasPrefix(strings.ToLower(trimmed), "before") {
|
||||
parts := strings.SplitN(trimmed, "=", 2)
|
||||
if len(parts) == 2 {
|
||||
file := strings.TrimSpace(parts[1])
|
||||
if file != "" {
|
||||
beforeFiles = append(beforeFiles, file)
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(strings.ToLower(trimmed), "after") {
|
||||
parts := strings.SplitN(trimmed, "=", 2)
|
||||
if len(parts) == 2 {
|
||||
file := strings.TrimSpace(parts[1])
|
||||
if file != "" {
|
||||
afterFiles = append(afterFiles, file)
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// Collect main content (everything except [INCLUDES] section)
|
||||
if !inIncludesSection {
|
||||
if i > 0 {
|
||||
mainContent.WriteString("\n")
|
||||
}
|
||||
mainContent.WriteString(line)
|
||||
}
|
||||
}
|
||||
|
||||
// Extract variables from main filter content first
|
||||
mainContentStr := mainContent.String()
|
||||
mainVariables := extractVariablesFromContent(mainContentStr)
|
||||
|
||||
// Build combined content: before files + main filter + after files
|
||||
var combined strings.Builder
|
||||
|
||||
// Load and append before files, removing duplicates that exist in main filter
|
||||
for _, fileName := range beforeFiles {
|
||||
// Remove any existing extension to get base name
|
||||
baseName := fileName
|
||||
if strings.HasSuffix(baseName, ".local") {
|
||||
baseName = strings.TrimSuffix(baseName, ".local")
|
||||
} else if strings.HasSuffix(baseName, ".conf") {
|
||||
baseName = strings.TrimSuffix(baseName, ".conf")
|
||||
}
|
||||
|
||||
// Skip if this is the same filter (avoid self-inclusion)
|
||||
if baseName == currentFilterName {
|
||||
config.DebugLog("Skipping self-inclusion of filter '%s' in before files", baseName)
|
||||
continue
|
||||
}
|
||||
|
||||
// Always try .local first, then .conf (matching fail2ban's behavior)
|
||||
localPath := filepath.Join(filterDPath, baseName+".local")
|
||||
confPath := filepath.Join(filterDPath, baseName+".conf")
|
||||
|
||||
var content []byte
|
||||
var err error
|
||||
var filePath string
|
||||
|
||||
// Try .local first
|
||||
if content, err = os.ReadFile(localPath); err == nil {
|
||||
filePath = localPath
|
||||
config.DebugLog("Loading included filter file from .local: %s", filePath)
|
||||
} else if content, err = os.ReadFile(confPath); err == nil {
|
||||
filePath = confPath
|
||||
config.DebugLog("Loading included filter file from .conf: %s", filePath)
|
||||
} else {
|
||||
config.DebugLog("Warning: could not load included filter file '%s' or '%s': %v", localPath, confPath, err)
|
||||
continue // Skip if neither file exists
|
||||
}
|
||||
|
||||
contentStr := string(content)
|
||||
// Remove variables from included file that are defined in main filter (main filter takes precedence)
|
||||
cleanedContent := removeDuplicateVariables(contentStr, mainVariables)
|
||||
combined.WriteString(cleanedContent)
|
||||
if !strings.HasSuffix(cleanedContent, "\n") {
|
||||
combined.WriteString("\n")
|
||||
}
|
||||
combined.WriteString("\n")
|
||||
}
|
||||
|
||||
// Append main filter content (unchanged - this is what the user is editing)
|
||||
combined.WriteString(mainContentStr)
|
||||
if !strings.HasSuffix(mainContentStr, "\n") {
|
||||
combined.WriteString("\n")
|
||||
}
|
||||
|
||||
// Load and append after files, also removing duplicates that exist in main filter
|
||||
for _, fileName := range afterFiles {
|
||||
// Remove any existing extension to get base name
|
||||
baseName := fileName
|
||||
if strings.HasSuffix(baseName, ".local") {
|
||||
baseName = strings.TrimSuffix(baseName, ".local")
|
||||
} else if strings.HasSuffix(baseName, ".conf") {
|
||||
baseName = strings.TrimSuffix(baseName, ".conf")
|
||||
}
|
||||
|
||||
// Note: Self-inclusion in "after" directive is intentional in fail2ban
|
||||
// (e.g., after = apache-common.local is standard pattern for .local files)
|
||||
// So we always load it, even if it's the same filter name
|
||||
|
||||
// Always try .local first, then .conf (matching fail2ban's behavior)
|
||||
localPath := filepath.Join(filterDPath, baseName+".local")
|
||||
confPath := filepath.Join(filterDPath, baseName+".conf")
|
||||
|
||||
var content []byte
|
||||
var err error
|
||||
var filePath string
|
||||
|
||||
// Try .local first
|
||||
if content, err = os.ReadFile(localPath); err == nil {
|
||||
filePath = localPath
|
||||
config.DebugLog("Loading included filter file from .local: %s", filePath)
|
||||
} else if content, err = os.ReadFile(confPath); err == nil {
|
||||
filePath = confPath
|
||||
config.DebugLog("Loading included filter file from .conf: %s", filePath)
|
||||
} else {
|
||||
config.DebugLog("Warning: could not load included filter file '%s' or '%s': %v", localPath, confPath, err)
|
||||
continue // Skip if neither file exists
|
||||
}
|
||||
|
||||
contentStr := string(content)
|
||||
// Remove variables from included file that are defined in main filter (main filter takes precedence)
|
||||
cleanedContent := removeDuplicateVariables(contentStr, mainVariables)
|
||||
combined.WriteString("\n")
|
||||
combined.WriteString(cleanedContent)
|
||||
if !strings.HasSuffix(cleanedContent, "\n") {
|
||||
combined.WriteString("\n")
|
||||
}
|
||||
}
|
||||
|
||||
return combined.String(), nil
|
||||
}
|
||||
|
||||
// TestFilterLocal tests a filter against log lines using fail2ban-regex
|
||||
// Returns the full output of fail2ban-regex command and the filter path used
|
||||
// Uses .local file if it exists, otherwise falls back to .conf file
|
||||
func TestFilterLocal(filterName string, logLines []string) (string, string, error) {
|
||||
// If filterContent is provided, it creates a temporary filter file and uses that instead
|
||||
func TestFilterLocal(filterName string, logLines []string, filterContent string) (string, string, error) {
|
||||
cleaned := normalizeLogLines(logLines)
|
||||
if len(cleaned) == 0 {
|
||||
return "No log lines provided.\n", "", nil
|
||||
}
|
||||
|
||||
var filterPath string
|
||||
var tempFilterFile *os.File
|
||||
var err error
|
||||
|
||||
// If custom filter content is provided, create a temporary filter file
|
||||
if filterContent != "" {
|
||||
tempFilterFile, err = os.CreateTemp("", "fail2ban-filter-*.conf")
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("failed to create temporary filter file: %w", err)
|
||||
}
|
||||
defer os.Remove(tempFilterFile.Name())
|
||||
defer tempFilterFile.Close()
|
||||
|
||||
// Resolve filter includes to get complete filter content with all dependencies
|
||||
filterDPath := "/etc/fail2ban/filter.d"
|
||||
contentToWrite, err := resolveFilterIncludes(filterContent, filterDPath, filterName)
|
||||
if err != nil {
|
||||
config.DebugLog("Warning: failed to resolve filter includes, using original content: %v", err)
|
||||
contentToWrite = filterContent
|
||||
}
|
||||
|
||||
// Ensure it ends with a newline for proper parsing
|
||||
if !strings.HasSuffix(contentToWrite, "\n") {
|
||||
contentToWrite += "\n"
|
||||
}
|
||||
|
||||
if _, err := tempFilterFile.WriteString(contentToWrite); err != nil {
|
||||
return "", "", fmt.Errorf("failed to write temporary filter file: %w", err)
|
||||
}
|
||||
|
||||
// Ensure the file is synced to disk
|
||||
if err := tempFilterFile.Sync(); err != nil {
|
||||
return "", "", fmt.Errorf("failed to sync temporary filter file: %w", err)
|
||||
}
|
||||
|
||||
tempFilterFile.Close()
|
||||
filterPath = tempFilterFile.Name()
|
||||
config.DebugLog("TestFilterLocal: using custom filter content from temporary file: %s (size: %d bytes, includes resolved: %v)", filterPath, len(contentToWrite), err == nil)
|
||||
} else {
|
||||
// Try .local first, then fallback to .conf
|
||||
localPath := filepath.Join("/etc/fail2ban/filter.d", filterName+".local")
|
||||
confPath := filepath.Join("/etc/fail2ban/filter.d", filterName+".conf")
|
||||
|
||||
var filterPath string
|
||||
if _, err := os.Stat(localPath); err == nil {
|
||||
filterPath = localPath
|
||||
config.DebugLog("TestFilterLocal: using .local file: %s", filterPath)
|
||||
@@ -376,6 +696,7 @@ func TestFilterLocal(filterName string, logLines []string) (string, string, erro
|
||||
} else {
|
||||
return "", "", fmt.Errorf("filter %s not found (checked both .local and .conf): %w", filterName, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Create a temporary log file with all log lines
|
||||
tmpFile, err := os.CreateTemp("", "fail2ban-test-*.log")
|
||||
|
||||
@@ -29,7 +29,7 @@ type Connector interface {
|
||||
|
||||
// Filter operations
|
||||
GetFilters(ctx context.Context) ([]string, error)
|
||||
TestFilter(ctx context.Context, filterName string, logLines []string) (output string, filterPath string, err error)
|
||||
TestFilter(ctx context.Context, filterName string, logLines []string, filterContent string) (output string, filterPath string, err error)
|
||||
|
||||
// Jail configuration operations
|
||||
GetJailConfig(ctx context.Context, jail string) (string, string, error) // Returns (config, filePath, error)
|
||||
|
||||
@@ -101,6 +101,11 @@
|
||||
"logs.modal.insights_recurring_empty": "Keine wiederkehrenden IPs erkannt.",
|
||||
"filter_debug.title": "Filter-Debug",
|
||||
"filter_debug.select_filter": "Wählen Sie einen Filter",
|
||||
"filter_debug.filter_content": "Filter-Inhalt",
|
||||
"filter_debug.filter_content_hint": "Bearbeiten Sie den Filter-Regex zum Testen. Änderungen sind temporär und werden nicht gespeichert.",
|
||||
"filter_debug.filter_content_hint_readonly": "Filter-Inhalt wird schreibgeschützt angezeigt. Klicken Sie auf 'Bearbeiten', um diesen für Tests zu ändern. Änderungen sind temporär und werden nicht gespeichert.",
|
||||
"filter_debug.edit_filter": "Bearbeiten",
|
||||
"filter_debug.cancel_edit": "Abbrechen",
|
||||
"filter_debug.log_lines": "Logzeilen",
|
||||
"filter_debug.log_lines_placeholder": "Geben Sie die Logzeilen hier ein...",
|
||||
"filter_debug.test_filter": "Filter testen",
|
||||
|
||||
@@ -101,6 +101,11 @@
|
||||
"logs.modal.insights_recurring_empty": "Ke wiederkehrendi IPs erkannt.",
|
||||
"filter_debug.title": "Filter Debug",
|
||||
"filter_debug.select_filter": "Wähl ä Filter us",
|
||||
"filter_debug.filter_content": "Filter-Inhalt",
|
||||
"filter_debug.filter_content_hint": "Bearbeit dr Fiuter-Regex unge zum Testä. Änderigä si temporär und wärde nid gspeichert.",
|
||||
"filter_debug.filter_content_hint_readonly": "Fiuter-Inhalt wird schribgschützt azeigt. Klick uf 'Bearbeitä', zum abändärä. Änderigä si temporär und wärde nid gspeichert.",
|
||||
"filter_debug.edit_filter": "Bearbeitä",
|
||||
"filter_debug.cancel_edit": "Abbrächä",
|
||||
"filter_debug.log_lines": "Log-Zile",
|
||||
"filter_debug.log_lines_placeholder": "Füeg ä Log-Zile ii...",
|
||||
"filter_debug.test_filter": "Filter teste",
|
||||
|
||||
@@ -101,6 +101,11 @@
|
||||
"logs.modal.insights_recurring_empty": "No recurring IPs detected.",
|
||||
"filter_debug.title": "Filter Debug",
|
||||
"filter_debug.select_filter": "Select a Filter",
|
||||
"filter_debug.filter_content": "Filter Content",
|
||||
"filter_debug.filter_content_hint": "Edit the filter regex below for testing. Changes are temporary and not saved.",
|
||||
"filter_debug.filter_content_hint_readonly": "Filter content is shown read-only. Click 'Edit' to modify for testing. Changes are temporary and not saved.",
|
||||
"filter_debug.edit_filter": "Edit",
|
||||
"filter_debug.cancel_edit": "Cancel",
|
||||
"filter_debug.log_lines": "Log Lines",
|
||||
"filter_debug.log_lines_placeholder": "Enter log lines here...",
|
||||
"filter_debug.test_filter": "Test Filter",
|
||||
|
||||
@@ -101,6 +101,11 @@
|
||||
"logs.modal.insights_recurring_empty": "No se detectaron IPs recurrentes.",
|
||||
"filter_debug.title": "Depuración de filtros",
|
||||
"filter_debug.select_filter": "Selecciona un filtro",
|
||||
"filter_debug.filter_content": "Contenido del filtro",
|
||||
"filter_debug.filter_content_hint": "Edita el regex del filtro a continuación para las pruebas. Los cambios son temporales y no se guardan.",
|
||||
"filter_debug.filter_content_hint_readonly": "El contenido del filtro se muestra de solo lectura. Haz clic en 'Editar' para modificar para las pruebas. Los cambios son temporales y no se guardan.",
|
||||
"filter_debug.edit_filter": "Editar",
|
||||
"filter_debug.cancel_edit": "Cancelar",
|
||||
"filter_debug.log_lines": "Líneas de log",
|
||||
"filter_debug.log_lines_placeholder": "Introduce las líneas de log aquí...",
|
||||
"filter_debug.test_filter": "Probar filtro",
|
||||
|
||||
@@ -101,6 +101,11 @@
|
||||
"logs.modal.insights_recurring_empty": "Aucune IP récurrente détectée.",
|
||||
"filter_debug.title": "Débogage des filtres",
|
||||
"filter_debug.select_filter": "Sélectionnez un filtre",
|
||||
"filter_debug.filter_content": "Contenu du filtre",
|
||||
"filter_debug.filter_content_hint": "Modifiez le regex du filtre ci-dessous pour les tests. Les modifications sont temporaires et ne sont pas enregistrées.",
|
||||
"filter_debug.filter_content_hint_readonly": "Le contenu du filtre est affiché en lecture seule. Cliquez sur 'Modifier' pour modifier pour les tests. Les modifications sont temporaires et ne sont pas enregistrées.",
|
||||
"filter_debug.edit_filter": "Modifier",
|
||||
"filter_debug.cancel_edit": "Annuler",
|
||||
"filter_debug.log_lines": "Lignes de log",
|
||||
"filter_debug.log_lines_placeholder": "Entrez les lignes de log ici...",
|
||||
"filter_debug.test_filter": "Tester le filtre",
|
||||
|
||||
@@ -101,6 +101,11 @@
|
||||
"logs.modal.insights_recurring_empty": "Nessun IP ricorrente rilevato.",
|
||||
"filter_debug.title": "Debug Filtro",
|
||||
"filter_debug.select_filter": "Seleziona un filtro",
|
||||
"filter_debug.filter_content": "Contenuto del filtro",
|
||||
"filter_debug.filter_content_hint": "Modifica il regex del filtro qui sotto per i test. Le modifiche sono temporanee e non vengono salvate.",
|
||||
"filter_debug.filter_content_hint_readonly": "Il contenuto del filtro è mostrato in sola lettura. Clicca su 'Modifica' per modificare per i test. Le modifiche sono temporanee e non vengono salvate.",
|
||||
"filter_debug.edit_filter": "Modifica",
|
||||
"filter_debug.cancel_edit": "Annulla",
|
||||
"filter_debug.log_lines": "Righe di log",
|
||||
"filter_debug.log_lines_placeholder": "Inserisci qui le righe di log...",
|
||||
"filter_debug.test_filter": "Testa filtro",
|
||||
|
||||
@@ -1982,6 +1982,31 @@ func ListFiltersHandler(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"filters": filters})
|
||||
}
|
||||
|
||||
func GetFilterContentHandler(c *gin.Context) {
|
||||
config.DebugLog("----------------------------")
|
||||
config.DebugLog("GetFilterContentHandler called (handlers.go)")
|
||||
filterName := c.Param("filter")
|
||||
conn, err := resolveConnector(c)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
content, filePath, err := conn.GetFilterConfig(c.Request.Context(), filterName)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get filter content: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Remove comments for display in Filter Debug page only
|
||||
content = fail2ban.RemoveComments(content)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"content": content,
|
||||
"filterPath": filePath,
|
||||
})
|
||||
}
|
||||
|
||||
func TestFilterHandler(c *gin.Context) {
|
||||
config.DebugLog("----------------------------")
|
||||
config.DebugLog("TestFilterHandler called (handlers.go)") // entry point
|
||||
@@ -1993,13 +2018,14 @@ func TestFilterHandler(c *gin.Context) {
|
||||
var req struct {
|
||||
FilterName string `json:"filterName"`
|
||||
LogLines []string `json:"logLines"`
|
||||
FilterContent string `json:"filterContent"` // Optional: if provided, use this instead of reading from file
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid JSON"})
|
||||
return
|
||||
}
|
||||
|
||||
output, filterPath, err := conn.TestFilter(c.Request.Context(), req.FilterName, req.LogLines)
|
||||
output, filterPath, err := conn.TestFilter(c.Request.Context(), req.FilterName, req.LogLines, req.FilterContent)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to test filter: " + err.Error()})
|
||||
return
|
||||
|
||||
@@ -62,6 +62,7 @@ func RegisterRoutes(r *gin.Engine, hub *Hub) {
|
||||
|
||||
// Filter debugger endpoints
|
||||
api.GET("/filters", ListFiltersHandler)
|
||||
api.GET("/filters/:filter/content", GetFilterContentHandler)
|
||||
api.POST("/filters/test", TestFilterHandler)
|
||||
api.POST("/filters", CreateFilterHandler)
|
||||
api.DELETE("/filters/:filter", DeleteFilterHandler)
|
||||
|
||||
@@ -43,9 +43,28 @@ function loadFilters() {
|
||||
select.setAttribute('data-listener-added', 'true');
|
||||
select.addEventListener('change', function() {
|
||||
if (deleteBtn) deleteBtn.disabled = !select.value;
|
||||
// Load filter content when a filter is selected
|
||||
if (select.value) {
|
||||
loadFilterContent(select.value);
|
||||
} else {
|
||||
const filterContentTextarea = document.getElementById('filterContentTextarea');
|
||||
const editBtn = document.getElementById('editFilterContentBtn');
|
||||
if (filterContentTextarea) {
|
||||
filterContentTextarea.value = '';
|
||||
filterContentTextarea.readOnly = true;
|
||||
filterContentTextarea.classList.add('bg-gray-50');
|
||||
filterContentTextarea.classList.remove('bg-white');
|
||||
}
|
||||
if (editBtn) editBtn.classList.add('hidden');
|
||||
updateFilterContentHints(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
if (deleteBtn) deleteBtn.disabled = !select.value;
|
||||
// If a filter is already selected (e.g., first one by default), load its content
|
||||
if (select.value) {
|
||||
loadFilterContent(select.value);
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
@@ -54,9 +73,93 @@ function loadFilters() {
|
||||
.finally(() => showLoading(false));
|
||||
}
|
||||
|
||||
function loadFilterContent(filterName) {
|
||||
const filterContentTextarea = document.getElementById('filterContentTextarea');
|
||||
const editBtn = document.getElementById('editFilterContentBtn');
|
||||
if (!filterContentTextarea) return;
|
||||
|
||||
showLoading(true);
|
||||
fetch(withServerParam('/api/filters/' + encodeURIComponent(filterName) + '/content'), {
|
||||
headers: serverHeaders()
|
||||
})
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
if (data.error) {
|
||||
showToast('Error loading filter content: ' + data.error, 'error');
|
||||
filterContentTextarea.value = '';
|
||||
filterContentTextarea.readOnly = true;
|
||||
if (editBtn) editBtn.classList.add('hidden');
|
||||
updateFilterContentHints(false);
|
||||
return;
|
||||
}
|
||||
filterContentTextarea.value = data.content || '';
|
||||
filterContentTextarea.readOnly = true; // Keep it readonly by default
|
||||
filterContentTextarea.classList.add('bg-gray-50');
|
||||
filterContentTextarea.classList.remove('bg-white');
|
||||
if (editBtn) editBtn.classList.remove('hidden');
|
||||
updateFilterContentHints(false);
|
||||
})
|
||||
.catch(err => {
|
||||
showToast('Error loading filter content: ' + err, 'error');
|
||||
filterContentTextarea.value = '';
|
||||
filterContentTextarea.readOnly = true;
|
||||
if (editBtn) editBtn.classList.add('hidden');
|
||||
updateFilterContentHints(false);
|
||||
})
|
||||
.finally(() => showLoading(false));
|
||||
}
|
||||
|
||||
function toggleFilterContentEdit() {
|
||||
const filterContentTextarea = document.getElementById('filterContentTextarea');
|
||||
const editBtn = document.getElementById('editFilterContentBtn');
|
||||
if (!filterContentTextarea) return;
|
||||
|
||||
if (filterContentTextarea.readOnly) {
|
||||
// Make editable
|
||||
filterContentTextarea.readOnly = false;
|
||||
filterContentTextarea.classList.remove('bg-gray-50');
|
||||
filterContentTextarea.classList.add('bg-white');
|
||||
if (editBtn) {
|
||||
editBtn.textContent = t('filter_debug.cancel_edit', 'Cancel');
|
||||
editBtn.classList.remove('bg-blue-600', 'hover:bg-blue-700');
|
||||
editBtn.classList.add('bg-gray-600', 'hover:bg-gray-700');
|
||||
}
|
||||
updateFilterContentHints(true);
|
||||
} else {
|
||||
// Make readonly
|
||||
filterContentTextarea.readOnly = true;
|
||||
filterContentTextarea.classList.add('bg-gray-50');
|
||||
filterContentTextarea.classList.remove('bg-white');
|
||||
if (editBtn) {
|
||||
editBtn.textContent = t('filter_debug.edit_filter', 'Edit');
|
||||
editBtn.classList.remove('bg-gray-600', 'hover:bg-gray-700');
|
||||
editBtn.classList.add('bg-blue-600', 'hover:bg-blue-700');
|
||||
}
|
||||
updateFilterContentHints(false);
|
||||
}
|
||||
}
|
||||
|
||||
function updateFilterContentHints(isEditable) {
|
||||
const readonlyHint = document.querySelector('p[data-i18n="filter_debug.filter_content_hint_readonly"]');
|
||||
const editableHint = document.getElementById('filterContentHintEditable');
|
||||
|
||||
if (isEditable) {
|
||||
if (readonlyHint) readonlyHint.classList.add('hidden');
|
||||
if (editableHint) editableHint.classList.remove('hidden');
|
||||
} else {
|
||||
if (readonlyHint) readonlyHint.classList.remove('hidden');
|
||||
if (editableHint) editableHint.classList.add('hidden');
|
||||
}
|
||||
|
||||
if (typeof updateTranslations === 'function') {
|
||||
updateTranslations();
|
||||
}
|
||||
}
|
||||
|
||||
function testSelectedFilter() {
|
||||
const filterName = document.getElementById('filterSelect').value;
|
||||
const lines = document.getElementById('logLinesTextarea').value.split('\n').filter(line => line.trim() !== '');
|
||||
const filterContentTextarea = document.getElementById('filterContentTextarea');
|
||||
|
||||
if (!filterName) {
|
||||
showToast('Please select a filter.', 'info');
|
||||
@@ -74,13 +177,24 @@ function testSelectedFilter() {
|
||||
testResultsEl.innerHTML = '';
|
||||
|
||||
showLoading(true);
|
||||
const requestBody = {
|
||||
filterName: filterName,
|
||||
logLines: lines
|
||||
};
|
||||
|
||||
// Only include filter content if textarea is editable (not readonly)
|
||||
// If readonly, test the original filter from server
|
||||
if (filterContentTextarea && !filterContentTextarea.readOnly) {
|
||||
const filterContent = filterContentTextarea.value.trim();
|
||||
if (filterContent) {
|
||||
requestBody.filterContent = filterContent;
|
||||
}
|
||||
}
|
||||
|
||||
fetch(withServerParam('/api/filters/test'), {
|
||||
method: 'POST',
|
||||
headers: serverHeaders({ 'Content-Type': 'application/json' }),
|
||||
body: JSON.stringify({
|
||||
filterName: filterName,
|
||||
logLines: lines
|
||||
})
|
||||
body: JSON.stringify(requestBody)
|
||||
})
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
@@ -122,6 +236,7 @@ function renderTestResults(output, filterPath) {
|
||||
|
||||
function showFilterSection() {
|
||||
const testResultsEl = document.getElementById('testResults');
|
||||
const filterContentTextarea = document.getElementById('filterContentTextarea');
|
||||
if (!currentServerId) {
|
||||
var notice = document.getElementById('filterNotice');
|
||||
if (notice) {
|
||||
@@ -130,6 +245,10 @@ function showFilterSection() {
|
||||
}
|
||||
document.getElementById('filterSelect').innerHTML = '';
|
||||
document.getElementById('logLinesTextarea').value = '';
|
||||
if (filterContentTextarea) {
|
||||
filterContentTextarea.value = '';
|
||||
filterContentTextarea.readOnly = true;
|
||||
}
|
||||
testResultsEl.innerHTML = '';
|
||||
testResultsEl.classList.add('hidden');
|
||||
document.getElementById('deleteFilterBtn').disabled = true;
|
||||
@@ -139,13 +258,38 @@ function showFilterSection() {
|
||||
testResultsEl.innerHTML = '';
|
||||
testResultsEl.classList.add('hidden');
|
||||
document.getElementById('logLinesTextarea').value = '';
|
||||
// Add change listener to enable/disable delete button
|
||||
const editBtn = document.getElementById('editFilterContentBtn');
|
||||
if (filterContentTextarea) {
|
||||
filterContentTextarea.value = '';
|
||||
filterContentTextarea.readOnly = true;
|
||||
filterContentTextarea.classList.add('bg-gray-50');
|
||||
filterContentTextarea.classList.remove('bg-white');
|
||||
}
|
||||
if (editBtn) editBtn.classList.add('hidden');
|
||||
updateFilterContentHints(false);
|
||||
// Add change listener to enable/disable delete button and load filter content
|
||||
const filterSelect = document.getElementById('filterSelect');
|
||||
const deleteBtn = document.getElementById('deleteFilterBtn');
|
||||
if (!filterSelect.hasAttribute('data-listener-added')) {
|
||||
filterSelect.setAttribute('data-listener-added', 'true');
|
||||
filterSelect.addEventListener('change', function() {
|
||||
deleteBtn.disabled = !filterSelect.value;
|
||||
if (filterSelect.value) {
|
||||
loadFilterContent(filterSelect.value);
|
||||
} else {
|
||||
const editBtn = document.getElementById('editFilterContentBtn');
|
||||
if (filterContentTextarea) {
|
||||
filterContentTextarea.value = '';
|
||||
filterContentTextarea.readOnly = true;
|
||||
filterContentTextarea.classList.add('bg-gray-50');
|
||||
filterContentTextarea.classList.remove('bg-white');
|
||||
}
|
||||
if (editBtn) editBtn.classList.add('hidden');
|
||||
updateFilterContentHints(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function openCreateFilterModal() {
|
||||
document.getElementById('newFilterName').value = '';
|
||||
@@ -234,6 +378,16 @@ function deleteFilter() {
|
||||
document.getElementById('testResults').innerHTML = '';
|
||||
document.getElementById('testResults').classList.add('hidden');
|
||||
document.getElementById('logLinesTextarea').value = '';
|
||||
const filterContentTextarea = document.getElementById('filterContentTextarea');
|
||||
const editBtn = document.getElementById('editFilterContentBtn');
|
||||
if (filterContentTextarea) {
|
||||
filterContentTextarea.value = '';
|
||||
filterContentTextarea.readOnly = true;
|
||||
filterContentTextarea.classList.add('bg-gray-50');
|
||||
filterContentTextarea.classList.remove('bg-white');
|
||||
}
|
||||
if (editBtn) editBtn.classList.add('hidden');
|
||||
updateFilterContentHints(false);
|
||||
})
|
||||
.catch(function(err) {
|
||||
console.error('Error deleting filter:', err);
|
||||
|
||||
@@ -163,6 +163,18 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Textarea for filter content (readonly by default, editable with Edit button) -->
|
||||
<div class="mb-4">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<label for="filterContentTextarea" class="block text-sm font-medium text-gray-700" data-i18n="filter_debug.filter_content">Filter Content</label>
|
||||
<button type="button" id="editFilterContentBtn" onclick="toggleFilterContentEdit()" class="text-sm px-3 py-1 bg-blue-600 text-white rounded hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 hidden" data-i18n="filter_debug.edit_filter">Edit</button>
|
||||
</div>
|
||||
<p class="text-xs text-gray-500 mb-2" data-i18n="filter_debug.filter_content_hint_readonly">Filter content is shown read-only. Click 'Edit' to modify for testing. Changes are temporary and not saved.</p>
|
||||
<p class="text-xs text-gray-500 mb-2 hidden" id="filterContentHintEditable" data-i18n="filter_debug.filter_content_hint">Edit the filter regex below for testing. Changes are temporary and not saved.</p>
|
||||
<textarea id="filterContentTextarea" class="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500 h-40 font-mono text-sm bg-gray-50"
|
||||
placeholder="Filter content will appear here when a filter is selected..." readonly></textarea>
|
||||
</div>
|
||||
|
||||
<!-- Textarea for log lines to test -->
|
||||
<div class="mb-4">
|
||||
<label for="logLinesTextarea" class="block text-sm font-medium text-gray-700 mb-2" data-i18n="filter_debug.log_lines">Log Lines</label>
|
||||
|
||||
Reference in New Issue
Block a user