mirror of
https://github.com/taylanbakircioglu/haproxy-openmanager.git
synced 2026-09-23 11:03:24 +00:00
b29c9e044e
This update consolidates bug fixes and improvements from internal development: ## Multi-Cluster Isolation (CRITICAL) - Backend delete now isolated per cluster (added cluster_id to WHERE clauses) - Orphan config version auto-detection and cleanup - Prevents cross-cluster contamination when deleting entities - REJECTED entities excluded from pending list ## Apply Management Fixes - Fixed phantom pending entities (REJECTED entities no longer shown) - Fixed cluster switch 404 errors (state cleared before fetch) - Fixed page reload not refreshing data (added mount useEffect) - Orphan entity status auto-cleanup on reject ## Backend Delete Improvements - Added cluster_id to server/frontend updates (multi-cluster safe) - Automatic ACL/use_backend cleanup from frontends - NULL cluster_id support for legacy data - Prevents HAProxy validation errors ## Orphan Version Detection - Backend GET: Validates entity belongs to version's cluster - Frontend GET: Validates entity belongs to version's cluster - Apply: Auto-detects and removes orphan versions before apply - Reject: Auto-detects and removes orphan versions before reject ## UI/UX Improvements - Apply Management state management improved - Debug logging for troubleshooting - Better cluster switch handling - README: Orphan version troubleshooting section Technical Changes: - backend/routers/backend.py: Multi-cluster isolation, orphan detection, NULL handling - backend/routers/frontend.py: Orphan detection, REJECTED filter - backend/routers/cluster.py: Orphan auto-cleanup, entity status cleanup - frontend/ApplyManagement.js: State management, mount useEffect - README.md: Troubleshooting documentation Impact: - Multi-cluster environments now fully isolated ✅ - Orphan config versions automatically cleaned ✅ - REJECTED entities properly filtered ✅ - Apply Management works correctly across cluster switches ✅ - No manual database intervention needed ✅
1442 lines
62 KiB
JavaScript
1442 lines
62 KiB
JavaScript
import React, { useState, useEffect } from 'react';
|
|
import {
|
|
Card, Button, Space, Row, Col, message, Alert, Spin,
|
|
Typography, Tag, Table, Modal, Divider, Badge, Empty,
|
|
Timeline, Descriptions, Tabs, Progress, Tooltip
|
|
} from 'antd';
|
|
import { getAgentSyncColor, getConfigStatusColor, COLORS } from '../utils/colors';
|
|
import { useProgress } from '../contexts/ProgressContext';
|
|
import {
|
|
CheckCircleOutlined, ExclamationCircleOutlined, SyncOutlined,
|
|
ClockCircleOutlined, PlayCircleOutlined, ReloadOutlined,
|
|
CloudServerOutlined, GlobalOutlined, SecurityScanOutlined,
|
|
SafetyCertificateOutlined, InfoCircleOutlined, HistoryOutlined,
|
|
EyeOutlined, CloseOutlined, CloseCircleOutlined, RedoOutlined,
|
|
UndoOutlined, CloudUploadOutlined
|
|
} from '@ant-design/icons';
|
|
import axios from 'axios';
|
|
import { useCluster } from '../contexts/ClusterContext';
|
|
import { Select } from 'antd';
|
|
import { useNavigate } from 'react-router-dom';
|
|
|
|
const { Title, Text, Paragraph } = Typography;
|
|
const { TabPane } = Tabs;
|
|
|
|
const ApplyManagement = () => {
|
|
const { selectedCluster } = useCluster();
|
|
const navigate = useNavigate();
|
|
|
|
// State
|
|
const [loading, setLoading] = useState(false);
|
|
const [applyLoading, setApplyLoading] = useState(false);
|
|
const [pendingChanges, setPendingChanges] = useState({
|
|
frontends: [],
|
|
backends: [],
|
|
waf_rules: [],
|
|
ssl_certificates: [],
|
|
total_count: 0
|
|
});
|
|
const [configVersions, setConfigVersions] = useState([]);
|
|
const [viewChangeModalVisible, setViewChangeModalVisible] = useState(false);
|
|
const [selectedVersion, setSelectedVersion] = useState(null);
|
|
const [diffData, setDiffData] = useState(null);
|
|
const [diffLoading, setDiffLoading] = useState(false);
|
|
const [agentSync, setAgentSync] = useState(null);
|
|
const [agentSyncLoading, setAgentSyncLoading] = useState(false);
|
|
const [syncProgress, setSyncProgress] = useState({ visible: false, step: '', progress: 0 });
|
|
const { startProgress, updateProgress, updateEntityCounts, completeProgress, isProgressActive } = useProgress();
|
|
const [entitySyncStates, setEntitySyncStates] = useState({});
|
|
|
|
// Initial load on component mount
|
|
useEffect(() => {
|
|
if (selectedCluster) {
|
|
fetchPendingChanges();
|
|
fetchConfigVersions();
|
|
fetchAgentSync();
|
|
}
|
|
}, []); // Run once on mount
|
|
|
|
// Cluster change handler
|
|
useEffect(() => {
|
|
if (selectedCluster) {
|
|
// CRITICAL: Clear all state immediately when cluster changes to prevent cross-cluster contamination
|
|
setPendingChanges({ frontends: [], backends: [], waf_rules: [], ssl_certificates: [], total_count: 0 });
|
|
setConfigVersions([]);
|
|
setAgentSync(null);
|
|
setEntitySyncStates({});
|
|
|
|
// Then fetch new data
|
|
fetchPendingChanges();
|
|
fetchConfigVersions();
|
|
fetchAgentSync();
|
|
} else {
|
|
setPendingChanges({ frontends: [], backends: [], waf_rules: [], ssl_certificates: [], total_count: 0 });
|
|
setConfigVersions([]);
|
|
setAgentSync(null);
|
|
setEntitySyncStates({});
|
|
}
|
|
}, [selectedCluster?.id]); // Use selectedCluster.id to prevent re-render on object change
|
|
|
|
// Fetch entity sync states when pending changes are loaded
|
|
// CRITICAL: Only fetch for the CURRENT cluster to prevent 404 errors on old entity IDs
|
|
useEffect(() => {
|
|
if (selectedCluster && (pendingChanges.frontends.length > 0 || pendingChanges.backends.length > 0 ||
|
|
pendingChanges.waf_rules.length > 0 || pendingChanges.ssl_certificates.length > 0)) {
|
|
fetchEntitySyncStates();
|
|
}
|
|
}, [selectedCluster?.id, pendingChanges]);
|
|
|
|
// Auto-refresh agent sync status every 10 seconds
|
|
useEffect(() => {
|
|
if (!selectedCluster) return;
|
|
|
|
const interval = setInterval(() => {
|
|
fetchAgentSync();
|
|
fetchEntitySyncStates(); // Also refresh entity sync states
|
|
}, 10000); // 10 seconds
|
|
|
|
return () => clearInterval(interval);
|
|
}, [selectedCluster]);
|
|
|
|
|
|
const fetchPendingChanges = async () => {
|
|
if (!selectedCluster) return;
|
|
|
|
setLoading(true);
|
|
try {
|
|
const token = localStorage.getItem('token');
|
|
if (!token || token === 'null' || token.trim() === '') {
|
|
message.error('Authentication required. Please login again.');
|
|
return;
|
|
}
|
|
|
|
// Fetch pending changes from all modules
|
|
// CRITICAL FIX: Add cache-control headers to prevent stale data
|
|
const cacheHeaders = {
|
|
'Authorization': `Bearer ${token}`,
|
|
'Cache-Control': 'no-cache, no-store, must-revalidate',
|
|
'Pragma': 'no-cache'
|
|
};
|
|
|
|
const [frontendsRes, backendsRes, wafRes, sslRes] = await Promise.all([
|
|
axios.get('/api/frontends', {
|
|
params: { cluster_id: selectedCluster.id, include_inactive: true },
|
|
headers: cacheHeaders
|
|
}).catch(() => ({ data: { frontends: [] } })),
|
|
|
|
axios.get('/api/backends', {
|
|
params: { cluster_id: selectedCluster.id, include_inactive: true },
|
|
headers: cacheHeaders
|
|
}).catch(() => ({ data: { backends: [] } })),
|
|
|
|
axios.get('/api/waf/rules', {
|
|
params: { cluster_id: selectedCluster.id },
|
|
headers: cacheHeaders
|
|
}).catch(() => ({ data: { rules: [] } })),
|
|
|
|
axios.get('/api/ssl/certificates', {
|
|
params: { cluster_id: selectedCluster.id },
|
|
headers: cacheHeaders
|
|
}).catch(() => ({ data: [] }))
|
|
]);
|
|
|
|
// DEBUG: Log raw backend data
|
|
console.log('🔍 APPLY MANAGEMENT DEBUG - Backend Data:');
|
|
console.log('Total backends from API:', backendsRes.data.backends?.length || 0);
|
|
|
|
// Check specific backends
|
|
const targetBackends = ['Apmserver', 'Elasticsearch', 'Kibana'];
|
|
const foundTargets = (backendsRes.data.backends || []).filter(b => targetBackends.includes(b.name));
|
|
foundTargets.forEach(b => {
|
|
console.log(`🔍 Backend: ${b.name} (id=${b.id})`, {
|
|
cluster_id: b.cluster_id,
|
|
last_config_status: b.last_config_status,
|
|
has_pending_config: b.has_pending_config,
|
|
is_active: b.is_active
|
|
});
|
|
});
|
|
|
|
// Filter pending changes
|
|
const frontends = (frontendsRes.data.frontends || []).filter(f => f.has_pending_config);
|
|
const backends = (backendsRes.data.backends || []).filter(b => b.has_pending_config);
|
|
|
|
// DEBUG: Log filtered backends
|
|
console.log('🔍 Filtered pending backends:', backends.length);
|
|
backends.filter(b => targetBackends.includes(b.name)).forEach(b => {
|
|
console.log(`🔍 PENDING Backend: ${b.name} (id=${b.id}) - has_pending_config=${b.has_pending_config}`);
|
|
});
|
|
|
|
const waf_rules = (wafRes.data.rules || []).filter(w => w.has_pending_config);
|
|
const ssl_certificates = (sslRes.data.ssl_certificates || sslRes.data || []).filter(s => s.has_pending_config);
|
|
|
|
const total_count = frontends.length + backends.length + waf_rules.length + ssl_certificates.length;
|
|
|
|
setPendingChanges({
|
|
frontends,
|
|
backends,
|
|
waf_rules,
|
|
ssl_certificates,
|
|
total_count
|
|
});
|
|
|
|
} catch (error) {
|
|
console.error('Error fetching pending changes:', error);
|
|
message.error('Failed to fetch pending changes');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const fetchConfigVersions = async () => {
|
|
if (!selectedCluster) return;
|
|
|
|
try {
|
|
const token = localStorage.getItem('token');
|
|
const response = await axios.get(
|
|
`/api/clusters/${selectedCluster.id}/config-versions`,
|
|
{ headers: { Authorization: `Bearer ${token}` } }
|
|
);
|
|
|
|
setConfigVersions(response.data.config_versions || []);
|
|
} catch (error) {
|
|
console.error('Error fetching config versions:', error);
|
|
}
|
|
};
|
|
|
|
const fetchAgentSync = async () => {
|
|
if (!selectedCluster) return null;
|
|
setAgentSyncLoading(true);
|
|
try {
|
|
const token = localStorage.getItem('token');
|
|
const response = await axios.get(
|
|
`/api/clusters/${selectedCluster.id}/agent-sync`,
|
|
{ headers: { Authorization: `Bearer ${token}` } }
|
|
);
|
|
setAgentSync(response.data);
|
|
return response.data; // Return the data for immediate use
|
|
} catch (error) {
|
|
console.error('Error fetching agent sync status:', error);
|
|
return null;
|
|
} finally {
|
|
setAgentSyncLoading(false);
|
|
}
|
|
};
|
|
|
|
const fetchEntitySyncStates = async () => {
|
|
if (!selectedCluster) return;
|
|
|
|
const newSyncStates = {};
|
|
const token = localStorage.getItem('token');
|
|
|
|
try {
|
|
// Fetch sync states for all pending entities
|
|
const allPendingEntities = [
|
|
...pendingChanges.frontends.map(item => ({ type: 'frontends', id: item.id, name: item.name })),
|
|
...pendingChanges.backends.map(item => ({ type: 'backends', id: item.id, name: item.name })),
|
|
...pendingChanges.waf_rules.map(item => ({ type: 'waf_rules', id: item.id, name: item.name })),
|
|
...pendingChanges.ssl_certificates.map(item => ({ type: 'ssl_certificates', id: item.id, name: item.name }))
|
|
];
|
|
|
|
await Promise.all(
|
|
allPendingEntities.map(async (entity) => {
|
|
try {
|
|
let endpoint;
|
|
if (entity.type === 'ssl_certificates') {
|
|
endpoint = `/api/clusters/${selectedCluster.id}/ssl_certificates/${entity.id}/agent-sync`;
|
|
} else {
|
|
endpoint = `/api/clusters/${selectedCluster.id}/entity-sync/${entity.type}/${entity.id}`;
|
|
}
|
|
|
|
const response = await axios.get(endpoint, {
|
|
headers: { Authorization: `Bearer ${token}` }
|
|
});
|
|
|
|
const key = `${entity.type}-${entity.id}`;
|
|
newSyncStates[key] = {
|
|
...response.data.sync_status,
|
|
status: response.data.sync_status?.synced_agents === response.data.sync_status?.total_agents ? 'synced' : 'syncing'
|
|
};
|
|
} catch (error) {
|
|
const key = `${entity.type}-${entity.id}`;
|
|
// 404 means entity was deleted - consider it as synced
|
|
if (error.response && error.response.status === 404) {
|
|
console.log(`🗑️ ENTITY DELETED: ${entity.type}/${entity.id} - marking as synced (successfully removed)`);
|
|
newSyncStates[key] = { status: 'synced', synced_agents: 1, total_agents: 1 };
|
|
} else {
|
|
console.error(`Failed to fetch sync state for ${entity.type}/${entity.id}:`, error);
|
|
newSyncStates[key] = { status: 'unknown' };
|
|
}
|
|
}
|
|
})
|
|
);
|
|
|
|
setEntitySyncStates(newSyncStates);
|
|
} catch (error) {
|
|
console.error('Error fetching entity sync states:', error);
|
|
}
|
|
};
|
|
|
|
// CRITICAL: Real agent sync verification - checks actual deployed state on agents
|
|
const verifyRealAgentSync = async (pendingEntities) => {
|
|
if (!selectedCluster || pendingEntities.length === 0) return false;
|
|
|
|
try {
|
|
const token = localStorage.getItem('token');
|
|
|
|
// Check each entity's real deployment status on agents
|
|
const syncResults = await Promise.all(
|
|
pendingEntities.map(async (entity) => {
|
|
try {
|
|
let endpoint;
|
|
if (entity.type === 'ssl_certificates') {
|
|
endpoint = `/api/clusters/${selectedCluster.id}/ssl_certificates/${entity.id}/agent-sync`;
|
|
} else {
|
|
endpoint = `/api/clusters/${selectedCluster.id}/entity-sync/${entity.type}/${entity.id}`;
|
|
}
|
|
|
|
const response = await axios.get(endpoint, {
|
|
headers: { Authorization: `Bearer ${token}` }
|
|
});
|
|
|
|
const syncStatus = response.data.sync_status;
|
|
const isFullySynced = syncStatus?.synced_agents === syncStatus?.total_agents &&
|
|
syncStatus?.synced_agents > 0;
|
|
|
|
console.log(`🔍 REAL SYNC CHECK: ${entity.type}/${entity.id} - ${syncStatus?.synced_agents}/${syncStatus?.total_agents} - ${isFullySynced ? 'SYNCED' : 'PENDING'}`);
|
|
|
|
return isFullySynced;
|
|
} catch (error) {
|
|
// 404 means entity was deleted - consider it as synced (successfully removed)
|
|
if (error.response && error.response.status === 404) {
|
|
console.log(`🗑️ ENTITY DELETED: ${entity.type}/${entity.id} - considering as synced (successfully removed)`);
|
|
return true;
|
|
}
|
|
console.error(`Failed to verify sync for ${entity.type}/${entity.id}:`, error);
|
|
return false; // If we can't verify, assume not synced
|
|
}
|
|
})
|
|
);
|
|
|
|
const allSynced = syncResults.every(result => result === true);
|
|
console.log(`🔍 REAL SYNC VERIFICATION: ${syncResults.filter(r => r).length}/${syncResults.length} entities synced - Overall: ${allSynced ? 'ALL SYNCED' : 'PENDING'}`);
|
|
|
|
return allSynced;
|
|
} catch (error) {
|
|
console.error('Error verifying real agent sync:', error);
|
|
return false;
|
|
}
|
|
};
|
|
|
|
const handleApplyAllChanges = async () => {
|
|
if (!selectedCluster) return;
|
|
|
|
if (effectiveTotal === 0) {
|
|
message.info('No pending changes to apply');
|
|
return;
|
|
}
|
|
|
|
Modal.confirm({
|
|
title: '🚀 Apply All Configuration Changes',
|
|
content: (
|
|
<div>
|
|
<p>You are about to apply <strong>{effectiveTotal}</strong> pending changes:</p>
|
|
<ul style={{ marginTop: 10, marginBottom: 10 }}>
|
|
{pendingChanges.frontends.length > 0 && (
|
|
<li>✅ <strong>{pendingChanges.frontends.length}</strong> Frontend changes</li>
|
|
)}
|
|
{pendingChanges.backends.length > 0 && (
|
|
<li>✅ <strong>{pendingChanges.backends.length}</strong> Backend changes</li>
|
|
)}
|
|
{pendingChanges.waf_rules.length > 0 && (
|
|
<li>✅ <strong>{pendingChanges.waf_rules.length}</strong> WAF rule changes</li>
|
|
)}
|
|
{pendingChanges.ssl_certificates.length > 0 && (
|
|
<li>✅ <strong>{pendingChanges.ssl_certificates.length}</strong> SSL certificate changes</li>
|
|
)}
|
|
</ul>
|
|
<Alert
|
|
message="All changes will be applied together and sent to agents"
|
|
type="info"
|
|
showIcon
|
|
style={{ marginTop: 16 }}
|
|
/>
|
|
</div>
|
|
),
|
|
okText: 'Apply All Changes',
|
|
okType: 'primary',
|
|
cancelText: 'Cancel',
|
|
width: 600,
|
|
onOk: executeApplyAll
|
|
});
|
|
};
|
|
|
|
const executeApplyAll = async () => {
|
|
setApplyLoading(true);
|
|
// CRITICAL: Mark this browser session as the one who initiated apply
|
|
// This prevents notification confusion when multiple users share same admin account
|
|
const applySessionId = `apply_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
|
sessionStorage.setItem(`apply_session_${selectedCluster.id}`, applySessionId);
|
|
sessionStorage.setItem(`apply_timestamp_${selectedCluster.id}`, Date.now().toString());
|
|
|
|
// CRITICAL: Save entities BEFORE apply (they'll be empty after apply)
|
|
const appliedEntities = [
|
|
...pendingChanges.frontends.map(item => ({ type: 'frontends', id: item.id })),
|
|
...pendingChanges.backends.map(item => ({ type: 'backends', id: item.id })),
|
|
...pendingChanges.waf_rules.map(item => ({ type: 'waf_rules', id: item.id })),
|
|
...pendingChanges.ssl_certificates.map(item => ({ type: 'ssl_certificates', id: item.id }))
|
|
];
|
|
|
|
// Calculate entity and agent counts for progress display
|
|
const totalEntities = appliedEntities.length;
|
|
const totalAgents = agentSync?.total_agents || selectedCluster?.total_agents || 1;
|
|
|
|
// Detect if this is a restore operation (safe display-only check)
|
|
const pendingVersions = configVersions.filter(v => v.status === 'PENDING');
|
|
const isRestoreOperation = totalEntities === 0 && pendingVersions.some(v => v.version_name.startsWith('restore-'));
|
|
|
|
if (isRestoreOperation) {
|
|
// Restore operation: Show "Configuration" instead of "Entities"
|
|
setSyncProgress({ visible: true, step: `Applying configuration restore... Configuration: 0/1, Agents: ⏳`, progress: 20 });
|
|
startProgress('apply', `Applying configuration restore... Configuration: 0/1, Agents: ⏳`);
|
|
updateEntityCounts(0, 1, 0, totalAgents); // Show 1 configuration item
|
|
} else {
|
|
// Normal operation: Show "Entities" as usual
|
|
setSyncProgress({ visible: true, step: `Applying configuration changes... Entities: 0/${totalEntities}, Agents: ⏳`, progress: 20 });
|
|
startProgress('apply', `Applying configuration changes... Entities: 0/${totalEntities}, Agents: ⏳`);
|
|
updateEntityCounts(0, totalEntities, 0, totalAgents);
|
|
}
|
|
|
|
try {
|
|
const token = localStorage.getItem('token');
|
|
|
|
const response = await axios.post(
|
|
`/api/clusters/${selectedCluster.id}/apply-changes`,
|
|
{},
|
|
{ headers: { Authorization: `Bearer ${token}` } }
|
|
);
|
|
|
|
setSyncProgress({ visible: true, step: `Configuration applied, syncing agents... Entities: ${totalEntities}/${totalEntities}, Agents: 0/${totalAgents}`, progress: 60 });
|
|
updateProgress(`Configuration applied, syncing agents... Entities: ${totalEntities}/${totalEntities}, Agents: 0/${totalAgents}`, 60);
|
|
updateEntityCounts(totalEntities, totalEntities, 0, totalAgents);
|
|
|
|
// Refresh data immediately
|
|
await fetchPendingChanges();
|
|
await fetchConfigVersions();
|
|
await fetchAgentSync();
|
|
|
|
setSyncProgress({ visible: true, step: `Waiting for agent synchronization... Entities: ${totalEntities}/${totalEntities}, Agents: ⏳/${totalAgents}`, progress: 80 });
|
|
updateProgress(`Waiting for agent synchronization... Entities: ${totalEntities}/${totalEntities}, Agents: ⏳/${totalAgents}`, 80);
|
|
|
|
// Enhanced refresh with progress tracking
|
|
let syncAttempts = 0;
|
|
const maxAttempts = 6; // 6 attempts over 18 seconds
|
|
|
|
const checkAgentSync = async () => {
|
|
syncAttempts++;
|
|
const freshAgentSync = await fetchAgentSync(); // Get fresh data immediately
|
|
await fetchEntitySyncStates(); // Also update individual entity states
|
|
|
|
// Use the saved appliedEntities (not pendingChanges which is now empty)
|
|
|
|
// CRITICAL: Real agent sync verification - check actual deployed state
|
|
const allSynced = await verifyRealAgentSync(appliedEntities);
|
|
|
|
// Additional check: Verify cluster-wide agent sync is also complete
|
|
// Use fresh data instead of stale state
|
|
const clusterSyncComplete = freshAgentSync?.synced_agents === freshAgentSync?.total_agents &&
|
|
freshAgentSync?.synced_agents > 0;
|
|
|
|
|
|
// Check for restore operation completion
|
|
const isRestoreOperation = appliedEntities.length === 0;
|
|
const shouldCompleteRestore = isRestoreOperation && clusterSyncComplete;
|
|
const shouldCompleteNormal = allSynced && clusterSyncComplete && appliedEntities.length > 0;
|
|
|
|
if (shouldCompleteRestore || shouldCompleteNormal) {
|
|
// All entities AND cluster are fully synced - complete immediately
|
|
const successMessage = isRestoreOperation ?
|
|
'Configuration restore completed successfully!' :
|
|
'All changes deployed and synchronized!';
|
|
|
|
setSyncProgress({ visible: true, step: successMessage, progress: 100 });
|
|
completeProgress(successMessage);
|
|
setTimeout(() => {
|
|
setSyncProgress({ visible: false, step: '', progress: 0 });
|
|
message.success(isRestoreOperation ?
|
|
'Configuration restored and synchronized successfully!' :
|
|
'All changes applied and agents synchronized successfully!');
|
|
}, 1500);
|
|
} else if (syncAttempts < maxAttempts) {
|
|
// Continue checking
|
|
const syncedCount = appliedEntities.filter(entity => {
|
|
const syncKey = `${entity.type}-${entity.id}`;
|
|
const entitySync = entitySyncStates[syncKey];
|
|
return entitySync?.status === 'synced';
|
|
}).length;
|
|
|
|
const clusterStatus = clusterSyncComplete ? '✓' : '⏳';
|
|
|
|
// Check if this is a restore operation for display purposes
|
|
const isRestoreOperation = appliedEntities.length === 0;
|
|
|
|
let progressStep, syncedLabel, totalLabel;
|
|
if (isRestoreOperation) {
|
|
// For restore: Show configuration sync instead of entity sync
|
|
const configSynced = clusterSyncComplete ? 1 : 0;
|
|
progressStep = `Syncing... Configuration: ${configSynced}/1, Cluster: ${clusterStatus}`;
|
|
syncedLabel = `${configSynced}/1`;
|
|
totalLabel = 'Configuration';
|
|
} else {
|
|
// For normal apply: Show entity sync as usual
|
|
progressStep = `Syncing... Entities: ${syncedCount}/${appliedEntities.length}, Cluster: ${clusterStatus}`;
|
|
syncedLabel = `${syncedCount}/${appliedEntities.length}`;
|
|
totalLabel = 'Entities';
|
|
}
|
|
|
|
const progressValue = 80 + (syncAttempts * 3);
|
|
|
|
setSyncProgress({
|
|
visible: true,
|
|
step: progressStep,
|
|
progress: progressValue
|
|
});
|
|
updateProgress(progressStep, progressValue, {
|
|
[`Synced ${totalLabel}`]: syncedLabel,
|
|
'Cluster Status': clusterStatus === '✓' ? 'Synced' : 'Syncing'
|
|
});
|
|
|
|
if (isRestoreOperation) {
|
|
updateEntityCounts(clusterSyncComplete ? 1 : 0, 1, freshAgentSync?.synced_agents || 0, freshAgentSync?.total_agents || totalAgents);
|
|
} else {
|
|
updateEntityCounts(syncedCount, appliedEntities.length, freshAgentSync?.synced_agents || 0, freshAgentSync?.total_agents || totalAgents);
|
|
}
|
|
setTimeout(checkAgentSync, 3000);
|
|
} else {
|
|
// Max attempts reached, but don't complete progress - keep monitoring
|
|
// Use real-time sync verification instead of cached state
|
|
const isAllSynced = await verifyRealAgentSync(appliedEntities);
|
|
const syncedCount = isAllSynced ? appliedEntities.length : 0;
|
|
|
|
const clusterStatus = clusterSyncComplete ? '✓' : '⏳';
|
|
const progressStep = `Still syncing... Entities: ${syncedCount}/${appliedEntities.length}, Cluster: ${clusterStatus}`;
|
|
|
|
setSyncProgress({
|
|
visible: true,
|
|
step: progressStep,
|
|
progress: 95 // Keep at 95% to show it's not complete
|
|
});
|
|
updateProgress(progressStep, 95, {
|
|
'Synced Entities': `${syncedCount}/${appliedEntities.length}`,
|
|
'Cluster Status': clusterStatus === '✓' ? 'Synced' : 'Syncing',
|
|
'Status': 'Monitoring continues...'
|
|
});
|
|
updateEntityCounts(syncedCount, appliedEntities.length, freshAgentSync?.synced_agents || 0, freshAgentSync?.total_agents || totalAgents);
|
|
|
|
// Check if we should complete the progress
|
|
// CRITICAL: Recalculate clusterSyncComplete with fresh agentSync data
|
|
const freshClusterSyncComplete = freshAgentSync?.synced_agents === freshAgentSync?.total_agents &&
|
|
freshAgentSync?.synced_agents > 0;
|
|
console.log('🔍 FRESH CLUSTER SYNC CHECK:', {
|
|
isAllSynced,
|
|
freshClusterSyncComplete,
|
|
agentSync: freshAgentSync ? { synced: freshAgentSync.synced_agents, total: freshAgentSync.total_agents } : 'null'
|
|
});
|
|
|
|
if (isAllSynced && freshClusterSyncComplete) {
|
|
console.log('🎯 APPLY COMPLETE: All entities and cluster synced, completing progress');
|
|
completeProgress('All changes successfully applied and synced!');
|
|
setApplyLoading(false);
|
|
|
|
// Only show success notification if THIS session initiated the apply
|
|
const applySession = sessionStorage.getItem(`apply_session_${selectedCluster.id}`);
|
|
const applyTimestamp = parseInt(sessionStorage.getItem(`apply_timestamp_${selectedCluster.id}`) || '0');
|
|
const isRecentApply = (Date.now() - applyTimestamp) < 300000; // 5 minutes
|
|
|
|
if (applySession && isRecentApply) {
|
|
message.success('All changes successfully applied and synced to agents!');
|
|
// Clear session markers after showing notification
|
|
sessionStorage.removeItem(`apply_session_${selectedCluster.id}`);
|
|
sessionStorage.removeItem(`apply_timestamp_${selectedCluster.id}`);
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
// Continue monitoring every 5 seconds until fully synced
|
|
setTimeout(checkAgentSync, 5000);
|
|
// Remove repetitive notification - progress bar shows status
|
|
}
|
|
};
|
|
|
|
setTimeout(checkAgentSync, 2000);
|
|
|
|
// Show sync results if available
|
|
if (response.data.sync_results && response.data.sync_results.length > 0) {
|
|
const agentResults = response.data.sync_results.filter(r => r.success);
|
|
if (agentResults.length > 0) {
|
|
message.info(`Configuration applied to ${agentResults.length} agent(s). HAProxy reloaded.`);
|
|
}
|
|
}
|
|
|
|
} catch (error) {
|
|
console.error('Apply all changes failed:', error);
|
|
setSyncProgress({ visible: false, step: '', progress: 0 });
|
|
completeProgress('Apply failed');
|
|
if (error.response?.status === 401) {
|
|
message.error('Authentication failed. Please login again.');
|
|
} else {
|
|
message.error(`Failed to apply changes: ${error.response?.data?.message || error.response?.data?.detail || error.message}`);
|
|
}
|
|
} finally {
|
|
setApplyLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleRejectAllChanges = async () => {
|
|
if (!selectedCluster) return;
|
|
|
|
if (effectiveTotal === 0) {
|
|
message.info('No pending changes to reject');
|
|
return;
|
|
}
|
|
|
|
Modal.confirm({
|
|
title: '🗑️ Reject All Configuration Changes',
|
|
content: (
|
|
<div>
|
|
<p>You are about to reject <strong>{effectiveTotal}</strong> pending changes:</p>
|
|
<ul style={{ marginTop: 10, marginBottom: 10 }}>
|
|
{pendingChanges.frontends.length > 0 && (
|
|
<li>❌ <strong>{pendingChanges.frontends.length}</strong> Frontend changes</li>
|
|
)}
|
|
{pendingChanges.backends.length > 0 && (
|
|
<li>❌ <strong>{pendingChanges.backends.length}</strong> Backend changes</li>
|
|
)}
|
|
{pendingChanges.waf_rules.length > 0 && (
|
|
<li>❌ <strong>{pendingChanges.waf_rules.length}</strong> WAF rule changes</li>
|
|
)}
|
|
{pendingChanges.ssl_certificates.length > 0 && (
|
|
<li>❌ <strong>{pendingChanges.ssl_certificates.length}</strong> SSL certificate changes</li>
|
|
)}
|
|
</ul>
|
|
<Alert
|
|
message="All pending changes will be permanently discarded"
|
|
type="warning"
|
|
showIcon
|
|
style={{ marginTop: 10 }}
|
|
/>
|
|
</div>
|
|
),
|
|
okText: 'Reject All Changes',
|
|
okType: 'danger',
|
|
cancelText: 'Cancel',
|
|
width: 600,
|
|
onOk: executeRejectAll
|
|
});
|
|
};
|
|
|
|
const executeRejectAll = async () => {
|
|
setApplyLoading(true);
|
|
try {
|
|
const token = localStorage.getItem('token');
|
|
|
|
const response = await axios.delete(
|
|
`/api/clusters/${selectedCluster.id}/pending-changes`,
|
|
{ headers: { Authorization: `Bearer ${token}` } }
|
|
);
|
|
|
|
message.success(response.data.message || 'All pending changes rejected successfully!');
|
|
|
|
// Refresh data after successful reject
|
|
await fetchPendingChanges();
|
|
await fetchConfigVersions();
|
|
await fetchAgentSync();
|
|
|
|
} catch (error) {
|
|
console.error('Error rejecting changes:', error);
|
|
message.error('Failed to reject changes: ' + (error.response?.data?.detail || error.message));
|
|
} finally {
|
|
setApplyLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleViewChange = async (version) => {
|
|
setSelectedVersion(version);
|
|
setDiffLoading(true);
|
|
setViewChangeModalVisible(true);
|
|
|
|
try {
|
|
const token = localStorage.getItem('token');
|
|
const response = await axios.get(
|
|
`/api/clusters/${selectedCluster.id}/config-versions/${version.id}/diff`,
|
|
{ headers: { Authorization: `Bearer ${token}` } }
|
|
);
|
|
|
|
setDiffData(response.data);
|
|
} catch (error) {
|
|
console.error('Error fetching config diff:', error);
|
|
message.error('Failed to fetch configuration changes');
|
|
setDiffData(null);
|
|
} finally {
|
|
setDiffLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleUndoRejected = async (version) => {
|
|
try {
|
|
setApplyLoading(true);
|
|
|
|
// Undo the rejection - mark this version as PENDING again
|
|
const response = await axios.patch(`/api/clusters/${selectedCluster.id}/config-versions/${version.id}/undo-reject`);
|
|
|
|
message.success(`Configuration "${version.version_name}" has been undone and is now PENDING. You can apply it from Configuration Changes.`);
|
|
|
|
// Refresh data
|
|
fetchPendingChanges();
|
|
fetchConfigVersions();
|
|
|
|
} catch (error) {
|
|
const errorMsg = error.response?.data?.detail || error.message;
|
|
message.error(`Failed to undo rejection: ${errorMsg}`);
|
|
} finally {
|
|
setApplyLoading(false);
|
|
}
|
|
};
|
|
|
|
const getStatusColor = (status) => {
|
|
return getConfigStatusColor(status);
|
|
};
|
|
|
|
const pendingVersions = configVersions.filter(v => v.status === 'PENDING');
|
|
const appliedVersions = configVersions.filter(v => v.status === 'APPLIED');
|
|
const rejectedVersions = configVersions.filter(v => v.status === 'REJECTED');
|
|
const effectiveTotal = pendingChanges.total_count > 0 ? pendingChanges.total_count : pendingVersions.length;
|
|
|
|
const renderPendingItem = (item, type, icon) => {
|
|
// For PENDING items, don't show sync status since they haven't been applied yet
|
|
// Just show the pending status consistently
|
|
|
|
return (
|
|
<div key={`${type}-${item.id}`} style={{
|
|
padding: 12,
|
|
border: '1px solid #d9d9d9',
|
|
borderRadius: 6,
|
|
marginBottom: 8,
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
backgroundColor: '#fff'
|
|
}}>
|
|
<div style={{ marginRight: 12, fontSize: 18 }}>
|
|
{icon}
|
|
</div>
|
|
<div style={{ flex: 1 }}>
|
|
<div style={{ fontWeight: 'bold' }}>{item.name}</div>
|
|
<div style={{ color: '#666', fontSize: 12 }}>
|
|
{type.charAt(0).toUpperCase() + type.slice(1)} • Updated: {new Date(item.updated_at).toLocaleString()}
|
|
</div>
|
|
</div>
|
|
<Space>
|
|
<Tag color="orange">PENDING</Tag>
|
|
</Space>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
if (!selectedCluster) {
|
|
return (
|
|
<Card>
|
|
<Empty
|
|
description="Please select a cluster to view pending changes"
|
|
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
|
/>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div style={{ padding: 24 }}>
|
|
<div style={{ marginBottom: 24, display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
|
<div>
|
|
<Title level={3}>
|
|
<PlayCircleOutlined style={{ marginRight: 8 }} />
|
|
Apply Management
|
|
</Title>
|
|
<Paragraph>
|
|
Review and apply all pending configuration changes for cluster <strong>{selectedCluster.name}</strong>
|
|
</Paragraph>
|
|
</div>
|
|
<Button
|
|
type="default"
|
|
icon={<HistoryOutlined />}
|
|
onClick={() => navigate('/version-history')}
|
|
size="large"
|
|
>
|
|
Version History
|
|
</Button>
|
|
</div>
|
|
|
|
{/* Remove main tabs, only show Pending Changes */}
|
|
<Row gutter={16}>
|
|
{/* Pending Changes Card */}
|
|
<Col span={16}>
|
|
<Card
|
|
title={
|
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
|
<span>Configuration Changes</span>
|
|
<Space>
|
|
<Button
|
|
type="primary"
|
|
icon={<PlayCircleOutlined />}
|
|
loading={applyLoading}
|
|
disabled={effectiveTotal === 0}
|
|
onClick={handleApplyAllChanges}
|
|
style={{
|
|
background: effectiveTotal === 0 ? undefined : 'linear-gradient(135deg, #52c41a 0%, #73d13d 100%)',
|
|
borderColor: effectiveTotal === 0 ? undefined : '#52c41a',
|
|
boxShadow: effectiveTotal === 0 ? 'none' : '0 2px 8px rgba(82, 196, 26, 0.3)',
|
|
fontWeight: 500,
|
|
height: 36,
|
|
transition: 'all 0.3s ease'
|
|
}}
|
|
onMouseEnter={(e) => {
|
|
if (effectiveTotal > 0 && !applyLoading) {
|
|
e.currentTarget.style.boxShadow = '0 4px 12px rgba(82, 196, 26, 0.5)';
|
|
e.currentTarget.style.transform = 'translateY(-1px)';
|
|
}
|
|
}}
|
|
onMouseLeave={(e) => {
|
|
if (effectiveTotal > 0 && !applyLoading) {
|
|
e.currentTarget.style.boxShadow = '0 2px 8px rgba(82, 196, 26, 0.3)';
|
|
e.currentTarget.style.transform = 'translateY(0)';
|
|
}
|
|
}}
|
|
>
|
|
Apply All
|
|
</Button>
|
|
<Button
|
|
danger
|
|
icon={<CloseOutlined />}
|
|
disabled={effectiveTotal === 0}
|
|
onClick={handleRejectAllChanges}
|
|
style={{
|
|
background: effectiveTotal === 0 ? undefined : 'linear-gradient(135deg, #ff4d4f 0%, #ff7875 100%)',
|
|
borderColor: effectiveTotal === 0 ? undefined : '#ff4d4f',
|
|
color: effectiveTotal === 0 ? undefined : '#fff',
|
|
boxShadow: effectiveTotal === 0 ? 'none' : '0 2px 8px rgba(255, 77, 79, 0.3)',
|
|
fontWeight: 500,
|
|
height: 36,
|
|
transition: 'all 0.3s ease'
|
|
}}
|
|
onMouseEnter={(e) => {
|
|
if (effectiveTotal > 0) {
|
|
e.currentTarget.style.boxShadow = '0 4px 12px rgba(255, 77, 79, 0.5)';
|
|
e.currentTarget.style.transform = 'translateY(-1px)';
|
|
}
|
|
}}
|
|
onMouseLeave={(e) => {
|
|
if (effectiveTotal > 0) {
|
|
e.currentTarget.style.boxShadow = '0 2px 8px rgba(255, 77, 79, 0.3)';
|
|
e.currentTarget.style.transform = 'translateY(0)';
|
|
}
|
|
}}
|
|
>
|
|
Reject All
|
|
</Button>
|
|
</Space>
|
|
|
|
{/* Progress Indicator */}
|
|
{syncProgress.visible && (
|
|
<div style={{ marginTop: 16, maxWidth: 400 }}>
|
|
<Progress
|
|
percent={syncProgress.progress}
|
|
status={syncProgress.progress === 100 ? "success" : "active"}
|
|
strokeColor={{
|
|
'0%': '#108ee9',
|
|
'100%': '#87d068',
|
|
}}
|
|
/>
|
|
<div style={{ marginTop: 8, color: '#666', fontSize: '12px' }}>
|
|
{syncProgress.step}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
}
|
|
extra={
|
|
<Button
|
|
icon={<ReloadOutlined />}
|
|
onClick={fetchPendingChanges}
|
|
loading={loading}
|
|
>
|
|
Refresh
|
|
</Button>
|
|
}
|
|
>
|
|
<Spin spinning={loading}>
|
|
{effectiveTotal === 0 ? (
|
|
<Empty
|
|
description="No pending changes found"
|
|
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
|
style={{ margin: '40px 0' }}
|
|
/>
|
|
) : (
|
|
<div>
|
|
<Alert
|
|
message={`${effectiveTotal} configuration changes are ready to be applied`}
|
|
type="info"
|
|
showIcon
|
|
style={{ marginBottom: 16 }}
|
|
/>
|
|
|
|
{/* Frontend Changes */}
|
|
{pendingChanges.frontends.length > 0 && (
|
|
<div style={{ marginBottom: 16 }}>
|
|
<Title level={5}>
|
|
<GlobalOutlined style={{ marginRight: 8 }} />
|
|
Frontend Changes ({pendingChanges.frontends.length})
|
|
</Title>
|
|
{pendingChanges.frontends.map(item =>
|
|
renderPendingItem(item, 'frontend', <GlobalOutlined style={{ color: '#1890ff' }} />)
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Backend Changes */}
|
|
{pendingChanges.backends.length > 0 && (
|
|
<div style={{ marginBottom: 16 }}>
|
|
<Title level={5}>
|
|
<CloudServerOutlined style={{ marginRight: 8 }} />
|
|
Backend Changes ({pendingChanges.backends.length})
|
|
</Title>
|
|
{pendingChanges.backends.map(item =>
|
|
renderPendingItem(item, 'backend', <CloudServerOutlined style={{ color: '#52c41a' }} />)
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* WAF Changes */}
|
|
{pendingChanges.waf_rules.length > 0 && (
|
|
<div style={{ marginBottom: 16 }}>
|
|
<Title level={5}>
|
|
<SecurityScanOutlined style={{ marginRight: 8 }} />
|
|
WAF Rule Changes ({pendingChanges.waf_rules.length})
|
|
</Title>
|
|
{pendingChanges.waf_rules.map(item =>
|
|
renderPendingItem(item, 'waf_rule', <SecurityScanOutlined style={{ color: '#fa8c16' }} />)
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* SSL Changes */}
|
|
{pendingChanges.ssl_certificates.length > 0 && (
|
|
<div style={{ marginBottom: 16 }}>
|
|
<Title level={5}>
|
|
<SafetyCertificateOutlined style={{ marginRight: 8 }} />
|
|
SSL Certificate Changes ({pendingChanges.ssl_certificates.length})
|
|
</Title>
|
|
{pendingChanges.ssl_certificates.map(item =>
|
|
renderPendingItem(item, 'ssl_certificate', <SafetyCertificateOutlined style={{ color: '#eb2f96' }} />)
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{pendingChanges.frontends.length === 0 && pendingChanges.backends.length === 0 && pendingChanges.waf_rules.length === 0 && pendingChanges.ssl_certificates.length === 0 && pendingVersions.length > 0 && (
|
|
<div style={{ marginTop: 8 }}>
|
|
{(() => {
|
|
const restoreVersions = pendingVersions.filter(v => v.version_name.startsWith('restore-'));
|
|
const bulkImportVersions = pendingVersions.filter(v => v.version_name.startsWith('bulk-import-'));
|
|
const otherVersions = pendingVersions.filter(v => !v.version_name.startsWith('restore-') && !v.version_name.startsWith('bulk-import-'));
|
|
|
|
return (
|
|
<>
|
|
{restoreVersions.length > 0 && (
|
|
<div style={{ marginBottom: 16 }}>
|
|
<Title level={5}>
|
|
<RedoOutlined style={{ marginRight: 8, color: '#1890ff' }} />
|
|
Configuration Restore ({restoreVersions.length})
|
|
</Title>
|
|
{restoreVersions.map(v => (
|
|
<div key={v.id} style={{
|
|
padding: 10,
|
|
border: '1px dashed #1890ff',
|
|
borderRadius: 6,
|
|
marginBottom: 8,
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'space-between',
|
|
backgroundColor: '#f0f8ff'
|
|
}}>
|
|
<span style={{ fontFamily: 'monospace' }}>{v.version_name}</span>
|
|
<Tag color="orange">PENDING</Tag>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{bulkImportVersions.length > 0 && (
|
|
<div style={{ marginBottom: 16 }}>
|
|
<Title level={5}>
|
|
<CloudUploadOutlined style={{ marginRight: 8, color: '#52c41a' }} />
|
|
Bulk Config Import ({bulkImportVersions.length})
|
|
</Title>
|
|
{bulkImportVersions.map(v => (
|
|
<div key={v.id} style={{
|
|
padding: 10,
|
|
border: '1px dashed #52c41a',
|
|
borderRadius: 6,
|
|
marginBottom: 8,
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'space-between',
|
|
backgroundColor: '#f6ffed'
|
|
}}>
|
|
<span style={{ fontFamily: 'monospace' }}>{v.version_name}</span>
|
|
<Tag color="orange">PENDING</Tag>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{otherVersions.length > 0 && (
|
|
<div>
|
|
<Title level={5}>
|
|
<HistoryOutlined style={{ marginRight: 8 }} />
|
|
Other Pending Versions ({otherVersions.length})
|
|
</Title>
|
|
{otherVersions.map(v => (
|
|
<div key={v.id} style={{
|
|
padding: 10,
|
|
border: '1px dashed #d9d9d9',
|
|
borderRadius: 6,
|
|
marginBottom: 8,
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'space-between'
|
|
}}>
|
|
<span style={{ fontFamily: 'monospace' }}>{v.version_name}</span>
|
|
<Tag color="orange">PENDING</Tag>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</>
|
|
);
|
|
})()}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</Spin>
|
|
</Card>
|
|
</Col>
|
|
|
|
{/* Status & History Card */}
|
|
<Col span={8}>
|
|
<Card
|
|
title={
|
|
<span>
|
|
<InfoCircleOutlined style={{ marginRight: 8 }} />
|
|
Apply Status
|
|
</span>
|
|
}
|
|
>
|
|
<div style={{ marginBottom: 16 }}>
|
|
<Descriptions column={1} size="small">
|
|
<Descriptions.Item label="Selected Cluster">
|
|
<strong>{selectedCluster.name}</strong>
|
|
</Descriptions.Item>
|
|
<Descriptions.Item label="Pending Versions">
|
|
<Tag color="orange">{pendingVersions.length}</Tag>
|
|
</Descriptions.Item>
|
|
<Descriptions.Item label="Last Applied">
|
|
{appliedVersions.length > 0 ? (
|
|
<div>
|
|
<div>{appliedVersions[0].version_name}</div>
|
|
<div style={{ fontSize: 12, color: '#666' }}>
|
|
{new Date(appliedVersions[0].created_at).toLocaleString()}
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<Text type="secondary">No applied versions</Text>
|
|
)}
|
|
</Descriptions.Item>
|
|
</Descriptions>
|
|
</div>
|
|
|
|
{/* Pending Versions Section */}
|
|
{pendingVersions.length > 0 && (
|
|
<div style={{ marginBottom: 24 }}>
|
|
<Title level={5}>
|
|
<ClockCircleOutlined style={{ marginRight: 8, color: '#faad14' }} />
|
|
Pending Changes ({pendingVersions.length})
|
|
</Title>
|
|
<Timeline size="small">
|
|
{pendingVersions.map((version, index) => (
|
|
<Timeline.Item
|
|
key={version.id}
|
|
color="orange"
|
|
dot={<ClockCircleOutlined style={{ color: '#faad14' }} />}
|
|
>
|
|
<div style={{ fontSize: 12 }}>
|
|
<div style={{ fontWeight: 'bold', marginBottom: 4 }}>{version.version_name}</div>
|
|
<div style={{ color: '#666', marginBottom: 8 }}>
|
|
{new Date(version.created_at).toLocaleString()}
|
|
</div>
|
|
<div style={{ marginBottom: 4 }}>
|
|
<Tag color="orange" size="small">PENDING</Tag>
|
|
</div>
|
|
<Button
|
|
type="link"
|
|
size="small"
|
|
icon={<EyeOutlined />}
|
|
onClick={() => handleViewChange(version)}
|
|
style={{ padding: 0, height: 'auto', fontSize: 11 }}
|
|
>
|
|
View Change
|
|
</Button>
|
|
</div>
|
|
</Timeline.Item>
|
|
))}
|
|
</Timeline>
|
|
</div>
|
|
)}
|
|
|
|
<div>
|
|
<Tabs defaultActiveKey="applied" size="small">
|
|
<TabPane
|
|
tab={
|
|
<span>
|
|
<CheckCircleOutlined />
|
|
Applied
|
|
<Tag color="green" style={{ marginLeft: 8 }}>{appliedVersions.length}</Tag>
|
|
</span>
|
|
}
|
|
key="applied"
|
|
>
|
|
{appliedVersions.length === 0 ? (
|
|
<Empty
|
|
description="No applied versions yet"
|
|
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
|
style={{ margin: '20px 0' }}
|
|
/>
|
|
) : (
|
|
<Timeline size="small">
|
|
{appliedVersions.map((version, index) => (
|
|
<Timeline.Item
|
|
key={version.id}
|
|
color={index === 0 ? 'green' : 'gray'}
|
|
dot={index === 0 ? <CheckCircleOutlined style={{ color: '#52c41a' }} /> : undefined}
|
|
>
|
|
<div style={{ fontSize: 12 }}>
|
|
<div style={{ fontWeight: 'bold', marginBottom: 4 }}>{version.version_name}</div>
|
|
<div style={{ color: '#666', marginBottom: 8 }}>
|
|
{new Date(version.created_at).toLocaleString()}
|
|
</div>
|
|
<Button
|
|
type="link"
|
|
size="small"
|
|
icon={<EyeOutlined />}
|
|
onClick={() => handleViewChange(version)}
|
|
style={{ padding: 0, height: 'auto', fontSize: 11 }}
|
|
>
|
|
View Change
|
|
</Button>
|
|
</div>
|
|
</Timeline.Item>
|
|
))}
|
|
</Timeline>
|
|
)}
|
|
</TabPane>
|
|
|
|
<TabPane
|
|
tab={
|
|
<span>
|
|
<CloseCircleOutlined />
|
|
Rejected
|
|
<Tag color="red" style={{ marginLeft: 8 }}>{rejectedVersions.length}</Tag>
|
|
</span>
|
|
}
|
|
key="rejected"
|
|
>
|
|
{rejectedVersions.length === 0 ? (
|
|
<Empty
|
|
description="No rejected versions"
|
|
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
|
style={{ margin: '20px 0' }}
|
|
/>
|
|
) : (
|
|
<Timeline size="small">
|
|
{rejectedVersions.map((version, index) => (
|
|
<Timeline.Item
|
|
key={version.id}
|
|
color="red"
|
|
dot={<CloseCircleOutlined style={{ color: '#ff4d4f' }} />}
|
|
>
|
|
<div style={{ fontSize: 12 }}>
|
|
<div style={{ fontWeight: 'bold', marginBottom: 4 }}>{version.version_name}</div>
|
|
<div style={{ color: '#666', marginBottom: 8 }}>
|
|
Rejected: {new Date(version.updated_at || version.created_at).toLocaleString()}
|
|
</div>
|
|
<Space size="small">
|
|
<Button
|
|
type="link"
|
|
size="small"
|
|
icon={<EyeOutlined />}
|
|
onClick={() => handleViewChange(version)}
|
|
style={{ padding: 0, height: 'auto', fontSize: 11 }}
|
|
>
|
|
View Changes
|
|
</Button>
|
|
<Button
|
|
type="link"
|
|
size="small"
|
|
icon={<UndoOutlined />}
|
|
onClick={() => handleUndoRejected(version)}
|
|
style={{ padding: 0, height: 'auto', fontSize: 11, color: '#1890ff' }}
|
|
>
|
|
Undo
|
|
</Button>
|
|
</Space>
|
|
</div>
|
|
</Timeline.Item>
|
|
))}
|
|
</Timeline>
|
|
)}
|
|
</TabPane>
|
|
</Tabs>
|
|
</div>
|
|
</Card>
|
|
|
|
<Card
|
|
style={{ marginTop: 16 }}
|
|
title={
|
|
<span>
|
|
<InfoCircleOutlined style={{ marginRight: 8 }} />
|
|
Agent Sync Status
|
|
</span>
|
|
}
|
|
extra={
|
|
<Button icon={<ReloadOutlined />} onClick={fetchAgentSync} loading={agentSyncLoading}>
|
|
Refresh
|
|
</Button>
|
|
}
|
|
>
|
|
<Spin spinning={agentSyncLoading}>
|
|
{!agentSync ? (
|
|
<Empty description="No data" image={Empty.PRESENTED_IMAGE_SIMPLE} />
|
|
) : (
|
|
<div>
|
|
<Descriptions column={1} size="small" style={{ marginBottom: 12 }}>
|
|
<Descriptions.Item label="Latest Applied Version">
|
|
<Text code>{agentSync.latest_version || '-'}</Text>
|
|
</Descriptions.Item>
|
|
<Descriptions.Item label="Sync Summary">
|
|
<Space>
|
|
<Tag color="green">Synced: {agentSync.synced_agents || 0}</Tag>
|
|
<Tag color="red">Unsynced: {agentSync.unsynced_agents || 0}</Tag>
|
|
<Tag>Total: {agentSync.total_agents || 0}</Tag>
|
|
</Space>
|
|
</Descriptions.Item>
|
|
{agentSync.validation_error && (
|
|
<Descriptions.Item label="HAProxy Validation Error">
|
|
<Alert
|
|
message="Configuration Validation Failed"
|
|
description={
|
|
<div>
|
|
<div style={{ marginBottom: 8 }}>
|
|
<strong>Error Details:</strong>
|
|
</div>
|
|
<pre style={{
|
|
backgroundColor: '#fff2f0',
|
|
padding: '12px',
|
|
borderRadius: '4px',
|
|
border: '1px solid #ffccc7',
|
|
fontSize: '12px',
|
|
fontFamily: 'monospace',
|
|
maxHeight: '200px',
|
|
overflowY: 'auto',
|
|
whiteSpace: 'pre-wrap',
|
|
wordBreak: 'break-word'
|
|
}}>
|
|
{agentSync.validation_error}
|
|
</pre>
|
|
{agentSync.validation_error_reported_at && (
|
|
<div style={{ marginTop: 8, fontSize: '12px', color: '#666' }}>
|
|
Reported at: {new Date(agentSync.validation_error_reported_at).toLocaleString()}
|
|
</div>
|
|
)}
|
|
</div>
|
|
}
|
|
type="error"
|
|
showIcon
|
|
style={{ marginTop: 8 }}
|
|
/>
|
|
</Descriptions.Item>
|
|
)}
|
|
</Descriptions>
|
|
|
|
<Table
|
|
size="small"
|
|
pagination={{ pageSize: 5 }}
|
|
rowKey={(r) => r.id}
|
|
dataSource={agentSync.agents || []}
|
|
columns={[
|
|
{ title: 'Agent', dataIndex: 'name', key: 'name' },
|
|
{
|
|
title: 'Agent Status',
|
|
dataIndex: 'status',
|
|
key: 'status',
|
|
render: (s) => (
|
|
<Tag color={s === 'online' ? 'green' : 'default'}>{s || '-'}</Tag>
|
|
)
|
|
},
|
|
{
|
|
title: 'HAProxy',
|
|
dataIndex: 'haproxy_status',
|
|
key: 'haproxy_status',
|
|
render: (s) => (
|
|
<Tag color={s === 'running' ? 'green' : 'default'}>{s || '-'}</Tag>
|
|
)
|
|
},
|
|
{ title: 'Delivered Version', dataIndex: 'delivered_version', key: 'delivered_version', render: (v) => <Text code>{v || '-'}</Text> },
|
|
{
|
|
title: 'In Sync',
|
|
dataIndex: 'in_sync',
|
|
key: 'in_sync',
|
|
render: (val) => (
|
|
<Tag color={val ? 'green' : 'red'}>{val ? 'Yes' : 'No'}</Tag>
|
|
)
|
|
},
|
|
{ title: 'Last Seen', dataIndex: 'last_seen', key: 'last_seen', render: (v) => v ? new Date(v).toLocaleString() : '-' },
|
|
]}
|
|
/>
|
|
</div>
|
|
)}
|
|
</Spin>
|
|
</Card>
|
|
</Col>
|
|
</Row>
|
|
|
|
{/* View Change Modal */}
|
|
<Modal
|
|
title={
|
|
<div>
|
|
<EyeOutlined style={{ marginRight: 8, color: '#1890ff' }} />
|
|
Configuration Changes - {selectedVersion?.version_name}
|
|
</div>
|
|
}
|
|
open={viewChangeModalVisible}
|
|
onCancel={() => setViewChangeModalVisible(false)}
|
|
footer={[
|
|
<Button key="close" type="primary" onClick={() => setViewChangeModalVisible(false)}>
|
|
Close
|
|
</Button>,
|
|
]}
|
|
width={1200}
|
|
bodyStyle={{ padding: '16px' }}
|
|
>
|
|
{selectedVersion && (
|
|
<div>
|
|
<Alert
|
|
message="Configuration Change Summary"
|
|
description={
|
|
<div>
|
|
<div><strong>Version:</strong> {selectedVersion.version_name}</div>
|
|
<div><strong>Type:</strong> {selectedVersion.type}</div>
|
|
<div><strong>Created:</strong> {new Date(selectedVersion.created_at).toLocaleString()}</div>
|
|
<div><strong>Status:</strong> <Tag color={getStatusColor(selectedVersion.status)}>{selectedVersion.status}</Tag></div>
|
|
</div>
|
|
}
|
|
type="info"
|
|
showIcon
|
|
style={{ marginBottom: 16 }}
|
|
/>
|
|
|
|
{diffLoading ? (
|
|
<div style={{ textAlign: 'center', padding: '40px 0' }}>
|
|
<Spin size="large" />
|
|
<div style={{ marginTop: 16 }}>Loading configuration changes...</div>
|
|
</div>
|
|
) : diffData ? (
|
|
<div>
|
|
<Title level={5}>Configuration Changes:</Title>
|
|
<div style={{ border: '1px solid #d9d9d9', borderRadius: 6, backgroundColor: '#fafafa' }}>
|
|
<div style={{ padding: '12px', borderBottom: '1px solid #d9d9d9', backgroundColor: '#f0f0f0' }}>
|
|
<Text strong>Changes from previous version:</Text>
|
|
</div>
|
|
<div style={{ padding: '16px', fontFamily: 'monospace', fontSize: '12px', lineHeight: '1.4', maxHeight: '400px', overflowY: 'auto' }}>
|
|
{diffData.changes && diffData.changes.length > 0 ? (
|
|
diffData.changes.map((change, index) => (
|
|
<div key={index} style={{ marginBottom: '4px' }}>
|
|
{change.type === 'added' && (
|
|
<div style={{ backgroundColor: '#f6ffed', color: '#52c41a', padding: '2px 8px', borderLeft: '3px solid #52c41a' }}>
|
|
+ {change.line}
|
|
</div>
|
|
)}
|
|
{change.type === 'removed' && (
|
|
<div style={{ backgroundColor: '#fff2f0', color: '#ff4d4f', padding: '2px 8px', borderLeft: '3px solid #ff4d4f' }}>
|
|
- {change.line}
|
|
</div>
|
|
)}
|
|
{change.type === 'context' && (
|
|
<div style={{ color: '#666', padding: '2px 8px' }}>
|
|
{change.line}
|
|
</div>
|
|
)}
|
|
</div>
|
|
))
|
|
) : (
|
|
<div style={{ textAlign: 'center', padding: '40px 0', color: '#666' }}>
|
|
No changes detected or this is the first version
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
{diffData.summary && (
|
|
<div style={{ marginTop: 16 }}>
|
|
<Title level={5}>Summary:</Title>
|
|
<div style={{ backgroundColor: '#f9f9f9', padding: '12px', borderRadius: '6px', border: '1px solid #d9d9d9' }}>
|
|
<Row gutter={16}>
|
|
<Col span={8}>
|
|
<div style={{ textAlign: 'center' }}>
|
|
<div style={{ fontSize: '20px', fontWeight: 'bold', color: '#52c41a' }}>
|
|
+{diffData.summary.added || 0}
|
|
</div>
|
|
<div style={{ fontSize: '12px', color: '#666' }}>Added Lines</div>
|
|
</div>
|
|
</Col>
|
|
<Col span={8}>
|
|
<div style={{ textAlign: 'center' }}>
|
|
<div style={{ fontSize: '20px', fontWeight: 'bold', color: '#ff4d4f' }}>
|
|
-{diffData.summary.removed || 0}
|
|
</div>
|
|
<div style={{ fontSize: '12px', color: '#666' }}>Removed Lines</div>
|
|
</div>
|
|
</Col>
|
|
<Col span={8}>
|
|
<div style={{ textAlign: 'center' }}>
|
|
<div style={{ fontSize: '20px', fontWeight: 'bold', color: '#1890ff' }}>
|
|
{diffData.summary.total_changes || 0}
|
|
</div>
|
|
<div style={{ fontSize: '12px', color: '#666' }}>Total Changes</div>
|
|
</div>
|
|
</Col>
|
|
</Row>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<div style={{ textAlign: 'center', padding: '40px 0', color: '#666' }}>
|
|
<ExclamationCircleOutlined style={{ fontSize: '24px', marginBottom: '8px' }} />
|
|
<div>Failed to load configuration changes</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</Modal>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default ApplyManagement; |