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
@@ -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
@@ -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');
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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 < 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 & 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 & 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 & 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>
|
||||
|
||||
Reference in new issue
Block a user