feat(cluster): module Haute Disponibilité (Cluster HA Actif/Passif Keepalived VRRP & Réplication temps réel)
This commit is contained in:
1 parent
bee2055b62
commit
521f21dc9c
10 files changed
+1310
-7
No files matched your search
+334
-2
@@ -3523,7 +3523,8 @@ const PAGE_TITLES = {
|
||||
certificates: 'Gestionnaire de Certificats SSL / TLS (Let\'s Encrypt & Imports)',
|
||||
system: 'Paramètres Globaux, Diagnostic Debian & Fichiers .conf',
|
||||
backups: 'Sauvegardes & Restauration Système (Disaster Recovery)',
|
||||
firewall: 'Pare-feu Réseau & Sécurité Téléphonie (Fail2ban & NFTables)'
|
||||
firewall: 'Pare-feu Réseau & Sécurité Téléphonie (Fail2ban & NFTables)',
|
||||
cluster: 'Haute Disponibilité & Redondance (Cluster HA Keepalived)'
|
||||
};
|
||||
|
||||
function switchTab(tabId) {
|
||||
@@ -3574,7 +3575,7 @@ function switchTab(tabId) {
|
||||
}
|
||||
|
||||
// Si on clique sur un sous-menu Système, s'assurer que le menu parent est bien déplié
|
||||
if (tabId === 'system' || tabId === 'backups' || tabId === 'firewall') {
|
||||
if (tabId === 'system' || tabId === 'backups' || tabId === 'firewall' || tabId === 'cluster') {
|
||||
const submenu = document.getElementById('submenu-system');
|
||||
const arrow = document.getElementById('arrow-menu-system');
|
||||
if (submenu && submenu.classList.contains('hidden')) {
|
||||
@@ -3598,6 +3599,11 @@ function switchTab(tabId) {
|
||||
loadFirewallStatus();
|
||||
}
|
||||
|
||||
// Si onglet Cluster HA, charger les interfaces et le statut en direct
|
||||
if (tabId === 'cluster') {
|
||||
loadCluster();
|
||||
}
|
||||
|
||||
toggleMobileSidebar(false);
|
||||
}
|
||||
|
||||
@@ -6318,3 +6324,329 @@ async function confirmClearCdr() {
|
||||
}
|
||||
}
|
||||
|
||||
/* ==============================================================================
|
||||
MODULE HAUTE DISPONIBILITÉ (CLUSTER HA ACTIF / PASSIF KEEPALIVED)
|
||||
============================================================================== */
|
||||
|
||||
let currentClusterData = null;
|
||||
|
||||
async function loadCluster() {
|
||||
await loadClusterInterfaces();
|
||||
await refreshClusterStatus();
|
||||
}
|
||||
|
||||
async function loadClusterInterfaces() {
|
||||
try {
|
||||
const res = await apiCall('cluster_interfaces');
|
||||
const select = document.getElementById('cluster-interface');
|
||||
if (!select || !res.interfaces) return;
|
||||
|
||||
const currentVal = select.value;
|
||||
select.innerHTML = res.interfaces.map(iface => {
|
||||
const ipDisplay = iface.primary ? `(${iface.primary}/${iface.netmask})` : '(pas d\'IP)';
|
||||
return `<option value="${iface.name}">${iface.name} ${ipDisplay}</option>`;
|
||||
}).join('');
|
||||
|
||||
if (currentVal && res.interfaces.some(i => i.name === currentVal)) {
|
||||
select.value = currentVal;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Erreur lors du chargement des interfaces réseau :", e);
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshClusterStatus() {
|
||||
try {
|
||||
const res = await apiCall('cluster_status');
|
||||
if (!res.success || !res.status) return;
|
||||
|
||||
const st = res.status;
|
||||
currentClusterData = st;
|
||||
|
||||
// Formulaire - Mise à jour des valeurs
|
||||
const chkEnabled = document.getElementById('cluster-enabled');
|
||||
if (chkEnabled) chkEnabled.checked = Boolean(st.enabled);
|
||||
|
||||
const radioRole = document.querySelector(`input[name="role"][value="${st.configured_role || 'master'}"]`);
|
||||
if (radioRole) radioRole.checked = true;
|
||||
|
||||
const ifaceSelect = document.getElementById('cluster-interface');
|
||||
if (ifaceSelect && st.interface) ifaceSelect.value = st.interface;
|
||||
|
||||
const vipInput = document.getElementById('cluster-virtual-ip');
|
||||
if (vipInput) vipInput.value = st.virtual_ip || '';
|
||||
|
||||
const vridInput = document.getElementById('cluster-vrid');
|
||||
if (vridInput && st.vrid) vridInput.value = st.vrid;
|
||||
|
||||
const peerIpInput = document.getElementById('cluster-peer-ip');
|
||||
if (peerIpInput) peerIpInput.value = st.peer_ip || '';
|
||||
|
||||
const secretInput = document.getElementById('cluster-secret');
|
||||
if (secretInput && st.cluster_secret) secretInput.value = st.cluster_secret;
|
||||
|
||||
// Badges & Cartes
|
||||
const headerBadge = document.getElementById('cluster-header-state-badge');
|
||||
const navBadge = document.getElementById('nav-badge-cluster');
|
||||
const cardRoleBadge = document.getElementById('cluster-card-role-badge');
|
||||
const cardState = document.getElementById('cluster-card-state');
|
||||
const cardVipInd = document.getElementById('cluster-card-vip-indicator');
|
||||
const cardIface = document.getElementById('cluster-card-iface');
|
||||
const cardVip = document.getElementById('cluster-card-vip');
|
||||
const cardVrid = document.getElementById('cluster-card-vrid');
|
||||
const cardPeerStatus = document.getElementById('cluster-card-peer-status');
|
||||
const cardPeerIp = document.getElementById('cluster-card-peer-ip');
|
||||
const cardPeerPing = document.getElementById('cluster-card-peer-ping');
|
||||
const cardSyncStatus = document.getElementById('cluster-card-sync-status');
|
||||
const cardSyncTime = document.getElementById('cluster-card-sync-time');
|
||||
|
||||
if (cardVrid) cardVrid.textContent = st.vrid || '51';
|
||||
if (cardIface) cardIface.textContent = `Interface : ${st.interface || 'eth0'}`;
|
||||
if (cardVip) cardVip.textContent = st.virtual_ip || 'Non configurée';
|
||||
|
||||
if (cardRoleBadge) {
|
||||
cardRoleBadge.textContent = (st.configured_role || 'master').toUpperCase();
|
||||
}
|
||||
|
||||
if (st.enabled) {
|
||||
if (st.effective_state === 'MASTER') {
|
||||
if (headerBadge) {
|
||||
headerBadge.className = 'px-2.5 py-0.5 rounded-full text-xs font-bold bg-emerald-500/20 text-emerald-300 border border-emerald-500/30';
|
||||
headerBadge.textContent = 'ACTIF (MASTER)';
|
||||
}
|
||||
if (navBadge) {
|
||||
navBadge.className = 'ml-2 shrink-0 px-1.5 py-0.2 text-[10px] rounded-full bg-emerald-500/20 text-emerald-300 border border-emerald-500/30 font-bold';
|
||||
navBadge.textContent = 'MASTER';
|
||||
}
|
||||
if (cardState) {
|
||||
cardState.className = 'text-lg font-bold text-emerald-400 tracking-wide';
|
||||
cardState.textContent = 'MASTER (ACTIF)';
|
||||
}
|
||||
if (cardVipInd) {
|
||||
cardVipInd.className = 'text-[10px] font-mono px-1.5 py-0.5 rounded bg-emerald-950 text-emerald-300 border border-emerald-800';
|
||||
cardVipInd.textContent = 'VIP: Portée';
|
||||
}
|
||||
} else {
|
||||
if (headerBadge) {
|
||||
headerBadge.className = 'px-2.5 py-0.5 rounded-full text-xs font-bold bg-blue-500/20 text-blue-300 border border-blue-500/30';
|
||||
headerBadge.textContent = 'STANDBY (EN VEILLE)';
|
||||
}
|
||||
if (navBadge) {
|
||||
navBadge.className = 'ml-2 shrink-0 px-1.5 py-0.2 text-[10px] rounded-full bg-blue-500/20 text-blue-300 border border-blue-500/30 font-bold';
|
||||
navBadge.textContent = 'STANDBY';
|
||||
}
|
||||
if (cardState) {
|
||||
cardState.className = 'text-lg font-bold text-blue-400 tracking-wide';
|
||||
cardState.textContent = 'STANDBY (VEILLE)';
|
||||
}
|
||||
if (cardVipInd) {
|
||||
cardVipInd.className = 'text-[10px] font-mono px-1.5 py-0.5 rounded bg-anthracite-900 text-slate-400';
|
||||
cardVipInd.textContent = 'VIP: Écoute';
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (headerBadge) {
|
||||
headerBadge.className = 'px-2.5 py-0.5 rounded-full text-xs font-bold bg-slate-700 text-slate-300';
|
||||
headerBadge.textContent = 'DÉSACTIVÉ';
|
||||
}
|
||||
if (navBadge) {
|
||||
navBadge.className = 'ml-2 shrink-0 px-1.5 py-0.2 text-[10px] rounded-full bg-anthracite-800 text-slate-400 border border-anthracite-700 font-bold';
|
||||
navBadge.textContent = 'OFF';
|
||||
}
|
||||
if (cardState) {
|
||||
cardState.className = 'text-lg font-bold text-slate-400 tracking-wide';
|
||||
cardState.textContent = 'STANDALONE';
|
||||
}
|
||||
if (cardVipInd) {
|
||||
cardVipInd.className = 'text-[10px] font-mono px-1.5 py-0.5 rounded bg-anthracite-900 text-slate-400';
|
||||
cardVipInd.textContent = 'VIP: Inactive';
|
||||
}
|
||||
}
|
||||
|
||||
// Nœud Distant (Peer)
|
||||
if (cardPeerIp) cardPeerIp.textContent = st.peer_ip || 'Non configuré';
|
||||
if (cardPeerStatus) {
|
||||
if (!st.peer_ip) {
|
||||
cardPeerStatus.className = 'px-2 py-0.5 rounded text-[10px] font-bold bg-slate-700 text-slate-300';
|
||||
cardPeerStatus.textContent = 'NON DÉFINI';
|
||||
} else if (st.peer_reachable) {
|
||||
cardPeerStatus.className = 'px-2 py-0.5 rounded text-[10px] font-bold bg-emerald-500/20 text-emerald-300 border border-emerald-500/30';
|
||||
cardPeerStatus.textContent = 'JOIGNABLE';
|
||||
} else {
|
||||
cardPeerStatus.className = 'px-2 py-0.5 rounded text-[10px] font-bold bg-red-500/20 text-red-300 border border-red-500/30';
|
||||
cardPeerStatus.textContent = 'INJOIGNABLE';
|
||||
}
|
||||
}
|
||||
if (cardPeerPing) {
|
||||
cardPeerPing.textContent = (st.peer_ping_ms !== null) ? `Latence ping : ${st.peer_ping_ms.toFixed(1)} ms` : 'Latence ping : Inaccessible';
|
||||
}
|
||||
|
||||
// Synchronisation
|
||||
if (cardSyncStatus) {
|
||||
if (st.last_sync_status === 'success' || st.last_sync_status === 'synced') {
|
||||
cardSyncStatus.className = 'text-sm font-bold text-emerald-400 truncate';
|
||||
cardSyncStatus.textContent = 'Synchronisation OK';
|
||||
} else if (st.last_sync_status === 'error') {
|
||||
cardSyncStatus.className = 'text-sm font-bold text-red-400 truncate';
|
||||
cardSyncStatus.textContent = 'Erreur réplication';
|
||||
} else {
|
||||
cardSyncStatus.className = 'text-sm font-bold text-slate-400 truncate';
|
||||
cardSyncStatus.textContent = 'En attente';
|
||||
}
|
||||
}
|
||||
if (cardSyncTime) {
|
||||
cardSyncTime.textContent = st.last_sync_at ? `Dernière synchro : ${st.last_sync_at}` : 'Dernière synchro : Jamais';
|
||||
}
|
||||
|
||||
// Services Système (Diagnostic)
|
||||
const svcKeepalived = document.getElementById('cluster-svc-keepalived');
|
||||
if (svcKeepalived) {
|
||||
if (!st.keepalived_installed) {
|
||||
svcKeepalived.className = 'font-mono text-amber-400 font-bold';
|
||||
svcKeepalived.textContent = '⚠️ Non installé';
|
||||
} else if (st.keepalived_running) {
|
||||
svcKeepalived.className = 'font-mono text-emerald-400 font-bold';
|
||||
svcKeepalived.textContent = '🟢 Actif (En cours)';
|
||||
} else {
|
||||
svcKeepalived.className = 'font-mono text-slate-400';
|
||||
svcKeepalived.textContent = '⚪ Arrêté';
|
||||
}
|
||||
}
|
||||
|
||||
const svcAsterisk = document.getElementById('cluster-svc-asterisk');
|
||||
if (svcAsterisk) {
|
||||
if (st.asterisk_healthy) {
|
||||
svcAsterisk.className = 'font-mono text-emerald-400 font-bold';
|
||||
svcAsterisk.textContent = '🟢 En ligne (Ping Pong OK)';
|
||||
} else if (st.asterisk_running) {
|
||||
svcAsterisk.className = 'font-mono text-amber-400 font-bold';
|
||||
svcAsterisk.textContent = '🟡 En cours (Ping en attente)';
|
||||
} else {
|
||||
svcAsterisk.className = 'font-mono text-red-400 font-bold';
|
||||
svcAsterisk.textContent = '🔴 Arrêté';
|
||||
}
|
||||
}
|
||||
|
||||
const svcPeer = document.getElementById('cluster-svc-peer');
|
||||
if (svcPeer) {
|
||||
if (!st.peer_ip) {
|
||||
svcPeer.textContent = 'Non configuré';
|
||||
svcPeer.className = 'font-mono text-slate-500';
|
||||
} else if (st.peer_reachable) {
|
||||
svcPeer.textContent = `🟢 Joignable (${st.peer_ping_ms !== null ? st.peer_ping_ms.toFixed(1) + 'ms' : 'OK'})`;
|
||||
svcPeer.className = 'font-mono text-emerald-400 font-bold';
|
||||
} else {
|
||||
svcPeer.textContent = '🔴 Injoignable (Timeout)';
|
||||
svcPeer.className = 'font-mono text-red-400 font-bold';
|
||||
}
|
||||
}
|
||||
|
||||
// Journal de logs
|
||||
const logsContainer = document.getElementById('cluster-logs-container');
|
||||
if (logsContainer && st.recent_logs) {
|
||||
if (st.recent_logs.length > 0) {
|
||||
logsContainer.innerHTML = st.recent_logs.map(line => {
|
||||
let color = 'text-slate-300';
|
||||
if (line.includes('MASTER')) color = 'text-emerald-300 font-bold';
|
||||
if (line.includes('BACKUP') || line.includes('STANDBY')) color = 'text-blue-300';
|
||||
if (line.includes('FAULT') || line.includes('ALERTE')) color = 'text-red-400 font-bold';
|
||||
return `<div class="${color}">${escapeHtml(line)}</div>`;
|
||||
}).join('');
|
||||
} else {
|
||||
logsContainer.innerHTML = '<p class="text-slate-500">Aucun événement enregistré.</p>';
|
||||
}
|
||||
}
|
||||
|
||||
} catch (e) {
|
||||
console.error("Erreur lors du rafraîchissement du cluster :", e);
|
||||
}
|
||||
}
|
||||
|
||||
async function saveClusterSettings(e) {
|
||||
e.preventDefault();
|
||||
const form = e.target;
|
||||
const payload = {
|
||||
enabled: form.elements['enabled'].checked ? 1 : 0,
|
||||
role: form.elements['role'].value,
|
||||
interface: form.elements['interface'].value,
|
||||
virtual_ip: form.elements['virtual_ip'].value.trim(),
|
||||
virtual_netmask: form.elements['virtual_netmask'].value.trim() || '24',
|
||||
peer_ip: form.elements['peer_ip'].value.trim(),
|
||||
cluster_secret: form.elements['cluster_secret'].value.trim(),
|
||||
vrid: parseInt(form.elements['vrid'].value, 10) || 51,
|
||||
auto_sync: form.elements['auto_sync'].checked ? 1 : 0
|
||||
};
|
||||
|
||||
if (payload.enabled && !payload.virtual_ip) {
|
||||
showToast("L'adresse IP Virtuelle Flottante (VIP) est requise pour activer le cluster.", 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await apiCall('cluster_save', payload);
|
||||
showToast(res.message || "Paramètres du cluster enregistrés.", 'success');
|
||||
refreshClusterStatus();
|
||||
} catch (err) {
|
||||
showToast(err.message || "Erreur lors de l'enregistrement du cluster.", 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function triggerClusterSyncNow() {
|
||||
if (!currentClusterData || !currentClusterData.peer_ip) {
|
||||
showToast("Veuillez d'abord renseigner et enregistrer l'adresse IP du Nœud Pair (Peer IP).", 'warning');
|
||||
return;
|
||||
}
|
||||
showToast("Déclenchement de la réplication vers le pair...", 'info');
|
||||
try {
|
||||
const res = await apiCall('cluster_sync_now', {});
|
||||
showToast(res.message || "Synchronisation réussie avec le nœud pair !", 'success');
|
||||
refreshClusterStatus();
|
||||
} catch (e) {
|
||||
showToast(e.message || "Erreur lors de la synchronisation.", 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function triggerClusterSwitchover() {
|
||||
if (!currentClusterData || !currentClusterData.enabled) {
|
||||
showToast("Le cluster doit être activé pour effectuer une bascule.", 'warning');
|
||||
return;
|
||||
}
|
||||
const currentRole = currentClusterData.configured_role || 'master';
|
||||
const targetRole = (currentRole === 'master') ? 'STANDBY' : 'MASTER';
|
||||
|
||||
if (!confirm(`Confirmez-vous la bascule manuelle de rôle (Switchover) vers l'état ${targetRole} ?\nL'adresse IP flottante va être transférée.`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await apiCall('cluster_switchover', {});
|
||||
showToast(res.message || `Bascule effectuée. Nouveau rôle : ${targetRole}`, 'success');
|
||||
refreshClusterStatus();
|
||||
} catch (e) {
|
||||
showToast(e.message || "Erreur lors de la bascule.", 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function regenerateClusterSecret() {
|
||||
const secretInput = document.getElementById('cluster-secret');
|
||||
if (!secretInput) return;
|
||||
const array = new Uint8Array(16);
|
||||
window.crypto.getRandomValues(array);
|
||||
const newSecret = Array.from(array, byte => byte.toString(16).padStart(2, '0')).join('');
|
||||
secretInput.value = newSecret;
|
||||
showToast("Nouveau jeton généré. N'oubliez pas d'enregistrer et de copier cette même clé sur le second serveur !", 'info');
|
||||
}
|
||||
|
||||
function copyClusterSecret() {
|
||||
const secretInput = document.getElementById('cluster-secret');
|
||||
if (!secretInput || !secretInput.value) return;
|
||||
navigator.clipboard.writeText(secretInput.value).then(() => {
|
||||
showToast("Jeton secret copié dans le presse-papier !", 'success');
|
||||
}).catch(() => {
|
||||
secretInput.select();
|
||||
document.execCommand('copy');
|
||||
showToast("Jeton secret copié !", 'success');
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user