Files
haproxy-openmanager/frontend/src/components/ClusterManagement.js
T
taylanbakircioglu a1192e602d feat: HA / VIP (Keepalived) management from the UI (#27)
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.
2026-06-07 01:52:20 +03:00

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;