mirror of
https://github.com/taylanbakircioglu/haproxy-openmanager.git
synced 2026-10-04 04:21:30 +00:00
a1192e602d
Manage highly-available virtual IPs backed by Keepalived (VRRP) directly from the OpenManager
UI — no more SSHing into nodes to install/configure Keepalived by hand. Builds on the agent
pull-architecture: define the VIP centrally, click Apply, and the agents converge.
Highlights:
- New "HA / VIP" tab: create a virtual IP, pick a per-node interface, select which pool nodes
participate (MASTER/BACKUP roles + priorities); live MASTER/BACKUP/FAULT per node.
- On Apply, agents install & configure Keepalived (unicast VRRP, cloud-safe default) across the
major distros (Debian/Ubuntu, RHEL/CentOS/Alma/Rocky, Fedora, SUSE/openSUSE, Alpine) with a
HAProxy health-check, so the VIP fails over automatically when HAProxy drops.
- Single-node (a managed floating IP without failover) and multi-node VRRP failover both work.
- VIP changes ride the standard Apply Management flow with the standard "View Change" diff.
- Approval-gated deletion (safety): deleting a running VIP is staged for approval and the VIP
keeps running, untouched, until you approve it — an agent never tears a VIP down without an
explicit human approval. Per-VIP Diagnostics view; opt-in package uninstall (only on nodes
where OpenManager installed it). A node already running a hand-managed Keepalived is detected
and never overwritten ("externally managed").
- Fully opt-in and backward compatible: nodes/clusters without a VIP are unaffected. Adds
vip_instances + vip_members tables (idempotent SCHEMA_VERSION bump; existing data and
passwords unaffected) and a `vip` RBAC permission group.
- Also includes a HAProxy config-generator robustness fix: auto-inject a stick-table when a
frontend uses a stick counter (track-sc / sc_*_rate) but declares none.
On-prem / L2 (VRRP) scope; the UI notes the cloud caveat.
900 lines
32 KiB
JavaScript
900 lines
32 KiB
JavaScript
import React, { useState, useEffect } from 'react';
|
|
import {
|
|
Table, Button, Space, Modal, Form, Input, Select, Tag, message, Switch,
|
|
Tooltip, Popconfirm, Row, Col, Divider, Typography, Badge, Card, Alert, theme
|
|
} from 'antd';
|
|
import {
|
|
PlusOutlined, EditOutlined, DeleteOutlined,
|
|
LoadingOutlined, CloudServerOutlined, DownloadOutlined, SettingOutlined,
|
|
SafetyCertificateOutlined, UsergroupAddOutlined, SearchOutlined, WarningOutlined
|
|
} from '@ant-design/icons';
|
|
import { useCluster } from '../contexts/ClusterContext';
|
|
import { extractApiError } from '../utils/apiError';
|
|
|
|
const { Option } = Select;
|
|
const { TextArea } = Input;
|
|
const { Title, Text } = Typography;
|
|
|
|
const ClusterManagement = () => {
|
|
const {
|
|
clusters,
|
|
loading,
|
|
addCluster,
|
|
updateCluster,
|
|
deleteCluster,
|
|
fetchClusters
|
|
} = useCluster();
|
|
|
|
const [modalVisible, setModalVisible] = useState(false);
|
|
const [editingCluster, setEditingCluster] = useState(null);
|
|
const [form] = Form.useForm();
|
|
const [searchText, setSearchText] = useState('');
|
|
const [filteredClusters, setFilteredClusters] = useState([]);
|
|
|
|
// Agent Management Integration
|
|
const [agentPools, setAgentPools] = useState([]);
|
|
const [loadingPools, setLoadingPools] = useState(false);
|
|
|
|
// Path Discovery
|
|
const [pathDiscoveryVisible, setPathDiscoveryVisible] = useState(false);
|
|
|
|
// Agent HAProxy versions per cluster
|
|
const [clusterAgents, setClusterAgents] = useState({});
|
|
const [loadingAgents, setLoadingAgents] = useState(false);
|
|
|
|
const showPathDiscovery = () => {
|
|
setPathDiscoveryVisible(true);
|
|
};
|
|
|
|
// Fetch agents for all clusters to get haproxy_version info
|
|
const fetchAgentsForClusters = async (clusterList) => {
|
|
if (!clusterList || clusterList.length === 0) return;
|
|
|
|
setLoadingAgents(true);
|
|
const agentsByCluster = {};
|
|
|
|
try {
|
|
// Get unique pool_ids from clusters
|
|
const poolIds = [...new Set(clusterList.filter(c => c.pool_id).map(c => c.pool_id))];
|
|
|
|
// Fetch agents for each pool
|
|
for (const poolId of poolIds) {
|
|
try {
|
|
const response = await fetch(`/api/agents?pool_id=${poolId}`, {
|
|
headers: {
|
|
'Authorization': `Bearer ${localStorage.getItem('authToken') || ''}`,
|
|
},
|
|
});
|
|
if (response.ok) {
|
|
const data = await response.json();
|
|
const agents = data.agents || [];
|
|
|
|
// Map agents to clusters by pool_id
|
|
clusterList.filter(c => c.pool_id === poolId).forEach(cluster => {
|
|
agentsByCluster[cluster.id] = agents.map(a => ({
|
|
name: a.name,
|
|
haproxy_version: a.haproxy_version,
|
|
status: a.status,
|
|
keepalive_state: a.keepalive_state,
|
|
keepalive_ip: a.keepalive_ip
|
|
}));
|
|
});
|
|
}
|
|
} catch (err) {
|
|
console.error(`Error fetching agents for pool ${poolId}:`, err);
|
|
}
|
|
}
|
|
|
|
setClusterAgents(agentsByCluster);
|
|
} catch (error) {
|
|
console.error('Error fetching agents for clusters:', error);
|
|
} finally {
|
|
setLoadingAgents(false);
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
fetchClusters();
|
|
fetchAgentPools();
|
|
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
|
|
|
useEffect(() => {
|
|
setFilteredClusters(clusters);
|
|
// Fetch agents for all clusters to display HAProxy versions
|
|
if (clusters && clusters.length > 0) {
|
|
fetchAgentsForClusters(clusters);
|
|
}
|
|
}, [clusters]);
|
|
|
|
const { token } = theme.useToken();
|
|
|
|
// Fetch Agent Pools for Agent connection type
|
|
const fetchAgentPools = async () => {
|
|
setLoadingPools(true);
|
|
try {
|
|
const response = await fetch('/api/haproxy-cluster-pools', {
|
|
headers: {
|
|
'Authorization': `Bearer ${localStorage.getItem('authToken') || ''}`,
|
|
},
|
|
});
|
|
if (response.ok) {
|
|
const data = await response.json();
|
|
setAgentPools(data.pools || []); // Extract pools array from response
|
|
} else {
|
|
console.error('Failed to fetch agent pools:', response.status, response.statusText);
|
|
setAgentPools([]);
|
|
}
|
|
} catch (error) {
|
|
console.error('Error fetching agent pools:', error);
|
|
setAgentPools([]);
|
|
} finally {
|
|
setLoadingPools(false);
|
|
}
|
|
};
|
|
|
|
const handleAddCluster = () => {
|
|
setEditingCluster(null);
|
|
form.resetFields();
|
|
form.setFieldsValue({
|
|
connection_type: 'agent'
|
|
// ✅ FIXED: No default values - let user enter their own paths
|
|
});
|
|
setModalVisible(true);
|
|
};
|
|
|
|
const handleEditCluster = (cluster) => {
|
|
setEditingCluster(cluster);
|
|
|
|
// 🔍 DEBUG: Log cluster data to see what we're getting from backend
|
|
console.log('🔍 CLUSTER EDIT DEBUG - Raw cluster data:', cluster);
|
|
console.log('🔍 CLUSTER EDIT DEBUG - haproxy_bin_path:', cluster.haproxy_bin_path);
|
|
|
|
const formValues = {
|
|
name: cluster.name,
|
|
description: cluster.description,
|
|
connection_type: cluster.connection_type,
|
|
stats_socket_path: cluster.stats_socket_path || '/run/haproxy/admin.sock',
|
|
haproxy_config_path: cluster.haproxy_config_path || '/etc/haproxy/haproxy.cfg',
|
|
haproxy_bin_path: cluster.haproxy_bin_path,
|
|
keepalived_config_path: cluster.keepalived_config_path || '/etc/keepalived/keepalived.conf',
|
|
agent_pool_id: cluster.pool_id || undefined,
|
|
haproxy_user: cluster.haproxy_user || '',
|
|
haproxy_group: cluster.haproxy_group || '',
|
|
acme_enabled: cluster.acme_enabled || false
|
|
};
|
|
|
|
console.log('🔍 CLUSTER EDIT DEBUG - Form values being set:', formValues);
|
|
|
|
form.setFieldsValue(formValues);
|
|
setModalVisible(true);
|
|
};
|
|
|
|
const handleDeleteCluster = async (clusterId) => {
|
|
try {
|
|
await deleteCluster(clusterId);
|
|
message.success('Cluster deleted successfully');
|
|
} catch (error) {
|
|
message.error('Failed to delete cluster: ' + (extractApiError(error, error.message)));
|
|
}
|
|
};
|
|
|
|
const handleModalCancel = () => {
|
|
setModalVisible(false);
|
|
setEditingCluster(null);
|
|
form.resetFields();
|
|
};
|
|
|
|
// Removed handleTestModalConnection
|
|
|
|
const handleModalOk = async () => {
|
|
try {
|
|
const values = await form.validateFields();
|
|
|
|
// Transform form values to match simplified backend API (agent-based only)
|
|
const transformedValues = {
|
|
name: values.name,
|
|
description: values.description,
|
|
connection_type: values.connection_type || 'agent',
|
|
stats_socket_path: values.stats_socket_path,
|
|
haproxy_config_path: values.haproxy_config_path,
|
|
haproxy_bin_path: values.haproxy_bin_path,
|
|
keepalived_config_path: values.keepalived_config_path,
|
|
pool_id: values.agent_pool_id || null,
|
|
haproxy_user: values.haproxy_user || null,
|
|
haproxy_group: values.haproxy_group || null,
|
|
acme_enabled: values.acme_enabled || false
|
|
};
|
|
|
|
// For new clusters, explicitly set is_active to true
|
|
// For existing clusters (edit), don't send is_active so backend keeps the current value
|
|
if (!editingCluster) {
|
|
transformedValues.is_active = true;
|
|
}
|
|
|
|
const response = await fetch(editingCluster ?
|
|
`/api/clusters/${editingCluster.id}` :
|
|
'/api/clusters', {
|
|
method: editingCluster ? 'PUT' : 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Authorization': `Bearer ${localStorage.getItem('authToken') || localStorage.getItem('token') || ''}`,
|
|
},
|
|
body: JSON.stringify(transformedValues),
|
|
});
|
|
|
|
const result = await response.json();
|
|
|
|
if (response.ok) {
|
|
message.success(editingCluster ? 'Cluster updated successfully' : 'Cluster created successfully');
|
|
setModalVisible(false);
|
|
setEditingCluster(null);
|
|
form.resetFields();
|
|
await fetchClusters();
|
|
} else {
|
|
message.error(result.detail || result.message || 'An error occurred');
|
|
}
|
|
} catch (error) {
|
|
message.error('Error: ' + error.message);
|
|
}
|
|
};
|
|
|
|
const handleSearch = (value) => {
|
|
const filtered = clusters.filter(cluster => {
|
|
const nameMatch = cluster.name.toLowerCase().includes(value.toLowerCase());
|
|
const descMatch = cluster.description && cluster.description.toLowerCase().includes(value.toLowerCase());
|
|
const agents = clusterAgents[cluster.id] || [];
|
|
const keepaliveMatch = agents.some(a =>
|
|
a.keepalive_ip && a.keepalive_ip.toLowerCase().includes(value.toLowerCase())
|
|
);
|
|
return nameMatch || descMatch || keepaliveMatch;
|
|
});
|
|
setFilteredClusters(filtered);
|
|
setSearchText(value);
|
|
};
|
|
|
|
const getStatusBadge = (cluster) => {
|
|
const status = cluster.agent_status || cluster.connection_status || 'unknown';
|
|
const statusConfig = {
|
|
'healthy': { status: 'success', text: 'Healthy' },
|
|
'warning': { status: 'warning', text: 'Warning' },
|
|
'offline': { status: 'error', text: 'Offline' },
|
|
'no-agents': { status: 'default', text: 'No Agents' },
|
|
'connected': { status: 'success', text: 'Connected' },
|
|
'unknown': { status: 'default', text: 'Unknown' },
|
|
'error': { status: 'error', text: 'Error' }
|
|
};
|
|
|
|
const config = statusConfig[status] || statusConfig['unknown'];
|
|
return <Badge status={config.status} text={config.text} />;
|
|
};
|
|
|
|
// Get HAProxy version info for a cluster
|
|
const getHAProxyVersionInfo = (clusterId) => {
|
|
const agents = clusterAgents[clusterId] || [];
|
|
if (agents.length === 0) {
|
|
return { versions: [], versionDetails: [], hasVersionMismatch: false, totalAgents: 0 };
|
|
}
|
|
|
|
// Get unique versions (excluding 'unknown' and empty values)
|
|
const validAgents = agents.filter(a => a.haproxy_version && a.haproxy_version !== 'unknown' && a.haproxy_version.trim() !== '');
|
|
|
|
// Group agents by version
|
|
const versionMap = {};
|
|
validAgents.forEach(a => {
|
|
if (!versionMap[a.haproxy_version]) {
|
|
versionMap[a.haproxy_version] = [];
|
|
}
|
|
versionMap[a.haproxy_version].push(a.name);
|
|
});
|
|
|
|
const uniqueVersions = Object.keys(versionMap);
|
|
const hasVersionMismatch = uniqueVersions.length > 1;
|
|
|
|
// Create version details with agent names
|
|
const versionDetails = uniqueVersions.map(version => ({
|
|
version,
|
|
agents: versionMap[version],
|
|
count: versionMap[version].length
|
|
}));
|
|
|
|
return {
|
|
versions: uniqueVersions,
|
|
versionDetails,
|
|
hasVersionMismatch,
|
|
totalAgents: agents.length,
|
|
validAgentsCount: validAgents.length
|
|
};
|
|
};
|
|
|
|
const columns = [
|
|
{
|
|
title: 'Name',
|
|
dataIndex: 'name',
|
|
key: 'name',
|
|
render: (text, record) => (
|
|
<div>
|
|
<strong>{text}</strong>
|
|
{record.is_default && (
|
|
<Tag color="gold" style={{ marginLeft: 8 }}>
|
|
Default
|
|
</Tag>
|
|
)}
|
|
</div>
|
|
),
|
|
},
|
|
{
|
|
title: 'Description',
|
|
dataIndex: 'description',
|
|
key: 'description',
|
|
width: 200,
|
|
render: (text) => {
|
|
if (!text) return <Text type="secondary">No description</Text>;
|
|
return (
|
|
<Tooltip title={text} mouseEnterDelay={0.5}>
|
|
<div style={{
|
|
fontSize: '12px',
|
|
lineHeight: '1.4',
|
|
display: '-webkit-box',
|
|
WebkitLineClamp: 2,
|
|
WebkitBoxOrient: 'vertical',
|
|
overflow: 'hidden',
|
|
textOverflow: 'ellipsis',
|
|
wordBreak: 'break-word',
|
|
}}>
|
|
{text}
|
|
</div>
|
|
</Tooltip>
|
|
);
|
|
},
|
|
},
|
|
{
|
|
title: 'Agent Pool',
|
|
dataIndex: 'pool_name',
|
|
key: 'pool_name',
|
|
render: (poolName, record) => {
|
|
if (!poolName) {
|
|
return <Text type="secondary">No pool assigned</Text>;
|
|
}
|
|
return (
|
|
<Tag color="blue" icon={<UsergroupAddOutlined />}>
|
|
{poolName}
|
|
</Tag>
|
|
);
|
|
},
|
|
},
|
|
{
|
|
title: 'Connection',
|
|
dataIndex: 'connection_type',
|
|
key: 'connection_type',
|
|
width: 110,
|
|
render: (type) => (
|
|
<Tag color={type === 'agent' ? 'green' : 'purple'} style={{ fontSize: '11px' }}>
|
|
{type === 'agent' ? 'Agent' : 'Sidecar'}
|
|
</Tag>
|
|
),
|
|
},
|
|
{
|
|
title: 'Status',
|
|
key: 'status',
|
|
render: (_, record) => (
|
|
<div>
|
|
{getStatusBadge(record)}
|
|
{(record.total_agents > 0) && (
|
|
<div style={{ fontSize: '12px', color: token.colorTextSecondary, marginTop: '4px' }}>
|
|
{record.healthy_agents}/{record.total_agents} agents healthy
|
|
</div>
|
|
)}
|
|
</div>
|
|
),
|
|
},
|
|
{
|
|
title: 'HAProxy Ver.',
|
|
key: 'haproxy_version',
|
|
width: 170,
|
|
render: (_, record) => {
|
|
const versionInfo = getHAProxyVersionInfo(record.id);
|
|
|
|
if (loadingAgents) {
|
|
return (
|
|
<Space size={4}>
|
|
<LoadingOutlined style={{ fontSize: '12px', color: '#1890ff' }} spin />
|
|
<Text type="secondary" style={{ fontSize: '11px' }}>Loading...</Text>
|
|
</Space>
|
|
);
|
|
}
|
|
|
|
if (!versionInfo.versions || versionInfo.versions.length === 0) {
|
|
if (record.total_agents === 0) {
|
|
return <Text type="secondary" style={{ fontSize: '11px' }}>No agents</Text>;
|
|
}
|
|
return <Text type="secondary" style={{ fontSize: '11px' }}>-</Text>;
|
|
}
|
|
|
|
return (
|
|
<div style={{ display: 'flex', alignItems: 'flex-start', gap: '6px' }}>
|
|
{versionInfo.hasVersionMismatch && (
|
|
<Tooltip title="Agents in this cluster have different HAProxy versions">
|
|
<WarningOutlined style={{ color: '#faad14', fontSize: '14px', marginTop: '2px' }} />
|
|
</Tooltip>
|
|
)}
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
|
|
{versionInfo.versionDetails.map((detail, idx) => (
|
|
<div key={idx}>
|
|
<Text
|
|
style={{
|
|
fontSize: '11px',
|
|
color: versionInfo.hasVersionMismatch ? '#faad14' : '#52c41a',
|
|
fontFamily: 'monospace',
|
|
fontWeight: 500
|
|
}}
|
|
>
|
|
{detail.version}
|
|
</Text>
|
|
<div style={{ marginLeft: '8px', marginTop: '1px' }}>
|
|
<Text type="secondary" style={{ fontSize: '10px' }}>
|
|
{detail.agents.join(', ')}
|
|
</Text>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
},
|
|
},
|
|
{
|
|
title: 'Keepalive',
|
|
key: 'keepalive',
|
|
width: 155,
|
|
render: (_, record) => {
|
|
const agents = clusterAgents[record.id] || [];
|
|
const keepaliveAgents = agents.filter(a => a.keepalive_state && a.keepalive_state !== 'NONE' && a.keepalive_state !== '');
|
|
|
|
if (loadingAgents) {
|
|
return (
|
|
<Space size={4}>
|
|
<LoadingOutlined style={{ fontSize: '12px', color: '#1890ff' }} spin />
|
|
<Text type="secondary" style={{ fontSize: '11px' }}>Loading...</Text>
|
|
</Space>
|
|
);
|
|
}
|
|
|
|
if (keepaliveAgents.length === 0) {
|
|
return <Text type="secondary" style={{ fontSize: '11px' }}>-</Text>;
|
|
}
|
|
|
|
const displayAgents = keepaliveAgents.slice(0, 4);
|
|
const remaining = keepaliveAgents.length - displayAgents.length;
|
|
const uniqueVips = [...new Set(keepaliveAgents.map(a => a.keepalive_ip).filter(Boolean))];
|
|
|
|
return (
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '3px' }}>
|
|
{displayAgents.map((a, idx) => (
|
|
<div key={idx} style={{ display: 'flex', alignItems: 'center', gap: '4px', flexWrap: 'nowrap' }}>
|
|
<Text style={{ fontSize: '10px', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', maxWidth: '70px' }}>{a.name}</Text>
|
|
<Tag
|
|
color={a.keepalive_state === 'MASTER' ? 'green' : 'orange'}
|
|
style={{ fontSize: '10px', lineHeight: '16px', padding: '0 3px', margin: 0, flexShrink: 0 }}
|
|
>
|
|
{a.keepalive_state}
|
|
</Tag>
|
|
</div>
|
|
))}
|
|
{remaining > 0 && (
|
|
<Text type="secondary" style={{ fontSize: '10px' }}>+{remaining} more</Text>
|
|
)}
|
|
{uniqueVips.length > 0 && (
|
|
<Text type="secondary" style={{ fontSize: '10px', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', maxWidth: '145px' }}>
|
|
VIP: {uniqueVips.join(', ')}
|
|
</Text>
|
|
)}
|
|
</div>
|
|
);
|
|
},
|
|
},
|
|
{
|
|
title: 'Actions',
|
|
key: 'actions',
|
|
render: (_, record) => (
|
|
<Space>
|
|
<Tooltip title="Edit Cluster">
|
|
<Button
|
|
type="text"
|
|
icon={<EditOutlined />}
|
|
onClick={() => handleEditCluster(record)}
|
|
/>
|
|
</Tooltip>
|
|
|
|
{!record.is_default && (
|
|
<Popconfirm
|
|
title="Delete Cluster"
|
|
description="Are you sure you want to delete this cluster?"
|
|
onConfirm={() => handleDeleteCluster(record.id)}
|
|
okText="Delete"
|
|
cancelText="Cancel"
|
|
okType="danger"
|
|
>
|
|
<Tooltip title="Delete Cluster">
|
|
<Button
|
|
type="text"
|
|
danger
|
|
icon={<DeleteOutlined />}
|
|
/>
|
|
</Tooltip>
|
|
</Popconfirm>
|
|
)}
|
|
</Space>
|
|
),
|
|
},
|
|
];
|
|
|
|
return (
|
|
<div>
|
|
<Card>
|
|
<div style={{ marginBottom: '24px' }}>
|
|
<Title level={2} style={{ margin: 0 }}>
|
|
HAProxy Cluster Management
|
|
</Title>
|
|
<Text type="secondary">
|
|
Manage remote HAProxy clusters through agent connections
|
|
</Text>
|
|
</div>
|
|
|
|
<div style={{ marginBottom: '16px', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
|
<div style={{
|
|
position: 'relative',
|
|
display: 'inline-block',
|
|
width: 300
|
|
}}>
|
|
<input
|
|
type="text"
|
|
placeholder="Search clusters..."
|
|
onChange={(e) => handleSearch(e.target.value)}
|
|
style={{
|
|
width: '100%',
|
|
height: 32,
|
|
paddingLeft: 8,
|
|
paddingRight: 8,
|
|
border: `1px solid ${token.colorBorder}`,
|
|
borderRadius: 6,
|
|
fontSize: 14,
|
|
outline: 'none',
|
|
boxShadow: 'none',
|
|
backgroundColor: token.colorBgContainer,
|
|
transition: 'border-color 0.3s ease'
|
|
}}
|
|
onFocus={(e) => {
|
|
e.target.style.borderColor = '#1890ff';
|
|
e.target.style.outline = 'none';
|
|
e.target.style.boxShadow = 'none';
|
|
}}
|
|
onBlur={(e) => {
|
|
e.target.style.borderColor = token.colorBorder;
|
|
}}
|
|
onMouseOver={(e) => {
|
|
if (e.target !== document.activeElement) {
|
|
e.target.style.borderColor = '#40a9ff';
|
|
}
|
|
}}
|
|
onMouseOut={(e) => {
|
|
if (e.target !== document.activeElement) {
|
|
e.target.style.borderColor = token.colorBorder;
|
|
}
|
|
}}
|
|
/>
|
|
</div>
|
|
<Button
|
|
type="primary"
|
|
icon={<PlusOutlined />}
|
|
onClick={handleAddCluster}
|
|
>
|
|
Add New Cluster
|
|
</Button>
|
|
</div>
|
|
|
|
<Table
|
|
columns={columns}
|
|
dataSource={filteredClusters}
|
|
rowKey="id"
|
|
loading={loading}
|
|
scroll={{ x: 1100 }}
|
|
pagination={{
|
|
pageSize: 10,
|
|
showSizeChanger: true,
|
|
showQuickJumper: true,
|
|
showTotal: (total) => `Total ${total} clusters`,
|
|
}}
|
|
/>
|
|
</Card>
|
|
|
|
<Modal
|
|
title={editingCluster ? 'Edit HAProxy Cluster' : 'Add New HAProxy Cluster'}
|
|
open={modalVisible}
|
|
onCancel={handleModalCancel}
|
|
width={600}
|
|
destroyOnClose
|
|
footer={[
|
|
<Button key="cancel" onClick={handleModalCancel}>
|
|
Cancel
|
|
</Button>,
|
|
<Button key="submit" type="primary" onClick={handleModalOk}>
|
|
{editingCluster ? 'Update' : 'Create'} Cluster
|
|
</Button>,
|
|
]}
|
|
>
|
|
<Form
|
|
form={form}
|
|
layout="vertical"
|
|
initialValues={{
|
|
connection_type: 'agent'
|
|
// ✅ FIXED: No default paths - user must enter their own values
|
|
}}
|
|
>
|
|
<Form.Item
|
|
label="Cluster Name"
|
|
name="name"
|
|
rules={[
|
|
{ required: true, message: 'Please enter cluster name' },
|
|
{ min: 2, message: 'Name must be at least 2 characters' }
|
|
]}
|
|
>
|
|
<Input placeholder="e.g., Production HAProxy" />
|
|
</Form.Item>
|
|
|
|
<Form.Item
|
|
label="Description"
|
|
name="description"
|
|
>
|
|
<TextArea
|
|
rows={2}
|
|
placeholder="Optional description for this cluster"
|
|
/>
|
|
</Form.Item>
|
|
|
|
<Form.Item
|
|
label="Connection Type"
|
|
name="connection_type"
|
|
rules={[{ required: true, message: 'Please select connection type' }]}
|
|
>
|
|
<Select>
|
|
<Option value="agent">Agent Connection</Option>
|
|
</Select>
|
|
</Form.Item>
|
|
|
|
<Form.Item
|
|
label="Agent Pool"
|
|
name="agent_pool_id"
|
|
tooltip="Select the pool where agents will connect to this cluster"
|
|
>
|
|
<Select
|
|
placeholder="Choose an agent pool (optional)"
|
|
loading={loadingPools}
|
|
allowClear
|
|
optionLabelProp="label"
|
|
>
|
|
{agentPools.map(pool => (
|
|
<Option
|
|
key={pool.id}
|
|
value={pool.id}
|
|
title={pool.description || `${pool.name} - ${pool.environment} environment`}
|
|
label={
|
|
<Space>
|
|
<Text strong>{pool.name}</Text>
|
|
<Tag color={pool.environment === 'production' ? 'red' : pool.environment === 'staging' ? 'orange' : 'blue'} size="small">
|
|
{pool.environment}
|
|
</Tag>
|
|
</Space>
|
|
}
|
|
>
|
|
<Space style={{ width: '100%', justifyContent: 'space-between' }}>
|
|
<Space>
|
|
<Text strong>{pool.name}</Text>
|
|
{pool.location && (
|
|
<Text type="secondary" style={{ fontSize: '11px' }}>
|
|
📍 {pool.location}
|
|
</Text>
|
|
)}
|
|
</Space>
|
|
<Tag color={pool.environment === 'production' ? 'red' : pool.environment === 'staging' ? 'orange' : 'blue'}>
|
|
{pool.environment}
|
|
</Tag>
|
|
</Space>
|
|
</Option>
|
|
))}
|
|
</Select>
|
|
</Form.Item>
|
|
|
|
<Row>
|
|
<Col span={20}>
|
|
<Typography.Title level={5} style={{ marginBottom: 16 }}>
|
|
HAProxy Paths Configuration
|
|
</Typography.Title>
|
|
</Col>
|
|
<Col span={4} style={{ textAlign: 'right' }}>
|
|
<Button
|
|
type="link"
|
|
size="small"
|
|
icon={<SearchOutlined />}
|
|
onClick={showPathDiscovery}
|
|
style={{ padding: 0 }}
|
|
>
|
|
Path Helper
|
|
</Button>
|
|
</Col>
|
|
</Row>
|
|
|
|
<Form.Item
|
|
label="Stats Socket Path"
|
|
name="stats_socket_path"
|
|
rules={[{ required: true, message: 'Please enter stats socket path' }]}
|
|
tooltip="HAProxy stats socket path - agent will use this to communicate with HAProxy"
|
|
>
|
|
<Input placeholder="/run/haproxy/admin.sock" />
|
|
</Form.Item>
|
|
|
|
<Form.Item
|
|
label="HAProxy Config Path"
|
|
name="haproxy_config_path"
|
|
rules={[{ required: true, message: 'Please enter HAProxy config path' }]}
|
|
tooltip="HAProxy configuration file path - agent will manage this file"
|
|
>
|
|
<Input placeholder="/etc/haproxy/haproxy.cfg" />
|
|
</Form.Item>
|
|
|
|
<Form.Item
|
|
label="HAProxy Binary Path"
|
|
name="haproxy_bin_path"
|
|
rules={[{ required: true, message: 'Please enter HAProxy binary path' }]}
|
|
tooltip="HAProxy binary executable path - used for config validation and service management"
|
|
>
|
|
<Input placeholder="/usr/sbin/haproxy" />
|
|
</Form.Item>
|
|
|
|
<Form.Item
|
|
label="Keepalived Config Path"
|
|
name="keepalived_config_path"
|
|
tooltip="Where the agent writes keepalived.conf for this cluster's HA/VIPs. The default (/etc/keepalived/keepalived.conf) is what the keepalived service loads on every distro — leave it unless you run a non-standard install AND have configured the keepalived service/unit to load this exact path."
|
|
>
|
|
<Input placeholder="/etc/keepalived/keepalived.conf" />
|
|
</Form.Item>
|
|
|
|
<Form.Item
|
|
label="ACME Challenge Routing"
|
|
name="acme_enabled"
|
|
valuePropName="checked"
|
|
tooltip="Enable automatic ACME HTTP-01 challenge routing through this cluster's HTTP frontends for automated SSL certificate management"
|
|
>
|
|
<Switch checkedChildren="Enabled" unCheckedChildren="Disabled" />
|
|
</Form.Item>
|
|
|
|
</Form>
|
|
</Modal>
|
|
|
|
{/* Path Discovery Helper Modal */}
|
|
<Modal
|
|
title="HAProxy Path Discovery Helper"
|
|
open={pathDiscoveryVisible}
|
|
onCancel={() => setPathDiscoveryVisible(false)}
|
|
footer={[
|
|
<Button key="close" onClick={() => setPathDiscoveryVisible(false)}>
|
|
Close
|
|
</Button>
|
|
]}
|
|
width={800}
|
|
>
|
|
<Alert
|
|
message="HAProxy Path Discovery"
|
|
description="Run this single command on your HAProxy server to discover all required paths at once."
|
|
type="info"
|
|
showIcon
|
|
style={{ marginBottom: 16 }}
|
|
/>
|
|
|
|
<Row gutter={16}>
|
|
<Col span={12}>
|
|
<Card
|
|
title={
|
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
|
<span>🐧 Linux</span>
|
|
<Typography.Text
|
|
copyable={{
|
|
text: `echo "=== HAProxy Paths ===" && HAPROXY_PID=$(pgrep -f 'haproxy.*-f' | head -1) && if [ ! -z "$HAPROXY_PID" ]; then HAPROXY_CMDLINE=$(cat /proc/$HAPROXY_PID/cmdline 2>/dev/null | tr '\\0' ' ') && CONFIG_FROM_CMDLINE=$(echo "$HAPROXY_CMDLINE" | grep -oE '\\-f[[:space:]]+[^[:space:]]+' | awk '{print $2}' | head -1) && SOCKET_PATH="" && if [ -f "$CONFIG_FROM_CMDLINE" ]; then SOCKET_PATH=$(grep -v '^[[:space:]]*#' "$CONFIG_FROM_CMDLINE" | grep 'stats socket' | sed 's/.*stats socket[[:space:]]*\\([^[:space:]]*\\).*/\\1/' | head -1); fi && echo "Socket: \${SOCKET_PATH:-'Not configured (HTTP stats may be used instead)'}" && echo "Config: \${CONFIG_FROM_CMDLINE:-'Not found'}" && echo "Binary: $(readlink -f /proc/$HAPROXY_PID/exe 2>/dev/null || which haproxy)"; else echo "HAProxy process not found"; fi`,
|
|
tooltips: ['Copy command', 'Copied!']
|
|
}}
|
|
style={{ fontSize: '12px' }}
|
|
>
|
|
Copy Script
|
|
</Typography.Text>
|
|
</div>
|
|
}
|
|
size="small"
|
|
>
|
|
<pre
|
|
style={{
|
|
background: token.colorFillQuaternary,
|
|
padding: '12px',
|
|
borderRadius: '6px',
|
|
fontSize: '12px',
|
|
lineHeight: '1.4',
|
|
margin: 0,
|
|
whiteSpace: 'pre-wrap',
|
|
wordBreak: 'break-word'
|
|
}}
|
|
>
|
|
<Typography.Text>
|
|
{`echo "=== HAProxy Paths ===" && \\
|
|
HAPROXY_PID=$(pgrep -f 'haproxy.*-f' | head -1) && \\
|
|
if [ ! -z "$HAPROXY_PID" ]; then \\
|
|
HAPROXY_CMDLINE=$(cat /proc/$HAPROXY_PID/cmdline 2>/dev/null | tr '\\0' ' ') && \\
|
|
CONFIG_FROM_CMDLINE=$(echo "$HAPROXY_CMDLINE" | grep -oE '\\-f[[:space:]]+[^[:space:]]+' | awk '{print $2}' | head -1) && \\
|
|
SOCKET_PATH="" && \\
|
|
if [ -f "$CONFIG_FROM_CMDLINE" ]; then \\
|
|
SOCKET_PATH=$(grep -v '^[[:space:]]*#' "$CONFIG_FROM_CMDLINE" | grep 'stats socket' | sed 's/.*stats socket[[:space:]]*\\([^[:space:]]*\\).*/\\1/' | head -1); \\
|
|
fi && \\
|
|
echo "Socket: \${SOCKET_PATH:-'Not configured (HTTP stats may be used instead)'}" && \\
|
|
echo "Config: \${CONFIG_FROM_CMDLINE:-'Not found'}" && \\
|
|
echo "Binary: $(readlink -f /proc/$HAPROXY_PID/exe 2>/dev/null || which haproxy)"; \\
|
|
else \\
|
|
echo "HAProxy process not found"; \\
|
|
fi`}
|
|
</Typography.Text>
|
|
</pre>
|
|
</Card>
|
|
</Col>
|
|
<Col span={12}>
|
|
<Card
|
|
title={
|
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
|
<span>🍎 macOS</span>
|
|
<Typography.Text
|
|
copyable={{
|
|
text: `echo "=== HAProxy Paths ===" && HAPROXY_PID=$(pgrep -f 'haproxy.*-f' | head -1) && if [ ! -z "$HAPROXY_PID" ]; then HAPROXY_CMDLINE=$(ps -p $HAPROXY_PID -o args= 2>/dev/null) && CONFIG_FROM_CMDLINE=$(echo "$HAPROXY_CMDLINE" | grep -oE '\\-f[[:space:]]+[^[:space:]]+' | awk '{print $2}' | head -1) && SOCKET_PATH="" && if [ -f "$CONFIG_FROM_CMDLINE" ]; then SOCKET_PATH=$(grep 'stats socket' "$CONFIG_FROM_CMDLINE" | grep -v '^#' | sed 's/.*stats socket *//' | awk '{print $1}' | head -1); fi && echo "Socket: \${SOCKET_PATH:-'Not configured (HTTP stats may be used instead)'}" && echo "Config: \${CONFIG_FROM_CMDLINE:-'Not found'}" && echo "Binary: $(ps -p $HAPROXY_PID -o comm= 2>/dev/null || which haproxy)"; else echo "HAProxy process not found"; fi`,
|
|
tooltips: ['Copy command', 'Copied!']
|
|
}}
|
|
style={{ fontSize: '12px' }}
|
|
>
|
|
Copy Script
|
|
</Typography.Text>
|
|
</div>
|
|
}
|
|
size="small"
|
|
>
|
|
<pre
|
|
style={{
|
|
background: token.colorFillQuaternary,
|
|
padding: '12px',
|
|
borderRadius: '6px',
|
|
fontSize: '12px',
|
|
lineHeight: '1.4',
|
|
margin: 0,
|
|
whiteSpace: 'pre-wrap',
|
|
wordBreak: 'break-word'
|
|
}}
|
|
>
|
|
<Typography.Text>
|
|
{`echo "=== HAProxy Paths ===" && \\
|
|
HAPROXY_PID=$(pgrep -f 'haproxy.*-f' | head -1) && \\
|
|
if [ ! -z "$HAPROXY_PID" ]; then \\
|
|
HAPROXY_CMDLINE=$(ps -p $HAPROXY_PID -o args= 2>/dev/null) && \\
|
|
CONFIG_FROM_CMDLINE=$(echo "$HAPROXY_CMDLINE" | grep -oE '\\-f[[:space:]]+[^[:space:]]+' | awk '{print $2}' | head -1) && \\
|
|
SOCKET_PATH="" && \\
|
|
if [ -f "$CONFIG_FROM_CMDLINE" ]; then \\
|
|
SOCKET_PATH=$(grep -v '^[[:space:]]*#' "$CONFIG_FROM_CMDLINE" | grep 'stats socket' | sed 's/.*stats socket[[:space:]]*\\([^[:space:]]*\\).*/\\1/' | head -1); \\
|
|
fi && \\
|
|
echo "Socket: \${SOCKET_PATH:-'Not configured (HTTP stats may be used instead)'}" && \\
|
|
echo "Config: \${CONFIG_FROM_CMDLINE:-'Not found'}" && \\
|
|
echo "Binary: $(ps -p $HAPROXY_PID -o comm= 2>/dev/null || which haproxy)"; \\
|
|
else \\
|
|
echo "HAProxy process not found"; \\
|
|
fi`}
|
|
</Typography.Text>
|
|
</pre>
|
|
</Card>
|
|
</Col>
|
|
</Row>
|
|
</Modal>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default ClusterManagement;
|