mirror of
https://github.com/taylanbakircioglu/haproxy-openmanager.git
synced 2026-09-21 01:53:24 +00:00
feat: bulk import change detection, multi-select delete, auto-content stripping
- Server-level change detection in bulk import (field-by-field comparison for 17 server attributes with UPDATE/NO CHANGES status and tooltip) - Multi-select delete for backends and frontends with dependency checks - Dashboard "Backends Summary" address column for servers - Fix unique constraint violation on bulk-create for existing servers (natural key lookup matching DB constraint instead of backend_id FK) - ORDER BY is_active DESC on all entity lookups to prefer active records - Strip auto-generated content (ACME, rate-limit, WAF) from bulk import comparison to eliminate false positive changes on re-import - Fix toolbar overflow with Space wrap prop - Frontend bulk delete modal clarity (selected vs deletable count) - Version bump to 1.3.0 Made-with: Cursor
This commit is contained in:
+1
-1
@@ -8,7 +8,7 @@ import redis
|
||||
import asyncio
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
_version_info = {"version": "1.2.0", "releaseName": "Dark Mode + UI Improvements", "releaseDate": "2026-04-02"}
|
||||
_version_info = {"version": "1.3.0", "releaseName": "Bulk Import Change Detection + Multi-Select Delete", "releaseDate": "2026-04-14"}
|
||||
for _vpath in ["/app/version.json", os.path.join(os.path.dirname(__file__), "..", "version.json")]:
|
||||
try:
|
||||
with open(_vpath) as _vf:
|
||||
|
||||
+139
-24
@@ -908,6 +908,10 @@ async def parse_bulk_config(
|
||||
"ssl_verify": ssl_verify, # Smart: 'required' if SSL matched, else parser value
|
||||
"ssl_certificate_id": ssl_certificate_id, # Smart: Auto-assigned if matched
|
||||
"ssl_certificate_name": ssl_certificate_name, # For UI display
|
||||
"ssl_sni": server.ssl_sni,
|
||||
"ssl_min_ver": server.ssl_min_ver,
|
||||
"ssl_max_ver": server.ssl_max_ver,
|
||||
"ssl_ciphers": server.ssl_ciphers,
|
||||
"cookie_value": server.cookie_value,
|
||||
"inter": server.inter,
|
||||
"fall": server.fall,
|
||||
@@ -936,6 +940,59 @@ async def parse_bulk_config(
|
||||
"servers": servers_data
|
||||
})
|
||||
|
||||
# Strip auto-generated content from parsed data.
|
||||
# The config generator injects content from multiple sources (ACME, rate_limit
|
||||
# field, WAF rules table) that the parser cannot distinguish from user-defined
|
||||
# configuration. Strip all auto-managed patterns so the preview and comparison
|
||||
# only reflect user-defined configuration.
|
||||
def _is_auto_header(line):
|
||||
s = line.strip()
|
||||
if "is_acme_challenge" in s:
|
||||
return True
|
||||
if "track-sc0 src" in s:
|
||||
return True
|
||||
if "sc_http_req_rate(0)" in s:
|
||||
return True
|
||||
if s.startswith("http-request") and " waf_" in s:
|
||||
return True
|
||||
return False
|
||||
|
||||
def _strip_auto_headers(headers_str):
|
||||
if not headers_str:
|
||||
return None
|
||||
lines = [l for l in headers_str.split("\n") if not _is_auto_header(l)]
|
||||
return "\n".join(lines) if lines else None
|
||||
|
||||
backends_data = [b for b in backends_data if b["name"] != "_acme_challenge_backend"]
|
||||
|
||||
for frontend in frontends_data:
|
||||
frontend["acl_rules"] = [
|
||||
r for r in (frontend.get("acl_rules") or [])
|
||||
if "is_acme_challenge" not in r and not r.strip().startswith("acl waf_")
|
||||
]
|
||||
frontend["use_backend_rules"] = [
|
||||
r for r in (frontend.get("use_backend_rules") or [])
|
||||
if "_acme_challenge_backend" not in r
|
||||
]
|
||||
frontend["request_headers"] = _strip_auto_headers(frontend.get("request_headers"))
|
||||
if frontend.get("tcp_request_rules"):
|
||||
lines = [
|
||||
l for l in frontend["tcp_request_rules"].split("\n")
|
||||
if "is_acme_challenge" not in l
|
||||
]
|
||||
frontend["tcp_request_rules"] = "\n".join(lines) if lines else None
|
||||
if frontend.get("default_backend") == "_acme_challenge_backend":
|
||||
remaining = frontend.get("use_backend_rules") or []
|
||||
if remaining:
|
||||
m = re.match(r'^use_backend\s+(\S+)', remaining[0])
|
||||
frontend["default_backend"] = m.group(1) if m else None
|
||||
else:
|
||||
frontend["default_backend"] = None
|
||||
|
||||
parse_result.warnings = [
|
||||
w for w in parse_result.warnings if "_acme_challenge_backend" not in w
|
||||
]
|
||||
|
||||
# CRITICAL: Filter out SSL warnings for auto-assigned certificates
|
||||
# If SSL was auto-assigned, user doesn't need warnings about manual assignment
|
||||
auto_assigned_ssl_names = set()
|
||||
@@ -1011,11 +1068,13 @@ async def parse_bulk_config(
|
||||
new_backends = 0
|
||||
update_backends = 0
|
||||
new_servers = 0
|
||||
update_servers = 0
|
||||
|
||||
for frontend in frontends_data:
|
||||
existing = await conn.fetchrow("""
|
||||
SELECT * FROM frontends
|
||||
WHERE name = $1 AND cluster_id = $2
|
||||
ORDER BY is_active DESC
|
||||
""", frontend["name"], request.cluster_id)
|
||||
|
||||
if existing:
|
||||
@@ -1047,9 +1106,11 @@ async def parse_bulk_config(
|
||||
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"]:
|
||||
parsed_rh = frontend.get("request_headers")
|
||||
db_rh = _strip_auto_headers(existing["request_headers"])
|
||||
if parsed_rh and parsed_rh != db_rh:
|
||||
has_changes = True
|
||||
changes["request_headers"] = {"old": existing["request_headers"], "new": frontend["request_headers"]}
|
||||
changes["request_headers"] = {"old": db_rh, "new": parsed_rh}
|
||||
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"]}
|
||||
@@ -1124,6 +1185,7 @@ async def parse_bulk_config(
|
||||
existing = await conn.fetchrow("""
|
||||
SELECT * FROM backends
|
||||
WHERE name = $1 AND cluster_id = $2
|
||||
ORDER BY is_active DESC
|
||||
""", backend["name"], request.cluster_id)
|
||||
|
||||
if existing:
|
||||
@@ -1197,10 +1259,30 @@ async def parse_bulk_config(
|
||||
if has_changes:
|
||||
update_backends += 1
|
||||
|
||||
# Check servers for this backend
|
||||
# Check servers for this backend — field-level comparison
|
||||
SERVER_COMPARISON_FIELDS = [
|
||||
# (parser_key, db_key, default_value)
|
||||
("server_address", "server_address", None),
|
||||
("server_port", "server_port", None),
|
||||
("weight", "weight", 100),
|
||||
("max_connections", "maxconn", None),
|
||||
("check_enabled", "check_enabled", True),
|
||||
("check_port", "check_port", None),
|
||||
("backup_server", "backup_server", False),
|
||||
("ssl_enabled", "ssl_enabled", False),
|
||||
("ssl_verify", "ssl_verify", None),
|
||||
("ssl_sni", "ssl_sni", None),
|
||||
("ssl_min_ver", "ssl_min_ver", None),
|
||||
("ssl_max_ver", "ssl_max_ver", None),
|
||||
("ssl_ciphers", "ssl_ciphers", None),
|
||||
("cookie_value", "cookie_value", None),
|
||||
("inter", "inter", None),
|
||||
("fall", "fall", None),
|
||||
("rise", "rise", None),
|
||||
]
|
||||
for server in backend.get("servers", []):
|
||||
existing_server = await conn.fetchrow("""
|
||||
SELECT id FROM backend_servers
|
||||
SELECT * FROM backend_servers
|
||||
WHERE backend_name = $1 AND server_name = $2
|
||||
AND cluster_id = $3 AND is_active = TRUE
|
||||
""", backend["name"], server["server_name"], request.cluster_id)
|
||||
@@ -1210,6 +1292,32 @@ async def parse_bulk_config(
|
||||
new_servers += 1
|
||||
else:
|
||||
server["_isNew"] = False
|
||||
server_changes = {}
|
||||
for parser_key, db_key, default_val in SERVER_COMPARISON_FIELDS:
|
||||
parser_val = server.get(parser_key)
|
||||
db_val = existing_server.get(db_key)
|
||||
if default_val is not None:
|
||||
parser_val = default_val if parser_val is None else parser_val
|
||||
db_val = default_val if db_val is None else db_val
|
||||
if parser_val != db_val:
|
||||
server_changes[db_key] = {"old": db_val, "new": parser_val}
|
||||
if server_changes:
|
||||
server["_isUpdate"] = True
|
||||
server["_changes"] = server_changes
|
||||
update_servers += 1
|
||||
else:
|
||||
server["_isUpdate"] = False
|
||||
|
||||
# Propagate server changes to parent backend
|
||||
# _hasServerChanges is informational only (UI tooltip); bulk_create decisions use _isUpdate
|
||||
has_server_changes = any(
|
||||
s.get("_isNew") or s.get("_isUpdate")
|
||||
for s in backend.get("servers", [])
|
||||
)
|
||||
if has_server_changes and not backend["_isUpdate"]:
|
||||
backend["_isUpdate"] = True
|
||||
backend["_hasServerChanges"] = True
|
||||
update_backends += 1
|
||||
else:
|
||||
backend["_isNew"] = True
|
||||
backend["_isUpdate"] = False
|
||||
@@ -1234,7 +1342,8 @@ async def parse_bulk_config(
|
||||
update_frontends=update_frontends,
|
||||
new_backends=new_backends,
|
||||
update_backends=update_backends,
|
||||
new_servers=new_servers
|
||||
new_servers=new_servers,
|
||||
update_servers=update_servers
|
||||
)
|
||||
|
||||
# Close connection before returning
|
||||
@@ -1259,7 +1368,8 @@ async def parse_bulk_config(
|
||||
"update_frontends": update_frontends,
|
||||
"new_backends": new_backends,
|
||||
"update_backends": update_backends,
|
||||
"new_servers": new_servers
|
||||
"new_servers": new_servers,
|
||||
"update_servers": update_servers
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1427,9 +1537,11 @@ async def bulk_create_entities(
|
||||
continue
|
||||
|
||||
# Check if backend already exists (check both active AND inactive)
|
||||
# ORDER BY is_active DESC ensures active backend is preferred over soft-deleted duplicates
|
||||
existing = await conn.fetchrow("""
|
||||
SELECT id, is_active FROM backends
|
||||
WHERE name = $1 AND cluster_id = $2
|
||||
ORDER BY is_active DESC
|
||||
""", backend_data["name"], request.cluster_id)
|
||||
|
||||
backend_id = None
|
||||
@@ -1669,20 +1781,20 @@ async def bulk_create_entities(
|
||||
# SERVERS: Process servers for both CREATE and UPDATE modes
|
||||
# MVP: Only add new servers, preserve existing ones (no deletion)
|
||||
# BUGFIX: Handle soft-deleted servers - check for existing (including inactive) before INSERT
|
||||
backend_has_new_servers = False # Track if new servers were added
|
||||
backend_has_server_changes = False
|
||||
if backend_id: # Only if we have a valid backend_id
|
||||
for server_data in backend_data.get("servers", []):
|
||||
# Only create server if marked as new (MVP: skip existing)
|
||||
if not server_data.get("_isNew", True):
|
||||
if not server_data.get("_isNew", True) and not server_data.get("_isUpdate", False):
|
||||
logger.debug(f"Server '{server_data['server_name']}' already exists in backend '{backend_data['name']}', preserving")
|
||||
continue
|
||||
|
||||
# BUGFIX: Check if server already exists (including soft-deleted)
|
||||
# This prevents unique constraint violation when re-importing after soft delete
|
||||
# Use (backend_name, server_name, cluster_id) to match the unique constraint
|
||||
# instead of backend_id, which may differ after backend soft-delete/re-create cycles
|
||||
existing_server = await conn.fetchrow("""
|
||||
SELECT id, is_active FROM backend_servers
|
||||
WHERE backend_id = $1 AND server_name = $2
|
||||
""", backend_id, server_data["server_name"])
|
||||
SELECT id, is_active, backend_id FROM backend_servers
|
||||
WHERE backend_name = $1 AND server_name = $2 AND cluster_id = $3
|
||||
""", backend_data["name"], server_data["server_name"], request.cluster_id)
|
||||
|
||||
if existing_server:
|
||||
# Server exists - UPDATE instead of INSERT (reactivate if soft-deleted)
|
||||
@@ -1691,7 +1803,7 @@ async def bulk_create_entities(
|
||||
|
||||
# Log warning if active server is being overwritten (unexpected state)
|
||||
# This helps debug when frontend incorrectly marks existing server as new
|
||||
if not was_inactive:
|
||||
if not was_inactive and not server_data.get("_isUpdate", False):
|
||||
logger.warning(f"BULK IMPORT: Server '{server_data['server_name']}' in backend '{backend_data['name']}' already exists and is ACTIVE, will be overwritten")
|
||||
|
||||
# PHASE 4: Create snapshot BEFORE update for rollback support
|
||||
@@ -1713,14 +1825,15 @@ async def bulk_create_entities(
|
||||
|
||||
await conn.execute("""
|
||||
UPDATE backend_servers SET
|
||||
backend_name = $1, server_address = $2, server_port = $3, weight = $4, maxconn = $5,
|
||||
check_enabled = $6, check_port = $7, backup_server = $8,
|
||||
ssl_enabled = $9, ssl_verify = $10, ssl_certificate_id = $11,
|
||||
ssl_sni = $12, ssl_min_ver = $13, ssl_max_ver = $14, ssl_ciphers = $15,
|
||||
cookie_value = $16, inter = $17, fall = $18, rise = $19,
|
||||
backend_id = $1, backend_name = $2, server_address = $3, server_port = $4, weight = $5, maxconn = $6,
|
||||
check_enabled = $7, check_port = $8, backup_server = $9,
|
||||
ssl_enabled = $10, ssl_verify = $11, ssl_certificate_id = $12,
|
||||
ssl_sni = $13, ssl_min_ver = $14, ssl_max_ver = $15, ssl_ciphers = $16,
|
||||
cookie_value = $17, inter = $18, fall = $19, rise = $20,
|
||||
is_active = TRUE, last_config_status = 'PENDING', updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = $20
|
||||
WHERE id = $21
|
||||
""",
|
||||
backend_id, # Fix backend_id if it drifted after soft-delete/re-create
|
||||
backend_data["name"], # backend_name - keep consistent
|
||||
server_data["server_address"],
|
||||
server_data["server_port"],
|
||||
@@ -1760,7 +1873,7 @@ async def bulk_create_entities(
|
||||
"overwritten": True
|
||||
})
|
||||
|
||||
backend_has_new_servers = True
|
||||
backend_has_server_changes = True
|
||||
else:
|
||||
# Server doesn't exist - INSERT new
|
||||
server_id = await conn.fetchval("""
|
||||
@@ -1803,7 +1916,7 @@ async def bulk_create_entities(
|
||||
"name": server_data["server_name"],
|
||||
"backend": backend_data["name"]
|
||||
})
|
||||
backend_has_new_servers = True
|
||||
backend_has_server_changes = True
|
||||
|
||||
# PHASE 4: Create snapshot for new server (CREATE operation)
|
||||
snapshot = await save_entity_snapshot(
|
||||
@@ -1818,14 +1931,14 @@ async def bulk_create_entities(
|
||||
|
||||
# If backend wasn't updated but has new servers, add to updated_entities
|
||||
# This ensures backend gets marked as PENDING for Apply Management
|
||||
if backend_has_new_servers and existing and backend_data.get("_isUpdate"):
|
||||
if backend_has_server_changes and existing and backend_data.get("_isUpdate"):
|
||||
if backend_id not in [b["id"] for b in updated_entities["backends"]]:
|
||||
updated_entities["backends"].append({
|
||||
"id": backend_id,
|
||||
"name": backend_data["name"],
|
||||
"was_inactive": not existing['is_active']
|
||||
})
|
||||
logger.info(f"Backend '{backend_data['name']}' marked for update (new servers added)")
|
||||
logger.info(f"Backend '{backend_data['name']}' marked for update (server changes detected)")
|
||||
|
||||
# BULK IMPORT MVP: Process frontends with UPSERT (merge strategy)
|
||||
for frontend_data in request.frontends:
|
||||
@@ -1840,9 +1953,11 @@ async def bulk_create_entities(
|
||||
continue
|
||||
|
||||
# Check if frontend already exists (check both active AND inactive)
|
||||
# ORDER BY is_active DESC ensures active frontend is preferred over soft-deleted duplicates
|
||||
existing = await conn.fetchrow("""
|
||||
SELECT id, is_active FROM frontends
|
||||
WHERE name = $1 AND cluster_id = $2
|
||||
ORDER BY is_active DESC
|
||||
""", frontend_data["name"], request.cluster_id)
|
||||
|
||||
frontend_id = None
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "haproxy-openmanager-frontend",
|
||||
"version": "1.0.3",
|
||||
"version": "1.3.0",
|
||||
"description": "HAProxy Load Balancer Management UI",
|
||||
"dependencies": {
|
||||
"react": "^18.2.0",
|
||||
|
||||
+1
-1
@@ -54,7 +54,7 @@ import './App.css';
|
||||
const { Header, Sider, Content } = Layout;
|
||||
const { Text } = Typography;
|
||||
|
||||
// v1.2.0 - Dark Mode + UI Improvements
|
||||
// v1.3.0 - Bulk Import Change Detection + Multi-Select Delete
|
||||
|
||||
const menuItems = [
|
||||
{
|
||||
|
||||
@@ -100,6 +100,8 @@ const BackendServers = () => {
|
||||
const [pendingChanges, setPendingChanges] = useState(false);
|
||||
const [versionModalVisible, setVersionModalVisible] = useState(false);
|
||||
const [selectedEntityForVersion, setSelectedEntityForVersion] = useState(null);
|
||||
const [selectedBackendKeys, setSelectedBackendKeys] = useState([]);
|
||||
const [bulkDeleting, setBulkDeleting] = useState(false);
|
||||
const [backendForm] = Form.useForm();
|
||||
const [serverForm] = Form.useForm();
|
||||
|
||||
@@ -116,12 +118,12 @@ const BackendServers = () => {
|
||||
|
||||
useEffect(() => {
|
||||
// CRITICAL FIX: Clear state when cluster changes to prevent showing other cluster's data
|
||||
// Race condition: Old cluster data remains visible while new cluster data is fetching
|
||||
if (selectedCluster) {
|
||||
setBackends([]);
|
||||
setFilteredBackends([]);
|
||||
setFrontends([]);
|
||||
setSslCertificates([]);
|
||||
setSelectedBackendKeys([]);
|
||||
}
|
||||
|
||||
fetchBackends();
|
||||
@@ -655,6 +657,71 @@ const BackendServers = () => {
|
||||
});
|
||||
};
|
||||
|
||||
const handleBulkDeleteBackends = () => {
|
||||
if (!selectedCluster || selectedBackendKeys.length === 0) return;
|
||||
|
||||
const selectedBackends = backends.filter(b => selectedBackendKeys.includes(b.id));
|
||||
const names = selectedBackends.map(b => b.name);
|
||||
const displayNames = names.length <= 10
|
||||
? names.join(', ')
|
||||
: `${names.slice(0, 10).join(', ')} ... and ${names.length - 10} more`;
|
||||
|
||||
Modal.confirm({
|
||||
title: `Delete ${selectedBackendKeys.length} Backend(s)`,
|
||||
content: (
|
||||
<div>
|
||||
<p>Are you sure you want to delete the following backends?</p>
|
||||
<p><Text code>{displayNames}</Text></p>
|
||||
<p><strong>This action cannot be undone.</strong></p>
|
||||
</div>
|
||||
),
|
||||
okText: 'Delete All',
|
||||
okType: 'danger',
|
||||
cancelText: 'Cancel',
|
||||
onOk: async () => {
|
||||
setBulkDeleting(true);
|
||||
let successCount = 0;
|
||||
let errorCount = 0;
|
||||
const errors = [];
|
||||
|
||||
for (const backendId of selectedBackendKeys) {
|
||||
try {
|
||||
await axios.delete(`/api/backends/${backendId}`, {
|
||||
data: { cluster_id: selectedCluster.id }
|
||||
});
|
||||
successCount++;
|
||||
} catch (error) {
|
||||
errorCount++;
|
||||
const backend = backends.find(b => b.id === backendId);
|
||||
const errorMsg = error.response?.data?.detail || error.message;
|
||||
errors.push(`${backend?.name || backendId}: ${errorMsg}`);
|
||||
}
|
||||
}
|
||||
|
||||
setBulkDeleting(false);
|
||||
setSelectedBackendKeys([]);
|
||||
|
||||
if (errorCount === 0) {
|
||||
message.success(`${successCount} backend(s) deleted successfully`);
|
||||
} else {
|
||||
message.warning(
|
||||
<div>
|
||||
<div><strong>{successCount} deleted, {errorCount} failed</strong></div>
|
||||
<div style={{ marginTop: 4, fontSize: '12px' }}>
|
||||
{errors.slice(0, 5).map((e, i) => <div key={i}>{e}</div>)}
|
||||
{errors.length > 5 && <div>... and {errors.length - 5} more errors</div>}
|
||||
</div>
|
||||
</div>,
|
||||
10
|
||||
);
|
||||
}
|
||||
|
||||
fetchBackends();
|
||||
checkPendingChanges();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// Version history modal handlers
|
||||
const handleShowVersionHistory = (record) => {
|
||||
setSelectedEntityForVersion({
|
||||
@@ -1439,7 +1506,7 @@ const BackendServers = () => {
|
||||
</Title>
|
||||
</Col>
|
||||
<Col span={12} style={{ textAlign: 'right' }}>
|
||||
<Space>
|
||||
<Space wrap>
|
||||
<Space>
|
||||
<span style={{ fontSize: 12 }}>Pending</span>
|
||||
<Switch
|
||||
@@ -1524,6 +1591,16 @@ const BackendServers = () => {
|
||||
>
|
||||
Add Backend
|
||||
</Button>
|
||||
{selectedBackendKeys.length > 0 && (
|
||||
<Button
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
onClick={handleBulkDeleteBackends}
|
||||
loading={bulkDeleting}
|
||||
>
|
||||
Delete Selected ({selectedBackendKeys.length})
|
||||
</Button>
|
||||
)}
|
||||
{pendingChanges && (
|
||||
<Button
|
||||
type="primary"
|
||||
@@ -1550,6 +1627,10 @@ const BackendServers = () => {
|
||||
dataSource={filteredBackends}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
rowSelection={{
|
||||
selectedRowKeys: selectedBackendKeys,
|
||||
onChange: setSelectedBackendKeys,
|
||||
}}
|
||||
expandable={{
|
||||
expandedRowRender: record => {
|
||||
// CRITICAL FIX: Handle null/undefined servers array for backends without servers
|
||||
|
||||
@@ -474,8 +474,11 @@ backend web-backend
|
||||
if (record._isNew) {
|
||||
return <Tag color="green">NEW</Tag>;
|
||||
} else if (record._isUpdate) {
|
||||
const tooltipText = record._hasServerChanges && !record._changes
|
||||
? 'Server changes detected - server updates will be applied'
|
||||
: 'Existing backend - values will be compared and updated fields will be saved';
|
||||
return (
|
||||
<Tooltip title="Existing backend - values will be compared and updated fields will be saved">
|
||||
<Tooltip title={tooltipText}>
|
||||
<Tag color="orange">UPDATE</Tag>
|
||||
</Tooltip>
|
||||
);
|
||||
@@ -543,9 +546,20 @@ backend web-backend
|
||||
width: 90,
|
||||
render: (_, record) => {
|
||||
if (record._isNew) {
|
||||
return <Tag color="green">New</Tag>;
|
||||
return <Tag color="green">NEW</Tag>;
|
||||
} else if (record._isUpdate) {
|
||||
const changedFields = record._changes ? Object.keys(record._changes).join(', ') : '';
|
||||
return (
|
||||
<Tooltip title={changedFields ? `Changed: ${changedFields}` : 'Server will be updated'}>
|
||||
<Tag color="orange">UPDATE</Tag>
|
||||
</Tooltip>
|
||||
);
|
||||
} else {
|
||||
return <Tag color="blue">Exists</Tag>;
|
||||
return (
|
||||
<Tooltip title="Server already exists with identical configuration">
|
||||
<Tag color="default">NO CHANGES</Tag>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -1023,6 +1037,9 @@ backend web-backend
|
||||
{parseResult.summary.new_servers > 0 && (
|
||||
<Tag color="green">{parseResult.summary.new_servers} new</Tag>
|
||||
)}
|
||||
{parseResult.summary.update_servers > 0 && (
|
||||
<Tag color="orange">{parseResult.summary.update_servers} update</Tag>
|
||||
)}
|
||||
</Space>
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
|
||||
@@ -139,6 +139,8 @@ const FrontendManagement = () => {
|
||||
const [selectedEntityForVersion, setSelectedEntityForVersion] = useState(null);
|
||||
const [aclBuilderData, setAclBuilderData] = useState({ aclRules: [], useBackendRules: [], redirectRules: [] });
|
||||
const [aclBuilderKey, setAclBuilderKey] = useState(0);
|
||||
const [selectedFrontendKeys, setSelectedFrontendKeys] = useState([]);
|
||||
const [bulkDeleting, setBulkDeleting] = useState(false);
|
||||
const [form] = Form.useForm();
|
||||
|
||||
// SSL visibility control - fields start visible for proper form registration
|
||||
@@ -162,12 +164,12 @@ const FrontendManagement = () => {
|
||||
|
||||
useEffect(() => {
|
||||
// CRITICAL FIX: Clear state when cluster changes to prevent showing other cluster's data
|
||||
// Race condition: Old cluster data remains visible while new cluster data is fetching
|
||||
if (selectedCluster) {
|
||||
setFrontends([]);
|
||||
setFilteredFrontends([]);
|
||||
setBackends([]);
|
||||
setSslCertificates([]);
|
||||
setSelectedFrontendKeys([]);
|
||||
}
|
||||
|
||||
fetchFrontends();
|
||||
@@ -767,6 +769,92 @@ const FrontendManagement = () => {
|
||||
});
|
||||
};
|
||||
|
||||
const handleBulkDeleteFrontends = () => {
|
||||
if (!selectedCluster || selectedFrontendKeys.length === 0) return;
|
||||
|
||||
const selectedFrontends = frontends.filter(f => selectedFrontendKeys.includes(f.id));
|
||||
const undeletable = selectedFrontends.filter(f =>
|
||||
f.default_backend && backends.some(b => b.name === f.default_backend)
|
||||
);
|
||||
const deletable = selectedFrontends.filter(f =>
|
||||
!f.default_backend || !backends.some(b => b.name === f.default_backend)
|
||||
);
|
||||
|
||||
if (deletable.length === 0) {
|
||||
Modal.error({
|
||||
title: 'Cannot Delete',
|
||||
content: `All ${undeletable.length} selected frontend(s) reference active backends and cannot be deleted. Remove their backend associations first.`,
|
||||
okText: 'Understood'
|
||||
});
|
||||
setSelectedFrontendKeys([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const names = deletable.map(f => f.name);
|
||||
const displayNames = names.length <= 10
|
||||
? names.join(', ')
|
||||
: `${names.slice(0, 10).join(', ')} ... and ${names.length - 10} more`;
|
||||
|
||||
const skippedMsg = undeletable.length > 0
|
||||
? `${undeletable.length} frontend(s) with active backend references will be skipped.`
|
||||
: '';
|
||||
|
||||
Modal.confirm({
|
||||
title: `Delete Frontends (${selectedFrontends.length} selected)`,
|
||||
content: (
|
||||
<div>
|
||||
<p>{deletable.length} frontend(s) will be deleted:</p>
|
||||
<p><Text code>{displayNames}</Text></p>
|
||||
{skippedMsg && <p style={{ color: '#faad14' }}>{skippedMsg}</p>}
|
||||
<p><strong>This action cannot be undone.</strong></p>
|
||||
</div>
|
||||
),
|
||||
okText: 'Delete All',
|
||||
okType: 'danger',
|
||||
cancelText: 'Cancel',
|
||||
onOk: async () => {
|
||||
setBulkDeleting(true);
|
||||
let successCount = 0;
|
||||
let errorCount = 0;
|
||||
const errors = [];
|
||||
|
||||
for (const frontend of deletable) {
|
||||
try {
|
||||
await axios.delete(`/api/frontends/${frontend.id}`, {
|
||||
data: { cluster_id: selectedCluster.id }
|
||||
});
|
||||
successCount++;
|
||||
} catch (error) {
|
||||
errorCount++;
|
||||
const errorMsg = error.response?.data?.detail || error.message;
|
||||
errors.push(`${frontend.name}: ${errorMsg}`);
|
||||
}
|
||||
}
|
||||
|
||||
setBulkDeleting(false);
|
||||
setSelectedFrontendKeys([]);
|
||||
|
||||
if (errorCount === 0) {
|
||||
message.success(`${successCount} frontend(s) deleted successfully`);
|
||||
} else {
|
||||
message.warning(
|
||||
<div>
|
||||
<div><strong>{successCount} deleted, {errorCount} failed</strong></div>
|
||||
<div style={{ marginTop: 4, fontSize: '12px' }}>
|
||||
{errors.slice(0, 5).map((e, i) => <div key={i}>{e}</div>)}
|
||||
{errors.length > 5 && <div>... and {errors.length - 5} more errors</div>}
|
||||
</div>
|
||||
</div>,
|
||||
10
|
||||
);
|
||||
}
|
||||
|
||||
fetchFrontends();
|
||||
checkPendingChanges();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleSubmit = async (values) => {
|
||||
if (!selectedCluster) {
|
||||
message.warning('Please select a HAProxy cluster first');
|
||||
@@ -1230,7 +1318,7 @@ const FrontendManagement = () => {
|
||||
</h2>
|
||||
</Col>
|
||||
<Col span={12} style={{ textAlign: 'right' }}>
|
||||
<Space>
|
||||
<Space wrap>
|
||||
<Space>
|
||||
<span style={{ fontSize: 12 }}>Pending</span>
|
||||
<Switch
|
||||
@@ -1307,6 +1395,16 @@ const FrontendManagement = () => {
|
||||
>
|
||||
Add Frontend
|
||||
</Button>
|
||||
{selectedFrontendKeys.length > 0 && (
|
||||
<Button
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
onClick={handleBulkDeleteFrontends}
|
||||
loading={bulkDeleting}
|
||||
>
|
||||
Delete Selected ({selectedFrontendKeys.length})
|
||||
</Button>
|
||||
)}
|
||||
{pendingChanges && (
|
||||
<Button
|
||||
type="primary"
|
||||
@@ -1331,6 +1429,10 @@ const FrontendManagement = () => {
|
||||
dataSource={filteredFrontends}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
rowSelection={{
|
||||
selectedRowKeys: selectedFrontendKeys,
|
||||
onChange: setSelectedFrontendKeys,
|
||||
}}
|
||||
pagination={{
|
||||
total: filteredFrontends.length,
|
||||
showSizeChanger: true,
|
||||
|
||||
@@ -130,7 +130,7 @@ const CompactServerList = ({ data, loading }) => {
|
||||
title: 'Server',
|
||||
dataIndex: 'name',
|
||||
key: 'name',
|
||||
width: '20%',
|
||||
width: '15%',
|
||||
sorter: (a, b) => a.name.localeCompare(b.name),
|
||||
render: (name, record) => (
|
||||
<Space>
|
||||
@@ -143,10 +143,18 @@ const CompactServerList = ({ data, loading }) => {
|
||||
title: 'Backend',
|
||||
dataIndex: 'backend',
|
||||
key: 'backend',
|
||||
width: '15%',
|
||||
width: '12%',
|
||||
sorter: (a, b) => a.backend.localeCompare(b.backend),
|
||||
render: (backend) => <Tag color="blue">{backend}</Tag>
|
||||
},
|
||||
{
|
||||
title: 'Address',
|
||||
dataIndex: 'address',
|
||||
key: 'address',
|
||||
width: '12%',
|
||||
sorter: (a, b) => (a.address || '').localeCompare(b.address || ''),
|
||||
render: (address) => address ? <Text code style={{ fontSize: '12px' }}>{address}</Text> : '-'
|
||||
},
|
||||
{
|
||||
title: 'Status',
|
||||
dataIndex: 'status',
|
||||
@@ -183,7 +191,7 @@ const CompactServerList = ({ data, loading }) => {
|
||||
title: 'Response Time',
|
||||
dataIndex: 'response_time',
|
||||
key: 'response_time',
|
||||
width: '12%',
|
||||
width: '10%',
|
||||
align: 'right',
|
||||
sorter: (a, b) => (a.response_time || 0) - (b.response_time || 0),
|
||||
render: (time) => <Text>{time ? `${time}ms` : '-'}</Text>
|
||||
@@ -201,7 +209,7 @@ const CompactServerList = ({ data, loading }) => {
|
||||
title: 'Health Check',
|
||||
dataIndex: 'check_status',
|
||||
key: 'check_status',
|
||||
width: '15%',
|
||||
width: '13%',
|
||||
ellipsis: {
|
||||
showTitle: false
|
||||
},
|
||||
@@ -244,6 +252,9 @@ const CompactServerList = ({ data, loading }) => {
|
||||
<div style={{ flex: '0 0 150px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
<Tag color="blue" style={{ fontSize: '11px' }}>{server.backend}</Tag>
|
||||
</div>
|
||||
<div style={{ flex: '0 0 150px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
<Text code style={{ fontSize: '11px' }}>{server.address || '-'}</Text>
|
||||
</div>
|
||||
<div style={{ flex: '0 0 80px' }}>
|
||||
<Tag color={statusColor} icon={statusIcon} style={{ fontSize: '11px' }}>
|
||||
{server.status}
|
||||
@@ -291,6 +302,7 @@ const CompactServerList = ({ data, loading }) => {
|
||||
<Badge status={server.status === 'UP' ? 'success' : server.status === 'DOWN' ? 'error' : 'default'} />
|
||||
<Text strong style={{ fontSize: '14px' }}>{server.name}</Text>
|
||||
<Tag color="blue">{server.backend}</Tag>
|
||||
{server.address && <Text code style={{ fontSize: '12px' }}>{server.address}</Text>}
|
||||
<Tag color={statusColor} icon={statusIcon}>{server.status}</Tag>
|
||||
</div>
|
||||
<div style={{
|
||||
@@ -535,6 +547,7 @@ const CompactServerList = ({ data, loading }) => {
|
||||
<div style={{ width: '24px' }}></div>
|
||||
<div style={{ flex: '0 0 200px' }}>Server Name</div>
|
||||
<div style={{ flex: '0 0 150px' }}>Backend</div>
|
||||
<div style={{ flex: '0 0 150px' }}>Address</div>
|
||||
<div style={{ flex: '0 0 80px' }}>Status</div>
|
||||
<div style={{ flex: '0 0 60px', textAlign: 'right' }}>Weight</div>
|
||||
<div style={{ flex: '0 0 80px', textAlign: 'right' }}>Response</div>
|
||||
|
||||
Reference in New Issue
Block a user