mirror of
https://github.com/taylanbakircioglu/haproxy-openmanager.git
synced 2026-10-06 21:22:34 +00:00
2e7db4d99f
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.
2292 lines
86 KiB
JavaScript
2292 lines
86 KiB
JavaScript
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;
|