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 && ( + } + style={{ + marginBottom: 24, + borderRadius: 8, + border: '1px solid #ffccc7', + boxShadow: '0 2px 8px rgba(255, 77, 79, 0.15)' + }} + message={ + + HAProxy Configuration Failed + {agentSync.parsed_error?.line_number && ( + Line {agentSync.parsed_error.line_number} + )} + {agentSync.parsed_error?.entity_name && ( + + {agentSync.parsed_error.entity_type}: {agentSync.parsed_error.entity_name} + + )} + {agentSync.parsed_error?.has_multiple_errors && ( + +{agentSync.parsed_error.additional_errors_count} more errors + )} + + } + description={ +
+ + {agentSync.parsed_error?.suggestion || 'Configuration validation failed on agent. Please check the error details.'} + + + + {agentSync.parsed_error?.quick_fix_available && agentSync.parsed_error?.quick_fix_url && ( + + )} + {agentSync.parsed_error?.entity_name && !agentSync.parsed_error?.quick_fix_available && ( + + )} + +
+ } + /> + )} + {/* Remove main tabs, only show Pending Changes */} {/* Pending Changes Card */} @@ -1295,28 +1381,85 @@ const ApplyManagement = () => { {agentSync.validation_error && ( + Configuration Validation Failed + {agentSync.parsed_error?.line_number && ( + Line {agentSync.parsed_error.line_number} + )} + {agentSync.parsed_error?.entity_name && ( + + {agentSync.parsed_error.entity_type}: {agentSync.parsed_error.entity_name} + + )} + + } description={
-
- Error Details: -
-
-                                {agentSync.validation_error}
-                              
+ {/* Parsed suggestion */} + {agentSync.parsed_error?.suggestion && ( +
+ Recommendation: + {agentSync.parsed_error.suggestion} +
+ )} + + {/* Action buttons */} + + + {agentSync.parsed_error?.quick_fix_available && agentSync.parsed_error?.quick_fix_url && ( + + )} + + + {/* Truncated raw error */} + + +
+                                    {agentSync.validation_error.length > 500 
+                                      ? agentSync.validation_error.substring(0, 500) + '...\n[Click "View Full Details" to see complete error]'
+                                      : agentSync.validation_error
+                                    }
+                                  
+
+
+ {agentSync.validation_error_reported_at && ( -
+
Reported at: {new Date(agentSync.validation_error_reported_at).toLocaleString()}
)} @@ -1490,6 +1633,18 @@ const ApplyManagement = () => {
)} + + {/* Validation Error Modal */} + { + setValidationErrorModalVisible(false); + setSelectedValidationError(null); + }} + validationError={selectedValidationError?.validation_error} + validationErrorReportedAt={selectedValidationError?.validation_error_reported_at} + parsedError={selectedValidationError?.parsed_error} + />
); }; diff --git a/frontend/src/components/BackendServers.js b/frontend/src/components/BackendServers.js index 4675be3..eff81c9 100644 --- a/frontend/src/components/BackendServers.js +++ b/frontend/src/components/BackendServers.js @@ -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 diff --git a/frontend/src/components/FrontendManagement.js b/frontend/src/components/FrontendManagement.js index 5aea9ad..41f6791 100644 --- a/frontend/src/components/FrontendManagement.js +++ b/frontend/src/components/FrontendManagement.js @@ -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 diff --git a/frontend/src/components/ValidationErrorModal.js b/frontend/src/components/ValidationErrorModal.js new file mode 100644 index 0000000..9b909ea --- /dev/null +++ b/frontend/src/components/ValidationErrorModal.js @@ -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 ( + + + HAProxy Validation Error + + } + open={visible} + onCancel={onClose} + width={800} + footer={[ + , + parsedError?.entity_name && ( + + ) + ].filter(Boolean)} + > + {/* Status Badge */} +
+ + Configuration Rejected + + + {confidenceInfo.text} + + {validationErrorReportedAt && ( + + Reported: {new Date(validationErrorReportedAt).toLocaleString()} + + )} +
+ + {/* Parsed Error Summary - Only shown if parse was successful enough */} + {hasGoodParse && ( + + + {parsedError?.line_number && ( + + Line {parsedError.line_number} + + )} + {parsedError?.error_type && ( + + {ERROR_TYPE_LABELS[parsedError.error_type] || parsedError.error_type} + + )} + {parsedError?.entity_type && ( + + {parsedError.entity_type} + + )} + {parsedError?.entity_name && ( + + {parsedError.entity_name} + + )} + {parsedError?.related_entity && ( + + {parsedError.related_entity} + + )} + {parsedError?.field_hint && ( + + {parsedError.field_hint} + + )} + + + )} + + {/* Quick Fix Suggestion */} + {parsedError?.suggestion && parsedError?.suggestion_type !== 'none' && ( + + + Recommended Action + + } + size="small" + style={{ marginBottom: 16 }} + > + + {parsedError.suggestion} + + {parsedError?.quick_fix_available && parsedError?.quick_fix_url && ( + + )} + + )} + + {/* Manual Troubleshooting Guide - Shown when parse failed or low confidence */} + {(!parsedError?.parse_success || parsedError?.parse_confidence < 30) && ( + + + Manual Troubleshooting + + } + size="small" + style={{ marginBottom: 16, background: '#fffbe6', border: '1px solid #ffe58f' }} + > + + The error message could not be automatically analyzed. Follow these steps to identify and fix the issue: + + + + Look for :XX] pattern in the error (e.g., :45] means line 45) + + } + /> + + On the agent server, run: cat /tmp/haproxy-failed-*.cfg + + } + /> + + Run: haproxy -c -f /tmp/haproxy-failed-*.cfg for detailed output + + } + /> + + + + + + + + + + )} + + {/* Raw Error Output - Always Shown */} + + + + Raw HAProxy Output + + + + } + size="small" + bodyStyle={{ padding: 0 }} + > +
+          {validationError || 'No error message available'}
+        
+
+ + {/* Help Tip */} + } + message="Debug Tip" + description={ + + If the error persists, check the agent logs on the HAProxy server. + Failed configurations are saved at /tmp/haproxy-failed-*.cfg for inspection. + + } + style={{ marginTop: 16 }} + /> + + {/* Multiple Errors Warning */} + {parsedError?.has_multiple_errors && ( + + )} +
+ ); +}; + +export default ValidationErrorModal;