Files
haproxy-openmanager/frontend/src/components/FrontendManagement.js
T
taylanbakircioglu 2e7db4d99f fix: v1.5.1 — Round-23 + Round-24 audit follow-ups (Bulgu #83 → #93)
A live-deployment audit pass over the v1.5.0 Site Wizard + ACME
Diagnostic Panel surface. Two adversarial review rounds (R23, R24)
each capped by an end-to-end smoke test against a multi-cluster
staging deployment.

Bulgu #83 — Frontend Management page warned about stale data
without a clear retry CTA. The toast now carries an in-place
"Reload" action and the page-level Empty state surfaces the same
recovery affordance, so operators never get stuck on a stale-data
view without an obvious way out.

Bulgu #84 — ACME diagnostics ran with the wrong "last_heartbeat"
column reference against the agents table. Aligned the SELECT
with the actual schema column (`last_seen`); pinned by an idempotent
regression test in `test_acme_diagnostics.py`.

Bulgu #85 — ACME order error_detail rendering could leak the raw
asyncpg/SQL exception class name when humanize_error_detail
encountered an unhandled CA response shape. Added a backwards-
compatible fallback branch that emits an "ACME error (raw)" panel
without exposing parse_error class name to the user.

Bulgu #86 — Multi-cluster apply with concurrent rejects could
leave wizard_staged orders dangling without their parent draft.
Pinned via reject_order_with_cluster_orphan test.

Bulgu #87 — Frontend Management page list virtualization
mis-keyed during a re-sort + stale-row replace race; fixed by
keying rows on `id + version` so React reconciler does not reuse
DOM for a logically different row.

Bulgu #88 — Site Wizard "Cancel" mid-flow now surfaces an
unsaved-draft prompt with explicit Save / Discard buttons (and
the same prompt on browser tab close), so the operator never
loses 5 steps of input to an accidental ESC.

Bulgu #89 — Existing-cert SSL mode showed an empty dropdown when
the cluster had >100 certs because the listing endpoint
default-limited results. Endpoint now exposes pagination AND
the wizard switches to client-side filtering above 50 rows.

Bulgu #90 — ACME pre-check on the wizard preview path did NOT
re-validate the account against `letsencrypt_accounts` if the
operator stepped Back/Forward between SSL and Review. Added a
debounced re-validation on Review entry.

Bulgu #93 — Site Wizard hsts_enabled toggle in HTTPS frontend
was idempotent-by-name (the generated `http-response set-header
Strict-Transport-Security` line could duplicate across a Save +
Apply cycle). The renderer now upserts the header in place.

Cumulative outcome: backend pytest 1084/1084, frontend lint
clean, and a 6-hour live-deployment smoke session against staging
with no regressions reported.
2026-05-14 00:06:02 +03:00

2292 lines
86 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 (
<Alert
message="Frontend Management Error"
description="There was an error loading Frontend Management. Please refresh the page."
type="error"
showIcon
action={
<Button size="small" danger onClick={() => window.location.reload()}>
Refresh Page
</Button>
}
/>
);
}
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(
<div>
<div><strong>Configuration restored as PENDING</strong></div>
<div style={{ fontSize: '12px', marginTop: 4 }}>
Use "Apply Changes" button to activate the restored configuration
</div>
</div>,
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: (
<div>
<p><strong>Multiple configuration issues detected!</strong></p>
<p style={{ color: '#ff4d4f', marginBottom: 16 }}>{error.response.data.message}</p>
{validationErrors.map((error, index) => (
<div key={index} style={{
marginBottom: 16,
padding: 12,
border: '1px solid #ffccc7',
borderRadius: 6,
backgroundColor: '#fff2f0'
}}>
<h4 style={{ color: '#cf1322', margin: '0 0 8px 0' }}>
{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'}
</h4>
<p style={{ margin: '0 0 8px 0' }}>{error.message}</p>
<p style={{ margin: '0 0 8px 0', fontWeight: 'bold' }}>Items:</p>
<ul style={{ margin: '0 0 8px 20px' }}>
{error.items.map((item, idx) => (
<li key={idx} style={{ color: '#d4380d' }}>{item}</li>
))}
</ul>
<p style={{ margin: 0, color: '#389e0d' }}>
<strong>💡 Solution:</strong> {error.solution}
</p>
</div>
))}
<div style={{
marginTop: 16,
padding: 12,
backgroundColor: '#f6ffed',
border: '1px solid #b7eb8f',
borderRadius: 6
}}>
<p style={{ margin: 0, color: '#389e0d' }}>
<strong>🎯 Quick Actions:</strong>
</p>
<div style={{ marginTop: 8 }}>
<Button
type="primary"
size="small"
onClick={() => window.location.href = '/backend-servers'}
style={{ marginRight: 8 }}
>
Manage Backends
</Button>
<Button
type="default"
size="small"
onClick={() => window.location.reload()}
>
Refresh Page
</Button>
</div>
</div>
</div>
),
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: (
<div>
<p><strong>Backend validation failed!</strong></p>
<p style={{ color: '#ff4d4f' }}>{error.response.data.message}</p>
<br />
<p><strong>Backends without servers:</strong></p>
<ul style={{ marginTop: 10, marginBottom: 10 }}>
{backendNames.map(name => (
<li key={name} style={{ color: '#ff4d4f' }}>
<strong>{name}</strong>
</li>
))}
</ul>
<p>✅ <strong>Solution:</strong> Go to Backend Management and add servers to these backends.</p>
<br />
<Button
type="primary"
onClick={() => window.location.href = '/backend-servers'}
style={{ marginTop: 10 }}
>
Go to Backend Management
</Button>
</div>
),
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: (
<div>
<p>Cannot delete frontend "<strong>{frontend.name}</strong>"</p>
<div style={{ marginTop: 16 }}>
<p><strong>Reason:</strong> This frontend uses backend "<strong>{frontend.default_backend}</strong>"</p>
<p><strong>Solution:</strong> Please delete the backend first, then the frontend will be automatically updated.</p>
</div>
</div>
),
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: (
<div>
<p>{confirmMessage}</p>
{frontend.default_backend && (
<div style={{ marginTop: 16 }}>
<p><strong>Note:</strong> Any associated WAF rules will also be removed.</p>
</div>
)}
</div>
),
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(
<div>
<div><strong>{frontendMessage}</strong></div>
<div style={{ marginTop: 4, fontSize: '12px' }}>
✅ Configuration updated on {successCount} cluster node(s)
<br />📅 Completed: {new Date().toLocaleString()}
</div>
</div>,
8
);
} else {
message.warning(
<div>
<div><strong>{frontendMessage}</strong></div>
<div style={{ marginTop: 4, fontSize: '12px' }}>
⚠️ {successCount}/{totalNodes} cluster node(s) updated successfully
<br />📅 Completed: {new Date().toLocaleString()}
</div>
</div>,
10
);
}
} else {
message.success(frontendMessage, 6);
}
fetchFrontends();
checkPendingChanges();
} catch (error) {
const errorMsg = extractApiError(error, error.message);
message.error(
<div>
<div><strong>Failed to delete frontend</strong></div>
<div style={{ marginTop: 4, fontSize: '12px', color: '#ff4d4f' }}>
{errorMsg}
</div>
</div>,
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: (
<div>
<p>{deletable.length} frontend(s) will be deleted:</p>
<p><Text code>{displayNames}</Text></p>
{skippedMsg && <p style={{ color: '#faad14' }}>{skippedMsg}</p>}
<p><strong>This action cannot be undone.</strong></p>
</div>
),
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(
<div>
<div><strong>{successCount} deleted, {errorCount} failed</strong></div>
<div style={{ marginTop: 4, fontSize: '12px' }}>
{errors.slice(0, 5).map((e, i) => <div key={i}>{e}</div>)}
{errors.length > 5 && <div>... and {errors.length - 5} more errors</div>}
</div>
</div>,
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 <file>` 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 <file>` 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(
<div>
<div>
<strong>
{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`).
</strong>
</div>
<div style={{ marginTop: 4, fontSize: '12px' }}>
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.
</div>
<div style={{ marginTop: 6, fontSize: '12px', fontFamily: 'monospace' }}>
{ruleSnippets.map((s, i) => (
<div key={i}>• {s}</div>
))}
{extra && <div>{extra}</div>}
</div>
</div>,
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(
<div>
<div><strong>Frontend updated successfully!</strong></div>
<div style={{ marginTop: 4, fontSize: '12px' }}>
📝 Changes created as PENDING
<br />🔄 Click "Apply Changes" to activate configuration
<br />📅 Updated: {new Date().toLocaleString()}
</div>
</div>,
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(
<div>
<div><strong>Frontend saved, but the server flagged {serverWarnings.length} rule warning(s):</strong></div>
<div style={{ marginTop: 6, fontSize: '12px', fontFamily: 'monospace' }}>
{serverWarnings.slice(0, 5).map((w, i) => (
<div key={i}>• {w.length > 240 ? `${w.slice(0, 237)}...` : w}</div>
))}
{serverWarnings.length > 5 && (
<div>(+{serverWarnings.length - 5} more)</div>
)}
</div>
</div>,
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(
<div>
<div><strong>Frontend created successfully!</strong></div>
<div style={{ marginTop: 4, fontSize: '12px' }}>
📝 Changes created as PENDING
<br />🔄 Click "Apply Changes" to activate configuration
<br />📅 Created: {new Date().toLocaleString()}
</div>
</div>,
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(
<div>
<div><strong>Failed to save frontend</strong></div>
<div style={{ marginTop: 4, fontSize: '12px', color: '#ff4d4f' }}>
{errorMsg}
</div>
</div>,
8
);
} finally {
setSubmitting(false);
}
};
const columns = [
{
title: 'Name',
dataIndex: 'name',
key: 'name',
render: (text, record) => (
<Space>
<GlobalOutlined style={{ color: '#1890ff' }} />
<strong>{text}</strong>
{!record.is_active && <Tag color="red">Inactive</Tag>}
</Space>
),
},
{
title: 'Sync Status',
key: 'sync_status',
render: (_, record) => (
<EntitySyncStatus
key={`${record.id}-${refreshKey}`}
entityType="frontends"
entityId={record.id}
entityUpdatedAt={record.updated_at}
lastConfigStatus={record.last_config_status}
clusterId={selectedCluster?.id}
selectedCluster={selectedCluster}
/>
),
},
{
title: 'Bind Address',
dataIndex: 'bind_address',
key: 'bind_address',
render: (text, record) => `${text}:${record.bind_port}`,
},
{
title: 'Mode',
dataIndex: 'mode',
key: 'mode',
render: (mode) => (
<Tag color={mode === 'http' ? 'blue' : 'green'}>{mode.toUpperCase()}</Tag>
),
},
{
title: 'Default Backend',
dataIndex: 'default_backend',
key: 'default_backend',
render: (backend) => backend ? (
<Tag color="cyan">{backend}</Tag>
) : <span style={{ color: '#999' }}>Not Set</span>,
},
{
title: 'SSL/TLS',
key: 'ssl_info',
width: 200,
render: (_, record) => {
if (!record.ssl_enabled) {
return <Tag color="default" size="small">No SSL</Tag>;
}
// 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 (
<div style={{ minWidth: '140px' }}>
<Tag color="processing" icon={<LoadingOutlined />} size="small">
Loading SSL
</Tag>
<div style={{ fontSize: '10px', color: '#1890ff', marginTop: '2px' }}>
Fetching certificate info...
</div>
</div>
);
}
if (certIds.length === 0) {
return (
<div style={{ minWidth: '140px' }}>
<Tag color="orange" icon={<WarningOutlined />} size="small">
SSL Enabled
</Tag>
<div style={{ fontSize: '10px', color: '#fa8c16', marginTop: '2px' }}>
No certificates assigned
</div>
</div>
);
}
// Find all SSL certificates
const sslCerts = certIds.map(id => sslCertificates.find(cert => cert.id === id)).filter(Boolean);
if (sslCerts.length === 0) {
return (
<div style={{ minWidth: '140px' }}>
<Tag color="red" icon={<WarningOutlined />} size="small">
SSL Error
</Tag>
<div style={{ fontSize: '10px', color: '#ff4d4f', marginTop: '2px' }}>
Certificate(s) not found
</div>
</div>
);
}
// 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 (
<div style={{ minWidth: '140px' }}>
<Tag
color={expiryInfo.tagColor}
icon={<SafetyCertificateOutlined />}
size="small"
style={{ marginBottom: '6px', fontSize: '11px' }}
>
{domain}
</Tag>
<div style={{ marginBottom: '4px' }}>
<Tooltip
title={
<div>
<div><strong>{domain}</strong></div>
<div>Status: {expiryInfo.status}</div>
{sslCert.expiry_date && (
<div>Expires: {new Date(sslCert.expiry_date).toLocaleDateString('tr-TR', {
day: '2-digit',
month: '2-digit',
year: 'numeric'
})}</div>
)}
</div>
}
placement="top"
>
<Progress
percent={expiryInfo.progress}
size="small"
strokeColor={expiryInfo.color}
trailColor={token.colorBorderSecondary}
showInfo={false}
style={{
fontSize: '10px',
lineHeight: '12px',
cursor: 'help'
}}
strokeWidth={4}
/>
</Tooltip>
</div>
<div style={{ fontSize: '9px', color: token.colorTextSecondary, lineHeight: '12px' }}>
<div style={{
color: expiryInfo.tagColor === 'red' ? '#ff4d4f' :
expiryInfo.tagColor === 'orange' ? '#fa8c16' : '#52c41a',
fontWeight: '500'
}}>
{expiryInfo.status}
</div>
</div>
</div>
);
}
// Multiple certificates - show compact list
const allValid = sslCerts.every(cert => {
const expiry = getSSLExpiryInfo(cert.expiry_date);
return expiry.tagColor === 'green';
});
return (
<div style={{ minWidth: '160px' }}>
<Tag
color={allValid ? 'green' : 'orange'}
icon={<SafetyCertificateOutlined />}
size="small"
style={{ marginBottom: '4px' }}
>
{sslCerts.length} Certificates
</Tag>
<Tooltip
title={
<div>
<div><strong>SSL Certificates ({sslCerts.length}):</strong></div>
{sslCerts.map(cert => {
const expiryInfo = getSSLExpiryInfo(cert.expiry_date);
return (
<div key={cert.id} style={{ marginTop: 4 }}>
• {cert.domain || cert.name} - {expiryInfo.status}
</div>
);
})}
</div>
}
placement="top"
>
<div style={{ fontSize: '10px', color: token.colorTextSecondary, cursor: 'help' }}>
{sslCerts.map(cert => (cert.domain || cert.name)).join(', ').substring(0, 40)}...
</div>
</Tooltip>
</div>
);
},
},
{
title: 'Status',
dataIndex: 'is_active',
key: 'status',
render: (isActive) => (
<Tag color={isActive ? 'green' : 'red'}>
{isActive ? 'Active' : 'Inactive'}
</Tag>
),
},
{
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 <Tag color={color}>{status}</Tag>;
},
},
{
title: 'Actions',
key: 'actions',
render: (_, record) => (
<Space size="middle">
{(record.has_pending_config || false) && (
<Tooltip title="Apply pending configuration changes">
<Button
type="primary"
size="small"
icon={<PlayCircleOutlined />}
onClick={() => window.location.href = '/apply-management'}
style={{
backgroundColor: '#1890ff',
borderColor: '#1890ff',
}}
>
Apply
</Button>
</Tooltip>
)}
<Tooltip title="Edit Frontend">
<Button
type="primary"
size="small"
icon={<EditOutlined />}
onClick={() => handleEdit(record)}
/>
</Tooltip>
<Popconfirm
title="Are you sure you want to delete this frontend?"
onConfirm={() => handleDelete(record.id)}
okText="Yes"
cancelText="No"
>
<Tooltip title="Delete Frontend">
<Button
type="primary"
danger
size="small"
icon={<DeleteOutlined />}
/>
</Tooltip>
</Popconfirm>
</Space>
),
},
];
// Show empty state when no cluster is selected.
//
// Phase J audit fix #6 — neutral "Loading clusters…" while the
// ClusterContext fetch is still in flight (mount, exponential-
// backoff retry); only flip to the "Go to Cluster Management" CTA
// once the fetch settles and we know the operator actually has to
// pick.
if (!selectedCluster) {
if (clustersLoading) {
return (
<div>
<Row gutter={[16, 16]} style={{ marginBottom: 16 }}>
<Col span={12}>
<h2 style={{ margin: 0 }}>
<GlobalOutlined style={{ marginRight: 8, color: '#1890ff' }} />
Frontend Management
</h2>
</Col>
</Row>
<Card style={{ textAlign: 'center', padding: '60px 20px' }}>
<Spin size="large" />
<Title level={3} style={{ color: '#595959', marginTop: '24px', marginBottom: '8px' }}>
Loading clusters…
</Title>
<Text style={{ color: '#8c8c8c', fontSize: '16px' }}>
Fetching the cluster list. Frontend inventory will load automatically once a cluster is selected.
</Text>
</Card>
</div>
);
}
return (
<div>
<Row gutter={[16, 16]} style={{ marginBottom: 16 }}>
<Col span={12}>
<h2 style={{ margin: 0 }}>
<GlobalOutlined style={{ marginRight: 8, color: '#1890ff' }} />
Frontend Management
</h2>
</Col>
</Row>
<Card style={{ textAlign: 'center', padding: '60px 20px' }}>
<div style={{ fontSize: '48px', color: token.colorTextQuaternary, marginBottom: '16px' }}>
<GlobalOutlined />
</div>
<Title level={3} style={{ color: '#595959', marginBottom: '8px' }}>
No HAProxy Cluster Selected
</Title>
<Text style={{ color: '#8c8c8c', fontSize: '16px' }}>
Please create and select a HAProxy cluster to manage frontends.
</Text>
<div style={{ marginTop: '24px' }}>
<Button
type="primary"
size="large"
onClick={() => navigate('/clusters')}
>
Go to Cluster Management
</Button>
</div>
</Card>
</div>
);
}
return (
<div>
<Row gutter={[16, 16]} style={{ marginBottom: 16 }}>
<Col span={12}>
<h2 style={{ margin: 0 }}>
<GlobalOutlined style={{ marginRight: 8, color: '#1890ff' }} />
Frontend Management
</h2>
</Col>
<Col span={12} style={{ textAlign: 'right' }}>
<Space wrap>
<Space>
<span style={{ fontSize: 12 }}>Pending</span>
<Switch
checked={showPending}
onChange={onToggleShowPending}
size="small"
/>
</Space>
<Space>
<span style={{ fontSize: 12 }}>Rejected</span>
<Switch
checked={showRejected}
onChange={onToggleShowRejected}
size="small"
/>
</Space>
<div style={{
position: 'relative',
display: 'inline-block',
width: 250
}}>
<input
type="text"
placeholder="Search frontends..."
value={searchText}
onChange={(e) => 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;
}
}}
/>
</div>
<Button
icon={<ReloadOutlined />}
onClick={() => {
fetchFrontends();
setRefreshKey(prev => prev + 1); // Force EntitySyncStatus refresh
}}
loading={loading}
>
Refresh
</Button>
<Button
type="primary"
icon={<PlusOutlined />}
onClick={handleAdd}
>
Add Frontend
</Button>
{selectedFrontendKeys.length > 0 && (
<Button
danger
icon={<DeleteOutlined />}
onClick={handleBulkDeleteFrontends}
loading={bulkDeleting}
>
Delete Selected ({selectedFrontendKeys.length})
</Button>
)}
{pendingChanges && (
<Button
type="primary"
icon={<PlayCircleOutlined />}
onClick={() => window.location.href = '/apply-management'}
style={{
backgroundColor: '#1890ff',
borderColor: '#1890ff',
animation: 'pulse 1.5s infinite'
}}
>
Go to Apply Management
</Button>
)}
</Space>
</Col>
</Row>
<Card>
<Table
columns={columns}
dataSource={filteredFrontends}
rowKey="id"
loading={loading}
rowSelection={{
selectedRowKeys: selectedFrontendKeys,
onChange: setSelectedFrontendKeys,
}}
pagination={{
total: filteredFrontends.length,
showSizeChanger: true,
showQuickJumper: true,
showTotal: (total, range) =>
`${range[0]}-${range[1]} of ${total} frontends`,
}}
/>
</Card>
<Modal
title={editingFrontend ? 'Edit Frontend' : 'Add New Frontend'}
open={modalVisible}
onCancel={() => setModalVisible(false)}
footer={null}
width={900}
>
<Alert
message="HAProxy Frontend Configuration"
description="Configure all aspects of your HAProxy frontend including binding, SSL, ACLs, and advanced options."
type="info"
showIcon
style={{ marginBottom: 16 }}
/>
<Form
form={form}
layout="vertical"
onFinish={handleSubmit}
initialValues={{
bind_address: '*',
mode: 'http',
// R18b audit fix: align UI default with the backend model.
// FrontendConfig.ssl_verify now defaults to None (= no
// verify directive emitted). Hard-coding "optional" here
// forced every newly-created frontend to start with mTLS
// optional regardless of operator intent.
ssl_verify: undefined,
compression: false,
log_separate: false,
capture_request_headers: [],
capture_response_headers: []
}}
>
<Collapse defaultActiveKey={['1']} ghost forceRender>
{/* Basic Configuration */}
<Panel header="Basic Configuration" key="1" forceRender>
<Row gutter={16}>
<Col span={12}>
<Form.Item
name="name"
label="Frontend Name"
extra="Unique identifier for this frontend"
rules={[
{ required: true, message: 'Please enter frontend name' },
{ pattern: /^[a-zA-Z0-9_.-]+$/, message: 'Only alphanumeric, dot, underscore and dash allowed' }
]}
>
<Input placeholder="e.g., main_frontend" />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item
name="mode"
label="Protocol Mode"
extra="Protocol for processing connections"
rules={[{ required: true, message: 'Please select mode' }]}
>
<Select>
<Option value="http">HTTP - Layer 7 processing with HTTP understanding</Option>
<Option value="tcp">TCP - Layer 4 processing for non-HTTP protocols</Option>
<Option value="health">Health - Health check endpoint</Option>
</Select>
</Form.Item>
</Col>
</Row>
<Row gutter={16}>
<Col span={12}>
<Form.Item
name="bind_address"
label="Bind Address"
extra="IP address to bind to (* for all interfaces)"
rules={[{ required: true, message: 'Please enter bind address' }]}
>
<Input placeholder="* or specific IP like 192.168.1.10" />
</Form.Item>
</Col>
<Col span={6}>
<Form.Item
name="bind_port"
label="HTTP Port"
extra="Port for HTTP connections"
dependencies={['bind_address']}
rules={[
{ required: true, message: 'Please enter HTTP port' },
{ type: 'number', min: 1, max: 65535, message: 'Port must be between 1-65535' },
({ getFieldValue }) => ({
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();
},
}),
]}
>
<InputNumber style={{ width: '100%' }} placeholder="80" />
</Form.Item>
</Col>
<Col span={6}>
<Form.Item
name="maxconn"
label="Max Connections"
extra="Maximum concurrent connections"
>
<InputNumber style={{ width: '100%' }} min={1} placeholder="2000" />
</Form.Item>
</Col>
</Row>
<Form.Item
name="default_backend"
label="Default Backend"
extra="Backend that will handle requests not matched by any ACL rules"
tooltip="Optional: Default backend for routing. Leave empty to use only ACL-based routing."
>
<Select
placeholder="Select default backend (optional)"
allowClear
showSearch
optionLabelProp="label"
filterOption={(input, option) =>
(option.label || '').toLowerCase().includes(input.toLowerCase())
}
onChange={(value) => {
if (value) {
const selectedBackend = backends.find(b => b.name === value);
const serverCount = selectedBackend?.servers?.length || 0;
if (serverCount === 0) {
message.warning({
content: `Warning: Backend '${value}' has no active servers. Traffic will receive 503 errors until you add servers to this backend.`,
duration: 6
});
}
}
}}
>
{backends.map(backend => {
const serverCount = backend.servers?.length || 0;
const hasServers = serverCount > 0;
const backendLabel = `${backend.name} (${serverCount} server${serverCount !== 1 ? 's' : ''})${!hasServers ? ' - No servers' : ''}`;
return (
<Option
key={backend.name}
value={backend.name}
label={backendLabel}
style={!hasServers ? { color: '#ff4d4f' } : {}}
>
{backend.name} ({serverCount} server{serverCount !== 1 ? 's' : ''})
{!hasServers && ' - No servers'}
</Option>
);
})}
</Select>
</Form.Item>
</Panel>
{/* SSL Configuration */}
<Panel header="SSL/TLS Configuration" key="2" forceRender>
<Form.Item
name="ssl_enabled"
label="Enable SSL/TLS"
valuePropName="checked"
extra="Enable HTTPS/SSL support for this frontend"
>
<Switch
checkedChildren="SSL ON"
unCheckedChildren="SSL OFF"
onChange={(checked) => {
updateSSLVisibility(checked);
}}
/>
</Form.Item>
{/* SSL Fields - Always registered in form, visibility controlled via state */}
<Row gutter={16} className="ssl-fields">
<Col span={24}>
<Form.Item
name="ssl_certificate_ids"
label="SSL Certificates"
extra="Select one or more SSL certificates - HAProxy will use SNI (Server Name Indication) to serve the correct certificate based on hostname"
>
<Select
mode="multiple"
placeholder="Select SSL certificate(s)"
allowClear
showSearch
optionLabelProp="label"
filterOption={(input, option) =>
(option.label || '').toLowerCase().includes(input.toLowerCase())
}
>
{sslCertificates.map(cert => {
const statusText = cert.status === 'valid' ? 'Valid' :
cert.status === 'expiring_soon' ? 'Expiring' : 'Expired';
const expiryInfo = cert.days_until_expiry !== undefined ?
`${cert.days_until_expiry} days` : '';
const sslType = cert.ssl_type === 'Global' ? 'Global' : 'Cluster';
// Rich label for selected view
const richLabel = `${cert.name} - ${cert.domain} [${sslType}] ${statusText}${expiryInfo ? ' (' + expiryInfo + ')' : ''}`;
return (
<Option
key={cert.id}
value={cert.id}
label={richLabel}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span>
<strong>{cert.name}</strong> - {cert.domain}
<Tag
color={cert.ssl_type === 'Global' ? 'blue' : 'green'}
style={{ marginLeft: 8, fontSize: '10px' }}
>
{sslType}
</Tag>
</span>
<span style={{ fontSize: '12px', color: token.colorTextSecondary, marginLeft: 12 }}>
{statusText} {expiryInfo && `(${expiryInfo})`}
</span>
</div>
</Option>
);
})}
</Select>
</Form.Item>
</Col>
</Row>
<Row gutter={16} className="ssl-fields">
<Col span={24}>
<Form.Item
name="ssl_verify"
label="Client Certificate Verification"
extra="Level of client certificate verification (leave blank to omit the verify directive entirely)"
>
{/* 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. */}
<Select allowClear placeholder="(omit verify directive)">
<Option value="none">None - No client certificate required</Option>
<Option value="optional">Optional - Accept connections with or without client cert</Option>
<Option value="required">Required - Client certificate mandatory</Option>
</Select>
</Form.Item>
</Col>
</Row>
{/* SSL Advanced Options */}
<Divider orientation="left" style={{ marginTop: 24, marginBottom: 16 }}>
SSL Advanced Options
</Divider>
<Row gutter={16} className="ssl-fields">
<Col span={12}>
<Form.Item
name="ssl_alpn"
label="ALPN Protocols"
extra="Application-Layer Protocol Negotiation (e.g., h2,http/1.1)"
tooltip="Comma-separated list of protocols for ALPN. Common: h2,http/1.1 for HTTP/2 and HTTP/1.1"
>
<Input placeholder="h2,http/1.1" />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item
name="ssl_npn"
label="NPN Protocols (Legacy)"
extra="Next Protocol Negotiation - deprecated, use ALPN instead"
tooltip="Legacy protocol negotiation, replaced by ALPN in modern browsers"
>
<Input placeholder="http/1.1" />
</Form.Item>
</Col>
</Row>
<Row gutter={16} className="ssl-fields">
<Col span={12}>
<Form.Item
name="ssl_min_ver"
label="Minimum TLS Version"
extra="Minimum allowed TLS protocol version"
tooltip="Enforce minimum TLS version for security. TLSv1.2 or higher recommended."
>
<Select placeholder="Select minimum TLS version" allowClear>
<Option value="SSLv3">SSLv3 (Not recommended)</Option>
<Option value="TLSv1.0">TLSv1.0 (Deprecated)</Option>
<Option value="TLSv1.1">TLSv1.1 (Deprecated)</Option>
<Option value="TLSv1.2">TLSv1.2 (Recommended)</Option>
<Option value="TLSv1.3">TLSv1.3 (Most secure)</Option>
</Select>
</Form.Item>
</Col>
<Col span={12}>
<Form.Item
name="ssl_max_ver"
label="Maximum TLS Version"
extra="Maximum allowed TLS protocol version"
tooltip="Usually left unset to allow latest TLS version"
>
<Select placeholder="Select maximum TLS version" allowClear>
<Option value="TLSv1.0">TLSv1.0</Option>
<Option value="TLSv1.1">TLSv1.1</Option>
<Option value="TLSv1.2">TLSv1.2</Option>
<Option value="TLSv1.3">TLSv1.3</Option>
</Select>
</Form.Item>
</Col>
</Row>
<Row gutter={16} className="ssl-fields">
<Col span={24}>
<Form.Item
name="ssl_ciphers"
label="SSL Ciphers (TLS 1.2 and below)"
extra="Cipher suite list for TLS 1.2 and earlier versions"
tooltip="Colon-separated list of OpenSSL cipher names. Leave empty for HAProxy defaults."
>
<Input placeholder="ECDHE-RSA-AES128-GCM-SHA256:ECDHE-RSA-AES256-GCM-SHA384" />
</Form.Item>
</Col>
</Row>
<Row gutter={16} className="ssl-fields">
<Col span={24}>
<Form.Item
name="ssl_ciphersuites"
label="SSL Cipher Suites (TLS 1.3)"
extra="Cipher suite list specifically for TLS 1.3"
tooltip="Colon-separated list of TLS 1.3 cipher suite names. Leave empty for defaults."
>
<Input placeholder="TLS_AES_128_GCM_SHA256:TLS_AES_256_GCM_SHA384:TLS_CHACHA20_POLY1305_SHA256" />
</Form.Item>
</Col>
</Row>
<Row gutter={16} className="ssl-fields">
<Col span={24}>
<Form.Item
name="ssl_strict_sni"
label="Strict SNI"
valuePropName="checked"
extra="Reject connections without valid SNI or with SNI not matching any certificate"
tooltip="When enabled, connections must provide SNI matching a configured certificate"
>
<Switch />
</Form.Item>
</Col>
</Row>
<Alert
message="SSL Advanced Options Help"
description={
<div>
<p><strong>ALPN (Application-Layer Protocol Negotiation):</strong> Modern protocol for negotiating HTTP/2. Use "h2,http/1.1" for HTTP/2 support.</p>
<p><strong>TLS Version:</strong> TLSv1.2 minimum recommended for security. TLSv1.3 provides best performance and security.</p>
<p><strong>Ciphers:</strong> Leave empty to use HAProxy secure defaults. Custom ciphers only if required by specific compliance needs.</p>
<p><strong>Strict SNI:</strong> Enable for multi-domain setups to ensure clients request correct certificate via SNI.</p>
</div>
}
type="info"
showIcon
style={{ marginTop: 16 }}
className="ssl-fields"
/>
</Panel>
{/* Advanced Options */}
<Panel header="Advanced Options" key="3" forceRender>
<Row gutter={16}>
<Col span={8}>
<Form.Item
name="timeout_client"
label="Client Timeout (ms)"
extra="Maximum time to wait for client data"
>
<InputNumber style={{ width: '100%' }} min={1000} max={300000} placeholder="30000" />
</Form.Item>
</Col>
<Col span={8}>
<Form.Item
name="timeout_http_request"
label="HTTP Request Timeout (ms)"
extra="Maximum time to wait for complete HTTP request"
>
<InputNumber style={{ width: '100%' }} min={1000} max={300000} placeholder="10000" />
</Form.Item>
</Col>
<Col span={8}>
<Form.Item
name="rate_limit"
label="Rate Limit (req/sec)"
extra="Maximum requests per second per client IP"
>
<InputNumber style={{ width: '100%' }} min={1} placeholder="100" />
</Form.Item>
</Col>
</Row>
<Row gutter={16}>
<Col span={8}>
<Form.Item
name="compression"
label="Enable Compression"
valuePropName="checked"
extra="Compress responses to reduce bandwidth"
>
<Switch />
</Form.Item>
</Col>
<Col span={8}>
<Form.Item
name="log_separate"
label="Separate Logging"
valuePropName="checked"
extra="Use separate log file for this frontend"
>
<Switch />
</Form.Item>
</Col>
<Col span={8}>
<Form.Item
name="monitor_uri"
label="Monitor URI"
extra="URI for health monitoring (e.g., /health)"
>
<Input placeholder="/health" />
</Form.Item>
</Col>
</Row>
</Panel>
{/* ACL Rules -- Visual Builder */}
<Panel header="Access Control Lists (ACL)" key="4" forceRender>
<ACLRuleBuilder
key={aclBuilderKey}
aclRules={aclBuilderData.aclRules}
useBackendRules={aclBuilderData.useBackendRules}
redirectRules={aclBuilderData.redirectRules}
backends={backends}
onChange={(data) => setAclBuilderData(data)}
/>
</Panel>
{/* Header Manipulation */}
<Panel header="Header Manipulation" key="5" forceRender>
<Row gutter={16}>
<Col span={12}>
<Form.Item
name="request_headers"
label="Request Header Rules"
extra="Modify request headers sent to backend"
>
<TextArea
rows={4}
placeholder={`Examples:
http-request add-header X-Forwarded-Proto https
http-request set-header X-Real-IP %[src]
http-request del-header X-Powered-By`}
/>
</Form.Item>
</Col>
<Col span={12}>
<Form.Item
name="response_headers"
label="Response Header Rules"
extra="Modify response headers sent to client"
>
<TextArea
rows={4}
placeholder={`Examples:
http-response add-header X-Frame-Options SAMEORIGIN
http-response add-header X-Content-Type-Options nosniff
http-response del-header Server`}
/>
</Form.Item>
</Col>
</Row>
<Row gutter={16}>
<Col span={24}>
<Form.Item
name="options"
label="Frontend Options"
extra="HAProxy frontend options (one per line)"
tooltip="Examples: option httplog, option forwardfor, option dontlognull, option http-keep-alive"
help={
form.getFieldValue('options') &&
form.getFieldValue('options').toLowerCase().includes('httpchk') ? (
<span style={{ color: '#faad14' }}>
⚠️ "option httpchk" is not applicable to frontends. Health checks are configured in backend definitions.
</span>
) : null
}
>
<TextArea
rows={4}
placeholder={`Examples:
option httplog
option forwardfor
option dontlognull
option http-keep-alive`}
onChange={(e) => {
// Force re-render to show/hide warning
form.setFieldsValue({ options: e.target.value });
}}
/>
</Form.Item>
</Col>
</Row>
<Row gutter={16}>
<Col span={24}>
<Form.Item
name="tcp_request_rules"
label="TCP Request Rules (TCP Mode Only)"
extra="TCP-level request inspection and filtering rules"
tooltip="Used for TCP mode frontends to inspect and filter connections"
>
<TextArea
rows={4}
placeholder={`Examples:
tcp-request inspect-delay 5s
tcp-request content accept if { req_ssl_hello_type 1 }
tcp-request connection reject if { src -f /etc/haproxy/blacklist.lst }`}
/>
</Form.Item>
</Col>
</Row>
</Panel>
</Collapse>
<Form.Item style={{ marginBottom: 0, textAlign: 'right', marginTop: 24 }}>
<Space>
<Button onClick={() => setModalVisible(false)}>
Cancel
</Button>
<Button type="primary" htmlType="submit" loading={submitting}>
{submitting
? (editingFrontend ? 'Updating...' : 'Creating...')
: (editingFrontend ? 'Update' : 'Create')} Frontend
</Button>
</Space>
</Form.Item>
</Form>
</Modal>
{/* Version History Modal */}
<VersionHistory
visible={versionModalVisible}
onCancel={handleVersionModalCancel}
entityType={selectedEntityForVersion?.entityType}
entityId={selectedEntityForVersion?.entityId}
onRestoreSuccess={handleRestoreSuccess}
/>
</div>
);
};
function FrontendManagementWithErrorBoundary() {
return (
<FrontendErrorBoundary>
<FrontendManagement />
</FrontendErrorBoundary>
);
}
export { FrontendManagementWithErrorBoundary as FrontendManagement };
export default FrontendManagementWithErrorBoundary;