Files
haproxy-openmanager/frontend/src/components/SSLManagement.js
T
taylanbakircioglu c2766eadbd feat: dark mode support, UI improvements, and version bump to v1.2.0
- Full dark mode support across all pages with lightbulb toggle in header
- Theme preference persisted in localStorage across sessions
- Ant Design 5 token-based theming (40+ components updated)
- Recharts dark mode: axes, grids, tooltips adapt to theme
- Login page redesigned with product-consistent blue-gray palette
- Overscroll bounce background matches dark theme
- All Servers search with multi-field filtering
- Bulk Config Import UI streamlined with collapsible guidelines
- ConfigProvider moved above AppContent for correct token resolution

Made-with: Cursor
2026-04-03 01:16:05 +03:00

1494 lines
56 KiB
JavaScript

import React, { useState, useEffect, useContext } from 'react';
import {
Card, Table, Button, Modal, Form, Input, Space, message,
Popconfirm, Tag, Tooltip, Row, Col, Typography, Alert, Badge,
Progress, Tabs, Select, Switch, Spin, theme
} from 'antd';
import { getAgentSyncColor, getConfigStatusColor, getEntityStatusColor } from '../utils/colors';
import EntitySyncStatus from './EntitySyncStatus';
import {
PlusOutlined, DeleteOutlined, ReloadOutlined,
LockOutlined, EyeOutlined, WarningOutlined,
SafetyCertificateOutlined, SearchOutlined,
PlayCircleOutlined, EditOutlined,
CloudServerOutlined, CheckCircleOutlined, SyncOutlined,
ExclamationCircleOutlined, CloseCircleOutlined, ClockCircleOutlined,
ThunderboltOutlined
} from '@ant-design/icons';
import axios from 'axios';
import { useCluster } from '../contexts/ClusterContext';
import { useProgress } from '../contexts/ProgressContext';
import { formatEntityForSync } from '../utils/agentSync';
import ACMEAutomation from './ACMEAutomation';
const { Title, Text } = Typography;
const { TextArea } = Input;
const { TabPane } = Tabs;
const SSLManagement = () => {
const { token } = theme.useToken();
const { selectedCluster, clusters } = useCluster();
const [certificates, setCertificates] = useState([]);
const [filteredCertificates, setFilteredCertificates] = useState([]);
const [searchText, setSearchText] = useState('');
const [loading, setLoading] = useState(false);
const [modalVisible, setModalVisible] = useState(false);
const [viewModalVisible, setViewModalVisible] = useState(false);
const [selectedCertificate, setSelectedCertificate] = useState(null);
const [pendingChanges, setPendingChanges] = useState(false);
const [usageSearch, setUsageSearch] = useState('');
const [deploymentData, setDeploymentData] = useState([]);
const [deploymentLoading, setDeploymentLoading] = useState(false);
const [form] = Form.useForm();
// Filter states with localStorage persistence
const [showGlobal, setShowGlobal] = useState(() => {
const saved = localStorage.getItem('ssl_filter_global');
return saved !== null ? JSON.parse(saved) : true;
});
const [showClusterSpecific, setShowClusterSpecific] = useState(() => {
const saved = localStorage.getItem('ssl_filter_cluster');
return saved !== null ? JSON.parse(saved) : true;
});
const [showInUseOnly, setShowInUseOnly] = useState(() => {
const saved = localStorage.getItem('ssl_filter_in_use');
return saved !== null ? JSON.parse(saved) : false;
});
// Filter toggle handlers
const toggleGlobalFilter = () => {
const newValue = !showGlobal;
setShowGlobal(newValue);
localStorage.setItem('ssl_filter_global', JSON.stringify(newValue));
};
const toggleClusterFilter = () => {
const newValue = !showClusterSpecific;
setShowClusterSpecific(newValue);
localStorage.setItem('ssl_filter_cluster', JSON.stringify(newValue));
};
const toggleInUseFilter = () => {
const newValue = !showInUseOnly;
setShowInUseOnly(newValue);
localStorage.setItem('ssl_filter_in_use', JSON.stringify(newValue));
};
// Apply filters whenever certificates or filter states change
useEffect(() => {
let filtered = certificates;
// Apply SSL type filters
if (!showGlobal || !showClusterSpecific) {
filtered = filtered.filter(cert => {
if (cert.ssl_type === 'Global' && !showGlobal) return false;
if (cert.ssl_type === 'Cluster-specific' && !showClusterSpecific) return false;
return true;
});
}
// Apply In-Use filter
if (showInUseOnly) {
filtered = filtered.filter(cert => cert.usage_count > 0);
}
// Apply search filter
if (searchText) {
filtered = filtered.filter(cert =>
cert.name.toLowerCase().includes(searchText.toLowerCase()) ||
cert.domain.toLowerCase().includes(searchText.toLowerCase()) ||
(cert.issuer && cert.issuer.toLowerCase().includes(searchText.toLowerCase()))
);
}
setFilteredCertificates(filtered);
}, [certificates, showGlobal, showClusterSpecific, showInUseOnly, searchText]);
useEffect(() => {
if (selectedCluster) {
fetchCertificates();
checkPendingChanges();
} else {
// Clear certificates when no cluster is selected
setCertificates([]);
setFilteredCertificates([]);
setPendingChanges(false);
}
}, [selectedCluster]);
const fetchCertificates = async () => {
if (!selectedCluster) return;
setLoading(true);
// Clear existing certificates immediately when fetching new cluster data
setCertificates([]);
setFilteredCertificates([]);
try {
const response = await axios.get(`/api/ssl/certificates?cluster_id=${selectedCluster.id}`, {
headers: {
'Cache-Control': 'no-cache, no-store, must-revalidate',
'Pragma': 'no-cache'
}
});
// Handle different response formats
const certs = response.data.certificates || response.data || [];
console.log('SSL FETCH DEBUG: Response structure:', {
'response.data': Object.keys(response.data),
'certificates count': certs.length,
'sample cert': certs[0] ? {
name: certs[0].name,
has_pending_config: certs[0].has_pending_config,
last_config_status: certs[0].last_config_status
} : null
});
setCertificates(certs);
setFilteredCertificates(certs);
} catch (error) {
console.error('SSL certificates fetch error:', error);
message.error('Failed to fetch SSL certificates: ' + error.message);
setCertificates([]);
setFilteredCertificates([]);
} finally {
setLoading(false);
}
};
// Search filter function
const handleSearch = (value) => {
setSearchText(value);
if (!value) {
setFilteredCertificates(certificates || []);
} else {
const filtered = certificates.filter(cert =>
cert.name.toLowerCase().includes(value.toLowerCase()) ||
cert.domain.toLowerCase().includes(value.toLowerCase())
);
setFilteredCertificates(filtered);
}
};
// Update filtered data when certificates change
useEffect(() => {
if (searchText) {
handleSearch(searchText);
} else {
setFilteredCertificates(certificates);
}
}, [certificates, searchText]);
// Check for pending SSL configuration changes
const checkPendingChanges = async () => {
if (!selectedCluster) return;
try {
console.log('SSL 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.config_versions || response.data.versions || response.data || [];
console.log('SSL APPLY DEBUG: Versions type:', typeof versions, 'Array?', Array.isArray(versions), 'Value:', versions);
if (!Array.isArray(versions)) {
console.error('SSL APPLY DEBUG: versions is not an array!', versions);
setPendingChanges(false);
return;
}
const pendingVersions = versions.filter(version =>
version.status === 'PENDING' &&
version.version_name.includes('ssl-')
);
console.log('SSL APPLY DEBUG: Total versions:', versions.length);
console.log('SSL APPLY DEBUG: SSL pending versions:', pendingVersions.length);
console.log('SSL APPLY DEBUG: Pending SSL versions:', pendingVersions.map(v => v.version_name));
setPendingChanges(pendingVersions.length > 0);
} catch (error) {
console.error('SSL APPLY DEBUG: Failed to check pending changes:', error);
setPendingChanges(false);
}
};
// Fetch entity agent sync status (consistent with other pages)
const fetchEntityAgentSync = async (entityType, entityId) => {
if (!selectedCluster) return null;
try {
const token = localStorage.getItem('token');
// SSL certificates use a special endpoint for agent sync
let endpoint;
if (entityType === 'ssl_certificates') {
endpoint = `/api/clusters/${selectedCluster.id}/ssl_certificates/${entityId}/agent-sync`;
} else {
endpoint = `/api/clusters/${selectedCluster.id}/entity-sync/${entityType}/${entityId}`;
}
const response = await axios.get(endpoint, {
headers: { Authorization: `Bearer ${token}` }
});
return response.data;
} catch (error) {
console.error(`Failed to fetch entity sync for ${entityType}/${entityId}:`, error);
return null;
}
};
const handleAdd = () => {
form.resetFields();
// Set default values for new certificate
form.setFieldsValue({
ssl_type: 'cluster',
usage_type: 'frontend' // Default to frontend SSL
});
setSelectedCertificate(null);
setIsLetsEncryptCert(false);
setModalVisible(true);
};
const handleView = async (certificate) => {
try {
const response = await axios.get(`/api/ssl/certificates/${certificate.id}`);
setSelectedCertificate(response.data);
setUsageSearch('');
setViewModalVisible(true);
} catch (error) {
message.error('Failed to load certificate details: ' + error.message);
}
};
const [isLetsEncryptCert, setIsLetsEncryptCert] = useState(false);
const handleEdit = async (certificate) => {
try {
const response = await axios.get(`/api/ssl/certificates/${certificate.id}`);
const cert = response.data;
const isLE = cert.source === 'letsencrypt';
setIsLetsEncryptCert(isLE);
form.setFieldsValue({
name: cert.name,
certificate_content: cert.certificate_content,
private_key_content: cert.private_key_content,
chain_content: cert.chain_content,
ssl_type: cert.is_global ? 'global' : 'cluster',
cluster_ids: cert.is_global ? null : cert.cluster_ids,
usage_type: cert.usage_type || 'frontend'
});
setSelectedCertificate(cert);
setModalVisible(true);
} catch (error) {
message.error('Failed to load certificate for editing: ' + error.message);
}
};
const handleDelete = async (certificateId) => {
try {
const response = await axios.delete(`/api/ssl/certificates/${certificateId}`);
// Handle cluster sync results
const syncResults = response.data.sync_results || [];
const totalNodes = syncResults.length;
const successCount = syncResults.filter(result => result.success).length;
if (syncResults.length > 0) {
if (successCount === totalNodes) {
message.success(
<div>
<div><strong>SSL certificate deleted successfully</strong></div>
<div style={{ marginTop: 4, fontSize: '12px' }}>
Pending config version created for {successCount} cluster(s). Go to Apply Changes to deploy.
</div>
</div>,
6
);
} else {
message.warning(
<div>
<div><strong>SSL certificate deleted with warnings</strong></div>
<div style={{ marginTop: 4, fontSize: '12px' }}>
{successCount}/{totalNodes} cluster(s) have pending versions. Some may need manual cleanup.
</div>
</div>,
8
);
}
} else {
message.success('SSL certificate deleted successfully');
}
fetchCertificates();
} catch (error) {
message.error('Failed to delete certificate: ' + error.response?.data?.detail);
}
};
const handleSubmit = async (values) => {
// Check if editing existing certificate (define at function scope)
const isEditing = selectedCertificate && selectedCertificate.id;
try {
const isLetsEncrypt = isEditing && selectedCertificate?.source === 'letsencrypt';
const payload = {
name: values.name,
is_global: values.ssl_type === 'global',
cluster_ids: values.ssl_type === 'global' ? null : values.cluster_ids,
usage_type: values.usage_type || 'frontend'
};
if (!isLetsEncrypt) {
payload.certificate_content = values.certificate_content;
payload.private_key_content = values.private_key_content;
payload.chain_content = values.chain_content;
}
const response = isEditing
? await axios.put(`/api/ssl/certificates/${selectedCertificate.id}`, payload)
: await axios.post('/api/ssl/certificates', payload);
// Handle cluster sync results
const syncResults = response.data.sync_results || [];
const totalNodes = syncResults.length;
const successCount = syncResults.filter(result => result.success).length;
if (syncResults.length > 0) {
if (successCount === totalNodes) {
message.success(
<div>
<div><strong>SSL certificate {isEditing ? 'updated' : 'added'} successfully</strong></div>
<div style={{ marginTop: 4, fontSize: '12px' }}>
Pending config version created for {successCount} cluster(s). Go to Apply Changes to deploy.
</div>
</div>,
6
);
} else {
message.warning(
<div>
<div><strong>SSL certificate {isEditing ? 'updated' : 'added'} with warnings</strong></div>
<div style={{ marginTop: 4, fontSize: '12px' }}>
{successCount}/{totalNodes} cluster(s) have pending versions. Some may need attention.
</div>
</div>,
8
);
}
} else {
message.success(`SSL certificate ${isEditing ? 'updated' : 'added'} successfully`);
}
setModalVisible(false);
setSelectedCertificate(null);
fetchCertificates();
checkPendingChanges();
} catch (error) {
console.error('SSL certificate operation failed:', error);
// Handle specific error cases with user-friendly messages
if (error.response?.status === 400) {
const errorDetail = error.response?.data?.detail || '';
if (errorDetail.includes('already exists')) {
// SSL name already exists error
Modal.error({
title: 'SSL Certificate Name Already Exists',
content: (
<div>
<p>A certificate with the name <strong>"{form.getFieldValue('name')}"</strong> already exists.</p>
<p>Please choose one of the following options:</p>
<ul style={{ paddingLeft: 20, marginTop: 10 }}>
<li>Choose a different name for your certificate</li>
<li>Delete the existing certificate first if you want to replace it</li>
<li>Edit the existing certificate instead of creating a new one</li>
</ul>
</div>
),
okText: 'Got it',
width: 500
});
} else if (errorDetail.includes('Invalid SSL certificate')) {
// Invalid certificate content error
Modal.error({
title: 'Invalid SSL Certificate',
content: (
<div>
<p><strong>Certificate validation failed:</strong></p>
<p style={{ color: '#ff4d4f', fontFamily: 'monospace', background: '#fff2f0', padding: 8, borderRadius: 4 }}>
{errorDetail}
</p>
<p style={{ marginTop: 10 }}>Please check your certificate content and try again.</p>
</div>
),
okText: 'Fix Certificate',
width: 600
});
} else {
// Other 400 errors
message.error(`Certificate validation error: ${errorDetail}`);
}
} else if (error.response?.status === 401) {
message.error('Authentication failed. Please login again.');
} else if (error.response?.status === 403) {
message.error('You do not have permission to perform this action.');
} else {
// Generic error
message.error(`Failed to ${isEditing ? 'update' : 'add'} certificate: ${error.response?.data?.detail || error.message}`);
}
}
};
const getExpiryStatus = (expiryDate) => {
if (!expiryDate) return { status: 'default', text: 'No expiry set' };
const days = Math.ceil((new Date(expiryDate) - new Date()) / (1000 * 60 * 60 * 24));
if (days < 0) return { status: 'error', text: 'Expired' };
if (days <= 7) return { status: 'error', text: `${days} days left` };
if (days <= 30) return { status: 'warning', text: `${days} days left` };
return { status: 'success', text: `${days} days left` };
};
const fetchDeploymentStatus = async (cert) => {
if (!cert) return;
setDeploymentLoading(true);
setDeploymentData([]);
try {
const token = localStorage.getItem('token');
const targetClusters = cert.cluster_names && cert.cluster_names.length > 0
? clusters.filter(c => cert.cluster_names.includes(c.name))
: clusters;
const results = await Promise.allSettled(
targetClusters.map(async (cluster) => {
try {
const response = await axios.get(
`/api/clusters/${cluster.id}/ssl_certificates/${cert.id}/agent-sync`,
{ headers: { Authorization: `Bearer ${token}` } }
);
return {
cluster_id: cluster.id,
cluster_name: cluster.name,
...response.data.sync_status,
ssl_config_status: response.data.ssl_config_status,
version_applied_at: response.data.version_applied_at,
latest_applied_version: response.data.latest_applied_version,
agents: response.data.agents || [],
error: null
};
} catch (err) {
return {
cluster_id: cluster.id,
cluster_name: cluster.name,
error: err.response?.status === 404 ? 'No data' : err.message
};
}
})
);
setDeploymentData(results.map(r => r.status === 'fulfilled' ? r.value : { error: 'Request failed' }));
} catch (error) {
console.error('Failed to fetch deployment status:', error);
} finally {
setDeploymentLoading(false);
}
};
const columns = [
{
title: 'Certificate',
dataIndex: 'name',
key: 'name',
render: (text, record) => (
<Space>
<SafetyCertificateOutlined style={{ color: '#52c41a' }} />
<div>
<strong>{text}</strong>
<br />
<Text type="secondary" style={{ fontSize: 12 }}>
{record.domain}
</Text>
</div>
</Space>
),
},
{
title: 'Scope',
dataIndex: 'ssl_type',
key: 'ssl_type',
render: (type, record) => {
const isGlobal = type === 'Global';
return (
<Tag color={isGlobal ? 'blue' : 'green'}>
{isGlobal ? 'Global' : 'Cluster-specific'}
</Tag>
);
},
},
{
title: 'Usage',
dataIndex: 'usage_type',
key: 'usage_type',
render: (usage_type) => {
const isFrontend = usage_type === 'frontend';
return (
<Tag color={isFrontend ? 'purple' : 'orange'}>
{isFrontend ? 'Frontend SSL' : 'Server SSL'}
</Tag>
);
},
},
{
title: 'Source',
dataIndex: 'source',
key: 'source',
render: (source) => (
<Tag color={source === 'letsencrypt' ? 'green' : 'default'}
icon={source === 'letsencrypt' ? <SafetyCertificateOutlined /> : null}>
{source === 'letsencrypt' ? 'Auto (ACME)' : 'Manual'}
</Tag>
),
},
{
title: 'Sync Status',
key: 'sync_status',
render: (_, record) => (
<EntitySyncStatus
entityType="ssl_certificates"
entityId={record.id}
entityUpdatedAt={record.updated_at}
lastConfigStatus={record.last_config_status}
clusterId={selectedCluster?.id}
selectedCluster={selectedCluster}
/>
),
},
{
title: 'Expiry Status',
dataIndex: 'expiry_date',
key: 'expiry_status',
render: (expiryDate, record) => {
const status = getExpiryStatus(expiryDate);
return (
<Badge
status={status.status}
text={status.text}
/>
);
},
},
{
title: 'Expiry Date',
dataIndex: 'expiry_date',
key: 'expiry_date',
render: (date) => date ? new Date(date).toLocaleString(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
}) : '-',
},
{
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: 'Config Status',
key: 'config_status',
render: (_, record) => {
const status = record.last_config_status || 'APPLIED';
const color = getConfigStatusColor(status);
return (
<Tag color={color}>{status}</Tag>
);
},
},
{
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: 'Actions',
key: 'actions',
render: (_, record) => (
<Space size="small">
{(record.last_config_status === 'PENDING') && (
<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="View Certificate">
<Button
size="small"
icon={<EyeOutlined />}
onClick={() => handleView(record)}
/>
</Tooltip>
<Tooltip title="Edit Certificate">
<Button
size="small"
icon={<EditOutlined />}
onClick={() => handleEdit(record)}
/>
</Tooltip>
<Popconfirm
title="Are you sure you want to delete this certificate?"
description="This action cannot be undone and may affect frontends using this certificate."
onConfirm={() => handleDelete(record.id)}
okText="Yes"
cancelText="No"
>
<Tooltip title="Delete Certificate">
<Button
danger
size="small"
icon={<DeleteOutlined />}
/>
</Tooltip>
</Popconfirm>
</Space>
),
},
];
const expiringSoon = certificates?.filter(cert => {
if (!cert.expiry_date) return false;
const days = Math.ceil((new Date(cert.expiry_date) - new Date()) / (1000 * 60 * 60 * 24));
return days >= 0 && days <= 30;
}) || [];
const certificatesContent = (
<>
<Row gutter={[16, 16]} style={{ marginBottom: 16 }} align="middle">
<Col flex="auto" />
<Col>
<Space size={[12, 8]} align="center">
<Space size={4}>
<span style={{ fontSize: 12 }}>Global</span>
<Switch
checked={showGlobal}
onChange={toggleGlobalFilter}
size="small"
/>
</Space>
<Space size={4}>
<span style={{ fontSize: 12 }}>Cluster-specific</span>
<Switch
checked={showClusterSpecific}
onChange={toggleClusterFilter}
size="small"
/>
</Space>
<Space size={4}>
<span style={{ fontSize: 12 }}>In Use</span>
<Switch
checked={showInUseOnly}
onChange={toggleInUseFilter}
size="small"
/>
</Space>
<div style={{
position: 'relative',
display: 'inline-block',
width: 180
}}>
<SearchOutlined style={{
position: 'absolute',
left: 8,
top: '50%',
transform: 'translateY(-50%)',
color: '#bfbfbf',
zIndex: 1
}} />
<input
type="text"
placeholder="Search certificates..."
value={searchText}
onChange={(e) => handleSearch(e.target.value)}
style={{
width: '100%',
height: 32,
paddingLeft: 30,
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={fetchCertificates}
loading={loading}
>
Refresh
</Button>
<Button
type="primary"
icon={<PlusOutlined />}
onClick={handleAdd}
disabled={!selectedCluster}
>
Add Certificate
</Button>
</Space>
</Col>
</Row>
{!selectedCluster && (
<Alert
message="No Cluster Selected"
description="Please select a cluster from the top navigation to manage SSL certificates."
type="warning"
showIcon
style={{ marginBottom: 16 }}
/>
)}
<Row gutter={16} style={{ marginBottom: 16 }}>
<Col xs={12} sm={12} md={6} lg={4}>
<Card size="small" style={{ textAlign: 'center' }}>
<div style={{ fontSize: 20, fontWeight: 'bold', color: '#52c41a' }}>
{certificates.length}
</div>
<div>Total Certificates</div>
</Card>
</Col>
<Col xs={12} sm={12} md={6} lg={4}>
<Card size="small" style={{ textAlign: 'center' }}>
<div style={{ fontSize: 20, fontWeight: 'bold', color: '#1890ff' }}>
{certificates.filter(c => c.usage_count > 0).length}
</div>
<div>In Use</div>
</Card>
</Col>
<Col xs={12} sm={12} md={6} lg={4}>
<Tooltip
title={expiringSoon.length > 0 ? (
<div>
<div style={{ fontWeight: 'bold', marginBottom: 4, borderBottom: '1px solid rgba(255,255,255,0.2)', paddingBottom: 4 }}>
Certificates Expiring Soon
</div>
{expiringSoon.map(cert => (
<div key={cert.id} style={{ padding: '2px 0' }}>
<strong>{cert.name}</strong> ({cert.domain}) - {getExpiryStatus(cert.expiry_date).text}
</div>
))}
</div>
) : null}
mouseEnterDelay={0.4}
placement="bottom"
>
<Card size="small" style={{ textAlign: 'center', cursor: expiringSoon.length > 0 ? 'pointer' : 'default' }}>
<div style={{ fontSize: 20, fontWeight: 'bold', color: '#faad14' }}>
{expiringSoon.length}
</div>
<div>Expiring Soon</div>
</Card>
</Tooltip>
</Col>
<Col xs={12} sm={12} md={6} lg={4}>
<Card size="small" style={{ textAlign: 'center' }}>
<div style={{ fontSize: 20, fontWeight: 'bold', color: '#ff4d4f' }}>
{certificates.filter(c => c.expiry_date && new Date(c.expiry_date) < new Date()).length}
</div>
<div>Expired</div>
</Card>
</Col>
<Col xs={12} sm={12} md={6} lg={4}>
<Card size="small" style={{ textAlign: 'center' }}>
<div style={{ fontSize: 20, fontWeight: 'bold', color: '#13c2c2' }}>
{certificates.filter(c => c.source === 'letsencrypt').length}
</div>
<div>Auto-Managed</div>
</Card>
</Col>
</Row>
<Card>
<Table
columns={columns}
dataSource={filteredCertificates}
rowKey="id"
loading={loading}
pagination={{
showSizeChanger: true,
showQuickJumper: true,
showTotal: (total, range) =>
`${range[0]}-${range[1]} of ${total} certificates`,
}}
/>
</Card>
</>
);
return (
<div>
<Title level={2} style={{ margin: 0, marginBottom: 16 }}>
<LockOutlined style={{ marginRight: 8, color: '#52c41a' }} />
SSL Certificate Management
{selectedCluster && (
<Text type="secondary" style={{ fontSize: '14px', fontWeight: 'normal', marginLeft: '8px' }}>
- {selectedCluster.name}
</Text>
)}
</Title>
<Tabs
items={[
{
key: 'certificates',
label: 'Certificates',
children: certificatesContent,
},
{
key: 'acme',
label: <span><ThunderboltOutlined /> ACME Automation</span>,
children: <ACMEAutomation />,
},
]}
/>
{/* Add Certificate Modal */}
<Modal
title={selectedCertificate && selectedCertificate.id ? "Edit SSL Certificate" : "Add SSL Certificate"}
open={modalVisible}
onCancel={() => {
setModalVisible(false);
setSelectedCertificate(null);
}}
footer={null}
width={800}
>
<Form
form={form}
layout="vertical"
onFinish={handleSubmit}
>
<Row gutter={16}>
<Col span={12}>
<Form.Item
name="name"
label="Certificate Name"
rules={[
{ required: true, message: 'Please enter certificate name' },
{ pattern: /^[a-zA-Z0-9_-]+$/, message: 'Only alphanumeric, underscore and dash allowed' }
]}
extra={selectedCertificate && selectedCertificate.id ? "Certificate name cannot be changed (used as file path on servers)" : "Used as file path: /etc/ssl/haproxy/{name}.pem"}
tooltip={selectedCertificate && selectedCertificate.id ? "Certificate name is immutable after creation to maintain file system references" : null}
>
<Input
placeholder="e.g., my-domain-cert"
disabled={selectedCertificate && selectedCertificate.id ? true : false}
/>
</Form.Item>
</Col>
</Row>
{/* SSL Type and Cluster Selection */}
<Row gutter={16}>
<Col span={8}>
<Form.Item
name="usage_type"
label="SSL Usage Type"
rules={[{ required: true, message: 'Please select usage type' }]}
tooltip="Frontend SSL requires private key, Server SSL does not"
>
<Select placeholder="Select usage type">
<Select.Option value="frontend">Frontend SSL (HAProxy Listen)</Select.Option>
<Select.Option value="server">Server SSL (Backend Verification)</Select.Option>
</Select>
</Form.Item>
</Col>
<Col span={8}>
<Form.Item
name="ssl_type"
label="SSL Certificate Scope"
rules={[{ required: true, message: 'Please select SSL scope' }]}
>
<Select
placeholder="Select SSL scope"
onChange={(value) => {
form.setFieldsValue({ cluster_ids: undefined });
}}
>
<Select.Option value="global">Global (All Clusters)</Select.Option>
<Select.Option value="cluster">Cluster-specific</Select.Option>
</Select>
</Form.Item>
</Col>
<Col span={8}>
<Form.Item shouldUpdate={(prevValues, currentValues) => prevValues.ssl_type !== currentValues.ssl_type}>
{({ getFieldValue }) => {
const sslType = getFieldValue('ssl_type');
return (
<Form.Item
name="cluster_ids"
label="Target Clusters"
rules={[
{
validator: (_, value) => {
if (sslType === 'cluster' && (!value || value.length === 0)) {
return Promise.reject('Please select at least one cluster');
}
return Promise.resolve();
}
}
]}
>
<Select
mode="multiple"
placeholder="Select clusters for this SSL certificate"
disabled={sslType === 'global'}
showSearch
filterOption={(input, option) =>
option.children.toLowerCase().indexOf(input.toLowerCase()) >= 0
}
>
{(clusters || []).map(cluster => (
<Select.Option key={cluster.id} value={cluster.id}>
{cluster.name}
</Select.Option>
))}
</Select>
</Form.Item>
);
}}
</Form.Item>
</Col>
</Row>
<Alert
message="🔐 Auto-parsing Enabled"
description="Domain and expiry date will be automatically extracted from the certificate content. Just paste your PEM certificate, private key, and optional certificate chain."
type="info"
showIcon
style={{ marginBottom: 16 }}
/>
{isLetsEncryptCert && (
<Alert
type="info"
showIcon
icon={<SafetyCertificateOutlined />}
message="This certificate is managed by ACME automation. Content updates happen automatically during renewals."
style={{ marginBottom: 16 }}
/>
)}
<Form.Item
name="certificate_content"
label="Certificate Content (PEM Format)"
rules={[{ required: true, message: 'Please enter certificate content' }]}
extra="Domain and expiry date will be automatically parsed from this certificate"
>
<TextArea
rows={8}
disabled={isLetsEncryptCert}
placeholder={isLetsEncryptCert ? "Managed by ACME automation" : "-----BEGIN CERTIFICATE-----\nMIIDXTCCAkWgAwIBAgIJAKoK/OvD...\n-----END CERTIFICATE-----"}
/>
</Form.Item>
<Form.Item
noStyle
shouldUpdate={(prevValues, currentValues) =>
prevValues.usage_type !== currentValues.usage_type ||
prevValues.private_key_content !== currentValues.private_key_content
}
>
{({ getFieldValue }) => {
const usageType = getFieldValue('usage_type');
const privateKeyValue = getFieldValue('private_key_content');
const isRequired = usageType === 'frontend';
const hasValue = privateKeyValue && privateKeyValue.trim();
let extraMessage;
if (isRequired) {
extraMessage = hasValue
? <span style={{ color: '#52c41a' }}>Private key provided</span>
: <span style={{ color: '#ff4d4f' }}>Required for Frontend SSL</span>;
} else {
extraMessage = hasValue
? <span style={{ color: '#52c41a' }}>Private key provided (optional for Server SSL)</span>
: <span style={{ color: '#999' }}>Optional for Server SSL (used for backend verification)</span>;
}
return (
<Form.Item
name="private_key_content"
label={
<span>
Private Key Content (PEM Format)
{isRequired && <span style={{ color: 'red' }}> *</span>}
{usageType === 'server' && <span style={{ color: '#999', fontWeight: 'normal' }}> - Optional</span>}
</span>
}
rules={[
{
required: isRequired,
message: 'Private key is required for Frontend SSL'
},
{
validator: (_, value) => {
if (value && value.trim()) {
if (!value.includes('-----BEGIN') || !value.includes('-----END')) {
return Promise.reject('Private key must be in PEM format');
}
}
return Promise.resolve();
}
}
]}
extra={extraMessage}
>
<TextArea
rows={8}
disabled={isLetsEncryptCert}
placeholder={isLetsEncryptCert ? "Managed by ACME automation" : "-----BEGIN PRIVATE KEY-----\nMIIEvgIBADANBgkqhkiG9w0BAQEF...\n-----END PRIVATE KEY-----"}
/>
</Form.Item>
);
}}
</Form.Item>
<Form.Item
name="chain_content"
label="Certificate Chain (Optional)"
extra="Intermediate certificates for full chain validation"
>
<TextArea
rows={4}
disabled={isLetsEncryptCert}
placeholder={isLetsEncryptCert ? "Managed by ACME automation" : "-----BEGIN CERTIFICATE-----\n...intermediate certificate...\n-----END CERTIFICATE-----"}
/>
</Form.Item>
<Form.Item style={{ marginBottom: 0, textAlign: 'right' }}>
<Space>
<Button onClick={() => setModalVisible(false)}>
Cancel
</Button>
<Button type="primary" htmlType="submit">
{selectedCertificate && selectedCertificate.id ? 'Update Certificate' : 'Add Certificate'}
</Button>
</Space>
</Form.Item>
</Form>
</Modal>
{/* View Certificate Modal */}
<Modal
title={`Certificate Details: ${selectedCertificate?.name}`}
open={viewModalVisible}
onCancel={() => setViewModalVisible(false)}
footer={[
<Button key="close" onClick={() => setViewModalVisible(false)}>
Close
</Button>
]}
width={900}
>
{selectedCertificate && (
<Tabs defaultActiveKey="1">
<TabPane tab="General Info" key="1">
<Row gutter={16}>
<Col span={12}>
<Card size="small" title="Certificate Info">
<p><strong>Name:</strong> {selectedCertificate.name}</p>
<p><strong>Domain:</strong> {selectedCertificate.domain}</p>
<p><strong>Usage:</strong>
<Badge
status={selectedCertificate.usage_count > 0 ? 'success' : 'warning'}
text={selectedCertificate.usage_count > 0
? `In Use (${selectedCertificate.usage_count})`
: 'Not In Use'}
style={{ marginLeft: 8 }}
/>
</p>
<p><strong>Created:</strong> {new Date(selectedCertificate.created_at).toLocaleString(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
})}</p>
</Card>
</Col>
<Col span={12}>
<Card size="small" title="Expiry Info">
<p><strong>Expiry Date:</strong> {selectedCertificate.expiry_date ? new Date(selectedCertificate.expiry_date).toLocaleString(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
}) : 'Not set'}</p>
{selectedCertificate.expiry_date && (
<>
<p><strong>Status:</strong>
<Badge
{...getExpiryStatus(selectedCertificate.expiry_date)}
style={{ marginLeft: 8 }}
/>
</p>
<Progress
percent={Math.max(0, Math.min(100, (
(new Date(selectedCertificate.expiry_date) - new Date()) /
(365 * 24 * 60 * 60 * 1000) * 100
)))}
status={new Date(selectedCertificate.expiry_date) < new Date() ? 'exception' : 'active'}
format={() => getExpiryStatus(selectedCertificate.expiry_date).text}
/>
</>
)}
</Card>
</Col>
</Row>
</TabPane>
<TabPane tab="Certificate Content" key="2">
<div style={{ marginBottom: 16 }}>
<Title level={5}>Certificate (PEM)</Title>
<TextArea
value={selectedCertificate.certificate_content}
rows={10}
readOnly
style={{ fontFamily: 'monospace' }}
/>
</div>
{selectedCertificate.chain_content && (
<div>
<Title level={5}>Certificate Chain</Title>
<TextArea
value={selectedCertificate.chain_content}
rows={6}
readOnly
style={{ fontFamily: 'monospace' }}
/>
</div>
)}
</TabPane>
<TabPane tab="Private Key" key="3">
<Alert
message="Security Warning"
description="Private key content is sensitive. Only view when necessary and ensure secure handling."
type="warning"
showIcon
style={{ marginBottom: 16 }}
/>
<TextArea
value={selectedCertificate.private_key_content}
rows={12}
readOnly
style={{ fontFamily: 'monospace' }}
/>
</TabPane>
<TabPane tab={
<span>
Usage {selectedCertificate.usage_count > 0 &&
<Badge count={selectedCertificate.usage_count} size="small"
style={{ marginLeft: 4, backgroundColor: '#1890ff' }} />
}
</span>
} key="4">
{selectedCertificate.usage_count > 0 ? (
<div>
<Alert
message={`In Use — ${selectedCertificate.used_by_frontends?.length || 0} frontend(s), ${selectedCertificate.used_by_servers?.length || 0} server(s)`}
type="success"
showIcon
style={{ marginBottom: 16 }}
/>
<Input
placeholder="Search by name, backend, or cluster..."
prefix={<SearchOutlined />}
allowClear
onChange={e => setUsageSearch(e.target.value)}
value={usageSearch}
style={{ marginBottom: 16 }}
/>
{selectedCertificate.used_by_frontends?.length > 0 && (
<div style={{ marginBottom: 16 }}>
<Title level={5}>Frontends</Title>
<Table
dataSource={
(selectedCertificate.used_by_frontends || []).filter(f => {
if (!usageSearch) return true;
const s = usageSearch.toLowerCase();
return (f.name || '').toLowerCase().includes(s) ||
(f.cluster_name || '').toLowerCase().includes(s);
})
}
columns={[
{ title: 'Frontend Name', dataIndex: 'name', key: 'name' },
{ title: 'Cluster', dataIndex: 'cluster_name', key: 'cluster_name',
render: v => v || <Tag color="blue">Global</Tag> }
]}
rowKey="id"
size="small"
pagination={{ pageSize: 5, hideOnSinglePage: true }}
locale={{ emptyText: 'No matching frontends' }}
/>
</div>
)}
{selectedCertificate.used_by_servers?.length > 0 && (
<div>
<Title level={5}>Backend Servers</Title>
<Table
dataSource={
(selectedCertificate.used_by_servers || []).filter(sv => {
if (!usageSearch) return true;
const s = usageSearch.toLowerCase();
return (sv.server_name || '').toLowerCase().includes(s) ||
(sv.backend_name || '').toLowerCase().includes(s) ||
(sv.cluster_name || '').toLowerCase().includes(s);
})
}
columns={[
{ title: 'Server Name', dataIndex: 'server_name', key: 'server_name' },
{ title: 'Backend', dataIndex: 'backend_name', key: 'backend_name' },
{ title: 'Cluster', dataIndex: 'cluster_name', key: 'cluster_name',
render: v => v || <Tag color="blue">Global</Tag> }
]}
rowKey="id"
size="small"
pagination={{ pageSize: 5, hideOnSinglePage: true }}
locale={{ emptyText: 'No matching servers' }}
/>
</div>
)}
</div>
) : (
<Alert
message="Not In Use"
description="This certificate is not referenced by any frontend or backend server."
type="warning"
showIcon
/>
)}
</TabPane>
<TabPane tab={
<span>
<CloudServerOutlined /> Deployment Status
</span>
} key="5">
<div style={{ marginBottom: 16 }}>
<Button
type="primary"
icon={<ReloadOutlined />}
onClick={() => fetchDeploymentStatus(selectedCertificate)}
loading={deploymentLoading}
>
Refresh Deployment Status
</Button>
</div>
{deploymentLoading ? (
<div style={{ textAlign: 'center', padding: 40 }}>
<Spin size="large" />
<p style={{ marginTop: 16 }}>Fetching deployment status from all clusters...</p>
</div>
) : deploymentData.length > 0 ? (
<Table
dataSource={deploymentData.filter(d => !d.error)}
rowKey="cluster_id"
size="small"
pagination={false}
columns={[
{
title: 'Cluster',
dataIndex: 'cluster_name',
key: 'cluster_name',
render: (text) => <strong>{text}</strong>
},
{
title: 'Synced Agents',
key: 'sync',
render: (_, record) => (
<span>
{record.synced_agents ?? '-'}/{record.total_agents ?? '-'}
</span>
)
},
{
title: 'Sync %',
key: 'sync_pct',
render: (_, record) => {
const pct = record.sync_percentage ?? 0;
return (
<Progress
percent={pct}
size="small"
status={pct === 100 ? 'success' : 'active'}
style={{ width: 120 }}
/>
);
}
},
{
title: 'Offline',
key: 'offline',
render: (_, record) => {
const offline = record.offline_agents || 0;
return offline > 0
? <Tag color="orange" icon={<ExclamationCircleOutlined />}>{offline}</Tag>
: <Tag color="green">0</Tag>;
}
},
{
title: 'Status',
key: 'status',
render: (_, record) => {
if (record.error) return <Tag color="red">Error</Tag>;
if (record.ssl_config_status === 'PENDING') return <Tag color="orange" icon={<ClockCircleOutlined />}>NOT APPLIED</Tag>;
const pct = record.sync_percentage ?? 0;
if (pct === 100) return <Tag color="green" icon={<CheckCircleOutlined />}>SYNCED</Tag>;
if (record.synced_agents > 0) return <Tag color="blue" icon={<SyncOutlined spin />}>APPLYING</Tag>;
return <Tag color="orange" icon={<ExclamationCircleOutlined />}>PENDING</Tag>;
}
},
{
title: 'Applied At',
key: 'applied_at',
render: (_, record) => {
if (record.ssl_config_status === 'PENDING') return <Text type="secondary">-</Text>;
return record.version_applied_at
? new Date(record.version_applied_at).toLocaleString()
: '-';
}
}
]}
expandable={{
expandedRowRender: (record) => (
<Table
dataSource={record.agents || []}
rowKey="name"
size="small"
pagination={false}
columns={[
{ title: 'Agent', dataIndex: 'name', key: 'name' },
{
title: 'Status',
dataIndex: 'status',
key: 'status',
render: (v) => (
<Tag color={v === 'online' ? 'green' : 'red'}>
{v?.toUpperCase() || 'UNKNOWN'}
</Tag>
)
},
{
title: 'HAProxy',
dataIndex: 'haproxy_status',
key: 'haproxy_status',
render: (v) => (
<Tag color={v === 'running' ? 'green' : v === 'stopped' ? 'red' : 'default'}>
{v?.toUpperCase() || 'UNKNOWN'}
</Tag>
)
},
{
title: 'SSL Deployed',
dataIndex: 'ssl_file_deployed',
key: 'ssl_file_deployed',
render: (v) => v === true
? <CheckCircleOutlined style={{ color: '#52c41a' }} />
: v === false
? <CloseCircleOutlined style={{ color: '#ff4d4f' }} />
: '-'
},
{
title: 'Config Version',
dataIndex: 'delivered_version',
key: 'delivered_version',
render: (v) => v ? <Text code style={{ fontSize: 11 }}>{v}</Text> : '-'
}
]}
/>
)
}}
/>
) : (
<Alert
message="Click 'Refresh Deployment Status' to load per-cluster deployment information."
type="info"
showIcon
/>
)}
{deploymentData.length > 0 && deploymentData.some(d => d.error) && (
<Alert
message={`${deploymentData.filter(d => d.error).length} cluster(s) could not be reached`}
type="warning"
showIcon
style={{ marginTop: 8 }}
/>
)}
</TabPane>
</Tabs>
)}
</Modal>
</div>
);
};
export { SSLManagement };