mirror of
https://github.com/taylanbakircioglu/haproxy-openmanager.git
synced 2026-10-03 12:10:44 +00:00
feat: Add field-level change visualization in bulk import preview
UX IMPROVEMENT: Users can now see exactly what changed in existing entities.
BACKEND CHANGES (config.py):
- Parse endpoint now tracks field-level changes for frontends and backends
- Added '_changes' object to each entity containing old vs new values
- Format: { 'field_name': { 'old': value, 'new': value } }
- Applied to ALL updatable fields:
* Frontend: bind_address, bind_port, mode, timeouts, headers, options, etc.
* Backend: balance_method, mode, health_check, timeouts, headers, options, etc.
- Only includes fields that actually changed (empty object if no changes)
FRONTEND CHANGES (BulkConfigImport.js):
- Created FieldChange component for visual diff display
- Shows old value (strikethrough, red) vs new value (green highlight)
- Badge indicators: 'NEW' (green) or 'CHANGED' (orange)
- Applied to multi-line text fields:
* Backend Options
* Request Headers
* Response Headers
* Frontend Options
* TCP Request Rules
UI DESIGN:
┌─────────────────────────────────────────────────┐
│ Backend Options [NEW] ← Badge │
├─────────────────────────────────────────────────┤
│ ┌─────────────────────────────────────────────┐ │
│ │ Old: (crossed out, red background) │ │
│ │ null │ │
│ └─────────────────────────────────────────────┘ │
│ ┌─────────────────────────────────────────────┐ │
│ │ New: (green background) │ │
│ │ option http-keep-alive │ │
│ └─────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────┘
EXAMPLE SCENARIOS:
Scenario 1: New field added (like user's example)
- Backend: Elasticsearch
- Field: options
- Old: null → shown as red box with 'null' (crossed out)
- New: 'option http-keep-alive' → shown in green box
- Badge: 'NEW' (green)
Scenario 2: Existing field changed
- Backend: Elasticsearch
- Field: timeout_server
- Old: 30000 → shown in red box (crossed out)
- New: 60000 → shown in green box
- Badge: 'CHANGED' (orange)
Scenario 3: Multi-line text modified
- Backend: Elasticsearch
- Field: request_headers
- Old: 3 lines → shown in red box (all 3 lines crossed out)
- New: 5 lines → shown in green box (all 5 lines)
- Badge: 'CHANGED' (orange)
- Diff is clearly visible line by line
USER EXPERIENCE:
✅ Clear visual feedback: What was there before
✅ Clear visual feedback: What will be applied
✅ Color coding: Red (removed) → Green (added)
✅ Badge indicators: NEW vs CHANGED
✅ Works for multi-line content (preserves formatting)
✅ Only shows diff for fields that actually changed
✅ Parse preview now matches Apply Management diff view
Impact: Users can confidently review and approve bulk imports with full visibility into changes.
This commit is contained in:
@@ -985,18 +985,23 @@ async def parse_bulk_config(
|
||||
""", frontend["name"], request.cluster_id)
|
||||
|
||||
if existing:
|
||||
# Check if any field has actually changed
|
||||
# Check if any field has actually changed AND track which fields changed
|
||||
has_changes = False
|
||||
changes = {} # Track field-level changes for UI highlighting
|
||||
|
||||
# Compare all fields that can be updated
|
||||
if frontend.get("bind_address") and frontend["bind_address"] != existing["bind_address"]:
|
||||
has_changes = True
|
||||
changes["bind_address"] = {"old": existing["bind_address"], "new": frontend["bind_address"]}
|
||||
if frontend.get("bind_port") and frontend["bind_port"] != existing["bind_port"]:
|
||||
has_changes = True
|
||||
changes["bind_port"] = {"old": existing["bind_port"], "new": frontend["bind_port"]}
|
||||
if frontend.get("default_backend") and frontend["default_backend"] != existing["default_backend"]:
|
||||
has_changes = True
|
||||
changes["default_backend"] = {"old": existing["default_backend"], "new": frontend["default_backend"]}
|
||||
if frontend.get("mode") and frontend["mode"] != existing["mode"]:
|
||||
has_changes = True
|
||||
changes["mode"] = {"old": existing["mode"], "new": frontend["mode"]}
|
||||
# MVP DECISION: SSL settings are NOT compared for change detection
|
||||
# Bulk import preserves manual SSL configuration (ssl_enabled, ssl_certificate_ids, ssl_port)
|
||||
# This aligns with bulk-create endpoint behavior (line 1588-1589)
|
||||
@@ -1004,39 +1009,52 @@ async def parse_bulk_config(
|
||||
# So we don't mark frontend as UPDATE for SSL-only changes
|
||||
if frontend.get("timeout_client") and frontend["timeout_client"] != existing["timeout_client"]:
|
||||
has_changes = True
|
||||
changes["timeout_client"] = {"old": existing["timeout_client"], "new": frontend["timeout_client"]}
|
||||
if frontend.get("maxconn") and frontend["maxconn"] != existing["maxconn"]:
|
||||
has_changes = True
|
||||
changes["maxconn"] = {"old": existing["maxconn"], "new": frontend["maxconn"]}
|
||||
if frontend.get("request_headers") and frontend["request_headers"] != existing["request_headers"]:
|
||||
has_changes = True
|
||||
changes["request_headers"] = {"old": existing["request_headers"], "new": frontend["request_headers"]}
|
||||
if frontend.get("response_headers") and frontend["response_headers"] != existing["response_headers"]:
|
||||
has_changes = True
|
||||
changes["response_headers"] = {"old": existing["response_headers"], "new": frontend["response_headers"]}
|
||||
if frontend.get("options") and frontend["options"] != existing.get("options"):
|
||||
has_changes = True
|
||||
changes["options"] = {"old": existing.get("options"), "new": frontend["options"]}
|
||||
if frontend.get("tcp_request_rules") and frontend["tcp_request_rules"] != existing["tcp_request_rules"]:
|
||||
has_changes = True
|
||||
changes["tcp_request_rules"] = {"old": existing["tcp_request_rules"], "new": frontend["tcp_request_rules"]}
|
||||
|
||||
# Additional frontend fields (timeout_http_request, rate_limit, compression, log_separate, monitor_uri)
|
||||
if frontend.get("timeout_http_request") and frontend["timeout_http_request"] != existing.get("timeout_http_request"):
|
||||
has_changes = True
|
||||
changes["timeout_http_request"] = {"old": existing.get("timeout_http_request"), "new": frontend["timeout_http_request"]}
|
||||
if frontend.get("rate_limit") and frontend["rate_limit"] != existing.get("rate_limit"):
|
||||
has_changes = True
|
||||
changes["rate_limit"] = {"old": existing.get("rate_limit"), "new": frontend["rate_limit"]}
|
||||
if "compression" in frontend and frontend["compression"] != existing.get("compression", False):
|
||||
has_changes = True
|
||||
changes["compression"] = {"old": existing.get("compression", False), "new": frontend["compression"]}
|
||||
if "log_separate" in frontend and frontend["log_separate"] != existing.get("log_separate", False):
|
||||
has_changes = True
|
||||
changes["log_separate"] = {"old": existing.get("log_separate", False), "new": frontend["log_separate"]}
|
||||
if frontend.get("monitor_uri") and frontend["monitor_uri"] != existing.get("monitor_uri"):
|
||||
has_changes = True
|
||||
changes["monitor_uri"] = {"old": existing.get("monitor_uri"), "new": frontend["monitor_uri"]}
|
||||
|
||||
# Note: acl_rules and use_backend_rules are not stored in frontends table, they're managed separately
|
||||
|
||||
# Check if entity is inactive (reactivation counts as change)
|
||||
if not existing['is_active']:
|
||||
has_changes = True
|
||||
changes["is_active"] = {"old": False, "new": True}
|
||||
|
||||
frontend["_isNew"] = False
|
||||
frontend["_isUpdate"] = has_changes # Only true if actual changes detected
|
||||
frontend["_existingId"] = existing['id']
|
||||
frontend["_isActive"] = existing['is_active']
|
||||
frontend["_changes"] = changes if changes else None # Field-level changes for UI
|
||||
|
||||
if has_changes:
|
||||
update_frontends += 1
|
||||
@@ -1052,52 +1070,72 @@ async def parse_bulk_config(
|
||||
""", backend["name"], request.cluster_id)
|
||||
|
||||
if existing:
|
||||
# Check if any field has actually changed (same logic as bulk-create)
|
||||
# Check if any field has actually changed AND track which fields changed
|
||||
has_changes = False
|
||||
changes = {} # Track field-level changes for UI highlighting
|
||||
|
||||
if backend.get("balance_method") and backend["balance_method"] != existing["balance_method"]:
|
||||
has_changes = True
|
||||
changes["balance_method"] = {"old": existing["balance_method"], "new": backend["balance_method"]}
|
||||
if backend.get("mode") and backend["mode"] != existing["mode"]:
|
||||
has_changes = True
|
||||
changes["mode"] = {"old": existing["mode"], "new": backend["mode"]}
|
||||
if backend.get("health_check_uri") and backend["health_check_uri"] != existing["health_check_uri"]:
|
||||
has_changes = True
|
||||
changes["health_check_uri"] = {"old": existing["health_check_uri"], "new": backend["health_check_uri"]}
|
||||
if backend.get("health_check_interval") and backend["health_check_interval"] != existing["health_check_interval"]:
|
||||
has_changes = True
|
||||
changes["health_check_interval"] = {"old": existing["health_check_interval"], "new": backend["health_check_interval"]}
|
||||
if backend.get("health_check_expected_status") is not None and backend["health_check_expected_status"] != existing["health_check_expected_status"]:
|
||||
has_changes = True
|
||||
changes["health_check_expected_status"] = {"old": existing["health_check_expected_status"], "new": backend["health_check_expected_status"]}
|
||||
if backend.get("fullconn") and backend["fullconn"] != existing["fullconn"]:
|
||||
has_changes = True
|
||||
changes["fullconn"] = {"old": existing["fullconn"], "new": backend["fullconn"]}
|
||||
if backend.get("timeout_connect") and backend["timeout_connect"] != existing["timeout_connect"]:
|
||||
has_changes = True
|
||||
changes["timeout_connect"] = {"old": existing["timeout_connect"], "new": backend["timeout_connect"]}
|
||||
if backend.get("timeout_server") and backend["timeout_server"] != existing["timeout_server"]:
|
||||
has_changes = True
|
||||
changes["timeout_server"] = {"old": existing["timeout_server"], "new": backend["timeout_server"]}
|
||||
if backend.get("timeout_queue") and backend["timeout_queue"] != existing["timeout_queue"]:
|
||||
has_changes = True
|
||||
changes["timeout_queue"] = {"old": existing["timeout_queue"], "new": backend["timeout_queue"]}
|
||||
if backend.get("cookie_name") and backend["cookie_name"] != existing["cookie_name"]:
|
||||
has_changes = True
|
||||
changes["cookie_name"] = {"old": existing["cookie_name"], "new": backend["cookie_name"]}
|
||||
if backend.get("cookie_options") and backend["cookie_options"] != existing["cookie_options"]:
|
||||
has_changes = True
|
||||
changes["cookie_options"] = {"old": existing["cookie_options"], "new": backend["cookie_options"]}
|
||||
if backend.get("default_server_inter") and backend["default_server_inter"] != existing["default_server_inter"]:
|
||||
has_changes = True
|
||||
changes["default_server_inter"] = {"old": existing["default_server_inter"], "new": backend["default_server_inter"]}
|
||||
if backend.get("default_server_fall") and backend["default_server_fall"] != existing["default_server_fall"]:
|
||||
has_changes = True
|
||||
changes["default_server_fall"] = {"old": existing["default_server_fall"], "new": backend["default_server_fall"]}
|
||||
if backend.get("default_server_rise") and backend["default_server_rise"] != existing["default_server_rise"]:
|
||||
has_changes = True
|
||||
changes["default_server_rise"] = {"old": existing["default_server_rise"], "new": backend["default_server_rise"]}
|
||||
if backend.get("request_headers") and backend["request_headers"] != existing["request_headers"]:
|
||||
has_changes = True
|
||||
changes["request_headers"] = {"old": existing["request_headers"], "new": backend["request_headers"]}
|
||||
if backend.get("response_headers") and backend["response_headers"] != existing["response_headers"]:
|
||||
has_changes = True
|
||||
changes["response_headers"] = {"old": existing["response_headers"], "new": backend["response_headers"]}
|
||||
if backend.get("options") and backend["options"] != existing.get("options"):
|
||||
has_changes = True
|
||||
changes["options"] = {"old": existing.get("options"), "new": backend["options"]}
|
||||
|
||||
# Check if entity is inactive (reactivation counts as change)
|
||||
if not existing['is_active']:
|
||||
has_changes = True
|
||||
changes["is_active"] = {"old": False, "new": True}
|
||||
|
||||
backend["_isNew"] = False
|
||||
backend["_isUpdate"] = has_changes # Only true if actual changes detected
|
||||
backend["_existingId"] = existing['id']
|
||||
backend["_isActive"] = existing['is_active']
|
||||
backend["_changes"] = changes if changes else None # Field-level changes for UI
|
||||
|
||||
if has_changes:
|
||||
update_backends += 1
|
||||
|
||||
@@ -31,6 +31,66 @@ const { TextArea } = Input;
|
||||
const { Title, Text, Paragraph } = Typography;
|
||||
const { Panel } = Collapse;
|
||||
|
||||
// Helper component to display field changes (old vs new)
|
||||
const FieldChange = ({ label, oldValue, newValue, span = 2, isMultiline = false }) => {
|
||||
const hasChange = oldValue !== newValue;
|
||||
const isNew = !oldValue && newValue;
|
||||
|
||||
return (
|
||||
<Descriptions.Item
|
||||
label={
|
||||
<span>
|
||||
{label}
|
||||
{hasChange && (
|
||||
<Tag color={isNew ? "green" : "orange"} style={{ marginLeft: 8, fontSize: '10px' }}>
|
||||
{isNew ? "NEW" : "CHANGED"}
|
||||
</Tag>
|
||||
)}
|
||||
</span>
|
||||
}
|
||||
span={span}
|
||||
>
|
||||
<div style={{ position: 'relative' }}>
|
||||
{hasChange && oldValue && (
|
||||
<div style={{
|
||||
marginBottom: 8,
|
||||
padding: '4px 8px',
|
||||
backgroundColor: '#fff1f0',
|
||||
borderLeft: '3px solid #ff4d4f',
|
||||
borderRadius: 2
|
||||
}}>
|
||||
<Text type="secondary" style={{ fontSize: '10px' }}>Old:</Text>
|
||||
<br />
|
||||
<Text delete code={!isMultiline} style={{
|
||||
whiteSpace: isMultiline ? 'pre-wrap' : 'normal',
|
||||
fontSize: '11px',
|
||||
color: '#999'
|
||||
}}>
|
||||
{oldValue || 'null'}
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
<div style={hasChange ? {
|
||||
padding: '4px 8px',
|
||||
backgroundColor: '#f6ffed',
|
||||
borderLeft: '3px solid #52c41a',
|
||||
borderRadius: 2
|
||||
} : {}}>
|
||||
{hasChange && <Text type="success" style={{ fontSize: '10px' }}>New:</Text>}
|
||||
{hasChange && <br />}
|
||||
<Text code={!isMultiline} strong={hasChange} style={{
|
||||
whiteSpace: isMultiline ? 'pre-wrap' : 'normal',
|
||||
fontSize: '11px',
|
||||
color: hasChange ? '#52c41a' : 'inherit'
|
||||
}}>
|
||||
{newValue}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
</Descriptions.Item>
|
||||
);
|
||||
};
|
||||
|
||||
const BulkConfigImport = () => {
|
||||
const { selectedCluster } = useContext(ClusterContext);
|
||||
const [configContent, setConfigContent] = useState('');
|
||||
@@ -917,32 +977,40 @@ backend web-backend
|
||||
<Text strong>Frontend Configuration Details:</Text>
|
||||
<Descriptions size="small" bordered column={1} style={{ marginTop: 8 }}>
|
||||
{frontend.options && (
|
||||
<Descriptions.Item label="Frontend Options">
|
||||
<Text code style={{ whiteSpace: 'pre-wrap', fontSize: '11px' }}>
|
||||
{frontend.options}
|
||||
</Text>
|
||||
</Descriptions.Item>
|
||||
<FieldChange
|
||||
label="Frontend Options"
|
||||
oldValue={frontend._changes?.options?.old}
|
||||
newValue={frontend.options}
|
||||
span={1}
|
||||
isMultiline={true}
|
||||
/>
|
||||
)}
|
||||
{frontend.request_headers && (
|
||||
<Descriptions.Item label="Request Headers">
|
||||
<Text code style={{ whiteSpace: 'pre-wrap', fontSize: '11px' }}>
|
||||
{frontend.request_headers}
|
||||
</Text>
|
||||
</Descriptions.Item>
|
||||
<FieldChange
|
||||
label="Request Headers"
|
||||
oldValue={frontend._changes?.request_headers?.old}
|
||||
newValue={frontend.request_headers}
|
||||
span={1}
|
||||
isMultiline={true}
|
||||
/>
|
||||
)}
|
||||
{frontend.response_headers && (
|
||||
<Descriptions.Item label="Response Headers">
|
||||
<Text code style={{ whiteSpace: 'pre-wrap', fontSize: '11px' }}>
|
||||
{frontend.response_headers}
|
||||
</Text>
|
||||
</Descriptions.Item>
|
||||
<FieldChange
|
||||
label="Response Headers"
|
||||
oldValue={frontend._changes?.response_headers?.old}
|
||||
newValue={frontend.response_headers}
|
||||
span={1}
|
||||
isMultiline={true}
|
||||
/>
|
||||
)}
|
||||
{frontend.tcp_request_rules && (
|
||||
<Descriptions.Item label="TCP Request Rules">
|
||||
<Text code style={{ whiteSpace: 'pre-wrap', fontSize: '11px' }}>
|
||||
{frontend.tcp_request_rules}
|
||||
</Text>
|
||||
</Descriptions.Item>
|
||||
<FieldChange
|
||||
label="TCP Request Rules"
|
||||
oldValue={frontend._changes?.tcp_request_rules?.old}
|
||||
newValue={frontend.tcp_request_rules}
|
||||
span={1}
|
||||
isMultiline={true}
|
||||
/>
|
||||
)}
|
||||
{frontend.acl_rules && frontend.acl_rules.length > 0 && (
|
||||
<Descriptions.Item label={`ACL Rules (${frontend.acl_rules.length})`}>
|
||||
@@ -1020,25 +1088,31 @@ backend web-backend
|
||||
</Descriptions.Item>
|
||||
)}
|
||||
{backend.options && (
|
||||
<Descriptions.Item label="Backend Options" span={2}>
|
||||
<Text code style={{ whiteSpace: 'pre-wrap', fontSize: '11px' }}>
|
||||
{backend.options}
|
||||
</Text>
|
||||
</Descriptions.Item>
|
||||
<FieldChange
|
||||
label="Backend Options"
|
||||
oldValue={backend._changes?.options?.old}
|
||||
newValue={backend.options}
|
||||
span={2}
|
||||
isMultiline={true}
|
||||
/>
|
||||
)}
|
||||
{backend.request_headers && (
|
||||
<Descriptions.Item label="Request Headers" span={2}>
|
||||
<Text code style={{ whiteSpace: 'pre-wrap', fontSize: '11px' }}>
|
||||
{backend.request_headers}
|
||||
</Text>
|
||||
</Descriptions.Item>
|
||||
<FieldChange
|
||||
label="Request Headers"
|
||||
oldValue={backend._changes?.request_headers?.old}
|
||||
newValue={backend.request_headers}
|
||||
span={2}
|
||||
isMultiline={true}
|
||||
/>
|
||||
)}
|
||||
{backend.response_headers && (
|
||||
<Descriptions.Item label="Response Headers" span={2}>
|
||||
<Text code style={{ whiteSpace: 'pre-wrap', fontSize: '11px' }}>
|
||||
{backend.response_headers}
|
||||
</Text>
|
||||
</Descriptions.Item>
|
||||
<FieldChange
|
||||
label="Response Headers"
|
||||
oldValue={backend._changes?.response_headers?.old}
|
||||
newValue={backend.response_headers}
|
||||
span={2}
|
||||
isMultiline={true}
|
||||
/>
|
||||
)}
|
||||
</Descriptions>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user