Files
haproxy-openmanager/frontend/src/components/ApplyManagement.js
T
taylanbakircioglu dbb9189f16 fix(ui): make Apply Management readable in dark mode (v1.10.2)
Three dark-mode defects reported on the Apply Management page, all the same
class of bug: light-mode colour literals hardcoded where theme tokens belong.

1. The "Pending Changes" box was painted background #fffbe6 with border
   #ffe58f. In dark mode the text on top is light, so the version name,
   timestamp and "View Change" link sat on a cream panel and were unreadable.
   Measured contrast was 1.03:1; it is now 11.50:1 (secondary text 1.03:1 ->
   7.02:1).

2. The added/removed rows in the View Change diff used #f6ffed/#52c41a and
   #fff2f0/#ff4d4f, which stayed near-white inside the otherwise dark diff
   panel. Now 5.49:1 (added) and 4.01:1 (removed), from 2.21:1 and 2.99:1.

3. The "Apply All Configuration Changes" confirm dialog came up white. This one
   is not a colour literal: in Ant Design 5 the STATIC Modal.confirm / message /
   notification APIs render into their own detached root and never see the app's
   ConfigProvider, so they always fall back to the light algorithm. Registering
   ConfigProvider.config({ holderRender }) once at the app root wraps that
   detached root in the same ConfigProvider. Verified against the installed antd
   5.29.3 source rather than assumed: config-provider/index.js sets
   globalHolderRender, and modal/confirm.js wraps the dialog with it. This fixes
   EVERY static dialog in the application — 12 components call Modal.confirm —
   not just this page.

While in the file, six more instances of the same bug were fixed: the error
Alert border, the VIP pending-delete row, two ACME/pending version panels, the
applied-version panel and the agent-error recommendation box.

