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}):

)} {bes.length > 0 && ( <>

Backend servers ({bes.length}):

)}

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:

), 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') && ( )} {/* 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 (
<LockOutlined style={{ marginRight: 8, color: '#52c41a' }} /> SSL Certificate Management {selectedCluster && ( <Text type="secondary" style={{ fontSize: '14px', fontWeight: 'normal', marginLeft: '8px' }}> - {selectedCluster.name} </Text> )} ACME Automation, children: , }, ]} /> {/* Add Certificate Modal */} { setModalVisible(false); setSelectedCertificate(null); }} footer={null} width={800} >
{/* SSL Type and Cluster Selection */} prevValues.ssl_type !== currentValues.ssl_type}> {({ getFieldValue }) => { const sslType = getFieldValue('ssl_type'); return ( { if (sslType === 'cluster' && (!value || value.length === 0)) { return Promise.reject('Please select at least one cluster'); } return Promise.resolve(); } } ]} > ); }} {isLetsEncryptCert && ( } message="This certificate is managed by ACME automation. Content updates happen automatically during renewals." style={{ marginBottom: 16 }} /> )}