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 ; }; // 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) => (
{text} {record.is_default && ( Default )}
), }, { title: 'Description', dataIndex: 'description', key: 'description', width: 200, render: (text) => { if (!text) return No description; return (
{text}
); }, }, { title: 'Agent Pool', dataIndex: 'pool_name', key: 'pool_name', render: (poolName, record) => { if (!poolName) { return No pool assigned; } return ( }> {poolName} ); }, }, { title: 'Connection', dataIndex: 'connection_type', key: 'connection_type', width: 110, render: (type) => ( {type === 'agent' ? 'Agent' : 'Sidecar'} ), }, { title: 'Status', key: 'status', render: (_, record) => (
{getStatusBadge(record)} {(record.total_agents > 0) && (
{record.healthy_agents}/{record.total_agents} agents healthy
)}
), }, { title: 'HAProxy Ver.', key: 'haproxy_version', width: 170, render: (_, record) => { const versionInfo = getHAProxyVersionInfo(record.id); if (loadingAgents) { return ( Loading... ); } if (!versionInfo.versions || versionInfo.versions.length === 0) { if (record.total_agents === 0) { return No agents; } return -; } return (
{versionInfo.hasVersionMismatch && ( )}
{versionInfo.versionDetails.map((detail, idx) => (
{detail.version}
{detail.agents.join(', ')}
))}
); }, }, { 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 ( Loading... ); } if (keepaliveAgents.length === 0) { return -; } 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 (
{displayAgents.map((a, idx) => (
{a.name} {a.keepalive_state}
))} {remaining > 0 && ( +{remaining} more )} {uniqueVips.length > 0 && ( VIP: {uniqueVips.join(', ')} )}
); }, }, { title: 'Actions', key: 'actions', render: (_, record) => ( `Total ${total} clusters`, }} /> Cancel , , ]} >