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 { useSearchParams } from 'react-router-dom';
import { useCluster } from '../contexts/ClusterContext';
import { useProgress } from '../contexts/ProgressContext';
import { formatEntityForSync } from '../utils/agentSync';
import { extractApiError } from '../utils/apiError';
import ACMEAutomation from './ACMEAutomation';
const { Title, Text } = Typography;
const { TextArea } = Input;
const { TabPane } = Tabs;
const SSLManagement = () => {
const [searchParams] = useSearchParams();
const defaultTab = searchParams.get('tab') || 'certificates';
const { token } = theme.useToken();
const { selectedCluster, clusters, loading: clustersLoading } = 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);
}
};
// Bulgu #74 (round-22 audit) — the backend now returns HTTP 409
// with a structured body listing in-use frontends / backend
// servers when an operator tries to delete a cert that's still
// referenced. Surface that as a confirm dialog with two options:
// * Cancel — operator detaches the cert from each frontend
// manually (safer).
// * Force-delete — re-issue the request with `?force=true`,
// which NULLs the references and proceeds. The backend
// marks every affected frontend / backend server as PENDING
// so the next Apply re-renders without the cert.
// Pre-fix the 409 was caught by the generic catch block and
// displayed as a one-line toast with no breakdown of WHICH
// entities held the reference — operators had to guess.
const deleteCertificateRequest = async (certificateId, opts = {}) => {
const qs = opts.force ? '?force=true' : '';
const response = await axios.delete(`/api/ssl/certificates/${certificateId}${qs}`);
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(
SSL certificate deleted{opts.force ? ' (force)' : ''}
Pending config version created for {successCount} cluster(s). Go to Apply Changes to deploy.
,
6
);
} else {
message.warning(
SSL certificate deleted with warnings
{successCount}/{totalNodes} cluster(s) have pending versions. Some may need manual cleanup.
,
8
);
}
} else {
message.success('SSL certificate deleted successfully');
}
fetchCertificates();
};
const handleDelete = async (certificateId) => {
try {
await deleteCertificateRequest(certificateId);
} catch (error) {
// Bulgu #74 — branch on 409 to render the in-use breakdown.
// The backend wraps the structured detail through
// `GlobalExceptionHandler.create_error_response` which puts
// the raw HTTPException.detail under `data.error.message`
// (yes, the field name says "message" but for the in-use
// case it's a dict). Fall back to the legacy `data.detail`
// shape so direct-API callers that bypass the envelope
// still get the same UX.
const status = error?.response?.status;
const env = error?.response?.data?.error;
const usageData =
(env && typeof env.message === 'object' && env.message)
? env.message
: (typeof error?.response?.data?.detail === 'object'
? error.response.data.detail
: null);
const fes = Array.isArray(usageData?.frontends) ? usageData.frontends : [];
const bes = Array.isArray(usageData?.backend_servers) ? usageData.backend_servers : [];
if (status === 409 && (fes.length > 0 || bes.length > 0)) {
Modal.confirm({
title: 'Certificate is still in use',
width: 600,
okText: 'Force delete (NULL references)',
okType: 'danger',
cancelText: 'Cancel & detach manually',
content: (
This certificate is currently bound to:
{fes.length > 0 && (
<>
Frontends ({fes.length}):
{fes.slice(0, 10).map((f) => (
{f.name} (cluster {f.cluster_id ?? '—'})
))}
{fes.length > 10 && … and {fes.length - 10} more }
>
)}
{bes.length > 0 && (
<>
Backend servers ({bes.length}):
{bes.slice(0, 10).map((b) => (
{b.backend_name}/{b.server_name} (cluster {b.cluster_id ?? '—'})
))}
{bes.length > 10 && … and {bes.length - 10} more }
>
)}
Force-delete will silently drop the HTTPS bind on every
listed frontend and mark each one as PENDING. Only
proceed if you have already prepared a replacement (or
accept the security downgrade to plain HTTP).
),
onOk: async () => {
try {
await deleteCertificateRequest(certificateId, { force: true });
} catch (forceErr) {
message.error(extractApiError(forceErr, 'Force delete failed'));
}
},
});
return;
}
message.error(extractApiError(error, 'Failed to delete certificate'));
}
};
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(
SSL certificate {isEditing ? 'updated' : 'added'} successfully
Pending config version created for {successCount} cluster(s). Go to Apply Changes to deploy.
,
6
);
} else {
message.warning(
SSL certificate {isEditing ? 'updated' : 'added'} with warnings
{successCount}/{totalNodes} cluster(s) have pending versions. Some may need attention.
,
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: (
A certificate with the name "{form.getFieldValue('name')}" already exists.
Please choose one of the following options:
Choose a different name for your certificate
Delete the existing certificate first if you want to replace it
Edit the existing certificate instead of creating a new one
),
okText: 'Got it',
width: 500
});
} else if (errorDetail.includes('Invalid SSL certificate')) {
// Invalid certificate content error
Modal.error({
title: 'Invalid SSL Certificate',
content: (
Certificate validation failed:
{errorDetail}
Please check your certificate content and try again.
),
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: ${extractApiError(error, 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) => (
{text}
{record.domain}
),
},
{
title: 'Scope',
dataIndex: 'ssl_type',
key: 'ssl_type',
render: (type, record) => {
const isGlobal = type === 'Global';
return (
{isGlobal ? 'Global' : 'Cluster-specific'}
);
},
},
{
title: 'Usage',
dataIndex: 'usage_type',
key: 'usage_type',
render: (usage_type) => {
const isFrontend = usage_type === 'frontend';
return (
{isFrontend ? 'Frontend SSL' : 'Server SSL'}
);
},
},
{
title: 'Source',
dataIndex: 'source',
key: 'source',
render: (source) => (
: null}>
{source === 'letsencrypt' ? 'Auto (ACME)' : 'Manual'}
),
},
{
title: 'Sync Status',
key: 'sync_status',
render: (_, record) => (
),
},
{
title: 'Expiry Status',
dataIndex: 'expiry_date',
key: 'expiry_status',
render: (expiryDate, record) => {
const status = getExpiryStatus(expiryDate);
return (
);
},
},
{
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 (
{status}
);
},
},
{
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) => (
{(record.last_config_status === 'PENDING') && (
}
onClick={() => window.location.href = '/apply-management'}
style={{
backgroundColor: '#1890ff',
borderColor: '#1890ff',
}}
>
Apply
)}
}
onClick={() => handleView(record)}
/>
}
onClick={() => handleEdit(record)}
/>
handleDelete(record.id)}
okText="Yes"
cancelText="No"
>
}
/>
),
},
];
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 = (
<>
Global
Cluster-specific
In Use
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;
}
}}
/>
}
onClick={fetchCertificates}
loading={loading}
>
Refresh
}
onClick={handleAdd}
disabled={!selectedCluster}
>
Add Certificate
{/* Phase J audit fix #6 — While the ClusterContext is still
fetching the cluster list (initial mount, exponential-backoff
retry, etc.), `selectedCluster` is null but the operator is
NOT actually missing a cluster — the data just hasn't arrived
yet. Showing "No Cluster Selected" during that window was the
single most visible symptom of the original bug ("clusters
aren't listing, no entities show up, I have to wait"). Show a
neutral "Loading clusters..." affordance during the fetch and
only flip to the warning alert once the fetch has settled. */}
{!selectedCluster && (
clustersLoading ? (
) : (
)
)}
{certificates.length}
Total Certificates
{certificates.filter(c => c.usage_count > 0).length}
In Use
0 ? (
Certificates Expiring Soon
{expiringSoon.map(cert => (
{cert.name} ({cert.domain}) - {getExpiryStatus(cert.expiry_date).text}
))}
) : null}
mouseEnterDelay={0.4}
placement="bottom"
>
0 ? 'pointer' : 'default' }}>
{expiringSoon.length}
Expiring Soon
{certificates.filter(c => c.expiry_date && new Date(c.expiry_date) < new Date()).length}
Expired
{certificates.filter(c => c.source === 'letsencrypt').length}
Auto-Managed
`${range[0]}-${range[1]} of ${total} certificates`,
}}
/>
>
);
return (
SSL Certificate Management
{selectedCluster && (
- {selectedCluster.name}
)}
ACME Automation,
children: ,
},
]}
/>
{/* Add Certificate Modal */}
{
setModalVisible(false);
setSelectedCertificate(null);
}}
footer={null}
width={800}
>
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
? Private key provided
: Required for Frontend SSL ;
} else {
extraMessage = hasValue
? Private key provided (optional for Server SSL)
: Optional for Server SSL (used for backend verification) ;
}
return (
Private Key Content (PEM Format)
{isRequired && * }
{usageType === 'server' && - Optional }
}
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}
>
);
}}
setModalVisible(false)}>
Cancel
{selectedCertificate && selectedCertificate.id ? 'Update Certificate' : 'Add Certificate'}
{/* View Certificate Modal */}
setViewModalVisible(false)}
footer={[
setViewModalVisible(false)}>
Close
]}
width={900}
>
{selectedCertificate && (
Name: {selectedCertificate.name}
Domain: {selectedCertificate.domain}
Usage:
0 ? 'success' : 'warning'}
text={selectedCertificate.usage_count > 0
? `In Use (${selectedCertificate.usage_count})`
: 'Not In Use'}
style={{ marginLeft: 8 }}
/>
Created: {new Date(selectedCertificate.created_at).toLocaleString(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
})}
Expiry Date: {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'}
{selectedCertificate.expiry_date && (
<>
Status:
getExpiryStatus(selectedCertificate.expiry_date).text}
/>
>
)}
Certificate (PEM)
{selectedCertificate.chain_content && (
Certificate Chain
)}
Usage {selectedCertificate.usage_count > 0 &&
}
} key="4">
{selectedCertificate.usage_count > 0 ? (
}
allowClear
onChange={e => setUsageSearch(e.target.value)}
value={usageSearch}
style={{ marginBottom: 16 }}
/>
{selectedCertificate.used_by_frontends?.length > 0 && (
Frontends
{
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 || Global }
]}
rowKey="id"
size="small"
pagination={{ pageSize: 5, hideOnSinglePage: true }}
locale={{ emptyText: 'No matching frontends' }}
/>
)}
{selectedCertificate.used_by_servers?.length > 0 && (
Backend Servers
{
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 || Global }
]}
rowKey="id"
size="small"
pagination={{ pageSize: 5, hideOnSinglePage: true }}
locale={{ emptyText: 'No matching servers' }}
/>
)}
) : (
)}
Deployment Status
} key="5">
}
onClick={() => fetchDeploymentStatus(selectedCertificate)}
loading={deploymentLoading}
>
Refresh Deployment Status
{deploymentLoading ? (
Fetching deployment status from all clusters...
) : deploymentData.length > 0 ? (
!d.error)}
rowKey="cluster_id"
size="small"
pagination={false}
columns={[
{
title: 'Cluster',
dataIndex: 'cluster_name',
key: 'cluster_name',
render: (text) => {text}
},
{
title: 'Synced Agents',
key: 'sync',
render: (_, record) => (
{record.synced_agents ?? '-'}/{record.total_agents ?? '-'}
)
},
{
title: 'Sync %',
key: 'sync_pct',
render: (_, record) => {
const pct = record.sync_percentage ?? 0;
return (
);
}
},
{
title: 'Offline',
key: 'offline',
render: (_, record) => {
const offline = record.offline_agents || 0;
return offline > 0
? }>{offline}
: 0 ;
}
},
{
title: 'Status',
key: 'status',
render: (_, record) => {
if (record.error) return Error ;
if (record.ssl_config_status === 'PENDING') return }>NOT APPLIED;
const pct = record.sync_percentage ?? 0;
if (pct === 100) return }>SYNCED;
if (record.synced_agents > 0) return }>APPLYING;
return }>PENDING;
}
},
{
title: 'Applied At',
key: 'applied_at',
render: (_, record) => {
if (record.ssl_config_status === 'PENDING') return - ;
return record.version_applied_at
? new Date(record.version_applied_at).toLocaleString()
: '-';
}
}
]}
expandable={{
expandedRowRender: (record) => (
(
{v?.toUpperCase() || 'UNKNOWN'}
)
},
{
title: 'HAProxy',
dataIndex: 'haproxy_status',
key: 'haproxy_status',
render: (v) => (
{v?.toUpperCase() || 'UNKNOWN'}
)
},
{
title: 'SSL Deployed',
dataIndex: 'ssl_file_deployed',
key: 'ssl_file_deployed',
render: (v) => v === true
?
: v === false
?
: '-'
},
{
title: 'Config Version',
dataIndex: 'delivered_version',
key: 'delivered_version',
render: (v) => v ? {v} : '-'
}
]}
/>
)
}}
/>
) : (
)}
{deploymentData.length > 0 && deploymentData.some(d => d.error) && (
d.error).length} cluster(s) could not be reached`}
type="warning"
showIcon
style={{ marginTop: 8 }}
/>
)}
)}
);
};
export { SSLManagement };