feat(cluster): module Haute Disponibilité (Cluster HA Actif/Passif Keepalived VRRP & Réplication temps réel)

This commit is contained in:
sorcier committed 2026-11-05 14:32:35 +01:00
1 parent bee2055b62
commit 521f21dc9c
10 files changed
+1310 -7

No files matched your search

+44
View File
@@ -74,6 +74,16 @@ try {
exit;
}
// Action inter-serveurs réplication Cluster (authentifiée par signature HMAC)
if ($action === 'cluster_receive_sync') {
$rawBody = file_get_contents('php://input') ?: '';
$signature = $_SERVER['HTTP_X_NEXUS_CLUSTER_SIGNATURE'] ?? '';
$cm = new ClusterManager($db);
$res = $cm->receiveSyncPayload($rawBody, $signature);
echo json_encode($res);
exit;
}
// Toutes les autres actions requièrent une session authentifiée
if (!Auth::isAuthenticated()) {
http_response_code(401);
@@ -2414,6 +2424,40 @@ try {
}
break;
case 'cluster_status':
$cm = new ClusterManager($db);
echo json_encode([
'success' => true,
'status' => $cm->getClusterStatus(),
]);
break;
case 'cluster_interfaces':
$cm = new ClusterManager($db);
echo json_encode([
'success' => true,
'interfaces' => $cm->detectNetworkInterfaces(),
]);
break;
case 'cluster_save':
$cm = new ClusterManager($db);
$res = $cm->saveSettings($payload);
echo json_encode($res);
break;
case 'cluster_sync_now':
$cm = new ClusterManager($db);
$res = $cm->pushSyncToPeer();
echo json_encode($res);
break;
case 'cluster_switchover':
$cm = new ClusterManager($db);
$res = $cm->triggerSwitchover();
echo json_encode($res);
break;
default:
http_response_code(400);
echo json_encode([
+334 -2
View File
@@ -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');
});
}
+235
View File
@@ -454,6 +454,11 @@ $isDevEnv = file_exists(__DIR__ . '/../.dev')
<span class="truncate">• Pare-feu & Fail2ban</span>
<span id="nav-badge-firewall" class="ml-2 shrink-0 px-1.5 py-0.2 text-[10px] rounded-full bg-anthracite-800 text-emerald-300 border border-anthracite-700 font-bold">0</span>
</button>
<button type="button" data-tab="cluster" onclick="switchTab('cluster')"
class="tab-btn w-full flex items-center justify-between px-3 py-1.5 rounded-lg text-xs font-medium text-slate-300 hover:bg-anthracite-800 hover:text-white transition text-left">
<span class="truncate">• Haute Disponibilité (HA)</span>
<span id="nav-badge-cluster" class="ml-2 shrink-0 px-1.5 py-0.2 text-[10px] rounded-full bg-anthracite-800 text-cyan-300 border border-anthracite-700 font-bold">HA</span>
</button>
</div>
</div>
</div>
@@ -2755,6 +2760,236 @@ $isDevEnv = file_exists(__DIR__ . '/../.dev')
</div>
</section>
<!-- ========================================================================= -->
<!-- ONGLET 16 : HAUTE DISPONIBILITÉ & CLUSTER HA (ACTIF / PASSIF KEEPALIVED) -->
<!-- ========================================================================= -->
<section id="tab-cluster" class="tab-panel hidden space-y-6">
<!-- EN-TÊTE DE LA SECTION -->
<div class="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4 bg-anthracite-800 p-6 rounded-2xl border border-anthracite-700">
<div>
<div class="flex items-center gap-2 mb-1">
<span class="px-2.5 py-0.5 rounded-full text-xs font-bold bg-cyan-500/20 text-cyan-300 border border-cyan-500/30">CLUSTER HA</span>
<span id="cluster-header-state-badge" class="px-2.5 py-0.5 rounded-full text-xs font-bold bg-slate-700 text-slate-300">DÉSACTIVÉ</span>
</div>
<h2 class="text-xl font-black text-white tracking-wide">Haute Disponibilité (Cluster HA Actif / Passif)</h2>
<p class="text-xs text-slate-300 mt-1">Bascule automatique en &lt; 3 secondes par IP Virtuelle Flottante (VRRP / Keepalived) et synchronisation temps réel.</p>
</div>
<div class="flex items-center gap-2">
<button type="button" onclick="refreshClusterStatus()"
class="px-3.5 py-2 rounded-xl text-xs font-bold text-slate-300 bg-anthracite-900 border border-anthracite-700 hover:text-white hover:border-slate-500 transition flex items-center gap-2">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/></svg>
<span>Actualiser</span>
</button>
<button type="button" onclick="triggerClusterSwitchover()" id="btn-cluster-switchover"
class="px-3.5 py-2 rounded-xl text-xs font-bold text-amber-300 bg-amber-500/10 border border-amber-500/30 hover:bg-amber-500/20 transition flex items-center gap-2">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4"/></svg>
<span>Bascule Manuelle (Switchover)</span>
</button>
</div>
</div>
<!-- CARTES STATISTIQUES / ÉTAT EN TEMPS RÉEL -->
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
<!-- CARTE 1 : ÉTAT NŒUD LOCAL -->
<div class="p-4 rounded-xl bg-anthracite-800 border border-anthracite-700 space-y-2">
<div class="flex items-center justify-between text-xs text-slate-400">
<span>Nœud Local</span>
<span id="cluster-card-role-badge" class="px-2 py-0.5 rounded text-[10px] font-bold bg-slate-700 text-slate-300">MASTER</span>
</div>
<div class="flex items-baseline justify-between">
<span id="cluster-card-state" class="text-lg font-bold text-white tracking-wide">STANDALONE</span>
<span id="cluster-card-vip-indicator" class="text-[10px] font-mono px-1.5 py-0.5 rounded bg-anthracite-900 text-slate-400">VIP: Inactive</span>
</div>
<p id="cluster-card-iface" class="text-[11px] text-slate-400 font-mono">Interface : ens18</p>
</div>
<!-- CARTE 2 : ADRESSE IP VIRTUELLE (VIP) -->
<div class="p-4 rounded-xl bg-anthracite-800 border border-anthracite-700 space-y-2">
<div class="flex items-center justify-between text-xs text-slate-400">
<span>IP Virtuelle (VIP)</span>
<span class="text-[10px] font-bold text-cyan-400">VRRP ID: <span id="cluster-card-vrid">51</span></span>
</div>
<div class="text-lg font-bold font-mono text-cyan-300 tracking-wide" id="cluster-card-vip">
Non configurée
</div>
<p class="text-[11px] text-slate-400">Point de connexion des Postes &amp; Trunks</p>
</div>
<!-- CARTE 3 : NŒUD DISTANT (PEER) -->
<div class="p-4 rounded-xl bg-anthracite-800 border border-anthracite-700 space-y-2">
<div class="flex items-center justify-between text-xs text-slate-400">
<span>Nœud Distant (Pair)</span>
<span id="cluster-card-peer-status" class="px-2 py-0.5 rounded text-[10px] font-bold bg-slate-700 text-slate-300">INCONNU</span>
</div>
<div class="text-base font-bold font-mono text-white tracking-wide truncate" id="cluster-card-peer-ip">
Non configuré
</div>
<p id="cluster-card-peer-ping" class="text-[11px] text-slate-400 font-mono">Latence ping : —</p>
</div>
<!-- CARTE 4 : RÉPLICATION DES DONNÉES -->
<div class="p-4 rounded-xl bg-anthracite-800 border border-anthracite-700 space-y-2">
<div class="flex items-center justify-between text-xs text-slate-400">
<span>Synchronisation</span>
<button type="button" onclick="triggerClusterSyncNow()" class="text-[10px] text-indigo-400 hover:text-indigo-300 font-bold underline">Sync Now</button>
</div>
<div class="text-sm font-bold text-white truncate" id="cluster-card-sync-status">
Jamais synchronisé
</div>
<p id="cluster-card-sync-time" class="text-[11px] text-slate-400 font-mono">Dernier échange : —</p>
</div>
</div>
<!-- CONTENU PRINCIPAL : FORMULAIRE & ACTIONS -->
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<!-- FORMULAIRE DE CONFIGURATION DU CLUSTER (2/3) -->
<div class="lg:col-span-2 bg-anthracite-800 p-6 rounded-2xl border border-anthracite-700 space-y-5">
<h3 class="text-sm font-bold text-white uppercase tracking-wider flex items-center gap-2">
<svg class="w-4 h-4 text-cyan-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6V4m0 2a2 2 0 100 4m0-4a2 2 0 110 4m-6 8a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4m6 6v10m6-2a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4"/></svg>
<span>Paramétrage de la Haute Disponibilité (Keepalived VRRP)</span>
</h3>
<form id="form-cluster-settings" onsubmit="saveClusterSettings(event)" class="space-y-4">
<!-- SWITCH ACTIVATION -->
<div class="flex items-center justify-between p-3.5 rounded-xl bg-anthracite-900/80 border border-anthracite-700">
<div>
<label for="cluster-enabled" class="text-sm font-bold text-white cursor-pointer">Activer le Cluster Haute Disponibilité</label>
<p class="text-xs text-slate-400">Active Keepalived pour surveiller Asterisk et partager l'IP flottante.</p>
</div>
<label class="relative inline-flex items-center cursor-pointer">
<input type="checkbox" id="cluster-enabled" name="enabled" value="1" class="sr-only peer">
<div class="w-11 h-6 bg-anthracite-700 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-slate-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-cyan-500"></div>
</label>
</div>
<!-- RÔLE DU SERVEUR -->
<div class="space-y-1.5">
<label class="block text-xs font-bold text-slate-300 uppercase">Rôle initial de ce serveur</label>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
<label class="flex items-center gap-3 p-3 rounded-xl border border-anthracite-700 bg-anthracite-900/60 cursor-pointer hover:border-slate-500 transition">
<input type="radio" name="role" value="master" class="text-cyan-500 focus:ring-cyan-400" checked>
<div>
<span class="text-xs font-bold text-white">Nœud 1 : Master (Primaire)</span>
<p class="text-[10px] text-slate-400">Priorité VRRP 100 • Porte la VIP au démarrage.</p>
</div>
</label>
<label class="flex items-center gap-3 p-3 rounded-xl border border-anthracite-700 bg-anthracite-900/60 cursor-pointer hover:border-slate-500 transition">
<input type="radio" name="role" value="backup" class="text-cyan-500 focus:ring-cyan-400">
<div>
<span class="text-xs font-bold text-white">Nœud 2 : Standby (Secondaire)</span>
<p class="text-[10px] text-slate-400">Priorité VRRP 90 • Prend le relais si le Master tombe.</p>
</div>
</label>
</div>
</div>
<!-- INTERFACE RÉSEAU & IP VIRTUELLE -->
<div class="grid grid-cols-1 sm:grid-cols-3 gap-3">
<div class="sm:col-span-1">
<label class="block text-xs font-bold text-slate-300 uppercase mb-1">Carte Réseau</label>
<select id="cluster-interface" name="interface" required
class="w-full px-3 py-2 rounded-xl bg-anthracite-900 border border-anthracite-700 text-sm text-white focus:outline-none focus:border-cyan-400 font-mono">
<option value="eth0">eth0</option>
</select>
</div>
<div class="sm:col-span-2">
<label class="block text-xs font-bold text-slate-300 uppercase mb-1">IP Virtuelle Flottante (VIP)</label>
<div class="flex gap-2">
<input type="text" id="cluster-virtual-ip" name="virtual_ip" placeholder="ex: 10.10.0.100"
class="flex-1 px-3 py-2 rounded-xl bg-anthracite-900 border border-anthracite-700 text-sm text-white focus:outline-none focus:border-cyan-400 font-mono">
<span class="flex items-center text-slate-400 font-mono text-sm">/</span>
<input type="text" id="cluster-virtual-netmask" name="virtual_netmask" value="24" placeholder="24"
class="w-16 px-2 py-2 rounded-xl bg-anthracite-900 border border-anthracite-700 text-sm text-white text-center focus:outline-none focus:border-cyan-400 font-mono">
</div>
</div>
</div>
<!-- IP DU PAIR & JETON SECRET -->
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div>
<label class="block text-xs font-bold text-slate-300 uppercase mb-1">IP du Nœud Pair (Peer IP)</label>
<input type="text" id="cluster-peer-ip" name="peer_ip" placeholder="ex: 10.10.0.5"
class="w-full px-3 py-2 rounded-xl bg-anthracite-900 border border-anthracite-700 text-sm text-white focus:outline-none focus:border-cyan-400 font-mono">
</div>
<div>
<label class="block text-xs font-bold text-slate-300 uppercase mb-1">Jeton de Sécurité Cluster (Secret)</label>
<div class="flex gap-1.5">
<input type="text" id="cluster-secret" name="cluster_secret" required
class="flex-1 px-3 py-2 rounded-xl bg-anthracite-900 border border-anthracite-700 text-xs text-white font-mono focus:outline-none focus:border-cyan-400">
<button type="button" onclick="regenerateClusterSecret()" title="Générer un nouveau token"
class="px-2.5 py-2 rounded-xl bg-anthracite-900 border border-anthracite-700 text-slate-300 hover:text-white transition">🎲</button>
<button type="button" onclick="copyClusterSecret()" title="Copier le token"
class="px-2.5 py-2 rounded-xl bg-anthracite-900 border border-anthracite-700 text-slate-300 hover:text-white transition">📋</button>
</div>
</div>
</div>
<!-- OPTIONS AVANCÉES (VRID, AUTO-SYNC) -->
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3 pt-2 border-t border-anthracite-700/60">
<div class="flex items-center gap-2">
<input type="checkbox" id="cluster-auto-sync" name="auto_sync" value="1" checked
class="rounded bg-anthracite-900 border-anthracite-700 text-cyan-500 focus:ring-cyan-400">
<label for="cluster-auto-sync" class="text-xs text-slate-300 cursor-pointer">Synchroniser automatiquement la base et les sons vers le pair</label>
</div>
<div class="flex items-center justify-end gap-2 text-xs text-slate-400 font-mono">
<span>Virtual Router ID (VRID) :</span>
<input type="number" id="cluster-vrid" name="vrid" value="51" min="1" max="255"
class="w-16 px-2 py-1 rounded bg-anthracite-900 border border-anthracite-700 text-white text-center text-xs">
</div>
</div>
<div class="pt-2 flex justify-end">
<button type="submit"
class="px-5 py-2.5 rounded-xl font-bold text-xs bg-cyan-500 hover:bg-cyan-400 text-slate-950 transition flex items-center gap-2 shadow-lg shadow-cyan-500/20">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/></svg>
<span>Enregistrer &amp; Déployer le Cluster Keepalived</span>
</button>
</div>
</form>
</div>
<!-- PANNEAU DROIT : LOGS & DIAGNOSTIC EN DIRECT (1/3) -->
<div class="bg-anthracite-800 p-6 rounded-2xl border border-anthracite-700 space-y-4 flex flex-col justify-between">
<div class="space-y-4">
<h3 class="text-sm font-bold text-white uppercase tracking-wider flex items-center gap-2">
<svg class="w-4 h-4 text-emerald-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
<span>Services &amp; Santé Système</span>
</h3>
<div class="space-y-2 text-xs">
<div class="flex items-center justify-between p-2.5 rounded-xl bg-anthracite-900 border border-anthracite-700/80">
<span class="text-slate-300">Démon Keepalived</span>
<span id="cluster-svc-keepalived" class="font-mono text-slate-400">Vérification...</span>
</div>
<div class="flex items-center justify-between p-2.5 rounded-xl bg-anthracite-900 border border-anthracite-700/80">
<span class="text-slate-300">Processus Asterisk</span>
<span id="cluster-svc-asterisk" class="font-mono text-slate-400">Vérification...</span>
</div>
<div class="flex items-center justify-between p-2.5 rounded-xl bg-anthracite-900 border border-anthracite-700/80">
<span class="text-slate-300">Ping Nœud Pair</span>
<span id="cluster-svc-peer" class="font-mono text-slate-400">—</span>
</div>
</div>
<div class="pt-2">
<h4 class="text-xs font-bold text-slate-300 uppercase mb-1.5 flex items-center justify-between">
<span>Journal d'Événements Cluster</span>
<span class="text-[10px] text-slate-500 font-mono">/var/log/nexus_cluster.log</span>
</h4>
<div id="cluster-logs-container"
class="h-44 overflow-y-auto p-3 rounded-xl bg-anthracite-950 border border-anthracite-700/80 font-mono text-[11px] text-slate-300 space-y-1">
<p class="text-slate-500">Aucun événement enregistré.</p>
</div>
</div>
</div>
<div class="p-3 rounded-xl bg-indigo-950/40 border border-indigo-500/30 text-[11px] text-indigo-300">
💡 <strong>Conseil d'Architecture :</strong> Vos téléphones IP (Yealink, Groundwire) et vos Trunks SIP doivent pointer sur l'adresse <strong>IP Virtuelle (VIP)</strong>.
</div>
</div>
</div>
</section>
</main>
</div>
</div>