Files
haproxy-openmanager/frontend/src/components/BackendServers.js
T
taylanbakircioglu 8076f3fcfd feat: add intelligent HAProxy validation error display in UI
- Add haproxy_error_parser.py: Parses HAProxy validation errors with
  confidence scoring, extracts entity type/name, line number, error type
- Add ValidationErrorModal.js: Rich modal with parsed error summary,
  quick fix suggestions, and manual troubleshooting guide
- Update cluster.py: Integrate error parser into agent-sync and
  config-versions endpoints with graceful fallback
- Update ApplyManagement.js: Add validation error banner with quick
  navigation buttons and error detail modal
- Update FrontendManagement.js & BackendServers.js: Handle URL params
  for deep-linking to entity edit forms with field highlighting

Enables users to see actionable validation failure details directly
in the UI without needing server access for debugging.
2026-01-26 15:25:15 +03:00

2261 lines
78 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, Space, Tag, Modal, Form, Input, InputNumber,
Select, Row, Col, message, Popconfirm, Tooltip, Badge, Switch,
Tabs, Divider, Typography, List, Avatar, Alert, Spin
} from 'antd';
import { getAgentSyncColor, getConfigStatusColor, getEntityStatusColor } from '../utils/colors';
import EntitySyncStatus from './EntitySyncStatus';
import {
PlusOutlined, EditOutlined, DeleteOutlined, ReloadOutlined,
PlayCircleOutlined, PauseCircleOutlined,
CloudServerOutlined, SettingOutlined, UserOutlined, SearchOutlined,
HistoryOutlined, ContainerOutlined
} from '@ant-design/icons';
import axios from 'axios';
import { useNavigate, useLocation } from 'react-router-dom';
import { useCluster } from '../contexts/ClusterContext';
import { VersionHistory } from './VersionHistory';
// Error Boundary Component
class BackendErrorBoundary 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('Backend Servers Error:', error, errorInfo);
}
render() {
if (this.state.hasError) {
return (
<Alert
message="Backend Management Error"
description="There was an error loading Backend 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 { TabPane } = Tabs;
const { Text, Title } = Typography;
const BackendServers = () => {
const { selectedCluster } = useCluster();
const navigate = useNavigate();
const location = useLocation();
const [backends, setBackends] = useState([]);
const [frontends, setFrontends] = useState([]);
const [sslCertificates, setSslCertificates] = useState([]);
const [filteredBackends, setFilteredBackends] = 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('backend:showPending');
const sr = localStorage.getItem('backend:showRejected');
if (sp !== null) setShowPending(sp === 'true');
if (sr !== null) setShowRejected(sr === 'true');
}, []);
const onToggleShowPending = (checked) => {
setShowPending(checked);
localStorage.setItem('backend:showPending', String(checked));
};
const onToggleShowRejected = (checked) => {
setShowRejected(checked);
localStorage.setItem('backend:showRejected', String(checked));
};
const [searchText, setSearchText] = useState('');
const [loading, setLoading] = useState(false);
const [refreshKey, setRefreshKey] = useState(0);
const [backendModalVisible, setBackendModalVisible] = useState(false);
const [serverModalVisible, setServerModalVisible] = useState(false);
const [editingBackend, setEditingBackend] = useState(null);
const [editingServer, setEditingServer] = useState(null);
const [selectedBackend, setSelectedBackend] = useState(null);
const [activeTab, setActiveTab] = useState('1');
const [submittingBackend, setSubmittingBackend] = useState(false);
const [submittingServer, setSubmittingServer] = useState(false);
const [applyLoading, setApplyLoading] = useState(false);
const [pendingChanges, setPendingChanges] = useState(false);
const [versionModalVisible, setVersionModalVisible] = useState(false);
const [selectedEntityForVersion, setSelectedEntityForVersion] = useState(null);
const [backendForm] = Form.useForm();
const [serverForm] = Form.useForm();
// Status filter function - defined before fetchBackends 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
// Race condition: Old cluster data remains visible while new cluster data is fetching
if (selectedCluster) {
setBackends([]);
setFilteredBackends([]);
setFrontends([]);
setSslCertificates([]);
}
fetchBackends();
fetchFrontends();
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 && backends.length > 0) {
const backendToEdit = backends.find(b => b.id === parseInt(editId));
if (backendToEdit) {
// Open edit modal for the backend
setEditingBackend(backendToEdit);
backendForm.setFieldsValue({
...backendToEdit,
health_check_enabled: backendToEdit.health_check_enabled || false,
});
setBackendModalVisible(true);
// Clear URL params after opening modal
navigate(location.pathname, { replace: true });
// Show notification about which field to check
if (highlightField) {
message.info(`Please check the "${highlightField}" field - it may have caused a validation error.`, 5);
}
}
}
}, [location.search, backends]);
const fetchBackends = async () => {
// CRITICAL FIX: Don't fetch if no cluster selected to prevent race condition
// Race condition: First fetch (cluster=undefined) returns all backends and overwrites filtered results
if (!selectedCluster) {
setBackends([]);
setFilteredBackends([]);
return;
}
setLoading(true);
try {
const params = { cluster_id: selectedCluster.id };
// CRITICAL FIX: Add cache busting to prevent stale data from appearing
// Browser/axios may cache GET requests, causing deleted backends to reappear
const response = await axios.get('/api/backends', {
params,
headers: {
'Cache-Control': 'no-cache, no-store, must-revalidate',
'Pragma': 'no-cache',
'Expires': '0'
}
});
const fetchedBackends = response.data.backends || [];
setBackends(fetchedBackends);
// CRITICAL FIX: Apply status filters after fetching to maintain filter state
// This prevents backends from disappearing when updated (e.g., APPLIED → PENDING)
setFilteredBackends(applyStatusFilters(fetchedBackends));
} catch (error) {
message.error('Failed to fetch backends: ' + error.message);
} finally {
setLoading(false);
}
};
const fetchFrontends = async () => {
try {
const params = selectedCluster ? { cluster_id: selectedCluster.id } : {};
const response = await axios.get('/api/frontends', {
params,
headers: {
'Cache-Control': 'no-cache, no-store, must-revalidate',
'Pragma': 'no-cache'
}
});
setFrontends(response.data.frontends || []);
} catch (error) {
console.error('Failed to fetch frontends:', error);
// Don't show error message for frontends as it's just for dependency checking
}
};
const fetchSSLCertificates = async () => {
if (!selectedCluster) return;
try {
const token = localStorage.getItem('token');
// CRITICAL: Only fetch server SSL certificates (usage_type=server) for backend servers
const response = await axios.get(`/api/ssl/certificates?cluster_id=${selectedCluster.id}&usage_type=server`, {
headers: {
'Authorization': `Bearer ${token}`,
'Cache-Control': 'no-cache, no-store, must-revalidate',
'Pragma': 'no-cache'
}
});
// CRITICAL FIX: API returns array directly, not wrapped in {certificates: [...]}
const certificates = Array.isArray(response.data) ? response.data : (response.data.certificates || []);
console.log('🔍 SSL FETCH DEBUG (BackendServers):', {
cluster_id: selectedCluster.id,
certificates_count: certificates.length,
certificates: certificates.map(c => ({ id: c.id, name: c.name, ssl_type: c.ssl_type }))
});
setSslCertificates(certificates);
} catch (error) {
console.error('Failed to fetch SSL certificates:', error);
setSslCertificates([]);
// Don't show error message as SSL is optional
}
};
// Search filter function
const handleSearch = (value) => {
setSearchText(value);
if (!value) {
setFilteredBackends(applyStatusFilters(backends));
} else {
const filtered = backends.filter(backend =>
backend.name.toLowerCase().includes(value.toLowerCase()) ||
backend.balance_method.toLowerCase().includes(value.toLowerCase()) ||
backend.mode.toLowerCase().includes(value.toLowerCase())
);
setFilteredBackends(applyStatusFilters(filtered));
}
};
// Update filtered data when backends change
useEffect(() => {
if (searchText) {
handleSearch(searchText);
} else {
setFilteredBackends(applyStatusFilters(backends));
}
}, [backends, searchText, showPending, showRejected]);
// Check for pending configuration changes
const checkPendingChanges = async () => {
if (!selectedCluster) return;
try {
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');
setPendingChanges(hasPending);
} catch (error) {
console.error('Failed to check pending changes:', error);
}
};
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;
}
};
// Validate that all backends have at least one server
const validateBackendsHaveServers = () => {
const backendsWithoutServers = backends.filter(backend =>
!backend.servers || backend.servers.length === 0
);
if (backendsWithoutServers.length > 0) {
const backendNames = backendsWithoutServers.map(b => b.name).join(', ');
Modal.warning({
title: '⚠️ Backend Configuration Error',
content: (
<div>
<p><strong>Cannot apply configuration!</strong></p>
<p>The following backend(s) have no servers defined:</p>
<ul style={{ marginTop: 10, marginBottom: 10 }}>
{backendsWithoutServers.map(backend => (
<li key={backend.id} style={{ color: '#ff4d4f' }}>
<strong>{backend.name}</strong>
</li>
))}
</ul>
<p>❌ <strong>HAProxy validation will fail</strong> if backends have no servers.</p>
<p>✅ Please add at least one server to each backend before applying changes.</p>
<br />
<p><strong>How to fix:</strong></p>
<ol>
<li>Click on the backend name to expand server list</li>
<li>Click "Add Server" button</li>
<li>Enter server details (name, IP, port)</li>
<li>Save the server</li>
<li>Then try "Apply Changes" again</li>
</ol>
</div>
),
width: 600,
okText: 'I Understand',
okType: 'primary'
});
return false;
}
return true;
};
// Apply pending configuration changes
const handleApplyChanges = async () => {
if (!selectedCluster) return;
// Validate that all backends have servers before applying
if (!validateBackendsHaveServers()) {
return; // Stop if validation fails
}
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 backends list to update Config Status column
fetchBackends();
// 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 applied to ${agentResults.length} agent(s). HAProxy reloaded.`);
}
}
} 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 = '/frontend-management'}
style={{ marginRight: 8 }}
>
Manage Frontends
</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 on server!</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> {error.response.data.details?.solution}</p>
</div>
),
width: 600,
okText: 'Fix Backends',
okType: 'primary'
});
} else {
message.error(`Failed to apply changes: ${error.response?.data?.message || error.response?.data?.detail || error.message}`);
}
} finally {
setApplyLoading(false);
}
};
// Backend Operations
const handleAddBackend = () => {
setEditingBackend(null);
backendForm.resetFields();
backendForm.setFieldsValue({
balance_method: 'roundrobin',
mode: 'http',
health_check_interval: 2000,
timeout_connect: 10000,
timeout_server: 60000,
timeout_queue: 60000
});
setBackendModalVisible(true);
};
const handleEditBackend = (backend) => {
setEditingBackend(backend);
// Debug: Check if multi-line text fields exist in backend object
console.log('🔍 BACKEND EDIT DEBUG: Raw backend data:', backend);
console.log('🔍 BACKEND EDIT DEBUG: Options field:', backend.options);
console.log('🔍 BACKEND EDIT DEBUG: Request headers:', backend.request_headers);
console.log('🔍 BACKEND EDIT DEBUG: Response headers:', backend.response_headers);
backendForm.setFieldsValue({
...backend,
health_check_interval: backend.health_check_interval || 2000,
timeout_connect: backend.timeout_connect || 10000,
timeout_server: backend.timeout_server || 60000,
timeout_queue: backend.timeout_queue || 60000,
// Explicitly set multi-line text fields to handle null/undefined cases
options: backend.options || '',
request_headers: backend.request_headers || '',
response_headers: backend.response_headers || ''
});
setBackendModalVisible(true);
};
const handleDeleteBackend = async (backendId) => {
if (!selectedCluster) {
message.warning('Please select a HAProxy cluster first');
return;
}
// Find the backend to get details for confirmation
const backend = backends.find(b => b.id === backendId);
if (!backend) {
message.error('Backend not found');
return;
}
// Check dependencies and show confirmation dialog
const serverCount = backend.servers ? backend.servers.length : 0;
const frontendCount = backends.filter(b =>
frontends.some(f => f.default_backend === backend.name)
).length;
let confirmMessage = `Are you sure you want to delete backend "${backend.name}"?`;
let warningDetails = [];
if (serverCount > 0) {
warningDetails.push(`${serverCount} server(s) will also be deleted`);
}
// Check if any frontend uses this backend
const usingFrontends = frontends.filter(f => f.default_backend === backend.name);
if (usingFrontends.length > 0) {
const frontendNames = usingFrontends.map(f => f.name).join(', ');
warningDetails.push(`${usingFrontends.length} frontend(s) (${frontendNames}) will be updated (default backend cleared)`);
}
if (warningDetails.length > 0) {
confirmMessage += '\n\nThis action will also:';
warningDetails.forEach(detail => {
confirmMessage += `\n• ${detail}`;
});
}
// Show confirmation modal
Modal.confirm({
title: 'Delete Backend',
content: (
<div>
<p>{confirmMessage.split('\n')[0]}</p>
{warningDetails.length > 0 && (
<div style={{ marginTop: 16 }}>
<p><strong>This action will also:</strong></p>
<ul style={{ marginBottom: 0, paddingLeft: 20 }}>
{warningDetails.map((detail, index) => (
<li key={index}>{detail}</li>
))}
</ul>
</div>
)}
</div>
),
okText: 'Delete',
okType: 'danger',
cancelText: 'Cancel',
onOk: async () => {
try {
const response = await axios.delete(`/api/backends/${backendId}`, {
data: { cluster_id: selectedCluster.id }
});
// Show backend deletion success with details
const backendMessage = response.data.message || 'Backend 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>{backendMessage}</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>{backendMessage}</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(backendMessage, 6);
}
fetchBackends();
checkPendingChanges();
} catch (error) {
const errorMsg = error.response?.data?.detail || error.message;
message.error(
<div>
<div><strong>Failed to delete backend</strong></div>
<div style={{ marginTop: 4, fontSize: '12px', color: '#ff4d4f' }}>
{errorMsg}
</div>
</div>,
8
);
}
}
});
};
// Version history modal handlers
const handleShowVersionHistory = (record) => {
setSelectedEntityForVersion({
entityType: 'backend',
entityId: record.id
});
setVersionModalVisible(true);
};
const handleVersionModalCancel = () => {
setVersionModalVisible(false);
setSelectedEntityForVersion(null);
};
const handleRestoreSuccess = (restoreData) => {
// Refresh pending changes status after successful restore
checkPendingChanges();
// Refresh backends list to show updated data
fetchBackends();
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
);
};
const handleBackendSubmit = async (values) => {
if (!selectedCluster) {
message.warning('Please select a HAProxy cluster first');
return;
}
setSubmittingBackend(true);
try {
const requestData = {
...values,
cluster_id: selectedCluster.id
};
let response;
if (editingBackend) {
response = await axios.put(`/api/backends/${editingBackend.id}`, requestData);
// Show agent sync results
if (response.data.sync_results) {
const syncResults = response.data.sync_results;
const agentCount = syncResults.length;
message.success(
<div>
<div><strong>Backend 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('Backend updated successfully');
}
} else {
response = await axios.post('/api/backends', requestData);
// Show cluster sync results
if (response.data.sync_results) {
const syncResults = response.data.sync_results;
const successCount = syncResults.filter(r => r.success).length;
const totalNodes = syncResults.length;
message.success(
<div>
<div><strong>Backend 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('Backend created successfully');
}
}
setBackendModalVisible(false);
fetchBackends();
checkPendingChanges();
} catch (error) {
const errorMsg = error.response?.data?.detail || error.message;
message.error(
<div>
<div><strong>Failed to save backend</strong></div>
<div style={{ marginTop: 4, fontSize: '12px', color: '#ff4d4f' }}>
{errorMsg}
</div>
</div>,
8
);
} finally {
setSubmittingBackend(false);
}
};
// Server Operations
const handleAddServer = (backend) => {
setSelectedBackend(backend);
setEditingServer(null);
serverForm.resetFields();
serverForm.setFieldsValue({
weight: 100,
check_enabled: true,
backup_server: false,
ssl_enabled: false
});
setServerModalVisible(true);
};
const handleEditServer = (server, backend) => {
setSelectedBackend(backend);
setEditingServer(server);
// Parse address to get IP and port
const [address, port] = server.address.split(':');
serverForm.setFieldsValue({
server_name: server.name,
server_address: address,
server_port: parseInt(port),
weight: server.weight,
check_enabled: server.check_enabled,
check_port: server.check_port || null,
backup_server: server.backup_server,
ssl_enabled: server.ssl_enabled || false,
ssl_verify: server.ssl_verify || null,
ssl_certificate_id: server.ssl_certificate_id || null,
cookie_value: server.cookie_value || null,
inter: server.inter || null,
fall: server.fall || null,
rise: server.rise || null
});
setServerModalVisible(true);
};
const handleDeleteServer = async (serverId) => {
if (!selectedCluster) {
message.warning('Please select a HAProxy cluster first');
return;
}
try {
const response = await axios.delete(`/api/servers/${serverId}`, {
data: { cluster_id: selectedCluster.id }
});
// Show cluster sync results
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>Server deleted successfully!</strong></div>
<div style={{ marginTop: 4, fontSize: '12px' }}>
✅ Configuration removed from {successCount} cluster node(s)
<br />📅 Deleted: {new Date().toLocaleString()}
</div>
</div>,
6
);
} else {
message.warning(
<div>
<div><strong>Server deleted with warnings</strong></div>
<div style={{ marginTop: 4, fontSize: '12px' }}>
✅ {successCount}/{totalNodes} nodes updated successfully
<br />⚠️ Some nodes may need manual cleanup
</div>
</div>,
8
);
}
} else {
message.success('Server deleted successfully');
}
fetchBackends();
} catch (error) {
const errorMsg = error.response?.data?.detail || error.message;
message.error(
<div>
<div><strong>Failed to delete server</strong></div>
<div style={{ marginTop: 4, fontSize: '12px', color: '#ff4d4f' }}>
{errorMsg}
</div>
</div>,
8
);
}
};
const handleServerSubmit = async (values) => {
if (!selectedCluster) {
message.warning('Please select a HAProxy cluster first');
return;
}
setSubmittingServer(true);
try {
// Don't include cluster_id - backend endpoint gets it from the backend record
const requestData = {
...values
};
let response;
if (editingServer) {
response = await axios.put(`/api/backends/servers/${editingServer.id}`, requestData);
// Show cluster sync results
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>Server updated successfully!</strong></div>
<div style={{ marginTop: 4, fontSize: '12px' }}>
✅ Configuration synchronized to {successCount} cluster node(s)
<br />📅 Last update: {new Date().toLocaleString()}
</div>
</div>,
6
);
} else {
message.warning(
<div>
<div><strong>Server updated with warnings</strong></div>
<div style={{ marginTop: 4, fontSize: '12px' }}>
✅ {successCount}/{totalNodes} nodes updated successfully
<br />⚠️ Some nodes may need manual intervention
</div>
</div>,
8
);
}
} else {
message.success('Server updated successfully');
}
} else {
response = await axios.post(`/api/backends/${selectedBackend.id}/servers`, requestData);
// Show cluster sync results
if (response.data.sync_results) {
const syncResults = response.data.sync_results;
const pendingResults = syncResults.filter(r => r.status === 'PENDING');
if (pendingResults.length > 0) {
message.success(
<div>
<div><strong>Server added successfully!</strong></div>
<div style={{ marginTop: 4, fontSize: '12px' }}>
📝 Changes created as PENDING
<br />🔄 Click "Apply Changes" to activate configuration
<br />📅 Added: {new Date().toLocaleString()}
</div>
</div>,
6
);
} else {
const successCount = syncResults.filter(r => r.success).length;
const totalNodes = syncResults.length;
if (successCount === totalNodes) {
message.success(
<div>
<div><strong>Server added successfully!</strong></div>
<div style={{ marginTop: 4, fontSize: '12px' }}>
✅ Configuration deployed to {successCount} cluster node(s)
<br />📅 Added: {new Date().toLocaleString()}
</div>
</div>,
6
);
} else {
message.warning(
<div>
<div><strong>Server added with warnings</strong></div>
<div style={{ marginTop: 4, fontSize: '12px' }}>
✅ {successCount}/{totalNodes} nodes configured successfully
<br />⚠️ Some nodes may need manual intervention
</div>
</div>,
8
);
}
}
} else {
message.success('Server added successfully');
}
}
setServerModalVisible(false);
fetchBackends();
checkPendingChanges();
} catch (error) {
const errorMsg = error.response?.data?.detail || error.message;
message.error(
<div>
<div><strong>Failed to save server</strong></div>
<div style={{ marginTop: 4, fontSize: '12px', color: '#ff4d4f' }}>
{errorMsg}
</div>
</div>,
8
);
} finally {
setSubmittingServer(false);
}
};
const handleToggleServer = async (serverId, isActive) => {
console.log('🔄 FRONTEND DEBUG: handleToggleServer called', { serverId, isActive });
try {
console.log('🔄 FRONTEND DEBUG: Making API call to:', `/api/backends/servers/${serverId}/toggle`);
const response = await axios.put(`/api/backends/servers/${serverId}/toggle`);
console.log('🔄 FRONTEND DEBUG: API response:', response.data);
message.success(response.data.message || `Server ${isActive ? 'disabled' : 'enabled'} successfully`);
fetchBackends();
checkPendingChanges(); // Check for pending changes after toggle
} catch (error) {
console.error('🔄 FRONTEND DEBUG: API error:', error);
const errorMsg = error.response?.data?.detail || error.message;
message.error('Failed to toggle server: ' + errorMsg);
}
};
const backendColumns = [
{
title: 'Backend Name',
dataIndex: 'name',
key: 'name',
render: (text, record) => (
<Space>
<CloudServerOutlined style={{ color: '#1890ff' }} />
<strong>{text}</strong>
{!record.is_active && <Tag color="red">Inactive</Tag>}
</Space>
),
},
{
title: 'Sync Status',
key: 'agent_sync',
render: (_, record) => (
<EntitySyncStatus
key={`${record.id}-${refreshKey}`}
entityType="backends"
entityId={record.id}
entityUpdatedAt={record.updated_at}
lastConfigStatus={record.last_config_status}
clusterId={selectedCluster?.id}
selectedCluster={selectedCluster}
/>
),
},
{
title: 'Balance Method',
dataIndex: 'balance_method',
key: 'balance_method',
render: (method) => (
<Tag color="blue">{method}</Tag>
),
},
{
title: 'Mode',
dataIndex: 'mode',
key: 'mode',
render: (mode) => (
<Tag color={mode === 'http' ? 'green' : 'orange'}>{mode.toUpperCase()}</Tag>
),
},
{
title: 'Servers',
dataIndex: 'servers',
key: 'servers',
render: (servers = []) => {
// CRITICAL FIX: Handle null/undefined servers array for backends without servers
if (!servers || !Array.isArray(servers)) {
return (
<Space>
<Tag color="red">0</Tag>
<span>/0</span>
<Text type="secondary">active</Text>
<Tooltip title="No servers configured. Please add at least one server.">
<Tag color="warning" style={{ marginLeft: 4, fontSize: '10px' }}>⚠️ Empty</Tag>
</Tooltip>
</Space>
);
}
const activeServers = servers.filter(s => s.is_active).length;
const totalServers = servers.length;
return (
<Space>
<Tag color="green">{activeServers}</Tag>
<span>/{totalServers}</span>
<Text type="secondary">active</Text>
</Space>
);
},
},
{
title: 'Health Check',
dataIndex: 'health_check_uri',
key: 'health_check',
render: (uri) => uri ? (
<Tag color="cyan">{uri}</Tag>
) : <Text type="secondary">None</Text>,
},
{
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: 'Status',
dataIndex: 'is_active',
key: 'status',
render: (isActive) => (
<Badge
status={isActive ? 'success' : 'error'}
text={isActive ? 'Active' : 'Inactive'}
/>
),
},
{
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="small">
{(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="Add Server">
<Button
type="primary"
size="small"
icon={<PlusOutlined />}
onClick={() => handleAddServer(record)}
/>
</Tooltip>
<Tooltip title="Edit Backend">
<Button
size="small"
icon={<EditOutlined />}
onClick={() => handleEditBackend(record)}
/>
</Tooltip>
<Popconfirm
title="Are you sure you want to delete this backend and all its servers?"
onConfirm={() => handleDeleteBackend(record.id)}
okText="Yes"
cancelText="No"
>
<Tooltip title="Delete Backend">
<Button
danger
size="small"
icon={<DeleteOutlined />}
/>
</Tooltip>
</Popconfirm>
</Space>
),
},
];
const serverColumns = [
{
title: 'Server',
dataIndex: 'name',
key: 'name',
render: (text, record) => (
<Space>
<Avatar
size="small"
icon={<ContainerOutlined />}
style={{ backgroundColor: record.is_active ? '#52c41a' : '#ff4d4f' }}
/>
<strong>{text}</strong>
{record.backup_server && <Tag color="orange">Backup</Tag>}
</Space>
),
},
{
title: 'Backend',
dataIndex: 'backend_name',
key: 'backend_name',
render: (backend_name) => (
<Tag color="blue">{backend_name}</Tag>
),
},
{
title: 'Address',
dataIndex: 'address',
key: 'address',
render: (address) => <Text code>{address}</Text>,
},
{
title: 'Weight',
dataIndex: 'weight',
key: 'weight',
render: (weight) => (
<Tag color="purple">{weight}</Tag>
),
},
{
title: 'Health Check',
dataIndex: 'check_enabled',
key: 'check_enabled',
render: (enabled) => (
<Badge
status={enabled ? 'success' : 'default'}
text={enabled ? 'Enabled' : 'Disabled'}
/>
),
},
{
title: 'Status',
dataIndex: 'status',
key: 'status',
render: (status, record) => {
// Map HAProxy server status to Badge configuration
const getStatusConfig = (status) => {
// Handle status with age info like "DOWN (stale)" or "UP (fresh)"
const cleanStatus = status ? status.split(' ')[0] : 'UNKNOWN';
switch (cleanStatus) {
case 'UP':
return { status: 'success', text: status, color: '#52c41a' };
case 'DOWN':
return { status: 'error', text: status, color: '#ff4d4f' };
case 'MAINT':
return { status: 'warning', text: status, color: '#fadb14' };
case 'DRAIN':
return { status: 'processing', text: status, color: '#1890ff' };
case 'NOLB':
return { status: 'warning', text: status, color: '#fa8c16' };
case 'AGENT_OFFLINE':
return { status: 'default', text: 'Agent Offline', color: '#8c8c8c' };
case 'TIMEOUT':
return { status: 'processing', text: 'Timeout', color: '#722ed1' };
case 'NETWORK_ERROR':
return { status: 'error', text: 'Network Error', color: '#ff7875' };
case 'NO_AGENT_ASSIGNED':
return { status: 'warning', text: 'No Agent Assigned', color: '#faad14' };
case 'UNKNOWN':
return { status: 'default', text: status || 'Unknown', color: '#d9d9d9' };
default:
return { status: 'default', text: status || 'Unknown', color: '#d9d9d9' };
}
};
const statusConfig = getStatusConfig(status);
const cleanStatus = status ? status.split(' ')[0] : 'UNKNOWN';
return (
<Space>
<Tag
color={cleanStatus === 'UP' ? 'green' : cleanStatus === 'DOWN' ? 'red' : 'orange'}
>
{statusConfig.text}
</Tag>
<Switch
size="small"
checked={record.is_active}
onChange={() => handleToggleServer(record.id, record.is_active)}
checkedChildren="ON"
unCheckedChildren="OFF"
/>
</Space>
);
},
},
];
const renderServerList = () => {
// CRITICAL FIX: Handle null/undefined servers array for backends without servers
const allServers = backends.flatMap(backend => {
const servers = backend.servers || [];
return servers.map(server => ({
...server,
backend_name: backend.name,
backend_id: backend.id
}));
});
return (
<Table
columns={[
...serverColumns,
{
title: 'Backend',
dataIndex: 'backend_name',
key: 'backend_name',
render: (name) => <Tag color="blue">{name}</Tag>,
},
{
title: 'Actions',
key: 'actions',
render: (_, record) => (
<Space size="small">
<Tooltip title="Edit Server">
<Button
size="small"
icon={<EditOutlined />}
onClick={() => handleEditServer(record, { id: record.backend_id, name: record.backend_name })}
/>
</Tooltip>
<Popconfirm
title="Are you sure you want to delete this server?"
onConfirm={() => handleDeleteServer(record.id)}
okText="Yes"
cancelText="No"
>
<Tooltip title="Delete Server">
<Button
danger
size="small"
icon={<DeleteOutlined />}
/>
</Tooltip>
</Popconfirm>
</Space>
),
},
]}
dataSource={allServers}
rowKey="id"
loading={loading}
pagination={{
showSizeChanger: true,
showQuickJumper: true,
showTotal: (total, range) =>
`${range[0]}-${range[1]} of ${total} servers`,
}}
/>
);
};
// Show empty state when no cluster is selected
if (!selectedCluster) {
return (
<div>
<Row gutter={[16, 16]} style={{ marginBottom: 16 }}>
<Col span={12}>
<Title level={2} style={{ margin: 0 }}>
<CloudServerOutlined style={{ marginRight: 8, color: '#1890ff' }} />
Backend & Server Management
</Title>
</Col>
</Row>
<Card style={{ textAlign: 'center', padding: '60px 20px' }}>
<div style={{ fontSize: '48px', color: '#d9d9d9', marginBottom: '16px' }}>
<CloudServerOutlined />
</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 backends and servers.
</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}>
<Title level={2} style={{ margin: 0 }}>
<CloudServerOutlined style={{ marginRight: 8, color: '#1890ff' }} />
Backend & Server Management
</Title>
</Col>
<Col span={12} style={{ textAlign: 'right' }}>
<Space>
<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
}}>
<SearchOutlined style={{
position: 'absolute',
left: 8,
top: '50%',
transform: 'translateY(-50%)',
color: '#bfbfbf',
zIndex: 1
}} />
<input
type="text"
placeholder="Search backends..."
value={searchText}
onChange={(e) => handleSearch(e.target.value)}
style={{
width: '100%',
height: 32,
paddingLeft: 30,
paddingRight: 8,
border: '1px solid #d9d9d9',
borderRadius: 6,
fontSize: 14,
outline: 'none',
boxShadow: 'none',
backgroundColor: '#fff',
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 = '#d9d9d9';
}}
onMouseOver={(e) => {
if (e.target !== document.activeElement) {
e.target.style.borderColor = '#40a9ff';
}
}}
onMouseOut={(e) => {
if (e.target !== document.activeElement) {
e.target.style.borderColor = '#d9d9d9';
}
}}
/>
</div>
<Button
icon={<ReloadOutlined />}
onClick={() => {
fetchBackends();
setRefreshKey(prev => prev + 1); // Force EntitySyncStatus refresh
}}
loading={loading}
>
Refresh
</Button>
<Button
type="primary"
icon={<PlusOutlined />}
onClick={handleAddBackend}
>
Add Backend
</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>
<Tabs activeKey={activeTab} onChange={setActiveTab}>
<TabPane tab="Backends" key="1">
<Table
columns={backendColumns}
dataSource={filteredBackends}
rowKey="id"
loading={loading}
expandable={{
expandedRowRender: record => {
// CRITICAL FIX: Handle null/undefined servers array for backends without servers
const servers = record.servers || [];
return (
<div style={{ margin: 0 }}>
<Title level={5}>Servers in {record.name}:</Title>
{servers.length > 0 ? (
<Table
columns={serverColumns.concat([
{
title: 'Actions',
key: 'actions',
render: (_, server) => (
<Space size="small">
<Tooltip title="Edit Server">
<Button
size="small"
icon={<EditOutlined />}
onClick={() => handleEditServer(server, record)}
/>
</Tooltip>
<Popconfirm
title="Are you sure you want to delete this server?"
onConfirm={() => handleDeleteServer(server.id)}
okText="Yes"
cancelText="No"
>
<Tooltip title="Delete Server">
<Button
danger
size="small"
icon={<DeleteOutlined />}
/>
</Tooltip>
</Popconfirm>
</Space>
),
},
])}
dataSource={servers}
rowKey="id"
pagination={false}
size="small"
/>
) : (
<div style={{ textAlign: 'center', padding: 20 }}>
<Text type="secondary">No servers configured</Text>
<br />
<Button
type="link"
icon={<PlusOutlined />}
onClick={() => handleAddServer(record)}
>
Add First Server
</Button>
</div>
)}
</div>
);
},
}}
pagination={{
showSizeChanger: true,
showQuickJumper: true,
showTotal: (total, range) =>
`${range[0]}-${range[1]} of ${total} backends`,
}}
/>
</TabPane>
<TabPane tab="All Servers" key="2">
{renderServerList()}
</TabPane>
</Tabs>
</Card>
{/* Backend Modal */}
<Modal
title={editingBackend ? 'Edit Backend' : 'Add New Backend'}
open={backendModalVisible}
onCancel={() => setBackendModalVisible(false)}
footer={null}
width={700}
>
<Form
form={backendForm}
layout="vertical"
onFinish={handleBackendSubmit}
>
<Row gutter={16}>
<Col span={12}>
<Form.Item
name="name"
label="Backend Name"
rules={[
{ required: true, message: 'Please enter backend name' },
{ pattern: /^[a-zA-Z0-9_-]+$/, message: 'Only alphanumeric, underscore and dash allowed' }
]}
>
<Input placeholder="e.g., web_servers" />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item
name="mode"
label="Mode"
rules={[{ required: true, message: 'Please select mode' }]}
>
<Select>
<Option value="http">HTTP</Option>
<Option value="tcp">TCP</Option>
<Option value="health">Health</Option>
</Select>
</Form.Item>
</Col>
</Row>
<Row gutter={16}>
<Col span={12}>
<Form.Item
name="balance_method"
label="Balance Method"
rules={[{ required: true, message: 'Please select balance method' }]}
>
<Select>
<Option value="roundrobin">Round Robin</Option>
<Option value="leastconn">Least Connections</Option>
<Option value="source">Source IP</Option>
<Option value="uri">URI</Option>
<Option value="static-rr">Static Round Robin</Option>
<Option value="random">Random</Option>
<Option value="first">First Available</Option>
</Select>
</Form.Item>
</Col>
<Col span={12}>
<Form.Item
name="health_check_uri"
label="Health Check URI"
>
<Input placeholder="/health" />
</Form.Item>
</Col>
</Row>
<Row gutter={16}>
<Col span={12}>
<Form.Item
name="health_check_interval"
label="Health Check Interval (ms)"
>
<InputNumber
style={{ width: '100%' }}
min={1000}
max={60000}
placeholder="2000"
/>
</Form.Item>
</Col>
<Col span={12}>
<Form.Item
name="timeout_connect"
label="Connect Timeout (ms)"
>
<InputNumber
style={{ width: '100%' }}
min={1000}
max={60000}
placeholder="10000"
/>
</Form.Item>
</Col>
</Row>
<Row gutter={16}>
<Col span={12}>
<Form.Item
name="timeout_server"
label="Server Timeout (ms)"
>
<InputNumber
style={{ width: '100%' }}
min={1000}
max={300000}
placeholder="60000"
/>
</Form.Item>
</Col>
<Col span={12}>
<Form.Item
name="timeout_queue"
label="Queue Timeout (ms)"
>
<InputNumber
style={{ width: '100%' }}
min={1000}
max={300000}
placeholder="60000"
/>
</Form.Item>
</Col>
</Row>
<Row gutter={16}>
<Col span={12}>
<Form.Item
name="health_check_expected_status"
label="Expected HTTP Status"
tooltip="Expected HTTP status code for health checks (100-599)"
>
<InputNumber
style={{ width: '100%' }}
min={100}
max={599}
placeholder="200"
/>
</Form.Item>
</Col>
<Col span={12}>
<Form.Item
name="fullconn"
label="Full Connections"
tooltip="Maximum concurrent connections for this backend"
>
<InputNumber
style={{ width: '100%' }}
min={1}
placeholder="15000"
/>
</Form.Item>
</Col>
</Row>
<Row gutter={16}>
<Col span={12}>
<Form.Item
name="cookie_name"
label="Session Cookie Name"
tooltip="Cookie name for session persistence (e.g., 'SERVERID')"
>
<Input placeholder="e.g., SERVERID" />
</Form.Item>
</Col>
<Col span={24}>
<Form.Item
name="cookie_options"
label="Cookie Options"
tooltip="Cookie options: insert, indirect, nocache, httponly, secure, etc."
>
<Input placeholder="e.g., insert indirect nocache httponly secure" />
</Form.Item>
</Col>
</Row>
<Divider>Default Server Settings</Divider>
<Row gutter={16}>
<Col span={8}>
<Form.Item
name="default_server_inter"
label="Default Check Interval (ms)"
tooltip="Default health check interval for all servers"
>
<InputNumber
style={{ width: '100%' }}
min={1000}
placeholder="2000"
/>
</Form.Item>
</Col>
<Col span={8}>
<Form.Item
name="default_server_fall"
label="Default Fall Count"
tooltip="Number of failed checks before marking server down"
>
<InputNumber
style={{ width: '100%' }}
min={1}
placeholder="3"
/>
</Form.Item>
</Col>
<Col span={8}>
<Form.Item
name="default_server_rise"
label="Default Rise Count"
tooltip="Number of successful checks before marking server up"
>
<InputNumber
style={{ width: '100%' }}
min={1}
placeholder="2"
/>
</Form.Item>
</Col>
</Row>
<Divider>HTTP Headers (Advanced)</Divider>
<Row gutter={16}>
<Col span={12}>
<Form.Item
name="request_headers"
label="Request Headers"
tooltip="HTTP request header modifications (one per line)"
>
<Input.TextArea
rows={4}
placeholder="e.g., http-request set-header X-Forwarded-For %[src]"
/>
</Form.Item>
</Col>
<Col span={12}>
<Form.Item
name="response_headers"
label="Response Headers"
tooltip="HTTP response header modifications (one per line)"
>
<Input.TextArea
rows={4}
placeholder="e.g., http-response add-header X-Custom-Header value"
/>
</Form.Item>
</Col>
</Row>
<Row gutter={16}>
<Col span={24}>
<Form.Item
name="options"
label="Backend Options"
tooltip="HAProxy backend options (one per line). Examples: option http-keep-alive, option forwardfor, option redispatch"
help={
backendForm.getFieldValue('options') &&
backendForm.getFieldValue('options').toLowerCase().includes('httpchk') ? (
<span style={{ color: '#faad14' }}>
⚠️ Do not use "option httpchk" here. Use the "Health Check URI" field above instead to avoid duplication.
</span>
) : null
}
>
<Input.TextArea
rows={4}
placeholder="e.g., option http-keep-alive&#10;option forwardfor&#10;option redispatch"
onChange={(e) => {
// Force re-render to show/hide warning
backendForm.setFieldsValue({ options: e.target.value });
}}
/>
</Form.Item>
</Col>
</Row>
<Form.Item style={{ marginBottom: 0, textAlign: 'right' }}>
<Space>
<Button onClick={() => setBackendModalVisible(false)}>
Cancel
</Button>
<Button type="primary" htmlType="submit" loading={submittingBackend}>
{submittingBackend
? (editingBackend ? 'Updating...' : 'Creating...')
: (editingBackend ? 'Update' : 'Create')} Backend
</Button>
</Space>
</Form.Item>
</Form>
</Modal>
{/* Server Modal */}
<Modal
title={editingServer ? 'Edit Server' : `Add Server to ${selectedBackend?.name}`}
open={serverModalVisible}
onCancel={() => setServerModalVisible(false)}
footer={null}
width={600}
>
<Form
form={serverForm}
layout="vertical"
onFinish={handleServerSubmit}
>
<Row gutter={16}>
<Col span={12}>
<Form.Item
name="server_name"
label="Server Name"
rules={[
{ required: true, message: 'Please enter server name' },
{ pattern: /^[a-zA-Z0-9_-]+$/, message: 'Only alphanumeric, underscore and dash allowed' }
]}
>
<Input placeholder="e.g., web1" />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item
name="weight"
label="Weight"
rules={[{ required: true, message: 'Please enter weight' }]}
>
<InputNumber
style={{ width: '100%' }}
min={1}
max={256}
placeholder="100"
/>
</Form.Item>
</Col>
</Row>
<Row gutter={16}>
<Col span={16}>
<Form.Item
name="server_address"
label="IP Address"
rules={[{ required: true, message: 'Please enter IP address' }]}
>
<Input placeholder="192.168.1.10" />
</Form.Item>
</Col>
<Col span={8}>
<Form.Item
name="server_port"
label="Port"
rules={[
{ required: true, message: 'Please enter port' },
{ type: 'number', min: 1, max: 65535, message: 'Port must be between 1-65535' }
]}
>
<InputNumber style={{ width: '100%' }} placeholder="80" />
</Form.Item>
</Col>
</Row>
<Form.Item
name="max_connections"
label="Max Connections (Optional)"
>
<InputNumber
style={{ width: '100%' }}
min={1}
max={10000}
placeholder="Leave empty for unlimited"
/>
</Form.Item>
<Row gutter={16}>
<Col span={8}>
<Form.Item
name="check_enabled"
label="Health Check"
valuePropName="checked"
>
<Switch checkedChildren="ON" unCheckedChildren="OFF" />
</Form.Item>
</Col>
<Col span={8}>
<Form.Item
name="backup_server"
label="Backup Server"
valuePropName="checked"
>
<Switch checkedChildren="YES" unCheckedChildren="NO" />
</Form.Item>
</Col>
<Col span={8}>
<Form.Item
name="ssl_enabled"
label="SSL Enabled"
valuePropName="checked"
>
<Switch checkedChildren="SSL" unCheckedChildren="Plain" />
</Form.Item>
</Col>
</Row>
<Row gutter={16}>
<Col span={12}>
<Form.Item
name="check_port"
label="Health Check Port"
tooltip="Custom port for health checks (different from server port)"
>
<InputNumber
style={{ width: '100%' }}
min={1}
max={65535}
placeholder="Leave empty to use server port"
/>
</Form.Item>
</Col>
<Col span={12}>
<Form.Item
name="ssl_verify"
label="SSL Verification"
tooltip="SSL certificate verification method"
>
<Select allowClear placeholder="Select SSL verification">
<Option value="none">None</Option>
<Option value="required">Required</Option>
</Select>
</Form.Item>
</Col>
</Row>
<Form.Item noStyle shouldUpdate={(prevValues, currentValues) => prevValues.ssl_enabled !== currentValues.ssl_enabled}>
{({ getFieldValue }) =>
getFieldValue('ssl_enabled') ? (
<>
<Row gutter={16}>
<Col span={24}>
<Form.Item
name="ssl_certificate_id"
label="SSL Certificate"
tooltip="Select SSL certificate for backend server (required if ssl_verify is 'required')"
rules={[
({ getFieldValue }) => ({
validator(_, value) {
const sslVerify = getFieldValue('ssl_verify');
if (sslVerify === 'required' && !value) {
return Promise.reject(new Error('SSL Certificate is required when SSL verification is set to "required"'));
}
return Promise.resolve();
},
}),
]}
>
<Select
allowClear
placeholder="Select an SSL certificate (optional unless verify=required)"
showSearch
filterOption={(input, option) =>
(option.label || '').toLowerCase().includes(input.toLowerCase())
}
optionLabelProp="label"
>
{sslCertificates.map((cert) => {
// Status and type info for both dropdown and selected view
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 (shows in the input when selected)
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: '#666', marginLeft: 12 }}>
{statusText} {expiryInfo && `(${expiryInfo})`}
</span>
</div>
</Option>
);
})}
</Select>
</Form.Item>
</Col>
</Row>
{/* SSL Advanced Options for Backend Servers */}
<Divider orientation="left" style={{ marginTop: 16, marginBottom: 16 }}>
SSL Advanced Options
</Divider>
<Row gutter={16}>
<Col span={12}>
<Form.Item
name="ssl_sni"
label="SNI Hostname"
tooltip="Server Name Indication - hostname to send when connecting (useful for SNI-based backends)"
extra="Hostname sent via SNI during SSL handshake"
>
<Input placeholder="backend.example.com" />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item
name="ssl_min_ver"
label="Minimum TLS Version"
tooltip="Minimum TLS version for backend connection"
extra="Enforce minimum TLS version for backend SSL"
>
<Select placeholder="Select min TLS version" allowClear>
<Option value="SSLv3">SSLv3 (Not recommended)</Option>
<Option value="TLSv1.0">TLSv1.0</Option>
<Option value="TLSv1.1">TLSv1.1</Option>
<Option value="TLSv1.2">TLSv1.2 (Recommended)</Option>
<Option value="TLSv1.3">TLSv1.3</Option>
</Select>
</Form.Item>
</Col>
</Row>
<Row gutter={16}>
<Col span={12}>
<Form.Item
name="ssl_max_ver"
label="Maximum TLS Version"
tooltip="Maximum TLS version for backend connection"
extra="Usually left unset for latest TLS support"
>
<Select placeholder="Select max 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>
<Col span={12}>
<Form.Item
name="ssl_ciphers"
label="SSL Ciphers"
tooltip="Colon-separated list of SSL cipher suites for backend connection"
extra="Leave empty for HAProxy defaults"
>
<Input placeholder="ECDHE-RSA-AES128-GCM-SHA256:..." />
</Form.Item>
</Col>
</Row>
<Alert
message="Backend SSL Advanced Options"
description={
<div>
<p><strong>SNI Hostname:</strong> Specify hostname for SNI when connecting to SSL backend (e.g., for multiple virtual hosts on one IP).</p>
<p><strong>TLS Version:</strong> Control TLS version for backend connections. TLSv1.2+ recommended.</p>
<p><strong>Ciphers:</strong> Override default cipher suites if backend requires specific ciphers. Leave empty for secure defaults.</p>
</div>
}
type="info"
showIcon
style={{ marginTop: 16, marginBottom: 16 }}
/>
</>
) : null
}
</Form.Item>
<Row gutter={16}>
<Col span={24}>
<Form.Item
name="cookie_value"
label="Session Cookie Value"
tooltip="Cookie value for session persistence (used with backend cookie)"
>
<Input placeholder="e.g., srv1" />
</Form.Item>
</Col>
</Row>
<Divider>Health Check Settings (Optional)</Divider>
<Row gutter={16}>
<Col span={8}>
<Form.Item
name="inter"
label="Check Interval (ms)"
tooltip="Health check interval in milliseconds"
>
<InputNumber
style={{ width: '100%' }}
min={1000}
placeholder="2000"
/>
</Form.Item>
</Col>
<Col span={8}>
<Form.Item
name="fall"
label="Fall Count"
tooltip="Number of failed checks before marking down"
>
<InputNumber
style={{ width: '100%' }}
min={1}
placeholder="3"
/>
</Form.Item>
</Col>
<Col span={8}>
<Form.Item
name="rise"
label="Rise Count"
tooltip="Number of successful checks before marking up"
>
<InputNumber
style={{ width: '100%' }}
min={1}
placeholder="2"
/>
</Form.Item>
</Col>
</Row>
<Form.Item style={{ marginBottom: 0, textAlign: 'right' }}>
<Space>
<Button onClick={() => setServerModalVisible(false)}>
Cancel
</Button>
<Button type="primary" htmlType="submit" loading={submittingServer}>
{submittingServer
? (editingServer ? 'Updating...' : 'Adding...')
: (editingServer ? 'Update' : 'Add')} Server
</Button>
</Space>
</Form.Item>
</Form>
</Modal>
{/* Version History Modal */}
<VersionHistory
visible={versionModalVisible}
onCancel={handleVersionModalCancel}
entityType={selectedEntityForVersion?.entityType}
entityId={selectedEntityForVersion?.entityId}
onRestoreSuccess={handleRestoreSuccess}
/>
</div>
);
};
export default function BackendServersWithErrorBoundary() {
return (
<BackendErrorBoundary>
<BackendServers />
</BackendErrorBoundary>
);
}