import React, { useState, useEffect } from 'react'; import { Card, Table, Button, Modal, Form, Input, InputNumber, Select, Switch, Space, message, Popconfirm, Tag, Tooltip, Row, Col, Typography, Divider, Checkbox, Collapse, Alert, InputNumber as AntInputNumber, Spin, Progress, theme } from 'antd'; import { getAgentSyncColor, getConfigStatusColor, getEntityStatusColor, getSSLExpiryInfo } from '../utils/colors'; import EntitySyncStatus from './EntitySyncStatus'; import { PlusOutlined, EditOutlined, DeleteOutlined, ReloadOutlined, GlobalOutlined, SettingOutlined, LockOutlined, SafetyCertificateOutlined, WarningOutlined, SearchOutlined, HistoryOutlined, PlayCircleOutlined, LoadingOutlined } from '@ant-design/icons'; import axios from 'axios'; import { useNavigate, useLocation } from 'react-router-dom'; import { useCluster } from '../contexts/ClusterContext'; import { VersionHistory } from './VersionHistory'; import ACLRuleBuilder from './ACLRuleBuilder'; import { extractApiError } from '../utils/apiError'; // Error Boundary Component class FrontendErrorBoundary extends React.Component { constructor(props) { super(props); this.state = { hasError: false, error: null }; } static getDerivedStateFromError(error) { return { hasError: true, error }; } componentDidCatch(error, errorInfo) { console.error('Frontend Management Error:', error, errorInfo); } render() { if (this.state.hasError) { return ( window.location.reload()}> Refresh Page } /> ); } return this.props.children; } } const { Option } = Select; const { Title, Text } = Typography; const { Panel } = Collapse; const { TextArea } = Input; const FrontendManagement = () => { const { token } = theme.useToken(); const { selectedCluster, loading: clustersLoading } = useCluster(); const navigate = useNavigate(); const location = useLocation(); const [frontends, setFrontends] = useState([]); const [backends, setBackends] = useState([]); const [sslCertificates, setSslCertificates] = useState([]); // Port conflict validation helper // HAProxy allows same port on different bind addresses (e.g., bind 10.0.0.1:443 vs bind 10.0.0.2:443) // Conflict only when: same address+port, or wildcard (*) address overlaps with specific address const addressesConflict = (addr1, addr2) => { const a1 = (addr1 || '*').trim(); const a2 = (addr2 || '*').trim(); const wildCards = ['*', '0.0.0.0', '', '::']; const isWild1 = wildCards.includes(a1); const isWild2 = wildCards.includes(a2); if (isWild1 || isWild2) return true; return a1 === a2; }; const validatePortConflict = (bindAddress, port, sslPort, currentFrontendId = null) => { const conflicts = []; frontends.forEach(frontend => { if (currentFrontendId && frontend.id === currentFrontendId) return; const existingAddr = frontend.bind_address || '*'; if (frontend.bind_port === port && addressesConflict(bindAddress, existingAddr)) { conflicts.push(`Port ${port} is already used by frontend "${frontend.name}" (${existingAddr}:${port})`); } if (frontend.ssl_enabled && frontend.ssl_port === port && addressesConflict(bindAddress, existingAddr)) { conflicts.push(`Port ${port} is already used by frontend "${frontend.name}" (HTTPS ${existingAddr}:${frontend.ssl_port})`); } if (sslPort && frontend.bind_port === sslPort && addressesConflict(bindAddress, existingAddr)) { conflicts.push(`HTTPS port ${sslPort} conflicts with frontend "${frontend.name}" (${existingAddr}:${frontend.bind_port})`); } if (sslPort && frontend.ssl_enabled && frontend.ssl_port === sslPort && addressesConflict(bindAddress, existingAddr)) { conflicts.push(`HTTPS port ${sslPort} conflicts with frontend "${frontend.name}" (HTTPS ${existingAddr}:${frontend.ssl_port})`); } }); return conflicts; }; const [loading, setLoading] = useState(false); const [sslLoading, setSslLoading] = useState(false); const [modalVisible, setModalVisible] = useState(false); const [refreshKey, setRefreshKey] = useState(0); const [editingFrontend, setEditingFrontend] = useState(null); const [searchText, setSearchText] = useState(''); const [filteredFrontends, setFilteredFrontends] = useState([]); const [showPending, setShowPending] = useState(true); // Default TRUE: users must see their changes const [showRejected, setShowRejected] = useState(true); // Default TRUE: users must see rejected items // Persist toggle states across navigation useEffect(() => { const sp = localStorage.getItem('frontend:showPending'); const sr = localStorage.getItem('frontend:showRejected'); if (sp !== null) setShowPending(sp === 'true'); if (sr !== null) setShowRejected(sr === 'true'); }, []); const onToggleShowPending = (checked) => { setShowPending(checked); localStorage.setItem('frontend:showPending', String(checked)); }; const onToggleShowRejected = (checked) => { setShowRejected(checked); localStorage.setItem('frontend:showRejected', String(checked)); }; const [submitting, setSubmitting] = useState(false); const [applyLoading, setApplyLoading] = useState(false); const [pendingChanges, setPendingChanges] = useState(false); const [versionModalVisible, setVersionModalVisible] = useState(false); const [selectedEntityForVersion, setSelectedEntityForVersion] = useState(null); const [aclBuilderData, setAclBuilderData] = useState({ aclRules: [], useBackendRules: [], redirectRules: [] }); const [aclBuilderKey, setAclBuilderKey] = useState(0); const [selectedFrontendKeys, setSelectedFrontendKeys] = useState([]); const [bulkDeleting, setBulkDeleting] = useState(false); const [form] = Form.useForm(); // SSL visibility control - fields start visible for proper form registration const updateSSLVisibility = (sslEnabled) => { const sslFields = document.querySelectorAll('.ssl-fields'); sslFields.forEach(field => { field.style.display = sslEnabled ? 'block' : 'none'; }); }; // Status filter function - defined before fetchFrontends to avoid hoisting issues const applyStatusFilters = (items) => { return (items || []).filter(item => { const isPending = !!item.has_pending_config; const status = item.config_status || (isPending ? 'PENDING' : 'APPLIED'); if (!showPending && isPending) return false; if (!showRejected && status === 'REJECTED') return false; return true; }); }; useEffect(() => { // CRITICAL FIX: Clear state when cluster changes to prevent showing other cluster's data if (selectedCluster) { setFrontends([]); setFilteredFrontends([]); setBackends([]); setSslCertificates([]); setSelectedFrontendKeys([]); } fetchFrontends(); fetchBackends(); fetchSSLCertificates(); 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); setAclBuilderData({ aclRules: frontendToEdit.acl_rules || [], useBackendRules: frontendToEdit.use_backend_rules || [], redirectRules: frontendToEdit.redirect_rules || [], }); setAclBuilderKey(k => k + 1); form.setFieldsValue({ ...frontendToEdit, ssl_enabled: frontendToEdit.ssl_enabled || false, ssl_certificate_ids: frontendToEdit.ssl_certificate_ids || [], }); 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 if (!selectedCluster) { setFrontends([]); setFilteredFrontends([]); return; } setLoading(true); try { const params = { cluster_id: selectedCluster.id }; const response = await axios.get('/api/frontends', { params, headers: { 'Cache-Control': 'no-cache, no-store, must-revalidate', 'Pragma': 'no-cache' } }); // πŸ” DEBUG: Log frontend data to check SSL fields console.log('πŸ” FRONTEND FETCH DEBUG: Response data:', response.data); if (response.data.frontends && response.data.frontends.length > 0) { response.data.frontends.forEach(frontend => { if (frontend.ssl_enabled) { console.log(`πŸ” SSL FRONTEND DEBUG: ${frontend.name}`, { ssl_enabled: frontend.ssl_enabled, ssl_certificate_id: frontend.ssl_certificate_id, ssl_port: frontend.ssl_port }); } }); } const fetchedFrontends = response.data.frontends; setFrontends(fetchedFrontends); // CRITICAL FIX: Apply status filters after fetching to maintain filter state // This prevents frontends from disappearing when updated (e.g., APPLIED β†’ PENDING) setFilteredFrontends(applyStatusFilters(fetchedFrontends)); } catch (error) { message.error('Failed to fetch frontends: ' + error.message); } finally { setLoading(false); } }; // Search filter function const handleSearch = (value) => { setSearchText(value); if (!value) { setFilteredFrontends(applyStatusFilters(frontends)); } else { const filtered = frontends.filter(frontend => frontend.name.toLowerCase().includes(value.toLowerCase()) || frontend.bind_address.toLowerCase().includes(value.toLowerCase()) || frontend.default_backend?.toLowerCase().includes(value.toLowerCase()) || frontend.mode.toLowerCase().includes(value.toLowerCase()) ); setFilteredFrontends(applyStatusFilters(filtered)); } }; // Version history modal handlers const handleShowVersionHistory = (record) => { setSelectedEntityForVersion({ entityType: 'frontend', entityId: record.id }); setVersionModalVisible(true); }; const handleVersionModalCancel = () => { setVersionModalVisible(false); setSelectedEntityForVersion(null); }; const handleRestoreSuccess = (restoreData) => { // Refresh pending changes status after successful restore checkPendingChanges(); // Refresh frontends list to show updated data fetchFrontends(); message.info(
Configuration restored as PENDING
Use "Apply Changes" button to activate the restored configuration
, 4 ); }; // Update filtered data when frontends change useEffect(() => { if (searchText) { handleSearch(searchText); } else { setFilteredFrontends(applyStatusFilters(frontends)); } }, [frontends, showPending, showRejected]); const fetchBackends = async () => { // CRITICAL FIX: Guard clause to prevent race condition if (!selectedCluster) { setBackends([]); return; } try { const params = { cluster_id: selectedCluster.id }; const response = await axios.get('/api/backends', { params, headers: { 'Cache-Control': 'no-cache, no-store, must-revalidate', 'Pragma': 'no-cache' } }); setBackends(response.data.backends); } catch (error) { console.error('Failed to fetch backends:', error); } }; const fetchSSLCertificates = async () => { if (!selectedCluster) return; setSslLoading(true); try { console.log('πŸ” SSL FETCH DEBUG: Fetching FRONTEND certificates for cluster:', selectedCluster.id); const token = localStorage.getItem('token'); console.log('πŸ” SSL FETCH DEBUG: Token exists:', token ? 'Yes' : 'No'); // CRITICAL: Only fetch frontend SSL certificates (usage_type=frontend) const response = await axios.get(`/api/ssl/certificates?cluster_id=${selectedCluster.id}&usage_type=frontend`, { headers: { 'Authorization': `Bearer ${token}`, 'Cache-Control': 'no-cache, no-store, must-revalidate', 'Pragma': 'no-cache' } }); console.log('πŸ” SSL FETCH DEBUG: Response received:', response.data); // Handle different response formats and ensure we have the new fields const certs = response.data.certificates || response.data || []; console.log('πŸ” SSL FETCH DEBUG: Certificates processed:', certs.length, certs); console.log('πŸ” SSL FETCH DEBUG: Certificate details:', certs.map(cert => ({ id: cert.id, name: cert.name, domain: cert.domain || cert.primary_domain, ssl_type: cert.ssl_type }))); setSslCertificates(certs); } catch (error) { console.error('SSL FETCH: Failed to fetch SSL certificates:', error); const detail = extractApiError(error, error.message); if (error.response?.status === 500) { message.error(`Failed to load SSL certificates: ${detail}`); } setSslCertificates([]); } finally { setSslLoading(false); } }; const fetchEntityAgentSync = async (entityType, entityId) => { if (!selectedCluster) return null; try { const token = localStorage.getItem('token'); const response = await axios.get(`/api/clusters/${selectedCluster.id}/entity-sync/${entityType}/${entityId}`, { headers: { Authorization: `Bearer ${token}` } }); return response.data; } catch (error) { console.error(`Failed to fetch entity sync for ${entityType}/${entityId}:`, error); return null; } }; // Check for pending configuration changes const checkPendingChanges = async () => { if (!selectedCluster) return; try { console.log('🎯 APPLY DEBUG: Checking pending changes for cluster:', selectedCluster.id); const response = await axios.get(`/api/clusters/${selectedCluster.id}/config-versions`, { headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` } }); const versions = response.data.versions || []; const hasPending = versions.some(version => version.status === 'PENDING'); console.log('🎯 APPLY DEBUG: Config versions:', versions.length); console.log('🎯 APPLY DEBUG: Pending versions:', versions.filter(v => v.status === 'PENDING').length); console.log('🎯 APPLY DEBUG: Has pending changes:', hasPending); setPendingChanges(hasPending); } catch (error) { console.error('🎯 APPLY DEBUG: Failed to check pending changes:', error); } }; // Apply pending configuration changes const handleApplyChanges = async () => { if (!selectedCluster) return; setApplyLoading(true); try { const token = localStorage.getItem('token'); if (!token || token === 'null' || token.trim() === '') { message.error('Authentication required. Please login again.'); return; } const response = await axios.post( `/api/clusters/${selectedCluster.id}/apply-changes`, {}, { headers: { 'Authorization': `Bearer ${token}` } } ); message.success(response.data.message); // Refresh pending changes status from server await checkPendingChanges(); // Also refresh the frontends list to update Config Status column console.log('🎯 APPLY DEBUG: Refreshing frontends after apply...'); await fetchFrontends(); // Show sync results if available if (response.data.sync_results && response.data.sync_results.length > 0) { const agentResults = response.data.sync_results.filter(r => r.success); if (agentResults.length > 0) { message.info(`Configuration published. ${agentResults.length} agent(s) notified, sync in progress.`); } } } catch (error) { console.error('Apply changes failed:', error); if (error.response?.status === 401) { message.error('Authentication failed. Please login again.'); } else if (error.response?.data?.error === 'COMPREHENSIVE_VALIDATION_FAILED') { // Handle comprehensive validation errors from API const validationErrors = error.response.data.validation_errors || []; Modal.error({ title: '🚫 Configuration Validation Failed', content: (

