diff --git a/.gitignore b/.gitignore
index 2dbd9da..29ced2f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -5,6 +5,8 @@
/storage/*.sqlite-shm
/storage/*.sqlite-wal
/storage/*.prerestore.*
+/storage/*.bak
+*.bak
# Données dynamiques et métriques
/storage/metrics_prev.json
@@ -38,3 +40,8 @@ Thumbs.db
.idea/
.vscode/
+# Environnement local de développement (exclu de Git)
+/.dev
+/.env
+/.env.local
+
diff --git a/install.sh b/install.sh
index 9ca2b79..e13201c 100755
--- a/install.sh
+++ b/install.sh
@@ -64,6 +64,11 @@ apt-get install -y --no-install-recommends \
sox \
sshpass
+# Composants optionnels Haute Disponibilité (Cluster HA)
+apt-get install -y --no-install-recommends keepalived iputils-arping 2>/dev/null || true
+systemctl stop keepalived >/dev/null 2>&1 || true
+systemctl disable keepalived >/dev/null 2>&1 || true
+
# Vérification et installation d'Asterisk
echo -e "${C_GREEN} -> Installation du moteur Asterisk PBX...${C_RESET}"
if ! apt-cache policy asterisk 2>/dev/null | grep -q "Candidate: [0-9]"; then
@@ -126,11 +131,14 @@ fi
echo -e "${C_GREEN}[2/8] Préparation et déploiement des fichiers NexusPBX...${C_RESET}"
TARGET_DIR="/var/www/nexus-pbx"
+INSTALL_BRANCH="${1:-${INSTALL_BRANCH:-dev}}"
+
if [ -d "$TARGET_DIR/.git" ]; then
- echo " -> Dépôt NexusPBX déjà présent dans $TARGET_DIR, mise à jour..."
+ echo " -> Dépôt NexusPBX déjà présent dans $TARGET_DIR, mise à jour (branche: $INSTALL_BRANCH)..."
cd "$TARGET_DIR"
git config --global --add safe.directory "$TARGET_DIR" 2>/dev/null || true
- git pull origin main 2>/dev/null || true
+ git checkout "$INSTALL_BRANCH" 2>/dev/null || true
+ git pull origin "$INSTALL_BRANCH" 2>/dev/null || true
elif [ -d "$PWD/public" ] && [ -d "$PWD/src" ]; then
echo " -> Exécution depuis le dossier source, synchronisation vers $TARGET_DIR..."
if [ "$PWD" != "$TARGET_DIR" ]; then
@@ -139,13 +147,17 @@ elif [ -d "$PWD/public" ] && [ -d "$PWD/src" ]; then
cd "$TARGET_DIR"
fi
else
- echo " -> Clonage du dépôt public officiel depuis https://git.sorcier-research.com..."
+ echo " -> Clonage du dépôt public officiel (branche: $INSTALL_BRANCH)..."
rm -rf "$TARGET_DIR"
- git clone https://git.sorcier-research.com/sorcier/NexusPBX.git "$TARGET_DIR"
+ git clone -b "$INSTALL_BRANCH" https://git.sorcier-research.com/sorcier/NexusPBX.git "$TARGET_DIR"
git config --system --add safe.directory "$TARGET_DIR" 2>/dev/null || true
cd "$TARGET_DIR"
fi
+if [ "$INSTALL_BRANCH" = "dev" ]; then
+ touch "$TARGET_DIR/.dev"
+fi
+
# 4. Configuration des utilisateurs, groupes et arborescence de stockage
echo -e "${C_GREEN}[3/8] Configuration des permissions et de l'arborescence...${C_RESET}"
usermod -aG asterisk www-data 2>/dev/null || true
@@ -161,6 +173,12 @@ mkdir -p /var/lib/asterisk/sounds/custom
mkdir -p /var/spool/asterisk/voicemail/default
mkdir -p /var/log/asterisk /var/log/asterisk/cdr-csv
mkdir -p /var/log/nexus-pbx
+mkdir -p /etc/keepalived/scripts
+mkdir -p "$TARGET_DIR/scripts/cluster"
+touch /var/log/nexus_cluster.log
+chmod 666 /var/log/nexus_cluster.log
+sysctl -w net.ipv4.ip_nonlocal_bind=1 >/dev/null 2>&1 || true
+echo "net.ipv4.ip_nonlocal_bind=1" > /etc/sysctl.d/99-nexus-ha.conf 2>/dev/null || true
touch /var/log/asterisk/messages /var/log/asterisk/security.log /var/log/asterisk/cdr-csv/Master.csv
chown -R asterisk:asterisk /var/log/asterisk 2>/dev/null || true
@@ -189,7 +207,7 @@ setfacl -R -d -m u:www-data:rwx,g:asterisk:rwx /etc/asterisk /var/lib/asterisk/s
# Droits sudoers pour l'interface web (www-data)
cat << 'EOF' > /etc/sudoers.d/nexus-pbx
# Privilèges spécifiques NexusPBX pour l'utilisateur Web www-data
-www-data ALL=(ALL) NOPASSWD: /usr/sbin/asterisk, /usr/sbin/nginx, /usr/bin/systemctl, /usr/bin/fail2ban-client, /bin/fail2ban-client, /var/www/nexus-pbx/scripts/nexus-firewall, /usr/bin/certbot, /usr/sbin/nft, /usr/sbin/iptables, /var/www/nexus-pbx/scripts/update.sh, /usr/local/bin/nexus-update
+www-data ALL=(ALL) NOPASSWD: /usr/sbin/asterisk, /usr/sbin/nginx, /usr/bin/systemctl, /usr/bin/fail2ban-client, /bin/fail2ban-client, /var/www/nexus-pbx/scripts/nexus-firewall, /usr/bin/certbot, /usr/sbin/nft, /usr/sbin/iptables, /var/www/nexus-pbx/scripts/update.sh, /usr/local/bin/nexus-update, /var/www/nexus-pbx/scripts/nexus-cluster
EOF
chmod 0440 /etc/sudoers.d/nexus-pbx
@@ -201,7 +219,7 @@ for fpm_svc in /lib/systemd/system/php*-fpm.service /usr/lib/systemd/system/php*
cat << 'EOF_OVERRIDE' > "/etc/systemd/system/${svc_name}.d/override.conf"
[Service]
ProtectSystem=false
-ReadWritePaths=/etc/asterisk /etc/fail2ban /etc/nginx /var/log /var/lib/asterisk /var/spool/asterisk /var/www /usr/local/bin /etc/sudoers.d /etc/letsencrypt /var/lib/letsencrypt
+ReadWritePaths=/etc/asterisk /etc/fail2ban /etc/nginx /var/log /var/lib/asterisk /var/spool/asterisk /var/www /usr/local/bin /etc/sudoers.d /etc/letsencrypt /var/lib/letsencrypt /etc/keepalived
EOF_OVERRIDE
fi
done
@@ -470,11 +488,28 @@ EOF
chmod 0644 /etc/cron.d/nexus-backup
touch /var/log/nexus_backup.log && chown www-data:asterisk /var/log/nexus_backup.log && chmod 664 /var/log/nexus_backup.log
+# Cron synchronisation Cluster HA toutes les 2 minutes (synchronisation base + astdb contacts)
+cat << 'EOF' > /etc/cron.d/nexus-cluster-sync
+# Synchronisation périodique du cluster NexusPBX HA vers le nœud Backup
+*/2 * * * * www-data /usr/bin/php /var/www/nexus-pbx/scripts/nexus_cluster_sync.php >> /var/log/nexus_cluster.log 2>&1
+EOF
+chmod 0644 /etc/cron.d/nexus-cluster-sync
+
# CLI symlinks
-chmod +x "$TARGET_DIR/scripts/nexus-firewall" "$TARGET_DIR/scripts/nexus-banner.sh" "$TARGET_DIR/scripts/update.sh"
+chmod +x "$TARGET_DIR/scripts/nexus-firewall" "$TARGET_DIR/scripts/nexus-banner.sh" "$TARGET_DIR/scripts/update.sh" "$TARGET_DIR/scripts/nexus-cluster"
+chmod +x "$TARGET_DIR"/scripts/cluster/*.sh 2>/dev/null || true
ln -sf "$TARGET_DIR/scripts/nexus-firewall" /usr/local/bin/nexus-firewall
ln -sf "$TARGET_DIR/scripts/nexus-banner.sh" /usr/local/bin/nexus-banner
ln -sf "$TARGET_DIR/scripts/update.sh" /usr/local/bin/nexus-update
+ln -sf "$TARGET_DIR/scripts/nexus-cluster" /usr/local/bin/nexus-cluster
+
+# Pré-déploiement sécurisé des scripts Keepalived
+if [ -d "$TARGET_DIR/scripts/cluster" ]; then
+ cp -f "$TARGET_DIR/scripts/cluster/"*.sh /etc/keepalived/scripts/ 2>/dev/null || true
+ chown -R root:root /etc/keepalived
+ chmod 755 /etc/keepalived /etc/keepalived/scripts
+ chmod 755 /etc/keepalived/scripts/*.sh 2>/dev/null || true
+fi
# Bannière interactive lors de chaque connexion terminal (SSH / Console)
cat << 'EOF' > /etc/profile.d/nexus-motd.sh
@@ -489,6 +524,16 @@ chmod +x /etc/profile.d/nexus-motd.sh
echo -e "${C_GREEN}[8/8] Démarrage et activation des services système...${C_RESET}"
systemctl daemon-reload
+# Optimisation de la réserve de processus PHP-FPM pour supporter la télémétrie temps réel et le cluster HA
+for pool in /etc/php/*/fpm/pool.d/www.conf; do
+ if [ -f "$pool" ]; then
+ sed -i 's/^pm.max_children = .*/pm.max_children = 25/' "$pool"
+ sed -i 's/^pm.start_servers = .*/pm.start_servers = 4/' "$pool"
+ sed -i 's/^pm.min_spare_servers = .*/pm.min_spare_servers = 2/' "$pool"
+ sed -i 's/^pm.max_spare_servers = .*/pm.max_spare_servers = 6/' "$pool"
+ fi
+done
+
# Redémarrage PHP-FPM (rechargement de l'appartenance au groupe asterisk)
systemctl restart php*-fpm 2>/dev/null || true
PHP_SVC=$(systemctl list-unit-files --type=service 2>/dev/null | grep -E "php.*-fpm" | awk '{print $1}' | head -n 1 || echo "php8.4-fpm")
diff --git a/public/api.php b/public/api.php
index c86c05e..a8be442 100644
--- a/public/api.php
+++ b/public/api.php
@@ -12,6 +12,17 @@ require_once __DIR__ . '/../src/BackupManager.php';
require_once __DIR__ . '/../src/FirewallManager.php';
require_once __DIR__ . '/../src/PhonebookManager.php';
require_once __DIR__ . '/../src/CdrManager.php';
+require_once __DIR__ . '/../src/ClusterManager.php';
+
+// Autoloader PSR-4 automatique pour toutes les classes du namespace App
+spl_autoload_register(function (string $class): void {
+ if (str_starts_with($class, 'App\\')) {
+ $file = __DIR__ . '/../src/' . str_replace('\\', '/', substr($class, 4)) . '.php';
+ if (file_exists($file)) {
+ require_once $file;
+ }
+ }
+});
use App\Database;
use App\Auth;
@@ -24,13 +35,13 @@ use App\BackupManager;
use App\FirewallManager;
use App\PhonebookManager;
use App\CdrManager;
+use App\ClusterManager;
header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: no-store, no-cache, must-revalidate');
try {
$db = Database::getConnection();
- Auth::startSession();
$action = trim((string)($_GET['action'] ?? ''));
$rawBody = file_get_contents('php://input');
@@ -39,6 +50,39 @@ try {
$payload = $_POST;
}
+ // Action inter-serveurs réplication Cluster (authentifiée par signature HMAC - aucune session requise)
+ if ($action === 'cluster_receive_sync') {
+ $signature = $_SERVER['HTTP_X_NEXUS_CLUSTER_SIGNATURE'] ?? '';
+ $cm = new ClusterManager($db);
+ $res = $cm->receiveSyncPayload($rawBody ?: '', $signature);
+ echo json_encode($res);
+ exit;
+ }
+
+ if ($action === 'cluster_node_status') {
+ $signature = $_SERVER['HTTP_X_NEXUS_CLUSTER_SIGNATURE'] ?? '';
+ $cm = new ClusterManager($db);
+ $res = $cm->getNodeStatusForPeer($rawBody ?: '', $signature);
+ echo json_encode($res);
+ exit;
+ }
+
+ if ($action === 'cluster_request_push') {
+ $signature = $_SERVER['HTTP_X_NEXUS_CLUSTER_SIGNATURE'] ?? '';
+ $cm = new ClusterManager($db);
+ $res = $cm->handlePushRequestFromPeer($rawBody ?: '', $signature);
+ echo json_encode($res);
+ exit;
+ }
+
+ Auth::startSession();
+
+ // Pour les requêtes en lecture seule fréquente (polling métriques / statut), libérer immédiatement le verrou de session
+ // Cela évite de saturer les workers PHP-FPM par des verrous de fichiers sessions concurrents
+ if (session_status() === PHP_SESSION_ACTIVE && ($action === 'get_metrics' || $action === 'cluster_status' || $action === 'get_system_info')) {
+ session_write_close();
+ }
+
// Actions publiques (Login / Logout)
if ($action === 'login') {
$username = trim((string)($payload['username'] ?? ''));
@@ -74,6 +118,7 @@ try {
exit;
}
+
// Toutes les autres actions requièrent une session authentifiée
if (!Auth::isAuthenticated()) {
http_response_code(401);
@@ -403,6 +448,87 @@ try {
]);
break;
+ case 'check_extension_call_status':
+ $extension = trim((string)($payload['extension'] ?? ''));
+ if ($extension === '') {
+ throw new InvalidArgumentException("Extension requise.");
+ }
+
+ $inCall = false;
+ $callDetails = '';
+ try {
+ $ami = AmiClient::fromDatabase($db);
+ $rawChannels = $ami->runCommand("core show channels concise");
+ $channelLines = array_filter(explode("\n", trim($rawChannels)));
+ foreach ($channelLines as $cline) {
+ $parts = explode('!', trim($cline));
+ $chanName = $parts[0] ?? '';
+ if (preg_match('#^PJSIP/' . preg_quote($extension, '#') . '[-/]#i', $chanName)) {
+ $inCall = true;
+ $state = $parts[4] ?? 'Up';
+ $callerId = $parts[7] ?? '';
+ $duration = $parts[10] ?? '';
+ $callDetails = "Canal : {$chanName}";
+ if ($callerId !== '') {
+ $callDetails .= " • Interlocuteur : {$callerId}";
+ }
+ if ($duration !== '') {
+ $callDetails .= " • Durée : {$duration}";
+ }
+ break;
+ }
+ }
+
+ if (!$inCall) {
+ $rawHints = $ami->runCommand("core show hints {$extension}");
+ if (preg_match('/' . preg_quote($extension, '/') . '@[^\n]*State:(InUse|Busy|Ringing)/i', $rawHints, $m)) {
+ $inCall = true;
+ $callDetails = "Ligne active : " . ($m[1] === 'Ringing' ? 'En sonnerie' : 'En communication');
+ }
+ }
+ } catch (\Throwable $e) {
+ // AMI indisponible
+ }
+
+ echo json_encode([
+ 'success' => true,
+ 'extension' => $extension,
+ 'in_call' => $inCall,
+ 'call_details' => $callDetails,
+ ]);
+ break;
+
+ case 'reboot_extension':
+ $extension = trim((string)($payload['extension'] ?? ''));
+ if ($extension === '') {
+ throw new InvalidArgumentException("Extension requise.");
+ }
+
+ $stmt = $db->prepare('SELECT extension, name FROM extensions WHERE extension = :ext');
+ $stmt->execute([':ext' => $extension]);
+ $extRow = $stmt->fetch();
+ if (!$extRow) {
+ throw new InvalidArgumentException("Poste {$extension} introuvable.");
+ }
+
+ $cleanExt = preg_replace('/[^a-zA-Z0-9_\-]/', '', $extension);
+
+ try {
+ $ami = AmiClient::fromDatabase($db);
+ $cmdOutput = $ami->runCommand("pjsip send notify reboot endpoint {$cleanExt}");
+ if (stripos($cmdOutput, 'Unable to') !== false) {
+ throw new RuntimeException("Échec Asterisk : " . trim($cmdOutput));
+ }
+ echo json_encode([
+ 'success' => true,
+ 'message' => "Ordre de redémarrage envoyé au poste {$extension} ({$extRow['name']}).",
+ 'details' => trim($cmdOutput),
+ ]);
+ } catch (\Throwable $e) {
+ throw new RuntimeException("Erreur lors de l'envoi de la commande de redémarrage : " . $e->getMessage());
+ }
+ break;
+
case 'save_group':
$id = isset($payload['id']) && $payload['id'] !== '' ? (int)$payload['id'] : null;
$extension = trim((string)($payload['extension'] ?? ''));
@@ -418,6 +544,14 @@ try {
if ($name === '') {
throw new InvalidArgumentException("Le nom du groupe d'appel est obligatoire.");
}
+ if (str_starts_with($failover, 'vm-')) {
+ $vmTarget = substr($failover, 3);
+ $vmStmt = $db->prepare('SELECT voicemail_enabled FROM extensions WHERE extension = :ext AND enabled = 1');
+ $vmStmt->execute([':ext' => $vmTarget]);
+ if (!(int)$vmStmt->fetchColumn()) {
+ throw new InvalidArgumentException("Le répondeur du poste {$vmTarget} n'existe pas ou n'est pas activé.");
+ }
+ }
if ($id) {
$stmt = $db->prepare('
@@ -1738,6 +1872,14 @@ try {
]);
SystemChecker::ensureAsteriskIncludes();
+ $generator->writeAllConfigs();
+ try {
+ $ami = AmiClient::fromDatabase($db);
+ $ami->reloadModule('res_pjsip.so');
+ $ami->reloadModule('res_rtp_asterisk.so');
+ } catch (\Throwable $e) {
+ // Ignore reload error if Asterisk is not running
+ }
if (!empty($payload['new_admin_password'])) {
$newPass = (string)$payload['new_admin_password'];
@@ -1771,12 +1913,33 @@ try {
$ami = AmiClient::fromDatabase($db);
$reloadInfo = $ami->reloadPjsipAndDialplan();
+ // Actualisation de l'horodatage de modification en base pour la réconciliation cluster
+ try {
+ $db->exec("UPDATE cluster_settings SET updated_at = CURRENT_TIMESTAMP WHERE id = 1");
+ } catch (\Throwable) {}
+
+ // Synchronisation automatique temps réel vers le nœud pair si Cluster actif et Master
+ $clusterSyncMsg = null;
+ try {
+ $cm = new ClusterManager($db);
+ $cSet = $cm->getSettings();
+ $isVipActive = $cm->isVirtualIpActive($cSet['virtual_ip'] ?? '');
+ if (!empty($cSet['enabled']) && !empty($cSet['auto_sync']) && $isVipActive && !empty($cSet['peer_ip'])) {
+ $syncRes = $cm->pushSyncToPeer();
+ $clusterSyncMsg = $syncRes['message'] ?? 'Réplication pair OK';
+ }
+ } catch (\Throwable $ce) {
+ error_log("[CLUSTER] Auto-sync on apply_config error: " . $ce->getMessage());
+ $clusterSyncMsg = "Alerte réplication pair : " . $ce->getMessage();
+ }
+
echo json_encode([
'success' => true,
- 'message' => 'Configurations écrites, pile PJSIP rechargée et Re-Register SIP envoyé à tous les opérateurs !',
+ 'message' => 'Configurations appliquées avec succès !' . ($clusterSyncMsg ? ' (' . $clusterSyncMsg . ')' : ''),
'details' => [
- 'files' => $writeStats,
- 'reload' => $reloadInfo,
+ 'files' => $writeStats,
+ 'reload' => $reloadInfo,
+ 'cluster_sync' => $clusterSyncMsg,
],
]);
break;
@@ -1972,6 +2135,7 @@ try {
$rtpChecksums = !empty($payload['rtp_checksums']) ? 1 : 0;
$rtpStrict = !empty($payload['rtp_strict']) ? 1 : 0;
$rtpTimeout = max(5, (int)($payload['rtp_timeout'] ?? 30));
+ $rtpHoldTimeout = max(0, (int)($payload['rtp_timeout_hold'] ?? 0));
$rtpKeepalive = max(0, (int)($payload['rtp_keepalive'] ?? 15));
$allowAnonymousSip = !empty($payload['allow_anonymous_sip']) ? 1 : 0;
@@ -2006,6 +2170,7 @@ try {
rtp_checksums = :rtp_checksums,
rtp_strict = :rtp_strict,
rtp_timeout = :rtp_timeout,
+ rtp_timeout_hold = :rtp_timeout_hold,
rtp_keepalive = :rtp_keepalive,
allow_anonymous_sip = :anon,
updated_at = CURRENT_TIMESTAMP
@@ -2023,9 +2188,10 @@ try {
':rtp_start' => $rtpStart,
':rtp_end' => $rtpEnd,
':rtp_checksums' => $rtpChecksums,
- ':rtp_strict' => $rtpStrict,
- ':rtp_timeout' => $rtpTimeout,
- ':rtp_keepalive' => $rtpKeepalive,
+ ':rtp_strict' => $rtpStrict,
+ ':rtp_timeout' => $rtpTimeout,
+ ':rtp_timeout_hold' => $rtpHoldTimeout,
+ ':rtp_keepalive' => $rtpKeepalive,
':anon' => $allowAnonymousSip,
]);
@@ -2414,6 +2580,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([
diff --git a/public/assets/app.js b/public/assets/app.js
index 93274ac..8568ae4 100644
--- a/public/assets/app.js
+++ b/public/assets/app.js
@@ -951,9 +951,10 @@ function renderExtensionsTable() {
${escapeHtml(ext.secret)}
|
-
-
-
+ |
+
+
+
|
`;
@@ -1109,6 +1110,90 @@ async function deleteExtension(id, optNum = null) {
}
}
+let pendingRebootExtension = null;
+
+async function promptRebootExtension(id) {
+ const ext = (appState.extensions || []).find(e => Number(e.id) === Number(id));
+ if (!ext) return;
+
+ pendingRebootExtension = ext;
+
+ document.getElementById('reboot-modal-ext-num').textContent = ext.extension;
+ document.getElementById('reboot-modal-ext-name').textContent = ext.name || '';
+
+ const checkingEl = document.getElementById('reboot-modal-checking');
+ const warningEl = document.getElementById('reboot-modal-call-warning');
+ const normalEl = document.getElementById('reboot-modal-normal-info');
+ const btnAction = document.getElementById('btn-confirm-reboot-action');
+ const btnLabel = document.getElementById('reboot-confirm-btn-label');
+
+ checkingEl.classList.remove('hidden');
+ warningEl.classList.add('hidden');
+ normalEl.classList.add('hidden');
+
+ btnAction.disabled = true;
+ btnAction.className = 'px-5 py-2.5 rounded-xl text-xs font-bold bg-anthracite-700 text-slate-400 transition cursor-not-allowed flex items-center gap-2';
+ btnLabel.textContent = 'Vérification en cours...';
+
+ openModal('modal-confirm-reboot-extension');
+
+ try {
+ const res = await apiCall('check_extension_call_status', { extension: ext.extension });
+ checkingEl.classList.add('hidden');
+ btnAction.disabled = false;
+
+ if (res.in_call) {
+ warningEl.classList.remove('hidden');
+ normalEl.classList.add('hidden');
+
+ const detailsEl = document.getElementById('reboot-modal-call-details');
+ if (detailsEl) {
+ detailsEl.textContent = res.call_details || 'Communication active détectée sur ce combiné.';
+ }
+
+ btnAction.className = 'px-5 py-2.5 rounded-xl text-xs font-bold bg-rose-600 hover:bg-rose-500 text-white shadow-lg shadow-rose-600/30 transition flex items-center gap-2';
+ btnLabel.textContent = "Forcer le redémarrage (Couper l'appel)";
+ } else {
+ warningEl.classList.add('hidden');
+ normalEl.classList.remove('hidden');
+
+ btnAction.className = 'px-5 py-2.5 rounded-xl text-xs font-bold bg-amber-600 hover:bg-amber-500 text-white shadow-lg shadow-amber-600/30 transition flex items-center gap-2';
+ btnLabel.textContent = 'Confirmer le redémarrage';
+ }
+ } catch (err) {
+ checkingEl.classList.add('hidden');
+ normalEl.classList.remove('hidden');
+ btnAction.disabled = false;
+ btnAction.className = 'px-5 py-2.5 rounded-xl text-xs font-bold bg-amber-600 hover:bg-amber-500 text-white shadow-lg shadow-amber-600/30 transition flex items-center gap-2';
+ btnLabel.textContent = 'Confirmer le redémarrage';
+ }
+}
+
+function closeRebootModal() {
+ pendingRebootExtension = null;
+ closeModal('modal-confirm-reboot-extension');
+}
+
+async function confirmRebootExtension() {
+ if (!pendingRebootExtension) return;
+ const ext = pendingRebootExtension;
+ const btnAction = document.getElementById('btn-confirm-reboot-action');
+ const btnLabel = document.getElementById('reboot-confirm-btn-label');
+
+ if (btnAction) btnAction.disabled = true;
+ if (btnLabel) btnLabel.textContent = 'Envoi de l\'ordre...';
+
+ try {
+ const res = await apiCall('reboot_extension', { extension: ext.extension });
+ closeRebootModal();
+ showToast(res.message, 'success');
+ } catch (err) {
+ showToast(err.message, 'error');
+ } finally {
+ if (btnAction) btnAction.disabled = false;
+ }
+}
+
function syncEncryptionToggle(transport, currentVal = null) {
const sel = document.getElementById('ext-encryption');
if (!sel) return;
@@ -1149,6 +1234,9 @@ function renderGroupsTable() {
const badges = members.length
? members.map(m => `${escapeHtml(m)}`).join(' ')
: 'Aucun membre';
+ const failoverLabel = String(grp.failover_dest || '').startsWith('vm-')
+ ? `📬 Répondeur ${escapeHtml(String(grp.failover_dest).slice(3))}`
+ : `${escapeHtml(grp.failover_dest)}`;
return `
@@ -1156,7 +1244,7 @@ function renderGroupsTable() {
| ${escapeHtml(grp.name)} |
${escapeHtml(grp.strategy)} |
${badges} |
- ${Number(grp.ring_time)}s → ${escapeHtml(grp.failover_dest)} |
+ ${Number(grp.ring_time)}s → ${failoverLabel} |
@@ -1808,6 +1896,21 @@ function buildDestinationOptionsHtml(selectedValue = 'ext-101') {
html += ``;
}
+ const vmExts = exts.filter(e => Number(e.voicemail_enabled) === 1);
+ const hasMissingVm = normVal.startsWith('vm-') && !vmExts.some(e => `vm-${e.extension}` === normVal);
+ if (vmExts.length > 0 || hasMissingVm) {
+ html += ``;
+ }
+
// Piège à Spammeurs / Musique d'attente en boucle continue (MoH)
const mohSounds = sounds.filter(s => s.usage_type === 'moh');
html += ` |