mirror of
https://github.com/taylanbakircioglu/haproxy-openmanager.git
synced 2026-09-16 23:55:13 +00:00
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:
+89
-14
@@ -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:
|
||||
|
||||
@@ -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
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user