Light mode is byte-identical. Each token resolves under the default algorithm
to exactly the literal it replaced (colorWarningBg -> #fffbe6, colorSuccessBg ->
#f6ffed, colorErrorBg -> #fff2f0, colorInfoBg, colorErrorBorder, ...), so this
release can only change dark mode. Contrast was measured by resolving the real
design tokens under both algorithms and computing WCAG ratios, not by eye.

Note the diff rows were already low-contrast in LIGHT mode (2.21:1 and 2.99:1)
and remain so; that is the design system's own success/error pair and changing
it would alter the established light-mode appearance, so it is left alone.

holderRender is registered in an effect rather than during render, since
ConfigProvider.config() mutates antd module state; effects still run long
before a user can click anything that opens a static dialog.

Frontend only: no schema, no SCHEMA_VERSION bump, no API change, no environment
variable, zero agent impact. Backend suite unchanged at 1243 passed.
2026-08-08 02:39:23 +03:00

2045 lines
97 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, Collapse, Steps, Input, theme
} 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, WarningOutlined, CodeOutlined,
CopyOutlined, EditOutlined, RightOutlined
} from '@ant-design/icons';
import axios from 'axios';
import { useCluster } from '../contexts/ClusterContext';
import { useNavigate } from 'react-router-dom';
import ValidationErrorModal from './ValidationErrorModal';
import { extractApiError } from '../utils/apiError';
const { Title, Text, Paragraph } = Typography;
const { TabPane } = Tabs;
const ApplyManagement = () => {
const { token } = theme.useToken();
const { selectedCluster, loading: clustersLoading } = 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: [],
vips: [],
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({});
// Validation Error Modal state
const [validationErrorModalVisible, setValidationErrorModalVisible] = useState(false);
const [selectedValidationError, setSelectedValidationError] = useState(null);
// HA/VIP (Issue #27): VIP changes appear in the standard right-panel "Pending Versions"
// (vip-* config_versions) and use the standard "View Change" diff — no bespoke VIP modal.
// 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: [], vips: [], total_count: 0 });
setConfigVersions([]);
setAgentSync(null);
setEntitySyncStates({});
// Then fetch new data
fetchPendingChanges();
fetchConfigVersions();
fetchAgentSync();
} else {
setPendingChanges({ frontends: [], backends: [], waf_rules: [], ssl_certificates: [], vips: [], 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, vipsRes] = 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: [] })),
// HA/VIP (Issue #27): pool-scoped, fetched cluster-scoped so it lists like other entities
axios.get('/api/vip', {
params: { cluster_id: selectedCluster.id },
headers: cacheHeaders
}).catch(() => ({ data: { vips: [] } }))
]);
// DEBUG: Log raw backend data
console.log('[APPLY MANAGEMENT DEBUG] Backend Data:');
console.log('Total backends from API:', backendsRes.data.backends?.length || 0);
// CRITICAL DEBUG: Log ALL backends to find the hidden pending one
console.log('[ALL BACKENDS FROM API]:');
(backendsRes.data.backends || []).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);
// CRITICAL DEBUG: Log ALL filtered pending backends
console.log('[Filtered pending backends]:', backends.length);
console.log('[PENDING BACKENDS DETAILS]:');
backends.forEach(b => {
console.log(` [PENDING] ${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
});
});
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);
// VIP "pending" is its last_config_status (no has_pending_config flag).
const vips = (vipsRes.data.vips || []).filter(v => v.last_config_status === 'PENDING');
const total_count = frontends.length + backends.length + waf_rules.length + ssl_certificates.length + vips.length;
setPendingChanges({
frontends,
backends,
waf_rules,
ssl_certificates,
vips,
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;
// If sync_status is null or has 0 enabled agents, consider as synced - nothing to wait for
if (!syncStatus || syncStatus.total_agents === 0) {
console.log(`REAL SYNC CHECK: ${entity.type}/${entity.id} - No enabled agents, considering as synced`);
return true;
}
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>{modalChangeCount}</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>
)}
{(pendingChanges.vips || []).length > 0 && (
<li><strong>{pendingChanges.vips.length}</strong> HA/VIP changes</li>
)}
{acmeVersions.length > 0 && (
<li><strong>{acmeVersions.length}</strong> ACME Challenge Routing changes</li>
)}
{otherConfigVersions.length > 0 && (
<li><strong>{otherConfigVersions.length}</strong> Other configuration 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
});
};
// HA/VIP convergence tracking (Issue #27 follow-up). VIP teardown/deploy is asynchronous —
// member agents converge keepalived on their next poll. Mirror the HAProxy agent-sync widget
// so the progress popup keeps showing "Syncing HA/VIP... X/Y" until the nodes report the VIP
// ACTIVE, instead of flashing green while the HA/VIP page still shows SYNCING. Fire-and-forget
// recursive poll exactly like checkAgentSync (applyLoading is released immediately; this runs
// in the background and updates the floating widget). Bounded so an offline node can't poll
// forever — it then completes with an informational "still converging" note.
const trackVipConvergence = (clusterId, vipIds, startedAt) => {
const token = localStorage.getItem('token');
const total = vipIds.length;
const poll = async () => {
let vips = [];
try {
const r = await axios.get(`/api/vip?cluster_id=${clusterId}`, { headers: { Authorization: `Bearer ${token}` } });
vips = r.data.vips || [];
} catch (e) { /* transient — keep polling */ }
// A change has converged when the node reports the VIP ACTIVE (create/edit) OR the VIP is
// fully torn down (delete approval): deploy_status DELETED, or it has dropped off the list
// entirely once every member acked the teardown.
const isConverged = (vid) => {
const v = vips.find(x => x.id === vid);
if (!v) return true; // gone from the list → torn down / deleted
return v.deploy_status === 'ACTIVE' || v.deploy_status === 'DELETED';
};
const synced = vipIds.filter(isConverged).length; // VIP-level (drives completion)
const errored = vips.filter(v => vipIds.includes(v.id)
&& (v.deploy_status === 'ERROR' || v.deploy_status === 'ATTENTION')).length;
// Per-NODE progress: sum member acks across the tracked VIPs still present, so a multi-node
// VIP shows "1/2 node(s)" like the HA/VIP table — not just "1 change". Gone (torn-down) VIPs
// are already counted converged via isConverged.
let nodeTotal = 0, nodeSynced = 0;
for (const vid of vipIds) {
const v = vips.find(x => x.id === vid);
if (v) { nodeTotal += (v.deploy_total || 0); nodeSynced += (v.deploy_synced || 0); }
}
const label = nodeTotal > 0 ? `${nodeSynced}/${nodeTotal} node(s)` : `${synced}/${total} change(s)`;
const prog = Math.min(95, 60 + Math.round(35 * (nodeTotal > 0 ? nodeSynced / nodeTotal : synced / Math.max(1, total))));
updateEntityCounts(nodeTotal > 0 ? nodeSynced : synced, nodeTotal > 0 ? nodeTotal : total, 0, 0, 0);
if (synced === total) {
const msg = `${total} HA/VIP change(s) fully converged on all member node(s).`;
setSyncProgress({ visible: true, step: msg, progress: 100 });
completeProgress(msg);
setTimeout(() => { setSyncProgress({ visible: false, step: '', progress: 0 }); message.success(msg); }, 1500);
return;
}
if (errored > 0) {
const msg = 'HA/VIP applied, but a member node reported an issue — open Diagnostics on the HA / VIP page.';
setSyncProgress({ visible: true, step: msg, progress: 100 });
completeProgress(msg);
setTimeout(() => { setSyncProgress({ visible: false, step: '', progress: 0 }); message.warning(msg); }, 1800);
return;
}
if (Date.now() - startedAt > 300000) { // ~5 min cap (e.g. an offline member node)
const msg = `HA/VIP applied — ${label} converged; the rest are still converging (or a node's agent is offline). Track live status on the HA / VIP page.`;
setSyncProgress({ visible: true, step: msg, progress: 100 });
completeProgress(msg);
setTimeout(() => { setSyncProgress({ visible: false, step: '', progress: 0 }); message.info(msg); }, 2000);
return;
}
const step = `Syncing HA/VIP... ${label} converged (keepalived deploy can take a couple of minutes)`;
setSyncProgress({ visible: true, step, progress: prog });
updateProgress(step, prog, { 'Synced HA/VIP': label });
setTimeout(poll, 5000);
};
setTimeout(poll, 2000);
};
const executeApplyAll = async () => {
setApplyLoading(true);
// CRITICAL DEBUG: Log what we're about to apply
console.log('[EXECUTE APPLY ALL] Starting...');
console.log('[Pending Changes State]:', {
frontends: pendingChanges.frontends.length,
backends: pendingChanges.backends.length,
waf_rules: pendingChanges.waf_rules.length,
ssl_certificates: pendingChanges.ssl_certificates.length,
total: effectiveTotal
});
console.log('[Backend Details Being Applied]:');
pendingChanges.backends.forEach(b => {
console.log(` - Backend: ${b.name} (id=${b.id})`, {
last_config_status: b.last_config_status,
has_pending_config: b.has_pending_config,
is_active: b.is_active
});
});
// 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;
const disabledAgents = agentSync?.disabled_agents || 0;
// Detect if this is a restore operation (safe display-only check)
const pendingVersions = configVersions.filter(v => v.status === 'PENDING');
// HA/VIP (Issue #27): vip-* versions are VIP-owned and excluded from the HAProxy apply
// (cluster.py). Ignore them when deciding whether this apply has any HAProxy/cluster
// work to track — otherwise a VIP-only apply (which now stages a vip-* version) would
// look like it has a pending version and the agent-sync tracker would hang on 0/0.
const nonVipPendingVersions = pendingVersions.filter(v => !(v.version_name || '').startsWith('vip-'));
const isRestoreOperation = totalEntities === 0 && nonVipPendingVersions.some(v => v.version_name.startsWith('restore-'));
// HA/VIP-only apply: no HAProxy entity or config-version goes through the cluster-sync
// pipeline, so complete promptly after the isolated VIP apply (member nodes converge
// async on their next agent poll) instead of looping on "Entities: 0/0".
const vipCount = (pendingChanges.vips || []).length;
const isVipOnly = totalEntities === 0 && !isRestoreOperation && nonVipPendingVersions.length === 0 && vipCount > 0;
// Config-version-only apply (e.g. cluster ACME enable/disable): no entity rows, not a restore,
// but there ARE non-vip config versions to push. Without this branch it falls to the "else" and
// shows a misleading "Entities: 0/0" while still syncing agents.
const isConfigVersionOnly = totalEntities === 0 && !isRestoreOperation && vipCount === 0 && nonVipPendingVersions.length > 0;
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, disabledAgents); // Show 1 configuration item
} else if (isVipOnly) {
// HA/VIP-only: avoid the "Entities: 0/0" / agent-sync widget entirely.
setSyncProgress({ visible: true, step: `Applying ${vipCount} HA/VIP change(s)...`, progress: 20 });
startProgress('apply', `Applying ${vipCount} HA/VIP change(s)...`);
updateEntityCounts(0, vipCount, 0, 0, 0);
} else if (isConfigVersionOnly) {
// Config-version-only (ACME toggle, etc.): show "Configuration" instead of "Entities: 0/0".
const cfgCount = nonVipPendingVersions.length;
setSyncProgress({ visible: true, step: `Applying configuration change... Configuration: 0/${cfgCount}, Agents: ⏳`, progress: 20 });
startProgress('apply', `Applying configuration change... Configuration: 0/${cfgCount}, Agents: ⏳`);
updateEntityCounts(0, cfgCount, 0, totalAgents, disabledAgents);
} 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, disabledAgents);
}
try {
const token = localStorage.getItem('token');
// HA/VIP (Issue #27): apply pending VIPs first (isolated endpoint; safe no-op for
// HAProxy config). Done here so VIPs apply even when there are no HAProxy changes.
const pendingVips = pendingChanges.vips || [];
for (const vip of pendingVips) {
try {
await axios.post(`/api/vip/${vip.id}/apply`, {}, { headers: { Authorization: `Bearer ${token}` } });
} catch (vipErr) {
console.error(`[APPLY VIP ${vip.id}] failed:`, vipErr);
message.warning(`VIP "${vip.name}" apply failed: ${extractApiError(vipErr, 'error')}`);
}
}
// Apply HAProxy changes if there are entity-level changes OR any non-vip config version
// (cluster ACME enable/disable, restore, bulk-import). Gating only on haproxyPending used to
// skip the call for config-version-only states, leaving those versions stuck PENDING.
const haproxyPending = pendingChanges.frontends.length + pendingChanges.backends.length
+ pendingChanges.waf_rules.length + pendingChanges.ssl_certificates.length;
const response = (haproxyPending > 0 || nonVipPendingVersions.length > 0)
? await axios.post(
`/api/clusters/${selectedCluster.id}/apply-changes`,
{},
{ headers: { Authorization: `Bearer ${token}` } }
)
: { data: { message: `Applied ${pendingVips.length} HA/VIP change(s)`, applied_count: pendingVips.length } };
// CRITICAL DEBUG: Log apply response
console.log('[APPLY ALL RESPONSE]:', response.data);
// HA/VIP-only apply (Issue #27): nothing goes through the HAProxy config-version /
// cluster-sync pipeline, so the agent-sync tracker below would loop forever on
// "Entities: 0/0". The isolated VIP apply already staged each node's snapshot; member
// agents install/configure keepalived and converge on their next poll — live status
// shows on the HA / VIP page (PENDING → SYNCING → ACTIVE). Complete now (no flash).
if (haproxyPending === 0 && nonVipPendingVersions.length === 0) {
await fetchPendingChanges();
await fetchConfigVersions();
if (pendingVips.length > 0) {
// Keep the popup in a "Syncing HA/VIP... X/Y" state (like every other entity) until the
// member nodes report the VIP ACTIVE — instead of flashing green while the HA/VIP page
// still shows SYNCING. The poll runs in the background (finally{} releases applyLoading).
const step = `Applied — syncing HA/VIP... 0/${pendingVips.length} node(s) converged`;
updateEntityCounts(0, pendingVips.length, 0, 0, 0);
setSyncProgress({ visible: true, step, progress: 60 });
updateProgress(step, 60);
trackVipConvergence(selectedCluster.id, pendingVips.map(v => v.id), Date.now());
} else {
const vmsg = 'Changes applied.';
setSyncProgress({ visible: true, step: vmsg, progress: 100 });
completeProgress(vmsg);
setTimeout(() => { setSyncProgress({ visible: false, step: '', progress: 0 }); message.success(vmsg); }, 1500);
}
return; // finally{} resets applyLoading; the VIP poll runs in the background (like checkAgentSync)
}
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, disabledAgents);
// Refresh data immediately
console.log('[REFRESHING DATA] after apply...');
await fetchPendingChanges();
await fetchConfigVersions();
await fetchAgentSync();
console.log('[DATA REFRESHED] after apply');
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);
// CRITICAL FIX: Use allSynced result for syncedCount instead of stale entitySyncStates
// After Apply, fetchPendingChanges() refreshes to empty (entities deleted/applied),
// so fetchEntitySyncStates() has no entities to check, leaving entitySyncStates empty.
// This caused syncedCount to always be 0 even when all entities are confirmed synced.
const syncedCount = allSynced ? appliedEntities.length : appliedEntities.filter(entity => {
const syncKey = `${entity.type}-${entity.id}`;
const entitySync = entitySyncStates[syncKey];
return entitySync?.status === 'synced';
}).length;
// Additional check: Verify cluster-wide agent sync is also complete
// Use fresh data instead of stale state
// If no enabled agents exist (all disabled), cluster sync is inherently complete
const noEnabledAgents = freshAgentSync?.total_agents === 0 && freshAgentSync?.disabled_agents > 0;
const clusterSyncComplete = noEnabledAgents ||
(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;
// CRITICAL FIX: When ALL entities are confirmed synced (including via 404 for deleted entities)
// and we've waited long enough, complete even if cluster agent sync hasn't finished yet.
// The agent will receive the updated config on its next poll cycle (every 30s).
// This prevents the "stuck at Applying" bug when all entities are deleted.
const shouldCompleteEntitiesDone = allSynced && appliedEntities.length > 0 &&
!clusterSyncComplete && syncAttempts >= 4;
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 (shouldCompleteEntitiesDone) {
// All entities are confirmed applied/deleted, but agent hasn't synced yet
// Complete the progress - agent will sync on its next poll cycle
const successMessage = 'All changes applied successfully! Agent will sync shortly.';
setSyncProgress({ visible: true, step: successMessage, progress: 100 });
completeProgress(successMessage);
setTimeout(() => {
setSyncProgress({ visible: false, step: '', progress: 0 });
message.success(successMessage);
}, 1500);
} else if (syncAttempts < maxAttempts) {
// Continue checking
const clusterStatus = clusterSyncComplete ? '✓' : '⏳';
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, freshAgentSync?.disabled_agents || disabledAgents);
} else {
updateEntityCounts(syncedCount, appliedEntities.length, freshAgentSync?.synced_agents || 0, freshAgentSync?.total_agents || totalAgents, freshAgentSync?.disabled_agents || disabledAgents);
}
setTimeout(checkAgentSync, 3000);
} else {
// Max attempts reached - use real-time sync verification
const isAllSynced = await verifyRealAgentSync(appliedEntities);
const finalSyncedCount = isAllSynced ? appliedEntities.length : 0;
const clusterStatus = clusterSyncComplete ? '✓' : '⏳';
// CRITICAL FIX: If all entities are synced, complete the progress
// Don't stay stuck forever waiting for agent cluster sync
if (isAllSynced) {
const successMessage = clusterSyncComplete ?
'All changes deployed and synchronized!' :
'All changes applied successfully! Agent will sync shortly.';
console.log('APPLY COMPLETE: All entities confirmed synced, completing progress');
setSyncProgress({ visible: true, step: successMessage, progress: 100 });
completeProgress(successMessage);
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(successMessage);
sessionStorage.removeItem(`apply_session_${selectedCluster.id}`);
sessionStorage.removeItem(`apply_timestamp_${selectedCluster.id}`);
}
return;
}
// Entities not yet synced - continue monitoring
const progressStep = `Still syncing... Entities: ${finalSyncedCount}/${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': `${finalSyncedCount}/${appliedEntities.length}`,
'Cluster Status': clusterStatus === '✓' ? 'Synced' : 'Syncing',
'Status': 'Monitoring continues...'
});
updateEntityCounts(finalSyncedCount, appliedEntities.length, freshAgentSync?.synced_agents || 0, freshAgentSync?.total_agents || totalAgents, freshAgentSync?.disabled_agents || disabledAgents);
// Continue monitoring every 5 seconds until fully synced
setTimeout(checkAgentSync, 5000);
}
};
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 published. ${agentResults.length} agent(s) notified, sync in progress.`);
}
}
} 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: ${extractApiError(error, 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>{modalChangeCount}</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>
)}
{(pendingChanges.vips || []).length > 0 && (
<li><strong>{pendingChanges.vips.length}</strong> HA/VIP changes</li>
)}
{acmeVersions.length > 0 && (
<li><strong>{acmeVersions.length}</strong> ACME Challenge Routing changes</li>
)}
{otherConfigVersions.length > 0 && (
<li><strong>{otherConfigVersions.length}</strong> Other configuration 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);
// CRITICAL DEBUG: Log what we're about to reject
console.log('[EXECUTE REJECT ALL] Starting...');
console.log('[Pending Changes State]:', {
frontends: pendingChanges.frontends.length,
backends: pendingChanges.backends.length,
waf_rules: pendingChanges.waf_rules.length,
ssl_certificates: pendingChanges.ssl_certificates.length,
total: effectiveTotal
});
console.log('[Backend Details Being Rejected]:');
pendingChanges.backends.forEach(b => {
console.log(` - Backend: ${b.name} (id=${b.id})`, {
last_config_status: b.last_config_status,
has_pending_config: b.has_pending_config,
is_active: b.is_active
});
});
try {
const token = localStorage.getItem('token');
// HA/VIP (Issue #27): reject pending VIPs (isolated endpoint; restores last applied state).
const pendingVips = pendingChanges.vips || [];
for (const vip of pendingVips) {
try {
await axios.post(`/api/vip/${vip.id}/reject`, {}, { headers: { Authorization: `Bearer ${token}` } });
} catch (vipErr) {
console.error(`[REJECT VIP ${vip.id}] failed:`, vipErr);
message.warning(`VIP "${vip.name}" reject failed: ${extractApiError(vipErr, 'error')}`);
}
}
// Reject HAProxy changes if there are entity-level changes OR any non-vip config version
// (e.g. cluster ACME enable/disable, restore, bulk-import) — the backend DELETE rejects all
// non-vip PENDING versions and rolls back their snapshots. Gating only on haproxyPending used
// to skip the call for config-version-only states, returning the misleading "Rejected 0 HA/VIP".
const haproxyPending = pendingChanges.frontends.length + pendingChanges.backends.length
+ pendingChanges.waf_rules.length + pendingChanges.ssl_certificates.length;
const nonVipPendingVersions = configVersions.filter(
v => v.status === 'PENDING' && !(v.version_name || '').startsWith('vip-')
);
const response = (haproxyPending > 0 || nonVipPendingVersions.length > 0)
? await axios.delete(
`/api/clusters/${selectedCluster.id}/pending-changes`,
{ headers: { Authorization: `Bearer ${token}` } }
)
: { data: { message: `Rejected ${pendingVips.length} HA/VIP change(s)` } };
// CRITICAL DEBUG: Log reject response
console.log('[REJECT ALL RESPONSE]:', response.data);
message.success(response.data.message || 'All pending changes rejected successfully!');
// Refresh data after successful reject
console.log('[REFRESHING DATA] after reject...');
await fetchPendingChanges();
await fetchConfigVersions();
await fetchAgentSync();
console.log('[DATA REFRESHED] after reject');
} catch (error) {
console.error('Error rejecting changes:', error);
message.error('Failed to reject changes: ' + (extractApiError(error, 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 = extractApiError(error, 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;
// Issue #35: cluster ACME enable/disable produce `cluster-<id>-acme-<enable|disable>-<ts>` config
// versions that have NO entity-level pending flag, so they were neither categorized nor counted.
const ACME_VERSION_RE = /^cluster-\d+-acme-(enable|disable)-/;
const ENTITY_VERSION_PREFIXES = ['frontend-', 'backend-', 'server-', 'ssl-', 'waf-'];
const acmeVersions = pendingVersions.filter(v => ACME_VERSION_RE.test(v.version_name || ''));
// "Other" config versions for the confirm modal = non-vip, non-acme, non-entity-backed (i.e.
// restore-*/bulk-import-*/other cluster-level) — entity-backed versions are already counted via
// total_count, and vips are listed separately, so excluding them avoids double-counting.
const otherConfigVersions = pendingVersions.filter(v => {
const n = v.version_name || '';
if (n.startsWith('vip-') || ACME_VERSION_RE.test(n)) return false;
return !ENTITY_VERSION_PREFIXES.some(p => n.startsWith(p));
});
// Confirm-modal header count: entities+VIPs (total_count) + ACME + other config versions, so the
// header equals the sum of the listed <li> items in every state. The button-enable gate keeps
// using effectiveTotal (unchanged) so entity-only button/Alert behavior is byte-identical.
const modalChangeCount = (pendingChanges.total_count || 0) + acmeVersions.length + otherConfigVersions.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 ${token.colorBorder}`,
borderRadius: 6,
marginBottom: 8,
display: 'flex',
alignItems: 'center',
backgroundColor: token.colorBgContainer
}}>
<div style={{ marginRight: 12, fontSize: 18 }}>
{icon}
</div>
<div style={{ flex: 1 }}>
<div style={{ fontWeight: 'bold' }}>{item.name}</div>
<div style={{ color: token.colorTextSecondary, 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) {
// Phase J audit fix #6 — neutral "Loading clusters…" while the
// ClusterContext is still fetching, otherwise the page reads as
// if the operator forgot to pick a cluster during the legitimate
// post-deploy / post-login fetch window.
if (clustersLoading) {
return (
<Card>
<Empty
description="Loading clusters…"
image={Empty.PRESENTED_IMAGE_SIMPLE}
/>
</Card>
);
}
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>
{/* Validation Error Banner - Shows when there's a validation error from agent */}
{agentSync?.validation_error && (
<Alert
type="error"
banner
showIcon
icon={<ExclamationCircleOutlined />}
style={{
marginBottom: 24,
borderRadius: 8,
border: `1px solid ${token.colorErrorBorder}`,
boxShadow: '0 2px 8px rgba(255, 77, 79, 0.15)'
}}
message={
<Space size="middle">
<Text strong style={{ color: '#cf1322' }}>HAProxy Configuration Failed</Text>
{agentSync.parsed_error?.line_number && (
<Tag color="red">Line {agentSync.parsed_error.line_number}</Tag>
)}
{agentSync.parsed_error?.entity_name && (
<Tag color="orange">
{agentSync.parsed_error.entity_type}: {agentSync.parsed_error.entity_name}
</Tag>
)}
{agentSync.parsed_error?.has_multiple_errors && (
<Tag color="volcano">+{agentSync.parsed_error.additional_errors_count} more errors</Tag>
)}
</Space>
}
description={
<div style={{ marginTop: 8 }}>
<Paragraph style={{ margin: 0, marginBottom: 12 }}>
{agentSync.parsed_error?.suggestion || 'Configuration validation failed on agent. Please check the error details.'}
</Paragraph>
<Space>
<Button
type="primary"
danger
icon={<EyeOutlined />}
onClick={() => {
setSelectedValidationError({
validation_error: agentSync.validation_error,
validation_error_reported_at: agentSync.validation_error_reported_at,
parsed_error: agentSync.parsed_error
});
setValidationErrorModalVisible(true);
}}
>
View Error Details
</Button>
{agentSync.parsed_error?.quick_fix_available && agentSync.parsed_error?.quick_fix_url && (
<Button
type="primary"
icon={<EditOutlined />}
onClick={() => navigate(agentSync.parsed_error.quick_fix_url)}
>
Fix: {agentSync.parsed_error.entity_name}
</Button>
)}
{agentSync.parsed_error?.entity_name && !agentSync.parsed_error?.quick_fix_available && (
<Button
icon={<RightOutlined />}
onClick={() => {
const entityType = agentSync.parsed_error.entity_type;
if (entityType === 'frontend') {
navigate('/frontends');
} else if (entityType === 'backend') {
navigate('/backends');
}
}}
>
Go to {agentSync.parsed_error.entity_type === 'frontend' ? 'Frontends' : 'Backends'}
</Button>
)}
</Space>
</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: token.colorTextSecondary, 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 => (
<div key={`ssl-wrapper-${item.id}`}>
{renderPendingItem(item, 'ssl_certificate', <SafetyCertificateOutlined style={{ color: '#eb2f96' }} />)}
{item.cluster_names && item.cluster_names.length > 1 && (
<Alert
type="info"
showIcon
message={`This certificate is used in ${item.cluster_names.length} clusters. Applying here will auto-apply SSL changes to all clusters.`}
description={item.pending_cluster_names?.length > 0
? `Currently pending in: ${item.pending_cluster_names.join(', ')}`
: null}
style={{ marginBottom: 8, marginLeft: 30 }}
/>
)}
</div>
))}
</div>
)}
{/* HA/VIP Changes (Issue #27) */}
{(pendingChanges.vips || []).length > 0 && (
<div style={{ marginBottom: 16 }}>
<Title level={5}>
<CloudServerOutlined style={{ marginRight: 8, color: '#13c2c2' }} />
HA / VIP Changes ({pendingChanges.vips.length})
</Title>
{pendingChanges.vips.map(item => (
<div key={`vip-${item.id}`} style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '8px 12px', marginBottom: 6, border: `1px solid ${item.pending_delete ? token.colorErrorBorder : token.colorBorderSecondary}`, borderRadius: 6, background: item.pending_delete ? token.colorErrorBg : undefined }}>
<CloudServerOutlined style={{ color: item.pending_delete ? '#cf1322' : '#13c2c2' }} />
<span style={{ fontWeight: 500 }}>{item.name}</span>
<Tag>{item.virtual_ip}/{item.prefix_length}</Tag>
{item.pool_name && <Tag color="blue">{item.pool_name}</Tag>}
{item.pending_delete
? <Tag color="red">DELETION — approve to tear down, reject to keep</Tag>
: <Tag color="orange">PENDING</Tag>}
</div>
))}
</div>
)}
{/* Issue #35: ACME Challenge Routing (cluster-<id>-acme-*) versions have no entity
flag. Render them in their own section REGARDLESS of whether entity sections are
present, so a co-pending ACME toggle is never hidden in the left panel. */}
{acmeVersions.length > 0 && (
<div style={{ marginTop: 8, marginBottom: 8 }}>
<Title level={5}>
<SafetyCertificateOutlined style={{ marginRight: 8, color: '#1890ff' }} />
ACME Challenge Routing ({acmeVersions.length})
</Title>
{acmeVersions.map(v => {
const isEnable = /^cluster-\d+-acme-enable-/.test(v.version_name);
return (
<div key={v.id} style={{
padding: 10, border: `1px dashed ${token.colorPrimary}`, borderRadius: 6, marginBottom: 8,
display: 'flex', alignItems: 'center', justifyContent: 'space-between', backgroundColor: token.colorInfoBg
}}>
<span style={{ fontFamily: 'monospace' }}>{v.version_name}</span>
<span>
<Tag color={isEnable ? 'green' : 'default'}>{isEnable ? 'ENABLE' : 'DISABLE'}</Tag>
<Tag color="orange">PENDING</Tag>
</span>
</div>
);
})}
<div style={{ fontSize: 12, color: token.colorTextSecondary, marginTop: 4 }}>
ACME challenge routing change. Apply to push the updated HAProxy config to the agents.
</div>
</div>
)}
{pendingChanges.frontends.length === 0 && pendingChanges.backends.length === 0 && pendingChanges.waf_rules.length === 0 && pendingChanges.ssl_certificates.length === 0 && (pendingChanges.vips || []).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-'));
// Exclude restore-/bulk-import- (own sections), vip-* (VIP section), and acme-*
// (the dedicated ACME section above) so they aren't duplicated in "Other".
const otherVersions = pendingVersions.filter(v =>
!v.version_name.startsWith('restore-') && !v.version_name.startsWith('bulk-import-')
&& !v.version_name.startsWith('vip-') && !ACME_VERSION_RE.test(v.version_name));
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: token.colorInfoBg
}}>
<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: token.colorSuccessBg
}}>
<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 ${token.colorBorder}`,
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: token.colorTextSecondary }}>
{new Date(appliedVersions[0].created_at).toLocaleString()}
</div>
</div>
) : (
<Text type="secondary">No applied versions</Text>
)}
</Descriptions.Item>
</Descriptions>
</div>
{/* Pending Versions Section.
Theme tokens, not the light-mode literals #fffbe6/#ffe58f: in dark mode those
produced a cream panel with light text on it, so the version name, timestamp
and "View Change" link were unreadable. colorWarningBg/Border track the
algorithm, so the "pending" tint survives in both themes. */}
{pendingVersions.length > 0 && (
<div style={{ marginBottom: 24, background: token.colorWarningBg, borderRadius: 8, border: `1px solid ${token.colorWarningBorder}`, padding: '16px 16px 8px' }}>
<Title level={5} style={{ marginTop: 0 }}>
<ClockCircleOutlined style={{ marginRight: 8, color: '#faad14' }} />
Pending Changes ({pendingVersions.length})
</Title>
<div style={{ maxHeight: 240, overflowY: 'auto', paddingRight: 8, paddingTop: 4 }}>
<Timeline size="small">
{pendingVersions.map((version, index) => (
<Timeline.Item
key={version.id}
color="orange"
dot={<ClockCircleOutlined style={{ color: '#faad14' }} />}
>
<div style={{ fontSize: 12, wordBreak: 'break-all' }}>
<div style={{ fontWeight: 'bold', marginBottom: 4 }}>{version.version_name}</div>
<div style={{ color: token.colorTextSecondary, 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>
)}
<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' }}
/>
) : (
<div style={{ maxHeight: 420, overflowY: 'auto', paddingRight: 8, paddingTop: 4 }}>
<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, wordBreak: 'break-all' }}>
<div style={{ fontWeight: 'bold', marginBottom: 4 }}>{version.version_name}</div>
<div style={{ color: token.colorTextSecondary, 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>
</div>
)}
</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' }}
/>
) : (
<div style={{ maxHeight: 420, overflowY: 'auto', paddingRight: 8, paddingTop: 4 }}>
<Timeline size="small">
{rejectedVersions.map((version, index) => (
<Timeline.Item
key={version.id}
color="red"
dot={<CloseCircleOutlined style={{ color: '#ff4d4f' }} />}
>
<div style={{ fontSize: 12, wordBreak: 'break-all' }}>
<div style={{ fontWeight: 'bold', marginBottom: 4 }}>{version.version_name}</div>
<div style={{ color: token.colorTextSecondary, 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>
{/* Bulgu #16 (round-6 audit): hide
the Undo button for destructive
bulk operations. The backend
rejects undo of bulk-site-create-*
/ bulk-import-* / restore-* with
HTTP 409 because the underlying
entities were permanently deleted
on reject (the snapshot stores
new_values={} so there's nothing
to recreate from). Hiding the
button up front makes the
constraint self-documenting
instead of surfacing as a
confusing error post-click. */}
{(() => {
const vn = version?.version_name || '';
// vip-* versions ARE undoable (Issue #27): the VIP router
// re-stages the rejected change as PENDING (reactivating a
// rejected create, or re-applying a rejected edit), so they
// use the normal Undo button below — not this disabled case.
const destructive = (
vn.startsWith('bulk-site-create-')
|| vn.startsWith('bulk-import-')
|| vn.startsWith('bulk-proxied-host-create-')
|| vn.startsWith('restore-')
);
if (destructive) {
return (
<Tooltip title="Bulk-create / bulk-import / restore rejections are destructive — the underlying entities were permanently deleted. Re-create via the wizard / re-run the import to make a fresh PENDING version.">
<Button
type="link"
size="small"
icon={<UndoOutlined />}
disabled
style={{ padding: 0, height: 'auto', fontSize: 11 }}
>
Undo (n/a)
</Button>
</Tooltip>
);
}
return (
<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>
</div>
)}
</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>Active: {agentSync.total_agents || 0}</Tag>
{agentSync.disabled_agents > 0 && (
<Tag color="default">Disabled: {agentSync.disabled_agents}</Tag>
)}
</Space>
</Descriptions.Item>
{agentSync.validation_error && (
<Descriptions.Item label="HAProxy Validation Error">
<Alert
message={
<Space>
<span>Configuration Validation Failed</span>
{agentSync.parsed_error?.line_number && (
<Tag color="red">Line {agentSync.parsed_error.line_number}</Tag>
)}
{agentSync.parsed_error?.entity_name && (
<Tag color="orange">
{agentSync.parsed_error.entity_type}: {agentSync.parsed_error.entity_name}
</Tag>
)}
</Space>
}
description={
<div>
{/* Parsed suggestion */}
{agentSync.parsed_error?.suggestion && (
<div style={{ marginBottom: 12, padding: '8px 12px', background: token.colorWarningBg, borderRadius: 4, border: `1px solid ${token.colorWarningBorder}` }}>
<Text strong style={{ color: token.colorWarningText }}>Recommendation: </Text>
<Text>{agentSync.parsed_error.suggestion}</Text>
</div>
)}
{/* Action buttons */}
<Space style={{ marginBottom: 12 }}>
<Button
type="primary"
size="small"
danger
icon={<EyeOutlined />}
onClick={() => {
setSelectedValidationError({
validation_error: agentSync.validation_error,
validation_error_reported_at: agentSync.validation_error_reported_at,
parsed_error: agentSync.parsed_error
});
setValidationErrorModalVisible(true);
}}
>
View Full Details
</Button>
{agentSync.parsed_error?.quick_fix_available && agentSync.parsed_error?.quick_fix_url && (
<Button
type="primary"
size="small"
icon={<EditOutlined />}
onClick={() => navigate(agentSync.parsed_error.quick_fix_url)}
>
Fix: {agentSync.parsed_error.entity_name}
</Button>
)}
</Space>
{/* Truncated raw error */}
<Collapse size="small" ghost>
<Collapse.Panel header="Raw Error Output" key="raw">
<pre style={{
backgroundColor: '#1f1f1f',
color: '#ff6b6b',
padding: '12px',
borderRadius: '4px',
fontSize: '11px',
fontFamily: 'Monaco, Menlo, monospace',
maxHeight: '150px',
overflowY: 'auto',
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
margin: 0
}}>
{agentSync.validation_error.length > 500
? agentSync.validation_error.substring(0, 500) + '...\n[Click "View Full Details" to see complete error]'
: agentSync.validation_error
}
</pre>
</Collapse.Panel>
</Collapse>
{agentSync.validation_error_reported_at && (
<div style={{ marginTop: 8, fontSize: '11px', color: '#8c8c8c' }}>
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',
render: (name, record) => (
<span>
{name}
{record.sync_excluded && (
<Tag color="default" style={{ marginLeft: 6, fontSize: '10px' }}>OFF</Tag>
)}
</span>
)
},
{
title: 'Agent Status',
dataIndex: 'status',
key: 'status',
render: (s, record) => (
record.sync_excluded
? <Tag color="default">disabled</Tag>
: <Tag color={s === 'online' ? 'green' : 'default'}>{s || '-'}</Tag>
)
},
{
title: 'HAProxy',
dataIndex: 'haproxy_status',
key: 'haproxy_status',
render: (s, record) => (
record.sync_excluded
? <Tag color="default">-</Tag>
: <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, record) => (
record.sync_excluded
? <Tag color="default">Excluded</Tag>
: <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 ${token.colorBorder}`, borderRadius: 6, backgroundColor: token.colorFillQuaternary }}>
<div style={{ padding: '12px', borderBottom: `1px solid ${token.colorBorderSecondary}`, backgroundColor: token.colorFillTertiary }}>
<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' }}>
{/* Token-based, not the light literals #f6ffed/#fff2f0: those stayed
near-white in dark mode, so the added/removed rows glared against
the dark diff panel around them. colorSuccessBg/colorErrorBg darken
with the algorithm while keeping the green/red semantics. */}
{change.type === 'added' && (
<div style={{ backgroundColor: token.colorSuccessBg, color: token.colorSuccessText, padding: '2px 8px', borderLeft: `3px solid ${token.colorSuccess}` }}>
+ {change.line}
</div>
)}
{change.type === 'removed' && (
<div style={{ backgroundColor: token.colorErrorBg, color: token.colorErrorText, padding: '2px 8px', borderLeft: `3px solid ${token.colorError}` }}>
- {change.line}
</div>
)}
{change.type === 'context' && (
<div style={{ color: token.colorTextSecondary, padding: '2px 8px' }}>
{change.line}
</div>
)}
</div>
))
) : (
<div style={{ textAlign: 'center', padding: '40px 0', color: token.colorTextSecondary }}>
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: token.colorFillQuaternary, padding: '12px', borderRadius: '6px', border: `1px solid ${token.colorBorder}` }}>
<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: token.colorTextSecondary }}>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: token.colorTextSecondary }}>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: token.colorTextSecondary }}>Total Changes</div>
</div>
</Col>
</Row>
</div>
</div>
)}
</div>
) : (
<div style={{ textAlign: 'center', padding: '40px 0', color: token.colorTextSecondary }}>
<ExclamationCircleOutlined style={{ fontSize: '24px', marginBottom: '8px' }} />
<div>Failed to load configuration changes</div>
</div>
)}
</div>
)}
</Modal>
{/* Validation Error Modal */}
<ValidationErrorModal
visible={validationErrorModalVisible}
onClose={() => {
setValidationErrorModalVisible(false);
setSelectedValidationError(null);
}}
validationError={selectedValidationError?.validation_error}
validationErrorReportedAt={selectedValidationError?.validation_error_reported_at}
parsedError={selectedValidationError?.parsed_error}
/>
</div>
);
};
export default ApplyManagement;