diff --git a/backend/routers/cluster.py b/backend/routers/cluster.py
index 5534a77..491ec48 100644
--- a/backend/routers/cluster.py
+++ b/backend/routers/cluster.py
@@ -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:
diff --git a/backend/utils/haproxy_error_parser.py b/backend/utils/haproxy_error_parser.py
new file mode 100644
index 0000000..a2dbb7c
--- /dev/null
+++ b/backend/utils/haproxy_error_parser.py
@@ -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
diff --git a/frontend/src/components/ApplyManagement.js b/frontend/src/components/ApplyManagement.js
index fb60d40..0200596 100644
--- a/frontend/src/components/ApplyManagement.js
+++ b/frontend/src/components/ApplyManagement.js
@@ -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 = () => {
+ {/* Validation Error Banner - Shows when there's a validation error from agent */}
+ {agentSync?.validation_error && (
+
- {agentSync.validation_error}
-
+ {/* Parsed suggestion */}
+ {agentSync.parsed_error?.suggestion && (
+
+ {agentSync.validation_error.length > 500
+ ? agentSync.validation_error.substring(0, 500) + '...\n[Click "View Full Details" to see complete error]'
+ : agentSync.validation_error
+ }
+
+
+ {validationError || 'No error message available'}
+
+