import React, { useState, useEffect, useCallback } from 'react'; import { Table, Button, Space, Modal, Form, Input, InputNumber, Select, Tag, message, Switch, Typography, Card, Alert, Tooltip, Spin } from 'antd'; import { PlusOutlined, EditOutlined, DeleteOutlined, ReloadOutlined, WarningOutlined, PlayCircleOutlined, SyncOutlined, CrownOutlined, ClockCircleOutlined, FileSearchOutlined, InfoCircleOutlined } from '@ant-design/icons'; import { useCluster } from '../contexts/ClusterContext'; import { extractApiError } from '../utils/apiError'; const { Option } = Select; const { Title, Text } = Typography; // Interfaces that are never sensible VIP carriers — hidden from the dropdown. const IFACE_HIDE = /^(lo|docker|veth|br-|cni|flannel|kube|virbr)/i; const authHeaders = () => ({ 'Content-Type': 'application/json', 'Authorization': `Bearer ${localStorage.getItem('authToken') || ''}`, }); // Convergence-aware status (issue #27 follow-up): like other entities, a VIP only reads // "live" once its member agents have actually deployed & acked — never the instant Apply // is clicked. Backend returns deploy_status; we fall back to last_config_status. const DEPLOY_STATUS = { PENDING: { color: 'orange', label: 'PENDING', tip: 'Staged change — review and Apply (or Reject) it from the Apply Management page.' }, PENDING_DELETE: { color: 'volcano', label: 'PENDING DELETE', tip: 'Deletion staged for approval — the VIP keeps running untouched until you APPROVE it in Apply Management. Reject to keep it. The node is not changed until approval.' }, DELETING: { color: 'processing', label: 'DELETING', tip: 'Deletion approved — member node(s) are stopping keepalived and releasing the VIP. Disappears once every node has torn down.' }, DELETED: { color: 'default', label: 'DELETED', tip: 'All member nodes have torn keepalived down.' }, SYNCING: { color: 'processing', label: 'SYNCING', tip: 'Applied — an online member node is installing/configuring keepalived and will acknowledge on its next poll (~2–3 min).' }, AWAITING: { color: 'gold', label: 'AWAITING AGENT', tip: 'Applied, but the member node(s) that still need it are OFFLINE, so nothing can deploy yet. Bring the node\'s agent online — it converges on its next poll. (Not a hang.)' }, ACTIVE: { color: 'green', label: 'ACTIVE', tip: 'Applied and every member node has deployed keepalived and acknowledged the current config.' }, ERROR: { color: 'red', label: 'ERROR', tip: 'A member node failed to deploy keepalived — see Members / Live state for the node, then check that agent.' }, ATTENTION: { color: 'gold', label: 'ATTENTION',tip: 'A member already runs a hand-managed keepalived; the agent left it untouched (externally managed). Resolve it on that node or remove it from the VIP.' }, APPLIED: { color: 'green', label: 'APPLIED', tip: 'Applied.' }, }; // agents.capabilities / network_interfaces come from the API as JSONB → a JSON string // (asyncpg has no jsonb codec). Mirror AgentManagement.js and JSON.parse when needed. const parseArr = (v) => { if (Array.isArray(v)) return v; if (typeof v === 'string') { try { const p = JSON.parse(v); return Array.isArray(p) ? p : []; } catch { return []; } } return []; }; // This component uses raw fetch(), but extractApiError expects an axios-shaped error // (err.response.data). Read the fetch Response body and reuse the envelope-aware extractor // so backend messages — e.g. the 409 "node already in VIP X" — actually reach the user. const fetchApiError = async (res, fallback) => { try { const data = await res.json(); return extractApiError({ response: { data } }, fallback); } catch { return fallback; } }; const VIPManagement = () => { const { clusters } = useCluster(); const [vips, setVips] = useState([]); const [loading, setLoading] = useState(false); const [modalVisible, setModalVisible] = useState(false); const [editing, setEditing] = useState(null); const [selectedPoolId, setSelectedPoolId] = useState(null); // One row per agent in the selected pool — the user toggles which participate. const [memberRows, setMemberRows] = useState([]); const [form] = Form.useForm(); // Delete confirmation (with opt-in package uninstall) + diagnostics modal state. const [deleteTarget, setDeleteTarget] = useState(null); const [showL2Note, setShowL2Note] = useState(false); const [diagVip, setDiagVip] = useState(null); const [diagData, setDiagData] = useState(null); const [diagLoading, setDiagLoading] = useState(false); // Distinct pools derived from the cluster list (cluster -> pool_id). const pools = React.useMemo(() => { const seen = new Map(); (clusters || []).forEach((c) => { if (c.pool_id && !seen.has(c.pool_id)) seen.set(c.pool_id, c.name || `pool ${c.pool_id}`); }); return Array.from(seen, ([id, name]) => ({ id, name })); }, [clusters]); const fetchVips = useCallback(async () => { setLoading(true); try { const res = await fetch('/api/vip', { headers: authHeaders() }); if (res.ok) { const data = await res.json(); setVips(data.vips || []); } else if (res.status === 403) { message.warning('You do not have permission to view VIPs (vip.read).'); setVips([]); } } catch (e) { console.error('fetchVips failed', e); } finally { setLoading(false); } }, []); useEffect(() => { fetchVips(); const t = setInterval(fetchVips, 30000); // live MASTER/BACKUP via existing detection pipeline return () => clearInterval(t); }, [fetchVips]); // Build the participating-nodes table from the pool's EXISTING agents (installed via the // standard Agent Management process). On edit, pre-select the VIP's current members. const buildMemberRows = (agents, existing) => { const ex = {}; (existing || []).forEach((m) => { ex[m.agent_id] = m; }); return (agents || []) .filter((a) => !String(a.name).startsWith('token_')) .map((a) => { const interfaces = parseArr(a.network_interfaces).filter((n) => !IFACE_HIDE.test(n)); const e = ex[a.id]; return { agent_id: a.id, agent_name: a.name, ip_address: a.ip_address, capable: parseArr(a.capabilities).includes('keepalived_management'), interfaces, participate: !!e, role: e ? e.role : 'BACKUP', priority: e ? e.priority : 100, network_interface: e ? e.network_interface : (interfaces[0] || ''), }; }); }; const loadPoolMembers = useCallback(async (poolId, existing) => { if (!poolId) { setMemberRows([]); return; } try { const res = await fetch(`/api/agents?pool_id=${poolId}`, { headers: authHeaders() }); const data = res.ok ? await res.json() : { agents: [] }; setMemberRows(buildMemberRows(data.agents || [], existing)); } catch (e) { console.error('loadPoolMembers failed', e); setMemberRows([]); } }, []); const setRow = (agentId, patch) => setMemberRows((rows) => rows.map((r) => (r.agent_id === agentId ? { ...r, ...patch } : r))); // Toggling a node into the VIP: if no other participating node is MASTER yet, make this // one the MASTER. This makes the single-node case work without the operator having to flip // the role by hand (a one-node VIP's only node IS the master), and gives a sensible default // for multi-node (first picked = master, the rest backup). Editing keeps stored roles. const toggleParticipate = (agentId, on) => setMemberRows((rows) => { const otherMaster = rows.some((r) => r.agent_id !== agentId && r.participate && r.role === 'MASTER'); return rows.map((r) => { if (r.agent_id !== agentId) return r; if (on && !otherMaster) return { ...r, participate: true, role: 'MASTER', priority: 150 }; return { ...r, participate: on }; }); }); const openCreate = () => { setEditing(null); setSelectedPoolId(null); setMemberRows([]); form.resetFields(); form.setFieldsValue({ prefix_length: 24, advert_int: 1, use_unicast: true, track_haproxy: true }); setModalVisible(true); }; const openEdit = (vip) => { setEditing(vip); setSelectedPoolId(vip.pool_id); loadPoolMembers(vip.pool_id, vip.members); form.resetFields(); form.setFieldsValue({ name: vip.name, description: vip.description, pool_id: vip.pool_id, virtual_ip: vip.virtual_ip, prefix_length: vip.prefix_length, virtual_router_id: vip.virtual_router_id, advert_int: vip.advert_int, use_unicast: vip.use_unicast, track_haproxy: vip.track_haproxy, }); setModalVisible(true); }; const submit = async () => { let values; try { values = await form.validateFields(); } catch { return; } const chosen = memberRows.filter((r) => r.participate); if (chosen.length < 1) { message.error('Select at least 1 participating node.'); return; } const masters = chosen.filter((r) => r.role === 'MASTER'); if (masters.length !== 1) { message.error('Exactly one participating node must be MASTER.'); return; } if (chosen.some((r) => !r.network_interface)) { message.error('Pick a network interface for every participating node.'); return; } const maxBackup = Math.max(...chosen.filter((r) => r.role === 'BACKUP').map((r) => r.priority)); if (masters[0].priority <= maxBackup) { message.error('The MASTER must have a higher priority than every BACKUP.'); return; } const body = { ...values, members: chosen.map((r) => ({ agent_id: r.agent_id, network_interface: r.network_interface, role: r.role, priority: r.priority, })), }; if (!body.auth_pass) delete body.auth_pass; // omit to keep existing on edit try { const url = editing ? `/api/vip/${editing.id}` : '/api/vip'; const res = await fetch(url, { method: editing ? 'PUT' : 'POST', headers: authHeaders(), body: JSON.stringify(body) }); if (res.ok) { message.success(editing ? 'VIP updated (PENDING) — apply it from the Apply Management page' : 'VIP created (PENDING) — apply it from the Apply Management page'); setModalVisible(false); fetchVips(); } else { message.error(await fetchApiError(res, 'Failed to save VIP')); } } catch (e) { message.error('Failed to save VIP: ' + e.message); } }; const deleteVip = async (vip, purge) => { try { const res = await fetch(`/api/vip/${vip.id}${purge ? '?purge_package=true' : ''}`, { method: 'DELETE', headers: authHeaders() }); if (res.ok) { let body = {}; try { body = await res.json(); } catch (_) { /* ignore */ } // Backend returns staged=true (approval required, VIP still running) or staged=false // (never-applied VIP removed at once). Surface its exact message either way. (body.staged ? message.info : message.success)( body.message || 'Deletion requested.'); setDeleteTarget(null); fetchVips(); } else message.error(await fetchApiError(res, 'Delete failed')); } catch (e) { message.error('Delete failed: ' + e.message); } }; // Diagnostics: per-member deploy state/ack from GET /api/vip/{id}/status — the live view // of what each node reported (installing/applied/error/externally-managed), most useful // while a freshly-applied VIP is SYNCING (keepalived install can take ~30s). const openDiagnostics = async (vip) => { setDiagVip(vip); setDiagData(null); setDiagLoading(true); try { const res = await fetch(`/api/vip/${vip.id}/status`, { headers: authHeaders() }); if (res.ok) setDiagData(await res.json()); else message.error(await fetchApiError(res, 'Failed to load diagnostics')); } catch (e) { message.error('Diagnostics failed: ' + e.message); } finally { setDiagLoading(false); } }; // Colorful, IP-Inventory/Agent-consistent state: live VRRP MASTER (green, crowned) / // BACKUP (orange) / FAULT (red); when the agent hasn't reported a live state yet, show // the configured role as a dashed outline tag (same color) so it's clearly "intended, // not yet observed". A node whose agent is too old gets an "awaiting agent" flag. const renderMembers = (_, vip) => ( {(vip.members || []).map((m) => { const live = m.keepalive_state && m.keepalive_state !== 'NONE' ? m.keepalive_state : null; const roleColor = m.role === 'MASTER' ? 'green' : 'orange'; const awaiting = !live && !m.keepalived_capable; return ( {m.agent_name || `agent ${m.agent_id}`} {live ? ( : undefined} style={{ marginInlineEnd: 0, fontWeight: 600 }} > {live} ) : ( {m.role} )} {awaiting && ( } style={{ marginInlineEnd: 0 }}>awaiting agent )} ); })} ); const columns = [ { title: 'Name', dataIndex: 'name', key: 'name' }, { title: 'Virtual IP', key: 'vip', render: (_, v) => {v.virtual_ip}/{v.prefix_length} }, { title: 'Pool', dataIndex: 'pool_name', key: 'pool' }, { title: 'VRID', dataIndex: 'virtual_router_id', key: 'vrid' }, { title: 'Members / Live state', key: 'members', render: renderMembers }, { title: 'Status', key: 'status', render: (_, v) => { const s = v.deploy_status || v.last_config_status; const d = DEPLOY_STATUS[s] || { color: 'default', label: s, tip: '' }; const count = (s === 'SYNCING' || s === 'AWAITING' || s === 'ACTIVE' || s === 'DELETING' || s === 'DELETED') && v.deploy_total ? ` (${v.deploy_synced}/${v.deploy_total})` : ''; return ( : s === 'AWAITING' ? : undefined}> {(d.label || s)}{count} ); }, }, { title: 'Actions', key: 'actions', render: (_, v) => ( {v.last_config_status === 'PENDING' && ( )}