Multiple configuration issues detected!

{error.response.data.message}

{validationErrors.map((error, index) => (

{error.type === 'BACKENDS_WITHOUT_SERVERS' && 'πŸ”Έ Backends Missing Servers'} {error.type === 'UNUSED_BACKENDS' && 'πŸ”Έ Unused Backends'} {error.type === 'FRONTENDS_WITH_MISSING_BACKENDS' && 'πŸ”Έ Frontend β†’ Backend Mismatch'} {error.type === 'NO_FRONTENDS' && 'πŸ”Έ No Frontends Defined'}

{error.message}

Items:

    {error.items.map((item, idx) => (
  • {item}
  • ))}

πŸ’‘ Solution: {error.solution}

))}

🎯 Quick Actions:

), width: 800, okText: 'I Understand', okType: 'primary' }); } else if (error.response?.data?.error === 'BACKEND_VALIDATION_FAILED') { // Legacy: Handle old backend validation error from API const backendNames = error.response.data.details?.backends_without_servers || []; Modal.error({ title: '🚫 Configuration Apply Failed', content: (

Backend validation failed!

{error.response.data.message}


Backends without servers:

βœ… Solution: Go to Backend Management and add servers to these backends.


), width: 600, okText: 'I Understand', okType: 'default' }); } else { message.error(`Failed to apply changes: ${extractApiError(error, error.message)}`); } } finally { setApplyLoading(false); } }; const handleAdd = () => { setEditingFrontend(null); form.resetFields(); form.setFieldsValue({ bind_address: '*', mode: 'http', ssl_enabled: false, ssl_certificate_ids: [] }); setAclBuilderData({ aclRules: [], useBackendRules: [], redirectRules: [] }); setAclBuilderKey(k => k + 1); // Update SSL field visibility for new frontend setTimeout(() => { updateSSLVisibility(false); }, 100); setModalVisible(true); }; const handleEdit = (frontend) => { setEditingFrontend(frontend); console.log('πŸ” FRONTEND EDIT DEBUG: Raw frontend data:', frontend); console.log('πŸ” FRONTEND EDIT DEBUG: SSL fields:', { ssl_enabled: frontend.ssl_enabled, ssl_certificate_ids: frontend.ssl_certificate_ids, ssl_certificate_id: frontend.ssl_certificate_id }); console.log('🎯 SSL ADVANCED OPTIONS DEBUG:', { ssl_alpn: frontend.ssl_alpn, ssl_npn: frontend.ssl_npn, ssl_ciphers: frontend.ssl_ciphers, ssl_ciphersuites: frontend.ssl_ciphersuites, ssl_min_ver: frontend.ssl_min_ver, ssl_max_ver: frontend.ssl_max_ver, ssl_strict_sni: frontend.ssl_strict_sni }); console.log('πŸ” FRONTEND EDIT DEBUG: TCP Request Rules:', frontend.tcp_request_rules); console.log('πŸ” FRONTEND EDIT DEBUG: Request Headers:', frontend.request_headers); console.log('πŸ” FRONTEND EDIT DEBUG: Options:', frontend.options); // ENTERPRISE DUAL-MODE: Support both old and new SSL format let sslCertIds = []; if (frontend.ssl_certificate_ids && Array.isArray(frontend.ssl_certificate_ids)) { // NEW: Multiple SSL certificates sslCertIds = frontend.ssl_certificate_ids; } else if (frontend.ssl_certificate_id) { // OLD: Single SSL certificate - convert to array sslCertIds = [frontend.ssl_certificate_id]; } // Parse ACL rules for ACLRuleBuilder (keep as arrays) setAclBuilderData({ aclRules: Array.isArray(frontend.acl_rules) ? frontend.acl_rules : [], useBackendRules: Array.isArray(frontend.use_backend_rules) ? frontend.use_backend_rules : [], redirectRules: Array.isArray(frontend.redirect_rules) ? frontend.redirect_rules : [], }); setAclBuilderKey(k => k + 1); form.setFieldsValue({ ...frontend, ssl_enabled: frontend.ssl_enabled || false, ssl_certificate_ids: sslCertIds, // Explicitly set options field to handle null/undefined case (NEW field) options: frontend.options || '', // BUGFIX: Explicitly set tcp_request_rules field to handle null/undefined case tcp_request_rules: frontend.tcp_request_rules || '' }); // Update SSL field visibility after setting values setTimeout(() => { updateSSLVisibility(frontend.ssl_enabled || false); }, 100); setModalVisible(true); }; const handleDelete = async (frontendId) => { if (!selectedCluster) { message.warning('Please select a HAProxy cluster first'); return; } // Find the frontend to get details for confirmation const frontend = frontends.find(f => f.id === frontendId); if (!frontend) { message.error('Frontend not found'); return; } // Check if frontend uses a backend let confirmMessage = `Are you sure you want to delete frontend "${frontend.name}"?`; let warningDetails = []; if (frontend.default_backend) { // Check if the backend still exists const backendExists = backends.some(b => b.name === frontend.default_backend); if (backendExists) { // Show error - frontend uses an active backend Modal.error({ title: 'Cannot Delete Frontend', content: (

Cannot delete frontend "{frontend.name}"

Reason: This frontend uses backend "{frontend.default_backend}"

Solution: Please delete the backend first, then the frontend will be automatically updated.

), okText: 'Understood' }); return; } } // Check WAF rules (just for info, they will be auto-removed) // Note: We don't have easy access to WAF rules here, so we'll rely on backend handling // Show confirmation modal Modal.confirm({ title: 'Delete Frontend', content: (

