Files
haproxy-openmanager/frontend/src/components/SSLManagement.js
T
taylanbakircioglu 02b1cb2bca feat: v1.5.0 — Site Wizard (Issue #14) + ACME Diagnostic Panel (Issue #13)
Closes #13, Closes #14.

This release squashes the v1.4.0 → v1.5.0 development line. v1.4.0
shipped the ACME stability & enterprise audit (Issues #10/#11/#12).
v1.5.0 builds on that foundation with two co-equal headline features
plus a 22-round audit campaign hardening the prior configuration
surface. License remains MIT for v1.5.0 (relicense to AGPL-3.0
lands in v1.5.2).

------------------------------------------------------------------
HEADLINE FEATURE A — ACME Diagnostic Panel (Issue #13)
------------------------------------------------------------------
A live pre-flight + post-failure diagnostic surface for every ACME
order, reachable from the ACME Automation page. The panel exists
to make ACME failures legible to operators who do NOT have shell
access to the API host.

Endpoints (`backend/routers/acme_diagnostics.py`):
  POST /api/letsencrypt/orders/{order_id}/diagnostics
       Run the full 5-check suite (DNS / port-80 / routing /
       account / agents) and humanize the order's `error_detail`
       (>=11 RFC-8555 problem types, backwards compatible with
       legacy plain-string failures).
  POST /api/letsencrypt/orders/{order_id}/diagnostics/
                                {check_id}/rerun
       Re-run a single check in place — used by the "Re-run"
       button on every row of the modal's pre-flight table.
  GET  /api/letsencrypt/orders/{order_id}/events
       Merged event timeline combining the typed
       `acme_order_events` rows with correlated
       `user_activity_logs` entries (resource_type =
       'letsencrypt_order' AND resource_id = order_id). The
       diagnostic modal auto-tails this timeline every 5 seconds
       while open.

Service-level checks (`backend/services/acme_diagnostics.py`):
  * DNS resolution via stdlib socket.gethostbyname_ex through
    run_in_executor (intentionally avoiding an aiodns runtime
    dep for v1.5.0).
  * Port-80 HEAD probe, target locked to the order's domains,
    success on HTTP 200 OR 404, warns on egress timeout
    (corp egress policies routinely blackhole outbound 80 —
    fail-hard would be too noisy).
  * SSRF guard: probe refuses non-public IPs and surfaces the
    skip in the diagnostic result; IPv4-mapped IPv6 normalisation
    closes the `::ffff:169.254.169.254` cloud-metadata vector.
  * HAProxy routing presence check: matches the order's
    cluster_ids to a port-80 HTTP frontend.
  * ACME account validity check against `letsencrypt_accounts`.
  * Agent presence check (>=1 active agent in target cluster).
  * Every sub-check wrapped in a wall-clock timeout to bound
    impact on the API event loop.

RBAC: ssl.read for run, ssl.read for events. Per-user 5/min rate
limit on both run and rerun, backed by the (user_id, action,
created_at DESC) composite index.

Frontend (`frontend/src/components/ACMEAutomation.js`):
  * "Diagnose" button on every order row + the existing
    "stuck order" warning row.
  * Modal with two tabs:
    - Pre-flight Checks (Antd Table with status pills + Re-run
      buttons + humanized error banner)
    - Event Log (Antd Timeline with auto-tail polling, scroll-
      to-bottom, pause-on-hover)
  * Correlation IDs surfaced in error banners and individual
    check fail details for backend-log lookup.

------------------------------------------------------------------
HEADLINE FEATURE B — Site Setup Wizard (Issue #14)
------------------------------------------------------------------
A single guided flow that creates a Backend + Servers + HTTP
Frontend (and optional HTTPS Frontend) in one atomic transaction.

Endpoints (`backend/routers/site_wizard.py`):
  POST /api/site-wizard/preview     — diff-preview the changeset
  POST /api/site-wizard/create      — atomic execute
  POST /api/site-wizard/reject      — clean rollback (including
                                       any wizard_staged ACME
                                       orders)
  GET  /api/site-wizard/drafts      — draft persistence
  PUT  /api/site-wizard/drafts/{id} — save/update
  DELETE /api/site-wizard/drafts/{id}

Feature surface:
  * One screen captures both backend (mode + servers) AND
    frontend (http + optional https + SSL mode) inputs.
  * SSL modes: ACME (new order, HTTP-01 only for v1.5.0),
    Upload (existing PEM), Existing (link to a stored cert),
    or None.
  * ACME-staged path: wizard_staged_until watermark on the
    `letsencrypt_orders` row defers finalisation until agent
    confirmation; per-mode reject cleanly cancels and rolls
    back the staged order.
  * Live diff preview against the cluster's current generated
    config (renderer-evolution noise stripped — track-sc<N>
    dedup, per-server cookie strip, defaults-cookie
    inheritance, listen-block flattening).
  * Draft persistence with PEM stripped at save time (private
    keys never round-trip through the drafts table).
  * Per-cluster multi-tenancy: drafts and wizard_staged orders
    are isolated to the creating user's cluster scope.

Frontend (`frontend/src/components/SiteWizard.js`):
  * 4-step Antd Steps flow: Backend → Frontend → SSL → Review.
  * Render the live diff preview inline before commit.
  * Antd Form-level validation mirrors backend Pydantic
    validators (numeric bounds, HAProxy reserved keywords, ALPN
    consistency, IPv6 scope-id, domain regex, server name
    dedup).

------------------------------------------------------------------
AUDIT CAMPAIGN — Rounds 1 → 22 (Bulgu #1 → #82)
------------------------------------------------------------------
v1.5.0 includes 22 adversarial review passes. Each round produced
its own commit set in the corporate development line; this squash
collapses those into the v1.5.0 release artefact. Highlights:

  Round 1-4   Site Wizard core: dry-run parity, single-line
              value injection guard, ACL -f pattern-file block,
              SSL parity, timeout regex, form-state pin.
  Round 5-7   defaults-cookie inheritance, server-named-cookie
              guard, fe/be mode mismatch, duplicate server
              names, health_check_uri + server_address
              validators.
  Round 8-10  cookie_name / cookie_options newline-injection
              guard, dry-run parity (round 9), TCP-mode HTTP-only
              feature blockers.
  Round 11    SSL name path traversal + health-check >= 1.
  Round 12-13 SSL & ACME deep dive (Bulgu #23-#32).
  Round 14    single-line value injection (Bulgu #33).
  Round 15-17 ACME multi-tenant UX, numeric bounds, HAProxy
              reserved keywords, ALPN/TLS consistency,
              all-backup, multi-domain & multi-user enterprise
              edges, drain/HSTS/post-completion (Bulgu
              #34-#53).
  Round 18-21 concurrency, agent state, TCP-mode HTTP-only,
              list size caps, IPv6 scope-id, preview account
              validation, TCP backend + balance uri reject
              (Bulgu #54-#61).
  Round 22    FE error visibility + 3x stale-data lockouts,
              referential integrity + cascade safety,
              authentication & authorization, multi-cluster
              isolation, apply_pending_changes concurrency,
              script injection + bulk import multi-tenancy,
              prefix-stripped signature comparison
              (Bulgu #62-#82).

------------------------------------------------------------------
NO CORPORATE-SPECIFIC ARTIFACTS
------------------------------------------------------------------
This squash deliberately sanitises corporate hostnames, container
registry references, and TLS secret names into generic
placeholders (`your-registry.example.com/your-org`,
`haproxy-openmanager*.example.com`, `wildcard-tls`,
`taylanbakircioglu/haproxy-openmanager-*`) so the public artefact
contains no internal infrastructure detail. Pilot / development
history that retained those values stays in the corporate fork
and is NOT part of this commit.
2026-05-14 00:04:19 +03:00

1610 lines
61 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 { 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(
<div>
<div><strong>SSL certificate deleted{opts.force ? ' (force)' : ''}</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();
};
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: (
<div>
<p>This certificate is currently bound to:</p>
{fes.length > 0 && (
<>
<p style={{ marginBottom: 4, fontWeight: 600 }}>
Frontends ({fes.length}):
</p>
<ul style={{ marginTop: 0, paddingLeft: 20 }}>
{fes.slice(0, 10).map((f) => (
<li key={`fe-${f.id}`}>
{f.name} (cluster {f.cluster_id ?? '—'})
</li>
))}
{fes.length > 10 && <li>… and {fes.length - 10} more</li>}
</ul>
</>
)}
{bes.length > 0 && (
<>
<p style={{ marginBottom: 4, fontWeight: 600 }}>
Backend servers ({bes.length}):
</p>
<ul style={{ marginTop: 0, paddingLeft: 20 }}>
{bes.slice(0, 10).map((b) => (
<li key={`be-${b.id}`}>
{b.backend_name}/{b.server_name} (cluster {b.cluster_id ?? '—'})
</li>
))}
{bes.length > 10 && <li>… and {bes.length - 10} more</li>}
</ul>
</>
)}
<p style={{ marginTop: 12, color: '#ff4d4f' }}>
<strong>Force-delete will silently drop the HTTPS bind on every
listed frontend</strong> and mark each one as PENDING. Only
proceed if you have already prepared a replacement (or
accept the security downgrade to plain HTTP).
</p>
</div>
),
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(
<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: ${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) => (
<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>
{/* 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 ? (
<Alert
message="Loading clusters…"
description="Fetching the cluster list. This usually takes a few seconds after a deploy."
type="info"
showIcon
style={{ marginBottom: 16 }}
/>
) : (
<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
defaultActiveKey={defaultTab}
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, dot, 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 };