mirror of
https://github.com/taylanbakircioglu/haproxy-openmanager.git
synced 2026-09-24 03:16:24 +00:00
02b1cb2bca
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.
1610 lines
61 KiB
JavaScript
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 };
|