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:
taylanbakircioglu
2026-04-14 20:21:07 +03:00
parent 71c717364c
commit f37f3afd71
8 changed files with 366 additions and 38 deletions
+1 -1
View File
@@ -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
View File
@@ -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 -1
View File
@@ -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
View File
@@ -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 = [
{
+83 -2
View File
@@ -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
+20 -3
View File
@@ -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>
+104 -2
View File
@@ -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>