{confirmMessage}

{frontend.default_backend && (

Note: Any associated WAF rules will also be removed.

)}
), okText: 'Delete', okType: 'danger', cancelText: 'Cancel', onOk: async () => { try { const response = await axios.delete(`/api/frontends/${frontendId}`, { data: { cluster_id: selectedCluster.id } }); // Show frontend deletion success with details const frontendMessage = response.data.message || 'Frontend deleted successfully'; if (response.data.sync_results) { const syncResults = response.data.sync_results; const successCount = syncResults.filter(r => r.success).length; const totalNodes = syncResults.length; if (successCount === totalNodes) { message.success(
{frontendMessage}
βœ… Configuration updated on {successCount} cluster node(s)
πŸ“… Completed: {new Date().toLocaleString()}
, 8 ); } else { message.warning(
{frontendMessage}
⚠️ {successCount}/{totalNodes} cluster node(s) updated successfully
πŸ“… Completed: {new Date().toLocaleString()}
, 10 ); } } else { message.success(frontendMessage, 6); } fetchFrontends(); checkPendingChanges(); } catch (error) { const errorMsg = extractApiError(error, error.message); message.error(
Failed to delete frontend
{errorMsg}
, 8 ); } } }); }; const handleBulkDeleteFrontends = () => { if (!selectedCluster || selectedFrontendKeys.length === 0) return; const selectedFrontends = frontends.filter(f => selectedFrontendKeys.includes(f.id)); const undeletable = selectedFrontends.filter(f => f.default_backend && backends.some(b => b.name === f.default_backend) ); const deletable = selectedFrontends.filter(f => !f.default_backend || !backends.some(b => b.name === f.default_backend) ); if (deletable.length === 0) { Modal.error({ title: 'Cannot Delete', content: `All ${undeletable.length} selected frontend(s) reference active backends and cannot be deleted. Remove their backend associations first.`, okText: 'Understood' }); setSelectedFrontendKeys([]); return; } const names = deletable.map(f => f.name); const displayNames = names.length <= 10 ? names.join(', ') : `${names.slice(0, 10).join(', ')} ... and ${names.length - 10} more`; const skippedMsg = undeletable.length > 0 ? `${undeletable.length} frontend(s) with active backend references will be skipped.` : ''; Modal.confirm({ title: `Delete Frontends (${selectedFrontends.length} selected)`, content: (

{deletable.length} frontend(s) will be deleted:

{displayNames}

{skippedMsg &&

{skippedMsg}

}

This action cannot be undone.

), okText: 'Delete All', okType: 'danger', cancelText: 'Cancel', onOk: async () => { setBulkDeleting(true); let successCount = 0; let errorCount = 0; const errors = []; for (const frontend of deletable) { try { await axios.delete(`/api/frontends/${frontend.id}`, { data: { cluster_id: selectedCluster.id } }); successCount++; } catch (error) { errorCount++; const errorMsg = extractApiError(error, error.message); errors.push(`${frontend.name}: ${errorMsg}`); } } setBulkDeleting(false); setSelectedFrontendKeys([]); if (errorCount === 0) { message.success(`${successCount} frontend(s) deleted successfully`); } else { message.warning(
{successCount} deleted, {errorCount} failed
{errors.slice(0, 5).map((e, i) =>
{e}
)} {errors.length > 5 &&
... and {errors.length - 5} more errors
}
, 10 ); } fetchFrontends(); checkPendingChanges(); } }); }; const handleSubmit = async (values) => { if (!selectedCluster) { message.warning('Please select a HAProxy cluster first'); return; } // Phase K Phase D follow-up (Bulgu #12 round 3) β€” hard-gate any // ACL / use_backend / redirect rule that carries the unsupported // HAProxy `-f ` pattern-file flag. The Pydantic validator // on the backend (`models/frontend.py::validate_acl_rules`) // rejects the same shape; blocking here surfaces the error // immediately at the manual frontend form and matches the wizard // gate so operators see consistent behaviour between the two // entry points. const FILE_FLAG_RE = /(?:^|\s)-f(?:\s|$)/; const aclRulesAll = [ ...(aclBuilderData.aclRules || []), ...(aclBuilderData.useBackendRules || []), ...(aclBuilderData.redirectRules || []).map( (r) => (typeof r === 'string' ? r : ''), ), ]; if (aclRulesAll.some((r) => typeof r === 'string' && FILE_FLAG_RE.test(r))) { message.error( 'One or more ACL / routing / redirect rules use the unsupported HAProxy ' + '`-f ` pattern-file flag. HAProxy OpenManager does not provision ' + 'pattern files onto the HAProxy node filesystem, so the reference would ' + 'fail at reload time. Remove the `-f` flag and use inline values instead.' ); return; } // Phase K Phase D follow-up (Bulgu #13) β€” gate for // self-contradictory routing / redirect conditions (`X !X`). // HAProxy accepts the syntax but the rule never fires β†’ // silent fallback to `default_backend`. // // Bulgu #62 (round-22 audit) β€” grandfather rules that are // UNCHANGED from the existing DB row when editing. A // contradictory rule the operator did not type (legacy data // created by a pre-Bulgu-#13 wizard build or a direct API // caller) must NOT block an unrelated edit (port, max conn, // default_backend). New or modified contradictions still // hard-block. The same logic runs server-side in // `routers/frontend.py::_enforce_routing_rule_contradictions`. const CONTRA_TOKEN_RE = /^!?[A-Za-z_][\w.-]*$/; const extractContradictionText = (rule) => { if (typeof rule === 'string') return rule; if (rule && typeof rule === 'object') { return typeof rule.condition === 'string' ? rule.condition : (typeof rule.if === 'string' ? rule.if : ''); } return ''; }; const hasContradiction = (str) => { if (typeof str !== 'string' || !str) return false; const pos = new Set(); const neg = new Set(); for (const raw of str.split(/\s+/)) { if (!raw || raw === 'if' || raw === 'unless') continue; if (!CONTRA_TOKEN_RE.test(raw)) continue; if (raw.startsWith('!')) { neg.add(raw.slice(1)); } else { pos.add(raw); } } for (const n of pos) if (neg.has(n)) return true; return false; }; // Bulgu #62 follow-up (round-22 hot-fix) β€” the ACLRuleBuilder // parses the DB rule string into a structured `{backend, // operator, condition}` object and then serializes it BACK to // a string for submission. The serialiser drops the // `use_backend ` / `redirect ` prefix that the DB rule may // carry, collapses repeated whitespace, and may round-trip // case differences. A naive byte-for-byte signature // comparison therefore says "this is a NEW rule" even when // the operator hasn't touched the card, and the grandfathering // path turns into a hard block. // // `normalizeRuleString` reproduces the same trim / collapse / // strip-prefix steps so both sides of the comparison go // through the same shape. The signature that lands in // `grandfatheredSet` is the NORMALISED form, and the // signature we look up later is also normalised β€” they match // for rules the operator hasn't edited regardless of which // shape the DB happens to store. const normalizeRuleString = (s) => { if (typeof s !== 'string') return ''; let t = s.trim().replace(/\s+/g, ' '); const lower = t.toLowerCase(); if (lower.startsWith('use_backend ')) { t = t.substring('use_backend '.length).trim(); } else if (lower.startsWith('redirect ')) { t = t.substring('redirect '.length).trim(); } return t; }; const ruleSignature = (rule) => { if (typeof rule === 'string') { const t = normalizeRuleString(rule); return t ? `str::${t}` : null; } if (rule && typeof rule === 'object') { try { const keys = Object.keys(rule).sort(); const o = {}; for (const k of keys) o[k] = rule[k]; return 'dict::' + JSON.stringify(o); } catch (_e) { return null; } } return null; }; const grandfatheredSet = new Set(); if (editingFrontend) { const dbUseBackend = Array.isArray(editingFrontend.use_backend_rules) ? editingFrontend.use_backend_rules : []; const dbRedirect = Array.isArray(editingFrontend.redirect_rules) ? editingFrontend.redirect_rules : []; for (const r of [...dbUseBackend, ...dbRedirect]) { const sig = ruleSignature(r); if (sig) grandfatheredSet.add(sig); } } const blockingContradictions = []; const grandfatheredContradictions = []; for (const r of (aclBuilderData.useBackendRules || [])) { if (!hasContradiction(extractContradictionText(r))) continue; const sig = ruleSignature(r); if (sig && grandfatheredSet.has(sig)) { grandfatheredContradictions.push(r); } else { blockingContradictions.push(r); } } for (const r of (aclBuilderData.redirectRules || [])) { if (!hasContradiction(extractContradictionText(r))) continue; const sig = ruleSignature(r); if (sig && grandfatheredSet.has(sig)) { grandfatheredContradictions.push(r); } else { blockingContradictions.push(r); } } if (blockingContradictions.length > 0) { message.error( 'One or more routing / redirect rules contain the same ACL in both ' + 'positive AND negated form (e.g. `if acl1 !acl1`). HAProxy accepts this ' + 'syntax but `X AND NOT X` is always false, so the rule never fires and ' + 'traffic silently falls through to `default_backend`. Remove one of the ' + 'two tokens before saving.' ); return; } if (grandfatheredContradictions.length > 0) { // Bulgu #83 (round-23 audit) β€” surface the actual offending rule // string(s) instead of just a count. Pre-fix the warning said // "1 legacy rule has X !X" and the operator had to hunt // through the ACL Builder cards to figure out which rule the // gate was complaining about. The unchanged-rule path is the // common case (operator changes port / maxconn on a frontend // that already had a self-contradictory routing rule from a // prior session), so making the rule discoverable from the // toast keeps "Edit and Save" β†’ "fix the dead rule" workflows // single-screen. Also stop calling these rules "legacy" β€” // the operator may have written them seconds earlier; the // only thing this branch knows is that they weren't modified // by the current edit. const renderGrandfatheredRule = (r) => { if (typeof r === 'string') return r; if (r && typeof r === 'object') { try { return JSON.stringify(r); } catch (_e) { return '[rule]'; } } return '[rule]'; }; const ruleSnippets = grandfatheredContradictions .slice(0, 5) .map(renderGrandfatheredRule) .map((s) => (s.length > 160 ? `${s.slice(0, 157)}...` : s)); const extra = grandfatheredContradictions.length > ruleSnippets.length ? ` (+${grandfatheredContradictions.length - ruleSnippets.length} more)` : ''; message.warning(
{grandfatheredContradictions.length} routing/redirect rule(s) you didn't modify in this edit contain a self-contradictory `X !X` condition (e.g. `if acl1 !acl1`).
HAProxy accepts the syntax but `X AND NOT X` is always false, so the rule never fires and traffic silently falls through to `default_backend`. Your current edit will still be saved; fix the rule(s) at your convenience.
{ruleSnippets.map((s, i) => (
β€’ {s}
))} {extra &&
{extra}
}
, 10, ); } setSubmitting(true); try { // DEBUG: Log form values to see what's being sent console.log('🎯 FORM VALUES:', values); console.log('🎯 SSL CERTIFICATE DEBUG:', { ssl_enabled: values.ssl_enabled, ssl_certificate_id: values.ssl_certificate_id, ssl_port: values.ssl_port, sslCertificatesCount: sslCertificates.length }); // DEBUG: Log editing context if (editingFrontend) { console.log('🎯 EDITING FRONTEND:', { id: editingFrontend.id, name: editingFrontend.name, original_ssl_enabled: editingFrontend.ssl_enabled, original_ssl_certificate_id: editingFrontend.ssl_certificate_id, original_ssl_port: editingFrontend.ssl_port }); } const requestData = { ...values, cluster_id: selectedCluster.id, acl_rules: aclBuilderData.aclRules || [], use_backend_rules: aclBuilderData.useBackendRules || [], redirect_rules: aclBuilderData.redirectRules || [], }; // DEBUG: Log request data to see final payload console.log('🎯 REQUEST DATA:', requestData); let response; if (editingFrontend) { response = await axios.put(`/api/frontends/${editingFrontend.id}`, requestData); // Show agent sync results if (response.data.sync_results) { const syncResults = response.data.sync_results; const agentCount = syncResults.length; const successfulAgents = syncResults.filter(r => r.success).length; message.success(
Frontend updated successfully!
πŸ“ Changes created as PENDING
πŸ”„ Click "Apply Changes" to activate configuration
πŸ“… Updated: {new Date().toLocaleString()}
, 6 ); } else { message.success('Frontend updated successfully'); } // Bulgu #83 (round-23 audit) β€” surface server-emitted // grandfathered-rule warnings (e.g. `X !X` contradictions // in routing/redirect rules that the operator did not // touch this edit). The FE client-side gate ALSO catches // these and fires its own toast above the modal close; // we re-surface the server view here as a safety net in // case the client gate missed an edge shape (different // dict serialization, etc.). Server warnings already // include the verbatim rule text, so the operator sees // exactly which entry to fix. const serverWarnings = Array.isArray(response.data?.warnings) ? response.data.warnings : []; if (serverWarnings.length > 0 && grandfatheredContradictions.length === 0) { message.warning(
Frontend saved, but the server flagged {serverWarnings.length} rule warning(s):
{serverWarnings.slice(0, 5).map((w, i) => (
β€’ {w.length > 240 ? `${w.slice(0, 237)}...` : w}
))} {serverWarnings.length > 5 && (
(+{serverWarnings.length - 5} more)
)}
, 10, ); } } else { response = await axios.post('/api/frontends', requestData); // Show agent sync results if (response.data.sync_results) { const syncResults = response.data.sync_results; const agentCount = syncResults.length; const successfulAgents = syncResults.filter(r => r.success).length; message.success(
Frontend created successfully!
πŸ“ Changes created as PENDING
πŸ”„ Click "Apply Changes" to activate configuration
πŸ“… Created: {new Date().toLocaleString()}
, 6 ); } else { message.success('Frontend created successfully'); } } setModalVisible(false); fetchFrontends(); fetchSSLCertificates(); // Refresh SSL certificates after frontend update checkPendingChanges(); } catch (error) { const errorMsg = extractApiError(error, error.message); message.error(
Failed to save frontend
{errorMsg}
, 8 ); } finally { setSubmitting(false); } }; const columns = [ { title: 'Name', dataIndex: 'name', key: 'name', render: (text, record) => ( {text} {!record.is_active && Inactive} ), }, { title: 'Sync Status', key: 'sync_status', render: (_, record) => ( ), }, { title: 'Bind Address', dataIndex: 'bind_address', key: 'bind_address', render: (text, record) => `${text}:${record.bind_port}`, }, { title: 'Mode', dataIndex: 'mode', key: 'mode', render: (mode) => ( {mode.toUpperCase()} ), }, { title: 'Default Backend', dataIndex: 'default_backend', key: 'default_backend', render: (backend) => backend ? ( {backend} ) : Not Set, }, { title: 'SSL/TLS', key: 'ssl_info', width: 200, render: (_, record) => { if (!record.ssl_enabled) { return No SSL; } // ENTERPRISE DUAL-MODE: Support both ssl_certificate_ids (new) and ssl_certificate_id (old) let certIds = []; if (record.ssl_certificate_ids && Array.isArray(record.ssl_certificate_ids)) { certIds = record.ssl_certificate_ids; } else if (record.ssl_certificate_id) { certIds = [record.ssl_certificate_id]; } if (sslLoading) { return (
} size="small"> Loading SSL
Fetching certificate info...
); } if (certIds.length === 0) { return (
} size="small"> SSL Enabled
No certificates assigned
); } // Find all SSL certificates const sslCerts = certIds.map(id => sslCertificates.find(cert => cert.id === id)).filter(Boolean); if (sslCerts.length === 0) { return (
} size="small"> SSL Error
Certificate(s) not found
); } // Single certificate - show full details if (sslCerts.length === 1) { const sslCert = sslCerts[0]; const expiryInfo = getSSLExpiryInfo(sslCert.expiry_date); const domain = sslCert.domain || sslCert.primary_domain || sslCert.name; return (
} size="small" style={{ marginBottom: '6px', fontSize: '11px' }} > {domain}
{domain}
Status: {expiryInfo.status}
{sslCert.expiry_date && (
Expires: {new Date(sslCert.expiry_date).toLocaleDateString('tr-TR', { day: '2-digit', month: '2-digit', year: 'numeric' })}
)}
} placement="top" >
{expiryInfo.status}
); } // Multiple certificates - show compact list const allValid = sslCerts.every(cert => { const expiry = getSSLExpiryInfo(cert.expiry_date); return expiry.tagColor === 'green'; }); return (
} size="small" style={{ marginBottom: '4px' }} > {sslCerts.length} Certificates
SSL Certificates ({sslCerts.length}):
{sslCerts.map(cert => { const expiryInfo = getSSLExpiryInfo(cert.expiry_date); return (
β€’ {cert.domain || cert.name} - {expiryInfo.status}
); })}
} placement="top" >
{sslCerts.map(cert => (cert.domain || cert.name)).join(', ').substring(0, 40)}...
); }, }, { title: 'Status', dataIndex: 'is_active', key: 'status', render: (isActive) => ( {isActive ? 'Active' : 'Inactive'} ), }, { title: 'Created', dataIndex: 'created_at', key: 'created_at', render: (date) => date ? new Date(date).toLocaleString(undefined, { year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', second: '2-digit' }) : '-', }, { title: 'Last Update', dataIndex: 'updated_at', key: 'updated_at', render: (date) => date ? new Date(date).toLocaleString(undefined, { year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', second: '2-digit' }) : '-', }, { title: 'Config Status', key: 'config_status', render: (_, record) => { const status = record.config_status || (record.has_pending_config ? 'PENDING' : 'APPLIED'); const color = getConfigStatusColor(status); return {status}; }, }, { title: 'Actions', key: 'actions', render: (_, record) => ( {(record.has_pending_config || false) && ( )} ); } return (

Frontend Management

Pending Rejected
handleSearch(e.target.value)} style={{ width: '100%', height: 32, paddingLeft: 8, paddingRight: 8, border: `1px solid ${token.colorBorder}`, borderRadius: 6, fontSize: 14, outline: 'none', boxShadow: 'none', backgroundColor: token.colorBgContainer, transition: 'border-color 0.3s ease' }} onFocus={(e) => { e.target.style.borderColor = '#1890ff'; e.target.style.outline = 'none'; e.target.style.boxShadow = 'none'; }} onBlur={(e) => { e.target.style.borderColor = token.colorBorder; }} onMouseOver={(e) => { if (e.target !== document.activeElement) { e.target.style.borderColor = '#40a9ff'; } }} onMouseOut={(e) => { if (e.target !== document.activeElement) { e.target.style.borderColor = token.colorBorder; } }} />
{selectedFrontendKeys.length > 0 && ( )} {pendingChanges && ( )}
`${range[0]}-${range[1]} of ${total} frontends`, }} /> setModalVisible(false)} footer={null} width={900} >
{/* Basic Configuration */}
({ validator(_, value) { if (!value) return Promise.resolve(); const bindAddress = getFieldValue('bind_address') || '*'; const conflicts = validatePortConflict(bindAddress, value, null, editingFrontend?.id); if (conflicts.length > 0) { return Promise.reject(new Error(`Port conflict detected:\nβ€’ ${conflicts.join('\nβ€’ ')}`)); } return Promise.resolve(); }, }), ]} > {/* SSL Configuration */} { updateSSLVisibility(checked); }} /> {/* SSL Fields - Always registered in form, visibility controlled via state */} {/* R18b audit fix (A.2): drop `defaultValue` β€” a controlled `Form.Item` (`name=...`) must derive its initial value from the Form's `initialValues` / `setFieldsValue`, not the inner control. Pre-fix the hardcoded "optional" leaked back into the save payload whenever the row's stored value was NULL. */} {/* SSL Advanced Options */} SSL Advanced Options

ALPN (Application-Layer Protocol Negotiation): Modern protocol for negotiating HTTP/2. Use "h2,http/1.1" for HTTP/2 support.

TLS Version: TLSv1.2 minimum recommended for security. TLSv1.3 provides best performance and security.

Ciphers: Leave empty to use HAProxy secure defaults. Custom ciphers only if required by specific compliance needs.

Strict SNI: Enable for multi-domain setups to ensure clients request correct certificate via SNI.

} type="info" showIcon style={{ marginTop: 16 }} className="ssl-fields" /> {/* Advanced Options */}
{/* ACL Rules -- Visual Builder */} setAclBuilderData(data)} /> {/* Header Manipulation */}