feat: add intelligent HAProxy validation error display in UI

- Add haproxy_error_parser.py: Parses HAProxy validation errors with
  confidence scoring, extracts entity type/name, line number, error type
- Add ValidationErrorModal.js: Rich modal with parsed error summary,
  quick fix suggestions, and manual troubleshooting guide
- Update cluster.py: Integrate error parser into agent-sync and
  config-versions endpoints with graceful fallback
- Update ApplyManagement.js: Add validation error banner with quick
  navigation buttons and error detail modal
- Update FrontendManagement.js & BackendServers.js: Handle URL params
  for deep-linking to entity edit forms with field highlighting

Enables users to see actionable validation failure details directly
in the UI without needing server access for debugging.
This commit is contained in:
taylanbakircioglu
2026-01-26 15:25:15 +03:00
parent 0c1d68eb01
commit 8076f3fcfd
6 changed files with 1212 additions and 37 deletions
+89 -14
View File
@@ -646,6 +646,32 @@ async def get_cluster_agent_sync_status(cluster_id: int, authorization: str = He
latest_created_at = latest["created_at"].isoformat().replace('+00:00', 'Z') if latest and latest.get("created_at") else None
validation_error = latest.get("validation_error") if latest else None
validation_error_reported_at = latest["validation_error_reported_at"].isoformat().replace('+00:00', 'Z') if latest and latest.get("validation_error_reported_at") else None
# Parse validation error if present
parsed_error = None
if validation_error:
try:
from utils.haproxy_error_parser import parse_haproxy_error, find_affected_entity
parsed_error = parse_haproxy_error(validation_error)
# Try to find affected entity for quick fix URL
if parsed_error.get('parse_success') and parsed_error.get('entity_name'):
try:
entity_info = await find_affected_entity(parsed_error, cluster_id, conn)
if entity_info.get('entity_verified'):
parsed_error['entity_id'] = entity_info.get('entity_id')
parsed_error['entity_verified'] = True
parsed_error['quick_fix_url'] = entity_info.get('edit_url')
parsed_error['quick_fix_available'] = True
except Exception as entity_err:
logger.debug(f"Failed to find affected entity in agent-sync: {entity_err}")
except Exception as parse_err:
logger.warning(f"Failed to parse validation error in agent-sync: {parse_err}")
parsed_error = {
"parse_success": False,
"raw_message": validation_error,
"suggestion": "Hata mesajı parse edilemedi. Ham mesajı inceleyin."
}
# Agents under this cluster (via pool relationship)
agents = await conn.fetch(
@@ -714,6 +740,7 @@ async def get_cluster_agent_sync_status(cluster_id: int, authorization: str = He
"latest_created_at": latest_created_at,
"validation_error": validation_error,
"validation_error_reported_at": validation_error_reported_at,
"parsed_error": parsed_error,
"total_agents": total,
"online_agents": online,
"synced_agents": synced,
@@ -1070,10 +1097,11 @@ async def list_cluster_config_versions(cluster_id: int, authorization: str = Hea
# Get all config versions for this cluster (with schema compatibility)
try:
# Try with status column first
# Try with all columns including validation_error
versions = await conn.fetch("""
SELECT cv.id, cv.version_name, cv.description, cv.status, cv.is_active,
cv.created_at, cv.file_size, cv.checksum,
cv.validation_error, cv.validation_error_reported_at,
u.username as created_by_username
FROM config_versions cv
LEFT JOIN users u ON cv.created_by = u.id
@@ -1081,19 +1109,32 @@ async def list_cluster_config_versions(cluster_id: int, authorization: str = Hea
ORDER BY cv.created_at DESC
""", cluster_id)
except Exception as status_error:
logger.warning(f"Status column error in config-versions, using fallback: {status_error}")
# Fallback query without status column
versions = await conn.fetch("""
SELECT cv.id, cv.version_name, cv.description, cv.is_active,
cv.created_at, cv.file_size, cv.checksum,
u.username as created_by_username
FROM config_versions cv
LEFT JOIN users u ON cv.created_by = u.id
WHERE cv.cluster_id = $1
ORDER BY cv.created_at DESC
""", cluster_id)
logger.warning(f"Status/validation_error column error in config-versions, using fallback: {status_error}")
# Fallback query without validation_error columns
try:
versions = await conn.fetch("""
SELECT cv.id, cv.version_name, cv.description, cv.status, cv.is_active,
cv.created_at, cv.file_size, cv.checksum,
u.username as created_by_username
FROM config_versions cv
LEFT JOIN users u ON cv.created_by = u.id
WHERE cv.cluster_id = $1
ORDER BY cv.created_at DESC
""", cluster_id)
except Exception:
# Final fallback without status column
versions = await conn.fetch("""
SELECT cv.id, cv.version_name, cv.description, cv.is_active,
cv.created_at, cv.file_size, cv.checksum,
u.username as created_by_username
FROM config_versions cv
LEFT JOIN users u ON cv.created_by = u.id
WHERE cv.cluster_id = $1
ORDER BY cv.created_at DESC
""", cluster_id)
await close_database_connection(conn)
# Import error parser for parsing validation errors
from utils.haproxy_error_parser import parse_haproxy_error, find_affected_entity
# Format the response
formatted_versions = []
@@ -1111,6 +1152,34 @@ async def list_cluster_config_versions(cluster_id: int, authorization: str = Hea
elif "ssl-" in version['version_name']:
version_type = "SSL Certificate"
# Parse validation error if present
validation_error = version.get("validation_error")
parsed_error = None
if validation_error:
try:
parsed_error = parse_haproxy_error(validation_error)
# Try to find affected entity in database for quick fix URL
if parsed_error.get('parse_success') and parsed_error.get('entity_name'):
try:
entity_info = await find_affected_entity(parsed_error, cluster_id, conn)
if entity_info.get('entity_verified'):
parsed_error['entity_id'] = entity_info.get('entity_id')
parsed_error['entity_verified'] = True
parsed_error['quick_fix_url'] = entity_info.get('edit_url')
parsed_error['quick_fix_available'] = True
except Exception as entity_err:
logger.debug(f"Failed to find affected entity: {entity_err}")
# Continue without entity info - not critical
except Exception as parse_err:
logger.warning(f"Failed to parse validation error for version {version['id']}: {parse_err}")
parsed_error = {
"parse_success": False,
"raw_message": validation_error,
"suggestion": "Hata mesajı parse edilemedi. Ham mesajı inceleyin."
}
formatted_versions.append({
"id": version["id"],
"version_name": version["version_name"],
@@ -1121,9 +1190,15 @@ async def list_cluster_config_versions(cluster_id: int, authorization: str = Hea
"created_at": version["created_at"].isoformat().replace('+00:00', 'Z') if version.get("created_at") else None,
"created_by": version.get("created_by_username") or "System",
"file_size": version.get("file_size"),
"checksum": version["checksum"][:8] + "..." if version.get("checksum") else "No checksum"
"checksum": version["checksum"][:8] + "..." if version.get("checksum") else "No checksum",
# Validation error fields
"validation_error": validation_error,
"validation_error_reported_at": version["validation_error_reported_at"].isoformat().replace('+00:00', 'Z') if version.get("validation_error_reported_at") else None,
"parsed_error": parsed_error
})
await close_database_connection(conn)
return {"config_versions": formatted_versions}
except Exception as e:
+561
View File
@@ -0,0 +1,561 @@
"""
HAProxy Validation Error Parser
Parses HAProxy validation errors and provides structured information
for UI display and quick fix suggestions.
"""
import re
import logging
from typing import Dict, List, Optional, Any
from dataclasses import dataclass, asdict
from enum import Enum
logger = logging.getLogger("haproxy_openmanager.error_parser")
class ErrorType(Enum):
"""Known HAProxy error types"""
UNKNOWN_BACKEND = "unknown_backend"
UNKNOWN_FRONTEND = "unknown_frontend"
DUPLICATE_NAME = "duplicate_name"
MODE_MISMATCH = "mode_mismatch"
INVALID_KEYWORD = "invalid_keyword"
SYNTAX_ERROR = "syntax_error"
MISSING_SERVER = "missing_server"
ACL_ERROR = "acl_error"
BIND_ERROR = "bind_error"
UNKNOWN = "unknown"
# Error type to user-friendly display name mapping
ERROR_TYPE_DISPLAY = {
ErrorType.UNKNOWN_BACKEND: "Unknown Backend Reference",
ErrorType.UNKNOWN_FRONTEND: "Unknown Frontend Reference",
ErrorType.DUPLICATE_NAME: "Duplicate Proxy Name",
ErrorType.MODE_MISMATCH: "Mode Mismatch",
ErrorType.INVALID_KEYWORD: "Invalid Keyword for Mode",
ErrorType.SYNTAX_ERROR: "Syntax Error",
ErrorType.MISSING_SERVER: "Missing Server Definition",
ErrorType.ACL_ERROR: "ACL Definition Error",
ErrorType.BIND_ERROR: "Bind/Port Error",
ErrorType.UNKNOWN: "Configuration Error",
}
# Error type to field hint mapping
ERROR_TYPE_FIELD_HINTS = {
ErrorType.UNKNOWN_BACKEND: ["use_backend_rules", "default_backend"],
ErrorType.UNKNOWN_FRONTEND: ["use_backend_rules"],
ErrorType.DUPLICATE_NAME: ["name"],
ErrorType.MODE_MISMATCH: ["mode"],
ErrorType.INVALID_KEYWORD: ["options", "acl_rules", "use_backend_rules"],
ErrorType.SYNTAX_ERROR: ["options", "acl_rules"],
ErrorType.MISSING_SERVER: ["servers"],
ErrorType.ACL_ERROR: ["acl_rules"],
ErrorType.BIND_ERROR: ["bind_address", "bind_port"],
}
# Suggestion templates for each error type
SUGGESTION_TEMPLATES = {
ErrorType.UNKNOWN_BACKEND: "Backend '{related_entity}' bulunamadı. Bu backend'i oluşturun veya '{entity_name}' frontend'indeki use_backend/default_backend kurallarını düzeltin.",
ErrorType.UNKNOWN_FRONTEND: "Frontend '{related_entity}' bulunamadı. Bu frontend'i oluşturun veya referansı düzeltin.",
ErrorType.DUPLICATE_NAME: "'{entity_name}' ismi zaten kullanılıyor (muhtemelen agent'ın local listen block'u). Farklı bir isim seçin.",
ErrorType.MODE_MISMATCH: "'{entity_name}' frontend'inin mode'u ({mode}) backend ile uyumsuz. Frontend veya backend mode'unu değiştirin.",
ErrorType.INVALID_KEYWORD: "'{keyword}' keyword'ü {mode} mode'da geçersiz. HTTP mode için http-request, TCP mode için tcp-request kullanın.",
ErrorType.SYNTAX_ERROR: "Syntax hatası tespit edildi. İlgili satırı kontrol edin.",
ErrorType.MISSING_SERVER: "'{entity_name}' backend'inde en az bir server tanımlı olmalı.",
ErrorType.ACL_ERROR: "ACL tanımında hata var. Doğru format: 'acl name condition value'",
ErrorType.BIND_ERROR: "Port {port} zaten kullanımda veya erişilemez. Farklı bir port deneyin.",
ErrorType.UNKNOWN: "Lütfen ham hata mesajını inceleyin ve ilgili entity'yi kontrol edin.",
}
@dataclass
class ParsedError:
"""Structured representation of a parsed HAProxy error"""
# Parse status
parse_success: bool = False
parse_confidence: int = 0
# Raw message (always present)
raw_message: str = ""
raw_message_truncated: bool = False
# Parsed fields (nullable)
line_number: Optional[int] = None
error_type: str = "unknown"
error_type_display: str = "Configuration Error"
entity_type: Optional[str] = None # "frontend", "backend", "server"
entity_name: Optional[str] = None
related_entity: Optional[str] = None
field_hint: Optional[str] = None
mode: Optional[str] = None
keyword: Optional[str] = None
port: Optional[int] = None
# Suggestion
suggestion: str = "Lütfen ham hata mesajını inceleyin ve ilgili entity'yi kontrol edin."
suggestion_type: str = "generic" # "specific", "generic", "none"
# Quick fix
quick_fix_available: bool = False
quick_fix_url: Optional[str] = None
# Multiple errors
has_multiple_errors: bool = False
additional_errors_count: int = 0
additional_errors_raw: Optional[str] = None
# Entity verification (set later by entity matcher)
entity_id: Optional[int] = None
entity_verified: bool = False
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary for JSON serialization"""
return asdict(self)
class HAProxyErrorParser:
"""
Parses HAProxy validation error messages and extracts structured information.
Key design principle: ALWAYS return a valid result, never raise exceptions.
Parse is a "bonus" - the raw message is always the primary value.
"""
# Regex patterns for parsing HAProxy errors
PATTERNS = {
# Line number pattern: parsing [/path/file.cfg:45]
'line_number': re.compile(r'\[.*?:(\d+)\]'),
# Unknown backend: references unknown backend 'name' in frontend 'name'
'unknown_backend': re.compile(
r"references unknown backend '([^']+)'.*?(?:in (?:frontend|backend) '([^']+)')?",
re.IGNORECASE
),
# Duplicate name: has the same name as a previously declared 'listen' block
'duplicate_name': re.compile(
r"(?:frontend|backend|listen) (?:section )?'([^']+)' has the same name",
re.IGNORECASE
),
# Mode mismatch / invalid keyword: unexpected keyword 'tcp-request' in 'http' mode
'invalid_keyword': re.compile(
r"unexpected keyword '([^']+)'.*?'(http|tcp)' mode.*?(?:frontend|backend) '([^']+)'",
re.IGNORECASE
),
# ACL error: acl 'name' involves some response-only criteria
'acl_error': re.compile(
r"acl '([^']+)'.*?(?:frontend|backend) '([^']+)'",
re.IGNORECASE
),
# Bind error: cannot bind socket
'bind_error': re.compile(
r"cannot bind (?:socket|to) .*?:(\d+)",
re.IGNORECASE
),
# General frontend/backend name extraction
'entity_name': re.compile(
r"(?:in |section )(?:frontend|backend) '([^']+)'",
re.IGNORECASE
),
# Frontend keyword in error
'frontend_ref': re.compile(
r"frontend '([^']+)'",
re.IGNORECASE
),
# Backend keyword in error
'backend_ref': re.compile(
r"backend '([^']+)'",
re.IGNORECASE
),
# Multiple errors detection
'alert_count': re.compile(r'\[ALERT\]', re.IGNORECASE),
}
# Maximum message length before truncation
MAX_MESSAGE_LENGTH = 10000
def __init__(self):
pass
def parse(self, error_message: str) -> ParsedError:
"""
Parse a HAProxy validation error message.
ALWAYS returns a valid ParsedError, never raises exceptions.
If parsing fails, returns a result with parse_success=False but
the raw message is always preserved.
Args:
error_message: Raw HAProxy validation error output
Returns:
ParsedError with structured information
"""
result = ParsedError()
# Handle empty/null message
if not error_message or not error_message.strip():
result.raw_message = ""
result.suggestion = "Hata detayı alınamadı. Agent loglarını kontrol edin."
result.suggestion_type = "none"
return result
# Store raw message (with truncation if needed)
if len(error_message) > self.MAX_MESSAGE_LENGTH:
result.raw_message = error_message[:self.MAX_MESSAGE_LENGTH] + "\n... [truncated]"
result.raw_message_truncated = True
else:
result.raw_message = error_message
# Check for multiple errors
alert_matches = self.PATTERNS['alert_count'].findall(error_message)
if len(alert_matches) > 1:
result.has_multiple_errors = True
result.additional_errors_count = len(alert_matches) - 1
try:
# Extract line number
line_match = self.PATTERNS['line_number'].search(error_message)
if line_match:
result.line_number = int(line_match.group(1))
# Try to identify error type and extract details
parsed = self._identify_error_type(error_message)
result.error_type = parsed.get('error_type', ErrorType.UNKNOWN).value
result.error_type_display = ERROR_TYPE_DISPLAY.get(
parsed.get('error_type', ErrorType.UNKNOWN),
"Configuration Error"
)
result.entity_type = parsed.get('entity_type')
result.entity_name = parsed.get('entity_name')
result.related_entity = parsed.get('related_entity')
result.mode = parsed.get('mode')
result.keyword = parsed.get('keyword')
result.port = parsed.get('port')
# Set field hint based on error type
error_type_enum = parsed.get('error_type', ErrorType.UNKNOWN)
field_hints = ERROR_TYPE_FIELD_HINTS.get(error_type_enum, [])
if field_hints:
result.field_hint = field_hints[0]
# Generate suggestion
result.suggestion = self._generate_suggestion(result, parsed)
result.suggestion_type = "specific" if error_type_enum != ErrorType.UNKNOWN else "generic"
# Calculate confidence score
result.parse_confidence = self._calculate_confidence(result)
result.parse_success = result.parse_confidence >= 30
# Quick fix is available if we have entity info
result.quick_fix_available = bool(result.entity_name and result.entity_type)
logger.info(f"Parsed HAProxy error: type={result.error_type}, entity={result.entity_name}, confidence={result.parse_confidence}")
except Exception as e:
# Parse failed - log but don't raise
logger.warning(f"HAProxy error parse failed: {e}")
# result already has safe defaults
return result
def _identify_error_type(self, message: str) -> Dict[str, Any]:
"""
Identify the error type and extract relevant details.
Returns dict with:
- error_type: ErrorType enum
- entity_type: "frontend" or "backend"
- entity_name: name of affected entity
- related_entity: name of related entity (e.g., unknown backend name)
- mode: http/tcp if relevant
- keyword: problematic keyword if relevant
- port: port number if relevant
"""
result = {
'error_type': ErrorType.UNKNOWN,
'entity_type': None,
'entity_name': None,
'related_entity': None,
'mode': None,
'keyword': None,
'port': None,
}
message_lower = message.lower()
# Check for unknown backend
if 'unknown backend' in message_lower or 'references unknown' in message_lower:
match = self.PATTERNS['unknown_backend'].search(message)
if match:
result['error_type'] = ErrorType.UNKNOWN_BACKEND
result['related_entity'] = match.group(1) # The unknown backend
result['entity_type'] = 'frontend'
if match.group(2):
result['entity_name'] = match.group(2) # The frontend
else:
result['error_type'] = ErrorType.UNKNOWN_BACKEND
return result
# Check for duplicate name
if 'same name' in message_lower or 'already exists' in message_lower:
match = self.PATTERNS['duplicate_name'].search(message)
if match:
result['error_type'] = ErrorType.DUPLICATE_NAME
result['entity_name'] = match.group(1)
# Determine entity type from message
if 'frontend' in message_lower:
result['entity_type'] = 'frontend'
elif 'backend' in message_lower:
result['entity_type'] = 'backend'
else:
result['error_type'] = ErrorType.DUPLICATE_NAME
return result
# Check for invalid keyword (mode mismatch)
if 'unexpected keyword' in message_lower:
match = self.PATTERNS['invalid_keyword'].search(message)
if match:
result['error_type'] = ErrorType.INVALID_KEYWORD
result['keyword'] = match.group(1)
result['mode'] = match.group(2)
result['entity_name'] = match.group(3)
result['entity_type'] = 'frontend' # Usually frontend
else:
result['error_type'] = ErrorType.INVALID_KEYWORD
return result
# Check for ACL error
if 'acl' in message_lower and ('error' in message_lower or 'invalid' in message_lower or 'involves' in message_lower):
match = self.PATTERNS['acl_error'].search(message)
if match:
result['error_type'] = ErrorType.ACL_ERROR
result['related_entity'] = match.group(1) # ACL name
result['entity_name'] = match.group(2) if len(match.groups()) > 1 else None
result['entity_type'] = 'frontend'
else:
result['error_type'] = ErrorType.ACL_ERROR
# Try to extract entity name
frontend_match = self.PATTERNS['frontend_ref'].search(message)
if frontend_match:
result['entity_name'] = frontend_match.group(1)
result['entity_type'] = 'frontend'
return result
# Check for bind error
if 'cannot bind' in message_lower or 'address already in use' in message_lower:
match = self.PATTERNS['bind_error'].search(message)
if match:
result['error_type'] = ErrorType.BIND_ERROR
result['port'] = int(match.group(1))
else:
result['error_type'] = ErrorType.BIND_ERROR
# Try to extract frontend
frontend_match = self.PATTERNS['frontend_ref'].search(message)
if frontend_match:
result['entity_name'] = frontend_match.group(1)
result['entity_type'] = 'frontend'
return result
# Check for missing server
if 'no server' in message_lower or 'missing server' in message_lower:
result['error_type'] = ErrorType.MISSING_SERVER
backend_match = self.PATTERNS['backend_ref'].search(message)
if backend_match:
result['entity_name'] = backend_match.group(1)
result['entity_type'] = 'backend'
return result
# Generic error - try to extract any entity reference
frontend_match = self.PATTERNS['frontend_ref'].search(message)
backend_match = self.PATTERNS['backend_ref'].search(message)
if frontend_match:
result['entity_name'] = frontend_match.group(1)
result['entity_type'] = 'frontend'
elif backend_match:
result['entity_name'] = backend_match.group(1)
result['entity_type'] = 'backend'
# Check for syntax keywords
if 'syntax' in message_lower or 'parsing' in message_lower:
result['error_type'] = ErrorType.SYNTAX_ERROR
return result
def _generate_suggestion(self, result: ParsedError, parsed: Dict[str, Any]) -> str:
"""Generate a helpful suggestion based on the error type"""
error_type = parsed.get('error_type', ErrorType.UNKNOWN)
template = SUGGESTION_TEMPLATES.get(error_type, SUGGESTION_TEMPLATES[ErrorType.UNKNOWN])
try:
return template.format(
entity_name=result.entity_name or 'unknown',
related_entity=result.related_entity or 'unknown',
mode=result.mode or 'unknown',
keyword=result.keyword or 'unknown',
port=result.port or 'unknown',
)
except KeyError:
return SUGGESTION_TEMPLATES[ErrorType.UNKNOWN]
def _calculate_confidence(self, result: ParsedError) -> int:
"""
Calculate confidence score (0-100) based on parsed information.
Scoring:
- Line number found: +30
- Entity name found: +25
- Error type identified: +20
- Field hint available: +10
- Related entity found: +10
- Mode/keyword extracted: +5
"""
score = 0
if result.line_number:
score += 30
if result.entity_name:
score += 25
if result.error_type != "unknown":
score += 20
if result.field_hint:
score += 10
if result.related_entity:
score += 10
if result.mode or result.keyword:
score += 5
return min(score, 100)
# Singleton instance for convenience
_parser_instance = None
def get_parser() -> HAProxyErrorParser:
"""Get singleton parser instance"""
global _parser_instance
if _parser_instance is None:
_parser_instance = HAProxyErrorParser()
return _parser_instance
def parse_haproxy_error(error_message: str) -> Dict[str, Any]:
"""
Convenience function to parse HAProxy error.
Args:
error_message: Raw HAProxy validation error output
Returns:
Dictionary with parsed error information
"""
parser = get_parser()
result = parser.parse(error_message)
return result.to_dict()
async def find_affected_entity(
parsed_error: Dict[str, Any],
cluster_id: int,
conn
) -> Dict[str, Any]:
"""
Find the affected entity in the database based on parsed error.
Args:
parsed_error: Dictionary from parse_haproxy_error()
cluster_id: Cluster ID to search in
conn: Database connection
Returns:
Dictionary with entity information:
- entity_id: Database ID
- entity_type: "frontend" or "backend"
- entity_name: Entity name
- entity_verified: True if found in DB
- edit_url: URL to edit the entity
- field_to_fix: Field that needs fixing
- current_value: Current value of the field (if applicable)
"""
result = {
'entity_id': None,
'entity_type': parsed_error.get('entity_type'),
'entity_name': parsed_error.get('entity_name'),
'entity_verified': False,
'edit_url': None,
'field_to_fix': parsed_error.get('field_hint'),
'current_value': None,
}
entity_name = parsed_error.get('entity_name')
entity_type = parsed_error.get('entity_type')
if not entity_name or not entity_type:
return result
try:
if entity_type == 'frontend':
# Find frontend
frontend = await conn.fetchrow(
"""
SELECT id, name, default_backend, use_backend_rules, acl_rules, mode,
bind_address, bind_port, options
FROM frontends
WHERE cluster_id = $1 AND LOWER(name) = LOWER($2)
""",
cluster_id, entity_name
)
if frontend:
result['entity_id'] = frontend['id']
result['entity_verified'] = True
result['edit_url'] = f"/frontends?edit={frontend['id']}"
# Get current value of problematic field
field_hint = parsed_error.get('field_hint')
if field_hint and field_hint in frontend.keys():
result['current_value'] = frontend[field_hint]
result['edit_url'] += f"&highlight={field_hint}"
elif entity_type == 'backend':
# Find backend
backend = await conn.fetchrow(
"""
SELECT id, name, mode, options
FROM backends
WHERE cluster_id = $1 AND LOWER(name) = LOWER($2)
""",
cluster_id, entity_name
)
if backend:
result['entity_id'] = backend['id']
result['entity_verified'] = True
result['edit_url'] = f"/backends?edit={backend['id']}"
field_hint = parsed_error.get('field_hint')
if field_hint and field_hint in backend.keys():
result['current_value'] = backend[field_hint]
result['edit_url'] += f"&highlight={field_hint}"
logger.info(f"Entity lookup: type={entity_type}, name={entity_name}, verified={result['entity_verified']}")
except Exception as e:
logger.warning(f"Failed to find affected entity: {e}")
return result
+176 -21
View File
@@ -2,7 +2,7 @@ import React, { useState, useEffect } from 'react';
import {
Card, Button, Space, Row, Col, message, Alert, Spin,
Typography, Tag, Table, Modal, Divider, Badge, Empty,
Timeline, Descriptions, Tabs, Progress, Tooltip
Timeline, Descriptions, Tabs, Progress, Tooltip, Collapse, Steps, Input
} from 'antd';
import { getAgentSyncColor, getConfigStatusColor, COLORS } from '../utils/colors';
import { useProgress } from '../contexts/ProgressContext';
@@ -12,12 +12,14 @@ import {
CloudServerOutlined, GlobalOutlined, SecurityScanOutlined,
SafetyCertificateOutlined, InfoCircleOutlined, HistoryOutlined,
EyeOutlined, CloseOutlined, CloseCircleOutlined, RedoOutlined,
UndoOutlined, CloudUploadOutlined
UndoOutlined, CloudUploadOutlined, WarningOutlined, CodeOutlined,
CopyOutlined, EditOutlined, RightOutlined
} from '@ant-design/icons';
import axios from 'axios';
import { useCluster } from '../contexts/ClusterContext';
import { Select } from 'antd';
import { useNavigate } from 'react-router-dom';
import ValidationErrorModal from './ValidationErrorModal';
const { Title, Text, Paragraph } = Typography;
const { TabPane } = Tabs;
@@ -46,6 +48,10 @@ const ApplyManagement = () => {
const [syncProgress, setSyncProgress] = useState({ visible: false, step: '', progress: 0 });
const { startProgress, updateProgress, updateEntityCounts, completeProgress, isProgressActive } = useProgress();
const [entitySyncStates, setEntitySyncStates] = useState({});
// Validation Error Modal state
const [validationErrorModalVisible, setValidationErrorModalVisible] = useState(false);
const [selectedValidationError, setSelectedValidationError] = useState(null);
// Initial load on component mount
useEffect(() => {
@@ -830,6 +836,86 @@ const ApplyManagement = () => {
</Button>
</div>
{/* Validation Error Banner - Shows when there's a validation error from agent */}
{agentSync?.validation_error && (
<Alert
type="error"
banner
showIcon
icon={<ExclamationCircleOutlined />}
style={{
marginBottom: 24,
borderRadius: 8,
border: '1px solid #ffccc7',
boxShadow: '0 2px 8px rgba(255, 77, 79, 0.15)'
}}
message={
<Space size="middle">
<Text strong style={{ color: '#cf1322' }}>HAProxy Configuration Failed</Text>
{agentSync.parsed_error?.line_number && (
<Tag color="red">Line {agentSync.parsed_error.line_number}</Tag>
)}
{agentSync.parsed_error?.entity_name && (
<Tag color="orange">
{agentSync.parsed_error.entity_type}: {agentSync.parsed_error.entity_name}
</Tag>
)}
{agentSync.parsed_error?.has_multiple_errors && (
<Tag color="volcano">+{agentSync.parsed_error.additional_errors_count} more errors</Tag>
)}
</Space>
}
description={
<div style={{ marginTop: 8 }}>
<Paragraph style={{ margin: 0, marginBottom: 12 }}>
{agentSync.parsed_error?.suggestion || 'Configuration validation failed on agent. Please check the error details.'}
</Paragraph>
<Space>
<Button
type="primary"
danger
icon={<EyeOutlined />}
onClick={() => {
setSelectedValidationError({
validation_error: agentSync.validation_error,
validation_error_reported_at: agentSync.validation_error_reported_at,
parsed_error: agentSync.parsed_error
});
setValidationErrorModalVisible(true);
}}
>
View Error Details
</Button>
{agentSync.parsed_error?.quick_fix_available && agentSync.parsed_error?.quick_fix_url && (
<Button
type="primary"
icon={<EditOutlined />}
onClick={() => navigate(agentSync.parsed_error.quick_fix_url)}
>
Fix: {agentSync.parsed_error.entity_name}
</Button>
)}
{agentSync.parsed_error?.entity_name && !agentSync.parsed_error?.quick_fix_available && (
<Button
icon={<RightOutlined />}
onClick={() => {
const entityType = agentSync.parsed_error.entity_type;
if (entityType === 'frontend') {
navigate('/frontends');
} else if (entityType === 'backend') {
navigate('/backends');
}
}}
>
Go to {agentSync.parsed_error.entity_type === 'frontend' ? 'Frontends' : 'Backends'}
</Button>
)}
</Space>
</div>
}
/>
)}
{/* Remove main tabs, only show Pending Changes */}
<Row gutter={16}>
{/* Pending Changes Card */}
@@ -1295,28 +1381,85 @@ const ApplyManagement = () => {
{agentSync.validation_error && (
<Descriptions.Item label="HAProxy Validation Error">
<Alert
message="Configuration Validation Failed"
message={
<Space>
<span>Configuration Validation Failed</span>
{agentSync.parsed_error?.line_number && (
<Tag color="red">Line {agentSync.parsed_error.line_number}</Tag>
)}
{agentSync.parsed_error?.entity_name && (
<Tag color="orange">
{agentSync.parsed_error.entity_type}: {agentSync.parsed_error.entity_name}
</Tag>
)}
</Space>
}
description={
<div>
<div style={{ marginBottom: 8 }}>
<strong>Error Details:</strong>
</div>
<pre style={{
backgroundColor: '#fff2f0',
padding: '12px',
borderRadius: '4px',
border: '1px solid #ffccc7',
fontSize: '12px',
fontFamily: 'monospace',
maxHeight: '200px',
overflowY: 'auto',
whiteSpace: 'pre-wrap',
wordBreak: 'break-word'
}}>
{agentSync.validation_error}
</pre>
{/* Parsed suggestion */}
{agentSync.parsed_error?.suggestion && (
<div style={{ marginBottom: 12, padding: '8px 12px', background: '#fff7e6', borderRadius: 4, border: '1px solid #ffd591' }}>
<Text strong style={{ color: '#ad4e00' }}>Recommendation: </Text>
<Text>{agentSync.parsed_error.suggestion}</Text>
</div>
)}
{/* Action buttons */}
<Space style={{ marginBottom: 12 }}>
<Button
type="primary"
size="small"
danger
icon={<EyeOutlined />}
onClick={() => {
setSelectedValidationError({
validation_error: agentSync.validation_error,
validation_error_reported_at: agentSync.validation_error_reported_at,
parsed_error: agentSync.parsed_error
});
setValidationErrorModalVisible(true);
}}
>
View Full Details
</Button>
{agentSync.parsed_error?.quick_fix_available && agentSync.parsed_error?.quick_fix_url && (
<Button
type="primary"
size="small"
icon={<EditOutlined />}
onClick={() => navigate(agentSync.parsed_error.quick_fix_url)}
>
Fix: {agentSync.parsed_error.entity_name}
</Button>
)}
</Space>
{/* Truncated raw error */}
<Collapse size="small" ghost>
<Collapse.Panel header="Raw Error Output" key="raw">
<pre style={{
backgroundColor: '#1f1f1f',
color: '#ff6b6b',
padding: '12px',
borderRadius: '4px',
fontSize: '11px',
fontFamily: 'Monaco, Menlo, monospace',
maxHeight: '150px',
overflowY: 'auto',
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
margin: 0
}}>
{agentSync.validation_error.length > 500
? agentSync.validation_error.substring(0, 500) + '...\n[Click "View Full Details" to see complete error]'
: agentSync.validation_error
}
</pre>
</Collapse.Panel>
</Collapse>
{agentSync.validation_error_reported_at && (
<div style={{ marginTop: 8, fontSize: '12px', color: '#666' }}>
<div style={{ marginTop: 8, fontSize: '11px', color: '#8c8c8c' }}>
Reported at: {new Date(agentSync.validation_error_reported_at).toLocaleString()}
</div>
)}
@@ -1490,6 +1633,18 @@ const ApplyManagement = () => {
</div>
)}
</Modal>
{/* Validation Error Modal */}
<ValidationErrorModal
visible={validationErrorModalVisible}
onClose={() => {
setValidationErrorModalVisible(false);
setSelectedValidationError(null);
}}
validationError={selectedValidationError?.validation_error}
validationErrorReportedAt={selectedValidationError?.validation_error_reported_at}
parsedError={selectedValidationError?.parsed_error}
/>
</div>
);
};
+30 -1
View File
@@ -13,7 +13,7 @@ import {
HistoryOutlined, ContainerOutlined
} from '@ant-design/icons';
import axios from 'axios';
import { useNavigate } from 'react-router-dom';
import { useNavigate, useLocation } from 'react-router-dom';
import { useCluster } from '../contexts/ClusterContext';
import { VersionHistory } from './VersionHistory';
@@ -60,6 +60,7 @@ const { Text, Title } = Typography;
const BackendServers = () => {
const { selectedCluster } = useCluster();
const navigate = useNavigate();
const location = useLocation();
const [backends, setBackends] = useState([]);
const [frontends, setFrontends] = useState([]);
const [sslCertificates, setSslCertificates] = useState([]);
@@ -127,6 +128,34 @@ const BackendServers = () => {
checkPendingChanges();
}, [selectedCluster]);
// Handle URL parameters for quick edit navigation (from validation error modal)
useEffect(() => {
const params = new URLSearchParams(location.search);
const editId = params.get('edit');
const highlightField = params.get('highlight');
if (editId && backends.length > 0) {
const backendToEdit = backends.find(b => b.id === parseInt(editId));
if (backendToEdit) {
// Open edit modal for the backend
setEditingBackend(backendToEdit);
backendForm.setFieldsValue({
...backendToEdit,
health_check_enabled: backendToEdit.health_check_enabled || false,
});
setBackendModalVisible(true);
// Clear URL params after opening modal
navigate(location.pathname, { replace: true });
// Show notification about which field to check
if (highlightField) {
message.info(`Please check the "${highlightField}" field - it may have caused a validation error.`, 5);
}
}
}
}, [location.search, backends]);
const fetchBackends = async () => {
// CRITICAL FIX: Don't fetch if no cluster selected to prevent race condition
// Race condition: First fetch (cluster=undefined) returns all backends and overwrites filtered results
+33 -1
View File
@@ -12,7 +12,7 @@ import {
WarningOutlined, SearchOutlined, HistoryOutlined, PlayCircleOutlined, LoadingOutlined
} from '@ant-design/icons';
import axios from 'axios';
import { useNavigate } from 'react-router-dom';
import { useNavigate, useLocation } from 'react-router-dom';
import { useCluster } from '../contexts/ClusterContext';
import { VersionHistory } from './VersionHistory';
@@ -60,6 +60,7 @@ const { TextArea } = Input;
const FrontendManagement = () => {
const { selectedCluster } = useCluster();
const navigate = useNavigate();
const location = useLocation();
const [frontends, setFrontends] = useState([]);
const [backends, setBackends] = useState([]);
const [sslCertificates, setSslCertificates] = useState([]);
@@ -162,6 +163,37 @@ const FrontendManagement = () => {
checkPendingChanges();
}, [selectedCluster]);
// Handle URL parameters for quick edit navigation (from validation error modal)
useEffect(() => {
const params = new URLSearchParams(location.search);
const editId = params.get('edit');
const highlightField = params.get('highlight');
if (editId && frontends.length > 0) {
const frontendToEdit = frontends.find(f => f.id === parseInt(editId));
if (frontendToEdit) {
// Open edit modal for the frontend
setEditingFrontend(frontendToEdit);
form.setFieldsValue({
...frontendToEdit,
ssl_enabled: frontendToEdit.ssl_enabled || false,
ssl_certificate_ids: frontendToEdit.ssl_certificate_ids || [],
acl_rules: frontendToEdit.acl_rules || [],
use_backend_rules: frontendToEdit.use_backend_rules || [],
});
setModalVisible(true);
// Clear URL params after opening modal
navigate(location.pathname, { replace: true });
// Show notification about which field to check
if (highlightField) {
message.info(`Please check the "${highlightField}" field - it may have caused a validation error.`, 5);
}
}
}
}, [location.search, frontends]);
const fetchFrontends = async () => {
// CRITICAL FIX: Don't fetch if no cluster selected to prevent race condition
// Same race condition as BackendServers - prevents all frontends from appearing
@@ -0,0 +1,323 @@
/**
* ValidationErrorModal Component
*
* Displays detailed HAProxy validation error information with:
* - Parsed error summary
* - Quick fix suggestions
* - Manual troubleshooting guide (when parse fails)
* - Raw error output
* - Entity navigation
*/
import React from 'react';
import {
Modal, Card, Space, Tag, Button, Alert, Divider,
Typography, Descriptions, Steps, Collapse, message
} from 'antd';
import {
ExclamationCircleOutlined, CodeOutlined, CopyOutlined,
EditOutlined, CheckCircleOutlined, WarningOutlined,
RightOutlined, QuestionCircleOutlined, BugOutlined
} from '@ant-design/icons';
import { useNavigate } from 'react-router-dom';
const { Text, Paragraph, Title } = Typography;
const { Step } = Steps;
const { Panel } = Collapse;
// Error type display names
const ERROR_TYPE_LABELS = {
unknown_backend: 'Unknown Backend',
unknown_frontend: 'Unknown Frontend',
duplicate_name: 'Duplicate Name',
mode_mismatch: 'Mode Mismatch',
invalid_keyword: 'Invalid Keyword',
syntax_error: 'Syntax Error',
missing_server: 'Missing Server',
acl_error: 'ACL Error',
bind_error: 'Bind/Port Error',
unknown: 'Unknown Error'
};
const ValidationErrorModal = ({
visible,
onClose,
validationError,
validationErrorReportedAt,
parsedError
}) => {
const navigate = useNavigate();
// Copy raw error to clipboard
const copyToClipboard = (text) => {
navigator.clipboard.writeText(text);
message.success('Error message copied to clipboard');
};
// Navigate to entity
const navigateToEntity = () => {
if (parsedError?.quick_fix_url) {
navigate(parsedError.quick_fix_url);
onClose();
} else if (parsedError?.entity_type) {
const path = parsedError.entity_type === 'frontend' ? '/frontends' : '/backends';
navigate(path);
onClose();
}
};
// Determine confidence level for display
const getConfidenceLevel = () => {
const confidence = parsedError?.parse_confidence || 0;
if (confidence >= 70) return { level: 'high', color: 'success', text: 'High Confidence' };
if (confidence >= 30) return { level: 'medium', color: 'warning', text: 'Partial Match' };
return { level: 'low', color: 'error', text: 'Could Not Parse' };
};
const confidenceInfo = getConfidenceLevel();
const hasGoodParse = parsedError?.parse_success && parsedError?.parse_confidence >= 30;
return (
<Modal
title={
<Space>
<ExclamationCircleOutlined style={{ color: '#ff4d4f' }} />
<span>HAProxy Validation Error</span>
</Space>
}
open={visible}
onCancel={onClose}
width={800}
footer={[
<Button key="close" onClick={onClose}>
Close
</Button>,
parsedError?.entity_name && (
<Button
key="navigate"
type="primary"
icon={<EditOutlined />}
onClick={navigateToEntity}
>
{parsedError?.quick_fix_available
? `Fix: ${parsedError.entity_name}`
: `Go to ${parsedError.entity_type === 'frontend' ? 'Frontends' : 'Backends'}`
}
</Button>
)
].filter(Boolean)}
>
{/* Status Badge */}
<div style={{ marginBottom: 16, display: 'flex', alignItems: 'center', gap: 12 }}>
<Tag color="error" style={{ fontSize: 14, padding: '4px 12px' }}>
Configuration Rejected
</Tag>
<Tag color={confidenceInfo.color}>
{confidenceInfo.text}
</Tag>
{validationErrorReportedAt && (
<Text type="secondary" style={{ fontSize: 12 }}>
Reported: {new Date(validationErrorReportedAt).toLocaleString()}
</Text>
)}
</div>
{/* Parsed Error Summary - Only shown if parse was successful enough */}
{hasGoodParse && (
<Card
size="small"
style={{
marginBottom: 16,
background: 'linear-gradient(135deg, #fff2f0 0%, #ffebe6 100%)',
border: '1px solid #ffccc7'
}}
>
<Descriptions column={2} size="small">
{parsedError?.line_number && (
<Descriptions.Item label="Error Location">
<Tag color="red">Line {parsedError.line_number}</Tag>
</Descriptions.Item>
)}
{parsedError?.error_type && (
<Descriptions.Item label="Error Type">
<Text strong>{ERROR_TYPE_LABELS[parsedError.error_type] || parsedError.error_type}</Text>
</Descriptions.Item>
)}
{parsedError?.entity_type && (
<Descriptions.Item label="Entity Type">
<Tag color="blue">{parsedError.entity_type}</Tag>
</Descriptions.Item>
)}
{parsedError?.entity_name && (
<Descriptions.Item label="Entity Name">
<Text code>{parsedError.entity_name}</Text>
</Descriptions.Item>
)}
{parsedError?.related_entity && (
<Descriptions.Item label="Related Entity">
<Text code>{parsedError.related_entity}</Text>
</Descriptions.Item>
)}
{parsedError?.field_hint && (
<Descriptions.Item label="Problem Field">
<Text mark>{parsedError.field_hint}</Text>
</Descriptions.Item>
)}
</Descriptions>
</Card>
)}
{/* Quick Fix Suggestion */}
{parsedError?.suggestion && parsedError?.suggestion_type !== 'none' && (
<Card
title={
<Space>
<CheckCircleOutlined style={{ color: '#52c41a' }} />
<span>Recommended Action</span>
</Space>
}
size="small"
style={{ marginBottom: 16 }}
>
<Paragraph style={{ margin: 0 }}>
{parsedError.suggestion}
</Paragraph>
{parsedError?.quick_fix_available && parsedError?.quick_fix_url && (
<Button
type="primary"
icon={<EditOutlined />}
style={{ marginTop: 12 }}
onClick={navigateToEntity}
>
Edit {parsedError.entity_name} {parsedError.field_hint || 'configuration'}
</Button>
)}
</Card>
)}
{/* Manual Troubleshooting Guide - Shown when parse failed or low confidence */}
{(!parsedError?.parse_success || parsedError?.parse_confidence < 30) && (
<Card
title={
<Space>
<QuestionCircleOutlined style={{ color: '#faad14' }} />
<span>Manual Troubleshooting</span>
</Space>
}
size="small"
style={{ marginBottom: 16, background: '#fffbe6', border: '1px solid #ffe58f' }}
>
<Paragraph type="secondary" style={{ marginBottom: 16 }}>
The error message could not be automatically analyzed. Follow these steps to identify and fix the issue:
</Paragraph>
<Steps direction="vertical" size="small" current={-1}>
<Step
title="Find the Line Number"
description={
<span>
Look for <Text code>:XX]</Text> pattern in the error (e.g., <Text code>:45]</Text> means line 45)
</span>
}
/>
<Step
title="Check Failed Config"
description={
<span>
On the agent server, run: <Text code>cat /tmp/haproxy-failed-*.cfg</Text>
</span>
}
/>
<Step
title="Validate Manually"
description={
<span>
Run: <Text code>haproxy -c -f /tmp/haproxy-failed-*.cfg</Text> for detailed output
</span>
}
/>
<Step
title="Identify the Entity"
description="Determine if the error is in a frontend, backend, or server configuration"
/>
</Steps>
<Divider style={{ margin: '16px 0' }} />
<Space wrap>
<Button onClick={() => { navigate('/frontends'); onClose(); }}>
Go to Frontends
</Button>
<Button onClick={() => { navigate('/backends'); onClose(); }}>
Go to Backends
</Button>
<Button onClick={() => { navigate('/waf'); onClose(); }}>
Go to WAF Rules
</Button>
</Space>
</Card>
)}
{/* Raw Error Output - Always Shown */}
<Card
title={
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Space>
<CodeOutlined />
<span>Raw HAProxy Output</span>
</Space>
<Button
size="small"
icon={<CopyOutlined />}
onClick={() => copyToClipboard(validationError)}
>
Copy
</Button>
</div>
}
size="small"
bodyStyle={{ padding: 0 }}
>
<pre style={{
background: '#1f1f1f',
color: '#ff6b6b',
padding: 16,
borderRadius: '0 0 8px 8px',
margin: 0,
overflow: 'auto',
maxHeight: 250,
fontSize: 12,
lineHeight: 1.6,
fontFamily: 'Monaco, Menlo, "Ubuntu Mono", monospace'
}}>
{validationError || 'No error message available'}
</pre>
</Card>
{/* Help Tip */}
<Alert
type="info"
showIcon
icon={<BugOutlined />}
message="Debug Tip"
description={
<span>
If the error persists, check the agent logs on the HAProxy server.
Failed configurations are saved at <Text code>/tmp/haproxy-failed-*.cfg</Text> for inspection.
</span>
}
style={{ marginTop: 16 }}
/>
{/* Multiple Errors Warning */}
{parsedError?.has_multiple_errors && (
<Alert
type="warning"
showIcon
message={`${parsedError.additional_errors_count} Additional Error(s)`}
description="There are more errors in the configuration. Fix the first error and re-apply to see subsequent errors."
style={{ marginTop: 16 }}
/>
)}
</Modal>
);
};
export default ValidationErrorModal;