'use strict';
let appState = {
settings: {},
extensions: [],
groups: [],
paging_groups: [],
extension_groups: [],
trunks: [],
ivrs: [],
sounds: [],
sound_sites: [],
feature_codes: [],
certificates: [],
active_certificate: null,
diagnostics: { healthy: false, checks: [] },
metrics: null,
previews: { pjsip: '', extensions: '', moh: '' }
};
const MAX_HISTORY_POINTS = 24;
const chartHistory = {
cpu: Array(MAX_HISTORY_POINTS).fill(2),
ram: Array(MAX_HISTORY_POINTS).fill(15),
rx: Array(MAX_HISTORY_POINTS).fill(0.5),
tx: Array(MAX_HISTORY_POINTS).fill(0.2)
};
let metricsTimer = null;
// --- Notifications Toast Tailwind ---
function showToast(message, type = 'success') {
const container = document.getElementById('toast-container');
if (!container) return;
const colors = {
success: 'bg-emerald-600/95 border-emerald-400/40 text-white',
error: 'bg-red-600/95 border-red-400/40 text-white',
info: 'bg-indigo-600/95 border-indigo-400/40 text-white'
};
const toast = document.createElement('div');
toast.className = `pointer-events-auto flex items-start justify-between gap-3 px-4 py-3 rounded-xl border shadow-2xl backdrop-blur text-xs sm:text-sm font-medium transition-all duration-300 transform translate-y-2 opacity-0 ${colors[type] || colors.info}`;
toast.innerHTML = `
${escapeHtml(message)}
✕
`;
toast.querySelector('button').addEventListener('click', () => toast.remove());
container.appendChild(toast);
requestAnimationFrame(() => {
toast.classList.remove('translate-y-2', 'opacity-0');
});
setTimeout(() => {
toast.classList.add('opacity-0', 'translate-x-4');
setTimeout(() => toast.remove(), 300);
}, 4200);
}
function escapeHtml(str) {
return String(str ?? '')
.replace(/&/g, '&')
.replace(//g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
// --- Bascule propre des écrans Login / Dashboard ---
function showLoginScreen() {
stopMetricsPolling();
const appScreen = document.getElementById('app-screen');
const loginScreen = document.getElementById('login-screen');
if (appScreen) {
appScreen.classList.add('hidden');
appScreen.classList.remove('flex');
}
if (loginScreen) {
loginScreen.classList.remove('hidden');
loginScreen.classList.add('flex');
}
window.scrollTo(0, 0);
}
function showAppScreen() {
const loginScreen = document.getElementById('login-screen');
const appScreen = document.getElementById('app-screen');
if (loginScreen) {
loginScreen.classList.add('hidden');
loginScreen.classList.remove('flex');
}
if (appScreen) {
appScreen.classList.remove('hidden');
appScreen.classList.add('flex');
}
window.scrollTo(0, 0);
}
// --- Client AJAX Fetch API ---
async function apiCall(action, payload = null) {
const options = {
method: payload ? 'POST' : 'GET',
headers: { 'Accept': 'application/json' }
};
if (payload) {
options.headers['Content-Type'] = 'application/json';
options.body = JSON.stringify(payload);
}
const res = await fetch(`/api.php?action=${encodeURIComponent(action)}`, options);
const cType = res.headers.get('content-type') || '';
let data;
if (cType.includes('application/json')) {
data = await res.json();
} else {
const rawText = await res.text();
if (res.status === 413) {
throw new Error("Le contenu envoyé est trop volumineux pour le serveur (Limite 64 Mo).");
}
throw new Error(`Erreur serveur (${res.status}) : ${rawText.replace(/<[^>]*>/g, '').trim().slice(0, 150) || 'Réponse inattendue'}`);
}
if (res.status === 401 && data.unauthenticated) {
showLoginScreen();
throw new Error(data.message || 'Session expirée');
}
if (!res.ok || !data.success) {
throw new Error(data.message || 'Erreur lors de la requête AJAX');
}
return data;
}
// --- Chargement global de l'état ---
async function loadState() {
try {
const data = await apiCall('get_state');
appState = data;
if (data.metrics) {
pushMetricsToHistory(data.metrics);
}
renderAll();
loadBackups();
checkSystemUpdate(false);
startMetricsPolling();
} catch (err) {
if (err.message !== 'Session expirée') {
showToast(err.message, 'error');
}
}
}
async function pollMetrics() {
try {
const data = await apiCall('get_metrics');
if (data.metrics) {
appState.metrics = data.metrics;
pushMetricsToHistory(data.metrics);
renderDashboardMetrics();
renderExtensionsTable();
renderTrunksTable();
}
} catch (_) {
// Silencieux en arrière-plan
}
}
function startMetricsPolling() {
if (!metricsTimer) {
metricsTimer = setInterval(pollMetrics, 3000);
}
}
function stopMetricsPolling() {
if (metricsTimer) {
clearInterval(metricsTimer);
metricsTimer = null;
}
}
function pushMetricsToHistory(m) {
chartHistory.cpu.push(Number(m.cpu?.percent || 0));
chartHistory.cpu.shift();
chartHistory.ram.push(Number(m.memory?.percent || 0));
chartHistory.ram.shift();
chartHistory.rx.push(Number(m.network?.rx_kbps || 0));
chartHistory.rx.shift();
chartHistory.tx.push(Number(m.network?.tx_kbps || 0));
chartHistory.tx.shift();
}
function renderAll() {
renderCompanyBranding();
renderSummaryCards();
renderDashboardMetrics();
renderExtensionsTable();
renderGroupsTable();
renderExtensionGroupsTable();
renderPagingTable();
renderPhonebookTable();
renderTrunksTable();
renderOutboundRoutesTable();
renderIvrsTable();
renderSoundsTable();
renderFeatureCodes();
renderSipSettings();
renderCertificates();
renderSettingsAndDiagnostics();
}
function renderCompanyBranding() {
const s = appState.settings || {};
const logo = (s.company_logo || '').trim();
const name = (s.company_name || '').trim();
const emptyBox = document.getElementById('sidebar-company-empty');
const filledBox = document.getElementById('sidebar-company-filled');
const logoImg = document.getElementById('sidebar-company-logo');
const nameEl = document.getElementById('sidebar-company-name');
const slot = document.getElementById('sidebar-company-slot');
const loginBox = document.getElementById('login-company-box');
const loginLogo = document.getElementById('login-company-logo');
const loginName = document.getElementById('login-company-name');
// Aperçu dans l'administration (Onglet Système & Paramètres)
const settingsPreviewImg = document.getElementById('settings-company-preview-img');
const settingsPreviewEmpty = document.getElementById('settings-company-preview-empty');
if (settingsPreviewImg && settingsPreviewEmpty) {
if (logo) {
settingsPreviewImg.src = logo;
settingsPreviewImg.classList.remove('hidden');
settingsPreviewEmpty.classList.add('hidden');
} else {
settingsPreviewImg.src = '';
settingsPreviewImg.classList.add('hidden');
settingsPreviewEmpty.classList.remove('hidden');
}
}
if (logo || name) {
emptyBox?.classList.add('hidden');
filledBox?.classList.remove('hidden');
if (logo && logoImg) {
logoImg.src = logo;
logoImg.classList.remove('hidden');
} else if (logoImg) {
logoImg.src = '';
logoImg.classList.add('hidden');
}
if (name && nameEl) {
nameEl.textContent = name;
nameEl.classList.remove('hidden');
} else if (nameEl) {
nameEl.textContent = '';
nameEl.classList.add('hidden');
}
if (loginBox) {
loginBox.classList.remove('hidden');
if (logo && loginLogo) {
loginLogo.src = logo;
loginLogo.classList.remove('hidden');
} else if (loginLogo) {
loginLogo.classList.add('hidden');
}
if (loginName) loginName.textContent = name;
}
} else {
emptyBox?.classList.remove('hidden');
filledBox?.classList.add('hidden');
if (logoImg) { logoImg.src = ''; logoImg.classList.add('hidden'); }
if (nameEl) { nameEl.textContent = ''; nameEl.classList.add('hidden'); }
loginBox?.classList.add('hidden');
}
}
function triggerCompanyLogoPicker() {
document.getElementById('company-logo-file-input')?.click();
}
function handleCompanyLogoUpload(input) {
const file = input?.files?.[0];
if (!file) return;
if (file.size > 1500000) {
showToast('Le fichier logo ne doit pas dépasser 1.5 Mo.', 'error');
input.value = '';
return;
}
const reader = new FileReader();
reader.onload = async (ev) => {
const dataUrl = String(ev.target?.result || '');
try {
const res = await apiCall('save_company_branding', { company_logo: dataUrl });
showToast(res.message, 'success');
await loadState();
} catch (err) {
showToast(err.message, 'error');
} finally {
input.value = '';
}
};
reader.readAsDataURL(file);
}
async function clearCompanyLogo() {
try {
const res = await apiCall('save_company_branding', { company_logo: '' });
showToast(res.message, 'info');
await loadState();
} catch (err) {
showToast(err.message, 'error');
}
}
function renderSummaryCards() {
const exts = appState.extensions || [];
const grps = appState.groups || [];
const trks = appState.trunks || [];
const ivrs = appState.ivrs || [];
const obRoutes = appState.outbound_routes || [];
const tlsCount = exts.filter(e => e.transport === 'tls').length;
const udpCount = exts.length - tlsCount;
document.getElementById('stat-extensions').textContent = exts.length;
document.getElementById('stat-extensions-sub').textContent = `${udpCount} Interne UDP • ${tlsCount} TLS/SRTP`;
document.getElementById('stat-groups').textContent = grps.length;
document.getElementById('stat-trunks').textContent = trks.length;
document.getElementById('stat-ivrs').textContent = ivrs.length;
// Badges menu gauche
document.getElementById('nav-badge-extensions').textContent = exts.length;
document.getElementById('nav-badge-groups').textContent = grps.length;
const egBadge = document.getElementById('nav-badge-extgroups');
if (egBadge) egBadge.textContent = (appState.extension_groups || []).length;
const pagingBadge = document.getElementById('nav-badge-paging');
if (pagingBadge) pagingBadge.textContent = (appState.paging_groups || []).length;
const pbBadge = document.getElementById('nav-badge-phonebook');
if (pbBadge) pbBadge.textContent = (appState.phonebook_contacts || []).length;
document.getElementById('nav-badge-trunks').textContent = trks.length;
const obBadge = document.getElementById('nav-badge-outbound');
if (obBadge) obBadge.textContent = obRoutes.length;
document.getElementById('nav-badge-ivrs').textContent = ivrs.length;
const soundsBadge = document.getElementById('nav-badge-sounds');
if (soundsBadge) soundsBadge.textContent = (appState.sounds || []).length;
const backupsBadge = document.getElementById('nav-badge-backups');
if (backupsBadge && typeof appState.backups_count !== 'undefined') {
backupsBadge.textContent = appState.backups_count;
}
const diag = appState.diagnostics || { healthy: false, checks: [] };
const healthEl = document.getElementById('stat-health');
const amiDot = document.getElementById('ami-dot');
const sideBadge = document.getElementById('sidebar-health-badge');
if (diag.healthy) {
healthEl.textContent = 'AMI & PJSIP OK';
healthEl.className = 'text-[11px] font-bold text-emerald-400';
amiDot.className = 'w-2 h-2 rounded-full bg-emerald-400';
sideBadge.textContent = 'EN LIGNE';
sideBadge.className = 'px-2 py-0.5 rounded-full text-[10px] font-bold bg-emerald-500/20 text-emerald-300 border border-emerald-500/30';
} else {
healthEl.textContent = 'Vérifier Config';
healthEl.className = 'text-[11px] font-bold text-amber-400';
amiDot.className = 'w-2 h-2 rounded-full bg-amber-400';
sideBadge.textContent = 'ATTENTION';
sideBadge.className = 'px-2 py-0.5 rounded-full text-[10px] font-bold bg-amber-500/20 text-amber-300 border border-amber-500/30';
}
const lastApplied = appState.settings?.last_applied_at;
document.getElementById('last-applied-label').textContent = lastApplied
? `Dernière application Asterisk : ${lastApplied} UTC`
: `Cliquez sur « Appliquer les configurations » pour synchroniser Asterisk`;
}
// --- Rendu Dashboard Métriques & Graphes SVG ---
function renderDashboardMetrics() {
const m = appState.metrics;
if (!m) return;
// Sidebar infos
const osVerEl = document.getElementById('sidebar-os-version');
if (osVerEl && m.os_version) osVerEl.textContent = m.os_version;
const astVerEl = document.getElementById('sidebar-ast-version');
if (astVerEl && m.asterisk?.version) astVerEl.textContent = m.asterisk.version;
document.getElementById('sidebar-ip').textContent = m.network?.ip || '192.168.1.100';
document.getElementById('sidebar-uptime').textContent = m.uptime || '—';
document.getElementById('sidebar-online-phones').textContent = `${m.asterisk?.online_contacts ?? 0} / ${(appState.extensions || []).length}`;
// 1. CPU
const cpuPct = Number(m.cpu?.percent || 0);
document.getElementById('metric-cpu-pct').textContent = `${cpuPct.toFixed(1)}%`;
document.getElementById('metric-cpu-cores').textContent = `${m.cpu?.cores || 1} cœur(s)`;
document.getElementById('metric-cpu-model').textContent = m.cpu?.model || 'Processeur Debian';
document.getElementById('metric-cpu-load').textContent = `${m.cpu?.load_1 ?? 0} / ${m.cpu?.load_5 ?? 0} / ${m.cpu?.load_15 ?? 0}`;
document.getElementById('bar-cpu').style.width = `${Math.max(3, Math.min(100, cpuPct))}%`;
// 2. RAM
const ramPct = Number(m.memory?.percent || 0);
document.getElementById('metric-ram-pct').textContent = `${ramPct.toFixed(1)}%`;
document.getElementById('metric-ram-used').textContent = `${m.memory?.used_mb || 0} / ${m.memory?.total_mb || 0} Mo`;
document.getElementById('metric-ram-free').textContent = `Disponible : ${m.memory?.free_mb || 0} Mo`;
document.getElementById('metric-swap').textContent = `${m.memory?.swap_used_mb || 0} / ${m.memory?.swap_total_mb || 0} Mo`;
document.getElementById('bar-ram').style.width = `${Math.max(3, Math.min(100, ramPct))}%`;
// 3. Réseau
document.getElementById('metric-net-rx').textContent = `${Number(m.network?.rx_kbps || 0).toFixed(1)} Ko/s`;
document.getElementById('metric-net-tx').textContent = `${Number(m.network?.tx_kbps || 0).toFixed(1)} Ko/s`;
document.getElementById('metric-net-iface').textContent = `${m.network?.interface || 'eth0'} (${m.network?.ip || ''})`;
document.getElementById('metric-net-totals').textContent = `Total : ↓ ${m.network?.rx_total_mb || 0} Mo • ↑ ${m.network?.tx_total_mb || 0} Mo`;
// 4. Disque HDD
const diskPct = Number(m.disk?.percent || 0);
document.getElementById('metric-disk-pct').textContent = `${diskPct.toFixed(1)}%`;
document.getElementById('metric-disk-used').textContent = `${m.disk?.used_gb || 0} / ${m.disk?.total_gb || 0} Go`;
document.getElementById('metric-disk-free').textContent = `Espace libre sur / : ${m.disk?.free_gb || 0} Go`;
document.getElementById('bar-disk').style.width = `${Math.max(3, Math.min(100, diskPct))}%`;
// 5. Jauges Anneaux (Donuts)
updateDonut('donut-cpu', 'donut-cpu-label', cpuPct);
updateDonut('donut-ram', 'donut-ram-label', ramPct);
updateDonut('donut-disk', 'donut-disk-label', diskPct);
document.getElementById('dash-active-calls').textContent = m.asterisk?.active_calls ?? 0;
document.getElementById('dash-online-contacts').textContent = `${m.asterisk?.online_contacts ?? 0} / ${(appState.extensions || []).length}`;
// 6. Graphes SVG Temps Réel
drawDualAreaChart('chart-cpu-ram', chartHistory.cpu, chartHistory.ram, 100, '#818cf8', '#34d399', '%');
const maxNet = Math.max(10, ...chartHistory.rx, ...chartHistory.tx) * 1.25;
drawDualAreaChart('chart-network', chartHistory.rx, chartHistory.tx, maxNet, '#22d3ee', '#c084fc', 'Ko/s');
// 7. Table des téléphones SIP en direct sur le Dashboard
renderDashboardPhonesTable();
// 8. Sécurité, Pare-feu, Fail2ban & IPs bloquées
if (m.security) {
const sec = m.security;
const fwDot = document.getElementById('dash-fw-dot');
const fwStatus = document.getElementById('dash-fw-status');
const fwDetail = document.getElementById('dash-fw-detail');
const isFwActive = Boolean(sec.firewall_enabled);
if (isFwActive) {
if (fwDot) fwDot.className = 'w-2 h-2 rounded-full bg-emerald-400 shadow-sm shadow-emerald-500/50';
if (fwStatus) {
fwStatus.textContent = 'Actif';
fwStatus.className = 'text-sm font-extrabold text-emerald-400 mt-1';
}
if (fwDetail) fwDetail.textContent = sec.strict_port80 ? 'Mode Strict (Port 80 Certbot)' : `Port HTTPS : ${sec.admin_port || 8443}`;
} else {
if (fwDot) fwDot.className = 'w-2 h-2 rounded-full bg-slate-500';
if (fwStatus) {
fwStatus.textContent = 'Inactif';
fwStatus.className = 'text-sm font-extrabold text-slate-400 mt-1';
}
if (fwDetail) fwDetail.textContent = 'Tout le trafic autorisé';
}
const f2bDot = document.getElementById('dash-f2b-dot');
const f2bStatus = document.getElementById('dash-f2b-status');
const f2bDetail = document.getElementById('dash-f2b-detail');
if (sec.fail2ban_running) {
if (f2bDot) f2bDot.className = 'w-2 h-2 rounded-full bg-emerald-400 animate-pulse';
if (f2bStatus) {
f2bStatus.textContent = 'En ligne';
f2bStatus.className = 'text-sm font-extrabold text-emerald-400 mt-1';
}
const activeJailCount = Object.keys(sec.jails || {}).length;
if (f2bDetail) f2bDetail.textContent = activeJailCount > 0 ? `${activeJailCount} prison(s) active(s)` : 'Surveillance active';
} else {
if (f2bDot) f2bDot.className = 'w-2 h-2 rounded-full bg-rose-500';
if (f2bStatus) {
f2bStatus.textContent = 'Arrêté';
f2bStatus.className = 'text-sm font-extrabold text-rose-400 mt-1';
}
if (f2bDetail) f2bDetail.textContent = 'Service non démarré';
}
const bannedCountEl = document.getElementById('dash-banned-count');
if (bannedCountEl) bannedCountEl.textContent = sec.total_banned || 0;
let totalFailedAll = 0;
let totalBannedAll = 0;
Object.values(sec.jails || {}).forEach(j => {
totalFailedAll += (j.total_failed || 0);
totalBannedAll += (j.total_banned || 0);
});
const blockedBadge = document.getElementById('dash-total-blocked-badge');
if (blockedBadge) {
blockedBadge.textContent = `${totalFailedAll} attaque(s) contrée(s)`;
if (totalFailedAll > 0) {
blockedBadge.className = 'px-2.5 py-1 rounded-full text-[11px] font-bold bg-amber-500/15 text-amber-300 border border-amber-500/30 font-mono';
} else {
blockedBadge.className = 'px-2.5 py-1 rounded-full text-[11px] font-bold bg-emerald-500/15 text-emerald-300 border border-emerald-500/30 font-mono';
}
}
const latestBannedEl = document.getElementById('dash-latest-banned');
if (latestBannedEl) {
if (sec.banned_ips && sec.banned_ips.length > 0) {
latestBannedEl.textContent = `Bannie : ${sec.banned_ips[0].ip}`;
latestBannedEl.className = 'text-[10px] text-rose-400 mt-1 truncate font-mono font-bold';
} else {
latestBannedEl.textContent = 'Aucun ban actif';
latestBannedEl.className = 'text-[10px] text-emerald-400 mt-1 truncate';
}
}
const clientIpEl = document.getElementById('dash-client-ip');
if (clientIpEl && m.network?.ip) {
clientIpEl.textContent = `IP locale PBX : ${m.network.ip}`;
}
// Graphique Répartition Jails
const astJail = sec.jails?.asterisk || {};
const webJail = sec.jails?.['nexus-web'] || {};
const sshJail = sec.jails?.sshd || {};
const astFailed = astJail.total_failed || 0;
const webFailed = webJail.total_failed || 0;
const sshFailed = sshJail.total_failed || 0;
const totalThreats = astFailed + webFailed + sshFailed;
const astPct = totalThreats > 0 ? Math.round((astFailed / totalThreats) * 100) : 0;
const webPct = totalThreats > 0 ? Math.round((webFailed / totalThreats) * 100) : 0;
const sshPct = totalThreats > 0 ? Math.round((sshFailed / totalThreats) * 100) : 0;
const astStats = document.getElementById('dash-jail-ast-stats');
const astBar = document.getElementById('dash-jail-ast-bar');
if (astStats) astStats.textContent = `${astFailed} contrée(s) (${astPct}%) • ${astJail.currently_banned || 0} ban actif`;
if (astBar) astBar.style.width = `${astPct}%`;
const webStats = document.getElementById('dash-jail-web-stats');
const webBar = document.getElementById('dash-jail-web-bar');
if (webStats) webStats.textContent = `${webFailed} contrée(s) (${webPct}%) • ${webJail.currently_banned || 0} ban actif`;
if (webBar) webBar.style.width = `${webPct}%`;
const sshStats = document.getElementById('dash-jail-ssh-stats');
const sshBar = document.getElementById('dash-jail-ssh-bar');
if (sshStats) sshStats.textContent = `${sshFailed} contrée(s) (${sshPct}%) • ${sshJail.currently_banned || 0} ban actif`;
if (sshBar) sshBar.style.width = `${sshPct}%`;
}
// 9. Rendu de la liste des Services Système
if (m.services && Array.isArray(m.services)) {
renderDashboardServicesList(m.services);
}
}
function renderDashboardServicesList(services) {
const container = document.getElementById('dash-services-list');
if (!container) return;
let allOk = true;
const html = services.map(s => {
if (!s.active && s.id !== 'firewall') allOk = false;
const dotClass = s.active
? 'w-2 h-2 rounded-full bg-emerald-400 shadow-sm shadow-emerald-400/50'
: 'w-2 h-2 rounded-full bg-rose-500';
const badgeClass = s.active
? 'px-2 py-0.5 rounded text-[10px] font-bold bg-emerald-500/15 text-emerald-300 border border-emerald-500/30'
: 'px-2 py-0.5 rounded text-[10px] font-bold bg-rose-500/15 text-rose-300 border border-rose-500/30';
return `
${escapeHtml(s.name)}
(${escapeHtml(s.port)})
${escapeHtml(s.desc)}
${escapeHtml(s.status_text)}
${escapeHtml(s.uptime)}
`;
}).join('');
container.innerHTML = html;
const allBadge = document.getElementById('dash-services-all-badge');
if (allBadge) {
if (allOk) {
allBadge.textContent = 'TOUS OPÉRATIONNELS';
allBadge.className = 'px-2 py-0.5 rounded-full text-[10px] font-bold bg-emerald-500/20 text-emerald-300 border border-emerald-500/30';
} else {
allBadge.textContent = 'ATTENTION DÉMON';
allBadge.className = 'px-2 py-0.5 rounded-full text-[10px] font-bold bg-amber-500/20 text-amber-300 border border-amber-500/30';
}
}
}
function updateDonut(circleId, labelId, pct) {
const circle = document.getElementById(circleId);
const label = document.getElementById(labelId);
const clamped = Math.max(0, Math.min(100, Math.round(pct)));
if (circle) {
circle.setAttribute('stroke-dasharray', `${clamped}, 100`);
}
if (label) {
label.textContent = `${clamped}%`;
}
}
function drawDualAreaChart(svgId, seriesA, seriesB, maxVal, colorA, colorB, unitLabel) {
const svg = document.getElementById(svgId);
if (!svg) return;
const width = 500;
const height = 180;
const padTop = 14;
const padBottom = 18;
const plotH = height - padTop - padBottom;
const safeMax = maxVal > 0 ? maxVal : 100;
const toPoints = (arr) => arr.map((val, idx) => {
const x = (idx / (arr.length - 1)) * width;
const ratio = Math.max(0, Math.min(1, val / safeMax));
const y = padTop + plotH - (ratio * plotH);
return `${x.toFixed(1)},${y.toFixed(1)}`;
});
const ptsA = toPoints(seriesA);
const ptsB = toPoints(seriesB);
const areaA = `0,${padTop + plotH} ` + ptsA.join(' ') + ` ${width},${padTop + plotH}`;
const areaB = `0,${padTop + plotH} ` + ptsB.join(' ') + ` ${width},${padTop + plotH}`;
const gradIdA = `${svgId}-grad-a`;
const gradIdB = `${svgId}-grad-b`;
svg.innerHTML = `
${Math.round(safeMax)} ${unitLabel}
${Math.round(safeMax / 2)} ${unitLabel}
`;
}
function getContactMap() {
const map = {};
const contacts = appState.metrics?.asterisk?.contacts || [];
contacts.forEach(c => {
map[String(c.extension)] = c;
});
return map;
}
function extractCleanIp(uri) {
if (!uri) return '';
const m = uri.match(/@([0-9a-zA-Z\.\-]+(?::\d+)?)/);
if (m) return m[1];
const m2 = uri.match(/^sip:([0-9a-zA-Z\.\-]+(?::\d+)?)/);
if (m2) return m2[1];
return uri.split(';')[0].replace(/^sip:/, '');
}
function renderDashboardPhonesTable() {
const tbody = document.getElementById('dashboard-phones-tbody');
if (!tbody) return;
const exts = appState.extensions || [];
const contactMap = getContactMap();
if (exts.length === 0) {
tbody.innerHTML = `Aucun poste SIP configuré. `;
return;
}
tbody.innerHTML = exts.map(ext => {
const c = contactMap[String(ext.extension)];
const isTls = ext.transport === 'tls';
const st = c ? String(c.status).toLowerCase() : '';
const isUnavail = c && st.includes('unavail');
const isOnline = c && !isUnavail && (st.startsWith('avail') || st.includes('nonqual'));
// Chiffrement Voix (SRTP) configuré pour l'extension
const encMode = Number(ext.encryption ?? (isTls ? 1 : 0));
let srtpBadge = '';
if (encMode === 2) {
srtpBadge = `🛡️ SRTP Forcé `;
} else if (encMode === 1) {
srtpBadge = `🛡️ SRTP Négociable `;
} else {
srtpBadge = `🔈 RTP Clair `;
}
// Détection dynamique du mode réel négocié en direct par le téléphone
let modeBadge = '';
if (c) {
const actTr = (c.active_transport || 'udp').toLowerCase();
let trPill = '';
let profileNote = '';
if (actTr === 'tls') {
trPill = `
🔒 TLS Actif (Chiffré)
`;
profileNote = `${isTls ? 'TLS Strict' : 'UDP Interne'} `;
} else if (actTr === 'wss') {
trPill = `
🔒 WSS WebRTC
`;
profileNote = `WebRTC `;
} else if (actTr === 'tcp') {
trPill = `
🔓 TCP Actif (Clair)
`;
profileNote = `${isTls ? 'TLS Strict' : 'UDP Interne'} `;
} else {
// UDP
trPill = `
🔓 UDP Actif (Clair)
`;
profileNote = isTls ? '⚠️ Exige TLS ' : 'UDP Interne ';
}
modeBadge = `
${trPill}
${srtpBadge}
Profil: ${profileNote}
`;
} else {
modeBadge = `
Inactif
${srtpBadge}
Profil: ${isTls ? 'TLS Strict' : 'UDP Interne'}
`;
}
// Statut SIP
let statusBadge = '';
if (isOnline) {
statusBadge = ` En ligne (${escapeHtml(c.status)}) `;
} else if (isUnavail) {
statusBadge = ` Injoignable (${escapeHtml(c.status)}) `;
} else {
statusBadge = ` Hors-ligne `;
}
// Contact / IP & Modèle Téléphone (User-Agent)
let contactHtml = '';
if (c) {
const cleanIp = extractCleanIp(c.uri);
const ua = c.user_agent ? escapeHtml(c.user_agent) : '';
contactHtml = `
${escapeHtml(cleanIp)}
${ua ? `
📱 ${ua}
` : ''}
`;
} else {
contactHtml = `Non enregistré `;
}
let rttText = '—';
let rttColor = 'text-slate-500';
if (isOnline && c && c.rtt_ms !== null && !isNaN(c.rtt_ms)) {
rttText = `${c.rtt_ms} ms`;
rttColor = c.rtt_ms < 30 ? 'text-emerald-300 font-bold' : (c.rtt_ms < 100 ? 'text-cyan-300' : 'text-amber-300');
} else if (isUnavail) {
rttText = 'Inaccessible';
rttColor = 'text-rose-400/90 font-semibold italic';
}
return `
${escapeHtml(ext.extension)}
${escapeHtml(ext.name)}
${modeBadge}
${statusBadge}
${contactHtml}
${rttText}
`;
}).join('');
}
// --- 1. Rendu & CRUD Extensions ---
function renderExtensionsTable() {
const tbody = document.getElementById('extensions-tbody');
if (!tbody) return;
const rows = appState.extensions || [];
const contactMap = getContactMap();
const groups = appState.groups || [];
if (rows.length === 0) {
tbody.innerHTML = `Aucun poste configuré. Cliquez sur « + Nouveau Poste SIP ». `;
return;
}
tbody.innerHTML = rows.map(ext => {
const c = contactMap[String(ext.extension)];
const isTls = ext.transport === 'tls';
const encMode = Number(ext.encryption ?? (isTls ? 1 : 0));
let encLabel = '';
if (encMode === 2) {
encLabel = 'SRTP Forcé ';
} else if (encMode === 1) {
encLabel = 'SRTP Négociable ';
} else {
encLabel = 'RTP Clair ';
}
const badge = isTls
? `🔒 TLS (${encLabel}) `
: (encMode > 0
? `🛡️ UDP (${encLabel}) `
: `🏢 UDP (RTP Clair) `);
const actTr = c ? (c.active_transport || 'udp').toUpperCase() : '';
const stLower = c ? String(c.status).toLowerCase() : '';
const isCUnavail = c && stLower.includes('unavail');
const isCOnline = c && !isCUnavail && (stLower.startsWith('avail') || stLower.includes('nonqual'));
const regBadge = c
? (isCOnline
? ` Connecté (${actTr}${c.rtt_ms !== null && !isNaN(c.rtt_ms) ? ` • ${c.rtt_ms}ms` : ''}) `
: ` Injoignable (${actTr}) `)
: `Non connecté `;
// Trouve à quels groupes d'extensions (Sites/Droits) ce poste appartient (ex: Nice, Paris)
const extGroups = appState.extension_groups || [];
const myExtGroups = extGroups.filter(eg => {
const mList = Array.isArray(eg.members) ? eg.members.map(String) : [];
return mList.includes(String(ext.extension));
});
const groupBadges = myExtGroups.length > 0
? myExtGroups.map(eg => `${escapeHtml(eg.name)} `).join(' ')
: `Aucun `;
const vmBadge = Number(ext.voicemail_enabled) === 1
? `📟 Boîte `
: '';
return `
${escapeHtml(ext.extension)}
${escapeHtml(ext.name)}
${vmBadge}
${groupBadges}
${regBadge}
${badge}
${escapeHtml(ext.codecs)}
${escapeHtml(ext.secret)}
Modifier
Supprimer
`;
}).join('');
}
function switchExtModalTab(tab) {
const isGeneral = (tab === 'general');
const btnGen = document.getElementById('tab-btn-ext-general');
const btnVm = document.getElementById('tab-btn-ext-voicemail');
const panGen = document.getElementById('tab-panel-ext-general');
const panVm = document.getElementById('tab-panel-ext-voicemail');
if (btnGen) {
btnGen.className = isGeneral
? 'px-3 py-1.5 rounded-lg text-xs font-bold transition bg-indigo-600 text-white'
: 'px-3 py-1.5 rounded-lg text-xs font-semibold transition text-slate-300 hover:text-white hover:bg-anthracite-700';
}
if (btnVm) {
btnVm.className = !isGeneral
? 'px-3 py-1.5 rounded-lg text-xs font-bold transition bg-indigo-600 text-white flex items-center gap-1.5'
: 'px-3 py-1.5 rounded-lg text-xs font-semibold transition text-slate-300 hover:text-white hover:bg-anthracite-700 flex items-center gap-1.5';
}
if (panGen) panGen.classList.toggle('hidden', !isGeneral);
if (panVm) panVm.classList.toggle('hidden', isGeneral);
}
function syncVoicemailToggle(enabled) {
const details = document.getElementById('voicemail-details-container');
if (details) {
details.style.opacity = enabled ? '1' : '0.4';
details.style.pointerEvents = enabled ? 'auto' : 'none';
}
const badge = document.getElementById('badge-ext-vm-status');
if (badge) {
badge.className = `w-2 h-2 rounded-full ${enabled ? 'bg-amber-400' : 'bg-slate-500'}`;
}
}
function syncPinMode(mode) {
const isManual = (mode === 'manual');
const pinBox = document.getElementById('pin-input-box');
const forceInput = document.getElementById('ext-force-pin-change');
if (pinBox) {
pinBox.style.display = isManual ? 'block' : 'none';
}
if (forceInput) {
forceInput.value = isManual ? '0' : '1';
}
}
function openExtensionModal(ext = null) {
const form = document.getElementById('form-extension');
form.reset();
document.getElementById('modal-extension-title').textContent = ext ? `Modifier le poste ${ext.extension}` : 'Nouveau Poste SIP';
form.elements['id'].value = ext ? ext.id : '';
form.elements['extension'].value = ext ? ext.extension : '';
form.elements['name'].value = ext ? ext.name : '';
form.elements['secret'].value = ext ? ext.secret : generateSecretString();
form.elements['codecs'].value = ext ? ext.codecs : 'alaw,ulaw,g722,opus';
form.elements['max_contacts'].value = ext ? ext.max_contacts : 2;
const transport = ext ? ext.transport : 'udp';
form.querySelectorAll('input[name="transport"]').forEach(r => {
r.checked = (r.value === transport);
});
syncEncryptionToggle(transport, ext ? (ext.encryption ?? (transport === 'tls' ? 1 : 0)) : (transport === 'tls' ? 1 : 0));
// Réinitialisation de l'onglet actif vers "Général"
switchExtModalTab('general');
// Initialisation des champs Messagerie Vocale
const vmEnabled = ext ? (Number(ext.voicemail_enabled) === 1) : false;
const vmCheckbox = document.getElementById('ext-voicemail-enabled');
if (vmCheckbox) vmCheckbox.checked = vmEnabled;
const isFirstCall = ext ? (Number(ext.force_pin_change) === 1) : false;
form.querySelectorAll('input[name="pin_mode"]').forEach(r => {
r.checked = isFirstCall ? (r.value === 'first_call') : (r.value === 'manual');
});
syncPinMode(isFirstCall ? 'first_call' : 'manual');
if (form.elements['voicemail_pin']) {
form.elements['voicemail_pin'].value = ext ? (ext.voicemail_pin || '') : '';
}
if (form.elements['ring_time']) {
form.elements['ring_time'].value = ext ? (ext.ring_time || 20) : 20;
}
const skipPinCb = document.getElementById('ext-skip-pin');
if (skipPinCb) {
skipPinCb.checked = ext ? (Number(ext.voicemail_skip_pin) === 1) : true;
}
if (form.elements['voicemail_email']) {
form.elements['voicemail_email'].value = ext ? (ext.voicemail_email || '') : '';
}
const vmAttachCb = document.getElementById('ext-vm-attach');
if (vmAttachCb) {
vmAttachCb.checked = ext ? (Number(ext.voicemail_attach) === 1) : true;
}
const vmDeleteCb = document.getElementById('ext-vm-delete');
if (vmDeleteCb) {
vmDeleteCb.checked = ext ? (Number(ext.voicemail_delete) === 1) : false;
}
syncVoicemailToggle(vmEnabled);
// Rendu des cases d'appartenance aux Groupes d'Extensions (Sites: Nice, Paris...)
const grpBox = document.getElementById('extension-groups-checkboxes');
if (grpBox) {
const extGroups = appState.extension_groups || [];
if (extGroups.length === 0) {
grpBox.innerHTML = `Aucun groupe d'extensions créé. Vous pouvez en créer dans « Groupes » → « Groupes d'extensions ». `;
} else {
const currentExt = ext ? String(ext.extension) : '';
grpBox.innerHTML = extGroups.map(eg => {
const isMember = currentExt && Array.isArray(eg.members) && eg.members.map(String).includes(currentExt);
return `
${escapeHtml(eg.name)}
${eg.description ? `${escapeHtml(eg.description)} ` : ''}
`;
}).join('');
}
}
openModal('modal-extension');
}
function editExtension(id) {
const ext = (appState.extensions || []).find(e => Number(e.id) === Number(id));
if (ext) openExtensionModal(ext);
}
async function deleteExtension(id, optNum = null) {
const ext = (appState.extensions || []).find(e => Number(e.id) === Number(id));
const num = optNum || (ext ? ext.extension : 'ce poste');
if (!confirm(`Supprimer définitivement le poste SIP ${num} ?`)) return;
try {
const res = await apiCall('delete_extension', { id: Number(id) });
showToast(res.message, 'success');
await loadState();
} catch (err) {
showToast(err.message, 'error');
}
}
function syncEncryptionToggle(transport, currentVal = null) {
const sel = document.getElementById('ext-encryption');
if (!sel) return;
if (currentVal !== null) {
sel.value = String(currentVal);
} else if (transport === 'tls' && sel.value === '0') {
sel.value = '1';
}
}
function generateSecretString() {
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789!@#$%';
let out = '';
for (let i = 0; i < 14; i++) {
out += chars.charAt(Math.floor(Math.random() * chars.length));
}
return out;
}
function generateRandomSecret() {
const form = document.getElementById('form-extension');
form.elements['secret'].value = generateSecretString();
}
// --- 2A. Rendu & CRUD Groupes de Sonnerie (Ring Groups) ---
function renderGroupsTable() {
const tbody = document.getElementById('groups-tbody');
if (!tbody) return;
const rows = appState.groups || [];
if (rows.length === 0) {
tbody.innerHTML = `Aucun groupe de sonnerie configuré. Cliquez sur « + Nouveau Groupe de Sonnerie ». `;
return;
}
tbody.innerHTML = rows.map(grp => {
const members = Array.isArray(grp.members) ? grp.members : [];
const badges = members.length
? members.map(m => `${escapeHtml(m)} `).join(' ')
: 'Aucun membre ';
return `
${escapeHtml(grp.extension)}
${escapeHtml(grp.name)}
${escapeHtml(grp.strategy)}
${badges}
${Number(grp.ring_time)}s → ${escapeHtml(grp.failover_dest)}
Modifier
Supprimer
`;
}).join('');
}
let activeGroupSelectedExts = new Set();
function openGroupModal(grp = null) {
const form = document.getElementById('form-group');
form.reset();
document.getElementById('modal-group-title').textContent = grp ? `Modifier le groupe de sonnerie ${grp.name} (${grp.extension})` : "Nouveau Groupe de Sonnerie";
form.elements['id'].value = grp ? grp.id : '';
form.elements['extension'].value = grp ? grp.extension : '';
form.elements['name'].value = grp ? grp.name : '';
form.elements['strategy'].value = grp ? grp.strategy : 'ringall';
form.elements['ring_time'].value = grp ? grp.ring_time : 20;
// Failover destinations
const failoverSel = document.getElementById('group-failover-select');
if (failoverSel) {
failoverSel.innerHTML = buildDestinationOptionsHtml(grp ? grp.failover_dest : 'hangup');
}
activeGroupSelectedExts = new Set(grp && Array.isArray(grp.members) ? grp.members.map(String) : []);
renderGroupInteractiveBoxes();
openModal('modal-group');
}
function renderGroupInteractiveBoxes() {
const allowedBox = document.getElementById('group-members-allowed-box');
const blockedBox = document.getElementById('group-members-blocked-box');
const hiddenBox = document.getElementById('group-members-checkboxes');
if (!allowedBox || !blockedBox) return;
const allExts = appState.extensions || [];
let allowedHtml = '';
let blockedHtml = '';
let hiddenHtml = '';
let allowedCount = 0;
let blockedCount = 0;
allExts.forEach(ext => {
const extStr = String(ext.extension);
const isSel = activeGroupSelectedExts.has(extStr);
if (isSel) {
allowedCount++;
allowedHtml += `
${escapeHtml(extStr)}
(${escapeHtml(ext.name)})
✕
`;
hiddenHtml += ` `;
} else {
blockedCount++;
blockedHtml += `
${escapeHtml(extStr)}
(${escapeHtml(ext.name)})
+
`;
}
});
allowedBox.innerHTML = allowedHtml || `Aucun poste qui sonne. Cliquez sur un poste à droite pour l'ajouter.
`;
blockedBox.innerHTML = blockedHtml || `Tous les postes sonnent dans ce groupe !
`;
if (hiddenBox) hiddenBox.innerHTML = hiddenHtml;
const countA = document.getElementById('group-selected-count');
const countB = document.getElementById('group-unselected-count');
if (countA) countA.textContent = String(allowedCount);
if (countB) countB.textContent = String(blockedCount);
}
function toggleGroupMemberExt(extStr) {
if (activeGroupSelectedExts.has(extStr)) {
activeGroupSelectedExts.delete(extStr);
} else {
activeGroupSelectedExts.add(extStr);
}
renderGroupInteractiveBoxes();
}
function groupTransferAllExtensions(includeAll) {
const allExts = appState.extensions || [];
if (includeAll) {
allExts.forEach(e => activeGroupSelectedExts.add(String(e.extension)));
} else {
activeGroupSelectedExts.clear();
}
renderGroupInteractiveBoxes();
}
function editGroup(id) {
const grp = (appState.groups || []).find(g => Number(g.id) === Number(id));
if (grp) openGroupModal(grp);
}
async function deleteGroup(id, optNum = null) {
const grp = (appState.groups || []).find(g => Number(g.id) === Number(id));
const num = optNum || (grp ? grp.extension : 'ce groupe');
if (!confirm(`Supprimer le groupe de sonnerie ${num} ?`)) return;
try {
const res = await apiCall('delete_group', { id: Number(id) });
showToast(res.message, 'success');
await loadState();
} catch (err) {
showToast(err.message, 'error');
}
}
// --- 2B. Rendu & CRUD Groupes d'Extensions (Sites & Droits Routes Sortantes) ---
function renderExtensionGroupsTable() {
const tbody = document.getElementById('extgroups-tbody');
if (!tbody) return;
const rows = appState.extension_groups || [];
const obRoutes = appState.outbound_routes || [];
if (rows.length === 0) {
tbody.innerHTML = `Aucun groupe d'extensions configuré. Cliquez sur « + Nouveau Groupe d'Extensions ». `;
return;
}
tbody.innerHTML = rows.map(eg => {
const members = Array.isArray(eg.members) ? eg.members : [];
const badges = members.length
? members.map(m => `${escapeHtml(m)} `).join(' ')
: 'Aucune extension ';
// Routes sortantes où ce groupe d'extensions a accès
const egIdStr = String(eg.id);
const egNameStr = String(eg.name);
const accessibleRoutes = obRoutes.filter(r => {
if (r.permission_mode === 'all') return true;
const ag = Array.isArray(r.allowed_groups) ? r.allowed_groups.map(String) : [];
return ag.includes(egIdStr) || ag.includes(egNameStr);
});
const routeBadges = accessibleRoutes.length > 0
? accessibleRoutes.map(r => `${escapeHtml(r.name)} `).join(' ')
: `Aucune (Verrouillé) `;
return `
${escapeHtml(eg.name)}
${escapeHtml(eg.description || '—')}
${badges}
${routeBadges}
Modifier
Supprimer
`;
}).join('');
}
let activeExtGroupSelectedExts = new Set();
function openExtGroupModal(eg = null) {
const form = document.getElementById('form-extgroup');
form.reset();
document.getElementById('modal-extgroup-title').textContent = eg ? `Modifier le groupe d'extensions ${eg.name}` : "Nouveau Groupe d'Extensions";
form.elements['id'].value = eg ? eg.id : '';
form.elements['name'].value = eg ? eg.name : '';
form.elements['description'].value = eg ? eg.description : '';
activeExtGroupSelectedExts = new Set(eg && Array.isArray(eg.members) ? eg.members.map(String) : []);
renderExtGroupInteractiveBoxes();
openModal('modal-extgroup');
}
function renderExtGroupInteractiveBoxes() {
const allowedBox = document.getElementById('extgroup-members-allowed-box');
const blockedBox = document.getElementById('extgroup-members-blocked-box');
if (!allowedBox || !blockedBox) return;
const allExts = appState.extensions || [];
let allowedHtml = '';
let blockedHtml = '';
let allowedCount = 0;
let blockedCount = 0;
allExts.forEach(ext => {
const extStr = String(ext.extension);
const isSel = activeExtGroupSelectedExts.has(extStr);
if (isSel) {
allowedCount++;
allowedHtml += `
${escapeHtml(extStr)}
(${escapeHtml(ext.name)})
✕
`;
} else {
blockedCount++;
blockedHtml += `
${escapeHtml(extStr)}
(${escapeHtml(ext.name)})
+
`;
}
});
allowedBox.innerHTML = allowedHtml || `Aucune extension rattachée. Cliquez à droite pour en ajouter.
`;
blockedBox.innerHTML = blockedHtml || `Toutes les extensions sont dans ce groupe !
`;
const countA = document.getElementById('extgroup-selected-count');
const countB = document.getElementById('extgroup-unselected-count');
if (countA) countA.textContent = String(allowedCount);
if (countB) countB.textContent = String(blockedCount);
}
function toggleExtGroupMember(extStr) {
if (activeExtGroupSelectedExts.has(extStr)) {
activeExtGroupSelectedExts.delete(extStr);
} else {
activeExtGroupSelectedExts.add(extStr);
}
renderExtGroupInteractiveBoxes();
}
function extGroupTransferAll(includeAll) {
const allExts = appState.extensions || [];
if (includeAll) {
allExts.forEach(e => activeExtGroupSelectedExts.add(String(e.extension)));
} else {
activeExtGroupSelectedExts.clear();
}
renderExtGroupInteractiveBoxes();
}
function editExtGroup(id) {
const eg = (appState.extension_groups || []).find(g => Number(g.id) === Number(id));
if (eg) openExtGroupModal(eg);
}
async function deleteExtGroup(id, optName = null) {
const eg = (appState.extension_groups || []).find(g => Number(g.id) === Number(id));
const name = optName || (eg ? eg.name : 'ce groupe');
if (!confirm(`Supprimer définitivement le groupe d'extensions « ${name} » ?`)) return;
try {
const res = await apiCall('delete_extension_group', { id: Number(id) });
showToast(res.message, 'success');
await loadState();
} catch (err) {
showToast(err.message, 'error');
}
}
// --- 2C. Rendu & CRUD Groupes de Téléavertissement & Interphonie (Paging) ---
function renderPagingTable() {
const tbody = document.getElementById('paging-tbody');
if (!tbody) return;
const rows = appState.paging_groups || [];
if (rows.length === 0) {
tbody.innerHTML = `Aucun groupe de téléavertissement configuré. Cliquez sur « + Nouveau Groupe de Téléavertissement ». `;
return;
}
tbody.innerHTML = rows.map(pg => {
const members = Array.isArray(pg.members) ? pg.members : [];
const badges = members.length
? members.map(m => `${escapeHtml(m)} `).join(' ')
: 'Aucun membre ';
const isDuplex = Number(pg.duplex) === 1;
const typeBadge = isDuplex
? `Duplex (2 voies) `
: `Simplex (Haut-parleur) `;
const busyLabel = pg.busy_mode === 'force'
? `Forcer `
: `Ignorer si occupé `;
const beepLabel = Number(pg.play_beep) === 1
? `✓ Oui `
: `Non `;
const statusBadge = Number(pg.enabled) === 1
? `Actif `
: `Inactif `;
return `
${escapeHtml(pg.extension)}
${escapeHtml(pg.name)}
${pg.description ? `${escapeHtml(pg.description)}
` : ''}
${typeBadge}
${badges}
${busyLabel}
${beepLabel}
${statusBadge}
Modifier
Supprimer
`;
}).join('');
}
let activePagingSelectedExts = new Set();
function openPagingModal(pg = null) {
const form = document.getElementById('form-paging');
if (!form) return;
form.reset();
document.getElementById('modal-paging-title').textContent = pg
? `Modifier le téléavertissement ${pg.name} (${pg.extension})`
: "Nouveau Groupe de Téléavertissement";
form.elements['id'].value = pg ? pg.id : '';
form.elements['extension'].value = pg ? pg.extension : '';
form.elements['name'].value = pg ? pg.name : '';
form.elements['description'].value = pg ? (pg.description || '') : '';
form.elements['duplex'].value = pg ? String(pg.duplex) : '0';
form.elements['busy_mode'].value = pg ? (pg.busy_mode || 'skip') : 'skip';
form.elements['timeout'].value = pg ? (pg.timeout || 120) : 120;
if (form.elements['play_beep']) {
form.elements['play_beep'].checked = pg ? Number(pg.play_beep) === 1 : true;
}
if (form.elements['enabled']) {
form.elements['enabled'].checked = pg ? Number(pg.enabled) === 1 : true;
}
activePagingSelectedExts = new Set(pg && Array.isArray(pg.members) ? pg.members.map(String) : []);
renderPagingInteractiveBoxes();
openModal('modal-paging');
}
function renderPagingInteractiveBoxes() {
const allowedBox = document.getElementById('paging-members-allowed-box');
const blockedBox = document.getElementById('paging-members-blocked-box');
const hiddenBox = document.getElementById('paging-members-checkboxes');
if (!allowedBox || !blockedBox) return;
const allExts = appState.extensions || [];
let allowedHtml = '';
let blockedHtml = '';
let hiddenHtml = '';
let allowedCount = 0;
let blockedCount = 0;
allExts.forEach(ext => {
const extStr = String(ext.extension);
const isSel = activePagingSelectedExts.has(extStr);
if (isSel) {
allowedCount++;
allowedHtml += `
${escapeHtml(extStr)}
(${escapeHtml(ext.name)})
✕
`;
hiddenHtml += ` `;
} else {
blockedCount++;
blockedHtml += `
${escapeHtml(extStr)}
(${escapeHtml(ext.name)})
+
`;
}
});
allowedBox.innerHTML = allowedHtml || `Aucun poste qui reçoit l'annonce. Cliquez sur un poste à droite pour l'ajouter.
`;
blockedBox.innerHTML = blockedHtml || `Tous les postes reçoivent l'annonce !
`;
if (hiddenBox) hiddenBox.innerHTML = hiddenHtml;
const countA = document.getElementById('paging-selected-count');
const countB = document.getElementById('paging-unselected-count');
if (countA) countA.textContent = String(allowedCount);
if (countB) countB.textContent = String(blockedCount);
}
function togglePagingMemberExt(extStr) {
if (activePagingSelectedExts.has(extStr)) {
activePagingSelectedExts.delete(extStr);
} else {
activePagingSelectedExts.add(extStr);
}
renderPagingInteractiveBoxes();
}
function pagingTransferAllExtensions(includeAll) {
const allExts = appState.extensions || [];
if (includeAll) {
allExts.forEach(e => activePagingSelectedExts.add(String(e.extension)));
} else {
activePagingSelectedExts.clear();
}
renderPagingInteractiveBoxes();
}
function editPagingGroup(id) {
const pg = (appState.paging_groups || []).find(g => Number(g.id) === Number(id));
if (pg) openPagingModal(pg);
}
async function deletePagingGroup(id) {
const pg = (appState.paging_groups || []).find(g => Number(g.id) === Number(id));
const num = pg ? pg.extension : 'ce groupe';
if (!confirm(`Supprimer le groupe de téléavertissement ${num} ?`)) return;
try {
const res = await apiCall('delete_paging_group', { id: Number(id) });
showToast(res.message, 'success');
await loadState();
} catch (err) {
showToast(err.message, 'error');
}
}
// --- 3. Rendu & CRUD Trunks SIP (Configuration PJSIP Avancée) ---
function slugifyTrunkName(text) {
return String(text || '')
.toLowerCase()
.trim()
.replace(/^ivr-/, '')
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '') || 'default';
}
function renderTrunksTable() {
const tbody = document.getElementById('trunks-tbody');
if (!tbody) return;
const rows = appState.trunks || [];
const regs = appState.metrics?.asterisk?.registrations || {};
if (rows.length === 0) {
tbody.innerHTML = `Aucun Trunk SIP opérateur configuré. Cliquez sur « + Nouveau Trunk SIP ». `;
return;
}
tbody.innerHTML = rows.map(t => {
const slug = `trunk-${slugifyTrunkName(t.name)}`;
const regInfo = regs[slug];
const regMode = t.registration_mode || (Number(t.register) === 1 ? 'send' : 'none');
const isIpAuth = t.auth_mode === 'ip';
const wantsReg = regMode === 'send' && !isIpAuth;
let statusBadge = '';
if (Number(t.enabled ?? 1) === 0) {
statusBadge = `Désactivé `;
} else if (regMode === 'receive') {
const contacts = appState.metrics?.asterisk?.contacts || [];
const isOnline = contacts.some(c =>
(c.extension === slug || c.extension === t.username || (t.username && c.uri && c.uri.includes(t.username)))
&& String(c.status || '').toLowerCase().includes('avail')
);
if (isOnline) {
statusBadge = ` Passerelle En ligne `;
} else {
statusBadge = `En attente passerelle (Receive) `;
}
} else if (isIpAuth || regMode === 'none') {
statusBadge = `Peering IP direct `;
} else if (regInfo && /Registered/i.test(regInfo.status)) {
statusBadge = ` Registered `;
} else if (regInfo) {
statusBadge = `${escapeHtml(regInfo.status)} `;
} else {
statusBadge = `En attente Apply `;
}
const routesArr = Array.isArray(t.did_routes) ? t.did_routes : Object.entries(t.did_routes || {}).map(([did, destination]) => ({ cid: 'Any', did, description: '', destination }));
const didCount = routesArr.length;
const blockCount = routesArr.filter(r => String(r.destination || '').startsWith('terminate-')).length;
const tcCount = routesArr.filter(r => Number(r.time_condition_enabled) === 1).length;
const paiBadges = [
Number(t.send_pai ?? 1) === 1 ? 'PAI' : null,
Number(t.send_rpid ?? 0) === 1 ? 'RPID' : null,
t.media_encryption && t.media_encryption !== 'no' ? t.media_encryption.toUpperCase() : null
].filter(Boolean).map(b => `${b} `).join(' ');
const mohSound = (appState.sounds || []).find(s => Number(s.id) === Number(t.moh_sound_id));
const mohBadge = mohSound
? `🎵 ${escapeHtml(mohSound.name)} `
: `MoH : Globale `;
return `
${escapeHtml(t.name)}
[${escapeHtml(slug)}]
${statusBadge}
${t.host ? escapeHtml(t.host) + ':' + Number(t.port || 5060) : 'Passerelle locale (Dynamique)'}
${t.outbound_proxy ? `Proxy: ${escapeHtml(t.outbound_proxy)}
` : ''}
${escapeHtml(isIpAuth ? 'Auth par IP' : (t.username || '—'))}
${escapeHtml(t.from_domain || t.host)} ${paiBadges}
${escapeHtml(t.transport)}
${escapeHtml(t.codecs)}
→ ${escapeHtml(t.inbound_dest)}
${t.cid_name_prefix ? `CID: ${escapeHtml(t.cid_name_prefix)} ` : ''}
${didCount > 0 ? `${didCount} règle(s) entrante(s) ` : 'Tous numéros (Any)'}
${blockCount > 0 ? `${blockCount} blocage(s) CID ` : ''}
${tcCount > 0 ? `⏱️ ${tcCount} horaire(s) ` : ''}
${mohBadge}
${wantsReg ? `🔄 Re-Register ` : ''}
Routes Entrantes
Modifier
Supprimer
`;
}).join('');
}
async function forceRegisterTrunk(regName = '*all') {
try {
const res = await apiCall('force_register_trunk', { reg_name: regName });
showToast(res.message, 'success');
await loadState(true);
} catch (err) {
showToast(err.message, 'error');
}
}
function switchTrunkModalTab(subTab) {
document.querySelectorAll('.trunk-subpanel').forEach(p => p.classList.add('hidden'));
const target = document.getElementById(`trunk-panel-${subTab}`);
if (target) target.classList.remove('hidden');
document.querySelectorAll('.trunk-tab-btn').forEach(btn => {
if (btn.dataset.trunkTab === subTab) {
btn.className = 'trunk-tab-btn px-3.5 py-2 text-xs font-bold rounded-t-xl bg-indigo-600 text-white transition flex items-center gap-1.5';
} else {
btn.className = 'trunk-tab-btn px-3.5 py-2 text-xs font-semibold rounded-t-xl bg-anthracite-950 text-slate-300 hover:text-white transition flex items-center gap-1.5';
}
});
}
function toggleTrunkAuthFields(mode) {
const container = document.getElementById('trunk-auth-fields');
const form = document.getElementById('form-trunk');
if (!container || !form) return;
if (mode === 'ip') {
container.classList.add('opacity-40', 'pointer-events-none');
form.elements['register'].checked = false;
} else {
container.classList.remove('opacity-40', 'pointer-events-none');
}
}
function normalizeDestinationValue(rawVal) {
const exts = appState.extensions || [];
const grps = appState.groups || [];
const paging = appState.paging_groups || [];
let normVal = String(rawVal || '').trim();
if (!normVal) return 'ext-101';
if (normVal === 'busy') return 'terminate-busy';
if (normVal === 'congestion') return 'terminate-congestion';
if (normVal === 'zapateller') return 'terminate-zapateller';
if (normVal === 'hangup') return 'terminate-hangup';
if (/^\d+$/.test(normVal)) {
if (exts.some(e => String(e.extension) === normVal)) return `ext-${normVal}`;
if (grps.some(g => String(g.extension) === normVal)) return `grp-${normVal}`;
if (paging.some(p => String(p.extension) === normVal)) return `page-${normVal}`;
}
return normVal;
}
function buildDestinationOptionsHtml(selectedValue = 'ext-101') {
const exts = appState.extensions || [];
const grps = appState.groups || [];
const paging = appState.paging_groups || [];
const ivrs = appState.ivrs || [];
const sounds = appState.sounds || [];
const normVal = normalizeDestinationValue(selectedValue);
let html = '';
if (exts.length > 0) {
html += ``;
exts.forEach(e => {
const val = `ext-${e.extension}`;
html += `Poste: ${escapeHtml(e.extension)} — ${escapeHtml(e.name)} `;
});
html += ` `;
}
if (grps.length > 0) {
html += ``;
grps.forEach(g => {
const val = `grp-${g.extension}`;
html += `Ring Group: ${escapeHtml(g.extension)} — ${escapeHtml(g.name)} `;
});
html += ` `;
}
if (paging.length > 0) {
html += ``;
paging.forEach(pg => {
const val = `page-${pg.extension}`;
html += `Paging: ${escapeHtml(pg.extension)} — ${escapeHtml(pg.name)} `;
});
html += ` `;
}
if (ivrs.length > 0) {
html += ``;
ivrs.forEach(i => {
const val = i.name;
html += `SVI / IVR: ${escapeHtml(i.name)} ${i.description ? '(' + escapeHtml(i.description) + ')' : ''} `;
});
html += ` `;
}
// Piège à Spammeurs / Musique d'attente en boucle continue (MoH)
const mohSounds = sounds.filter(s => s.usage_type === 'moh');
html += ``;
html += `🎵 Musique MoH générale par défaut (en boucle infinie) `;
mohSounds.forEach(s => {
const val = `moh-${s.id}`;
html += `🎵 Musique MoH dédiée : ${escapeHtml(s.name)} [${escapeHtml(s.site_category)}] `;
});
html += ` `;
const announcements = sounds.filter(s => s.usage_type !== 'moh');
if (announcements.length > 0) {
html += ``;
announcements.forEach(s => {
const val = `sound-${s.id}`;
html += `Annonce : ${escapeHtml(s.name)} [${escapeHtml(s.site_category)}] `;
});
html += ` `;
html += ``;
announcements.forEach(s => {
const val = `sound-loop-${s.id}`;
html += `👵 Voix en boucle infinie (ex: vieille dame) : ${escapeHtml(s.name)} `;
});
html += ` `;
}
html += `
Raccrocher immédiatement (Hangup)
Tonalité d'occupation (Busy)
Tonalité de congestion / réseau saturé (Congestion)
Tonalité SIT Numéro non attribué (Anti-Spam / Robot)
Faire sonner indéfiniment (Wait Ringing)
`;
return html;
}
function updateInboundRoutesBadge() {
const count = document.querySelectorAll('#trunk-did-container .trunk-did-row').length;
const badge = document.getElementById('trunk-inbound-rules-badge');
if (badge) badge.textContent = String(count);
}
function styleDestinationSelect(selectEl) {
if (!selectEl) return;
const val = selectEl.value || '';
if (val.startsWith('terminate-')) {
selectEl.className = 'trunk-did-dest w-full px-2.5 py-1.5 rounded-lg bg-amber-500/10 border border-amber-500/40 text-xs text-amber-300 font-semibold';
} else if (val.startsWith('moh-') || val.startsWith('sound-loop-')) {
selectEl.className = 'trunk-did-dest w-full px-2.5 py-1.5 rounded-lg bg-fuchsia-500/15 border border-fuchsia-500/40 text-xs text-fuchsia-300 font-semibold';
} else if (val.startsWith('grp-')) {
selectEl.className = 'trunk-did-dest w-full px-2.5 py-1.5 rounded-lg bg-cyan-500/10 border border-cyan-500/40 text-xs text-cyan-300 font-semibold';
} else {
selectEl.className = 'trunk-did-dest w-full px-2.5 py-1.5 rounded-lg bg-emerald-500/10 border border-emerald-500/40 text-xs text-emerald-300 font-semibold';
}
}
function addTrunkInboundRouteRow(cid = 'Any', did = 'Any', description = '', dest = 'ext-101', prepend = false, timeOpts = {}) {
const container = document.getElementById('trunk-did-container');
if (!container) return;
const tcEnabled = Boolean(timeOpts && timeOpts.time_condition_enabled);
const tcStart = (timeOpts && timeOpts.time_start) ? timeOpts.time_start : '08:30';
const tcFinish = (timeOpts && timeOpts.time_finish) ? timeOpts.time_finish : '18:00';
const tcWeekdays = (timeOpts && timeOpts.weekdays) ? timeOpts.weekdays : 'mon-fri';
const tcClosed = (timeOpts && timeOpts.time_dest_closed) ? timeOpts.time_dest_closed : 'terminate-hangup';
const tcCidPrefix = (timeOpts && timeOpts.cid_prefix) ? timeOpts.cid_prefix : '';
const row = document.createElement('div');
row.className = 'trunk-did-row border-b border-anthracite-700/60 transition hover:bg-anthracite-800/40';
row.innerHTML = `
${buildDestinationOptionsHtml(dest)}
⏱️ Horaires
⧉
🗑️
`;
const selectEl = row.querySelector('.trunk-did-dest');
styleDestinationSelect(selectEl);
const tcCheck = row.querySelector('.trunk-tc-enabled');
const timeDrawer = row.querySelector('.time-drawer');
const timeFields = row.querySelector('.time-fields-grid');
const toggleBtn = row.querySelector('.btn-toggle-time');
const badgeContainer = row.querySelector('.time-badge-container');
const tcStartInput = row.querySelector('.trunk-tc-start');
const tcFinishInput = row.querySelector('.trunk-tc-finish');
const tcWeekdaysSelect = row.querySelector('.trunk-tc-weekdays');
function updateTimeUi() {
const isEnabled = tcCheck.checked;
timeFields.classList.toggle('opacity-50', !isEnabled);
timeFields.classList.toggle('pointer-events-none', !isEnabled);
if (isEnabled) {
toggleBtn.className = 'btn-toggle-time px-2 py-1.5 rounded-lg text-xs font-semibold bg-amber-500/20 text-amber-300 border border-amber-500/40 transition shadow-sm';
const daysLabel = tcWeekdaysSelect.value === 'mon-fri' ? 'Lun-Ven' : (tcWeekdaysSelect.value === 'mon-sat' ? 'Lun-Sam' : (tcWeekdaysSelect.value === '*' ? '7j/7' : 'Sam-Dim'));
badgeContainer.innerHTML = `⏱️ ${escapeHtml(tcStartInput.value)}-${escapeHtml(tcFinishInput.value)} (${escapeHtml(daysLabel)}) `;
} else {
toggleBtn.className = 'btn-toggle-time px-2 py-1.5 rounded-lg text-xs font-semibold bg-anthracite-950 hover:bg-anthracite-900 text-slate-400 hover:text-white border border-anthracite-700 transition';
badgeContainer.innerHTML = '';
}
}
tcCheck.addEventListener('change', updateTimeUi);
tcStartInput.addEventListener('change', updateTimeUi);
tcFinishInput.addEventListener('change', updateTimeUi);
tcWeekdaysSelect.addEventListener('change', updateTimeUi);
toggleBtn.addEventListener('click', () => {
timeDrawer.classList.toggle('hidden');
});
updateTimeUi();
row.querySelector('.btn-del-route').addEventListener('click', () => {
row.remove();
updateInboundRoutesBadge();
});
row.querySelector('.btn-dup-route').addEventListener('click', () => {
const curCid = row.querySelector('.trunk-did-cid').value;
const curDid = row.querySelector('.trunk-did-num').value;
const curDesc = row.querySelector('.trunk-did-desc').value + ' (Copie)';
const curDest = row.querySelector('.trunk-did-dest').value;
const curTcOpts = {
time_condition_enabled: tcCheck.checked,
time_start: tcStartInput.value,
time_finish: tcFinishInput.value,
weekdays: tcWeekdaysSelect.value,
time_dest_closed: row.querySelector('.trunk-tc-closed-dest').value
};
addTrunkInboundRouteRow(curCid, curDid, curDesc, curDest, false, curTcOpts);
});
if (prepend && container.firstChild) {
container.insertBefore(row, container.firstChild);
} else {
container.appendChild(row);
}
updateInboundRoutesBadge();
}
function addArcepSpamBlockPreset() {
const spamRanges = [
{ cid: '_0162XXXXXX', desc: 'Block 0162 Démarchage' },
{ cid: '_0163XXXXXX', desc: 'Block 0163 Démarchage' },
{ cid: '_0270XXXXXX', desc: 'Block 0270 Démarchage' },
{ cid: '_0271XXXXXX', desc: 'Block 0271 Démarchage' },
{ cid: '_0377XXXXXX', desc: 'Block 0377 Démarchage' },
{ cid: '_0378XXXXXX', desc: 'Block 0378 Démarchage' },
{ cid: '_0424XXXXXX', desc: 'Block 0424 Démarchage' },
{ cid: '_0425XXXXXX', desc: 'Block 0425 Démarchage' },
{ cid: '_0568XXXXXX', desc: 'Block 0568 Démarchage' },
{ cid: '_0569XXXXXX', desc: 'Block 0569 Démarchage' },
{ cid: '_0948XXXXXX', desc: 'Block 0948 Démarchage' },
];
const existingCids = new Set(
Array.from(document.querySelectorAll('#trunk-did-container .trunk-did-cid')).map(el => el.value.trim())
);
let added = 0;
spamRanges.forEach(item => {
if (!existingCids.has(item.cid)) {
addTrunkInboundRouteRow(item.cid, 'Any', item.desc, 'terminate-busy', false);
added++;
}
});
if (added > 0) {
showToast(`${added} plages de démarchage ajoutées en mode « Terminate Call: Occupation » !`, 'success');
} else {
showToast('Toutes les plages anti-démarchage sont déjà présentes dans la liste.', 'info');
}
}
function filterInboundRoutesRows(query) {
const q = String(query || '').toLowerCase().trim();
document.querySelectorAll('#trunk-did-container .trunk-did-row').forEach(row => {
const cid = row.querySelector('.trunk-did-cid')?.value.toLowerCase() || '';
const did = row.querySelector('.trunk-did-num')?.value.toLowerCase() || '';
const desc = row.querySelector('.trunk-did-desc')?.value.toLowerCase() || '';
const dest = row.querySelector('.trunk-did-dest')?.selectedOptions?.[0]?.textContent?.toLowerCase() || '';
const match = !q || cid.includes(q) || did.includes(q) || desc.includes(q) || dest.includes(q);
row.classList.toggle('hidden', !match);
});
}
function syncTrunkHostDomain(hostVal) {
const form = document.getElementById('form-trunk');
if (!form) return;
// Laisse from_domain vide pour qu'il prenne automatiquement la valeur exacte de host
form.elements['from_domain'].value = '';
}
function setTrunkDialOptionsPreset(val) {
const form = document.getElementById('form-trunk');
if (form && form.elements['dial_options']) {
form.elements['dial_options'].value = val;
}
}
function populateInboundDestDropdown(selectedValue = '101') {
const select = document.getElementById('trunk-inbound-select');
const form = document.getElementById('form-trunk');
if (!select || !form) return;
select.innerHTML = buildDestinationOptionsHtml(selectedValue);
form.elements['inbound_dest'].value = select.value;
}
function syncInboundDestSelect(val) {
const form = document.getElementById('form-trunk');
if (form) {
form.elements['inbound_dest'].value = val;
}
}
function toggleFreePbxImporter() {
const box = document.getElementById('freepbx-importer-box');
if (box) box.classList.toggle('hidden');
}
function parseFreePbxConfig() {
const raw = (document.getElementById('freepbx-paste-input')?.value || '').trim();
const form = document.getElementById('form-trunk');
if (!raw || !form) return;
// 1. Test format Register String : user:secret@host:port/contact
const regMatch = raw.match(/^(?:sip:)?([^:@\s]+):([^@\s]+)@([^:\/\s]+)(?::(\d+))?(?:\/(\S+))?$/i);
if (regMatch) {
const [, user, secret, host, port, contact] = regMatch;
if (!form.elements['name'].value) form.elements['name'].value = host.split('.')[0] + '-trunk';
form.elements['host'].value = host;
form.elements['port'].value = port || 5060;
form.elements['username'].value = user;
form.elements['secret'].value = secret;
form.elements['from_domain'].value = '';
if (contact) form.elements['contact_user'].value = contact;
form.elements['register'].checked = true;
toggleFreePbxImporter();
showToast('Register String FreePBX convertie et appliquée !', 'success');
return;
}
// 2. Format PEER Details (clé=valeur)
const lines = raw.split(/\r?\n/);
let found = 0;
lines.forEach(line => {
const m = line.match(/^\s*([a-zA-Z0-9_]+)\s*=\s*(.+?)\s*$/);
if (!m) return;
const key = m[1].toLowerCase();
const val = m[2].trim();
found++;
if (key === 'host') form.elements['host'].value = val;
else if (key === 'port') form.elements['port'].value = val;
else if (key === 'username' || key === 'defaultuser') form.elements['username'].value = val;
else if (key === 'authuser' || key === 'auth') form.elements['auth_username'].value = val;
else if (key === 'secret' || key === 'password') form.elements['secret'].value = val;
else if (key === 'fromuser') form.elements['from_user'].value = val;
else if (key === 'fromdomain') form.elements['from_domain'].value = val;
else if (key === 'context') {
if (val.includes('toheader')) form.elements['context'].value = 'from-pstn-toheader';
else form.elements['context'].value = 'from-external';
}
else if (key === 'dialopts' || key === 'dial_options' || key === 'asterisk_trunk_dial_options') form.elements['dial_options'].value = val;
else if (key === 'outboundproxy') form.elements['outbound_proxy'].value = val;
});
if (found > 0) {
if (!form.elements['name'].value && form.elements['host'].value) {
form.elements['name'].value = form.elements['host'].value.split('.')[0] + '-trunk';
}
toggleFreePbxImporter();
showToast(`${found} paramètres FreePBX convertis vers PJSIP !`, 'success');
} else {
showToast('Format non reconnu. Collez user:pass@host ou host=...', 'error');
}
}
function onTrunkRegistrationModeChange(mode) {
const form = document.getElementById('form-trunk');
if (!form) return;
const hostInput = form.elements['host'];
const hostLabel = document.getElementById('trunk-host-label');
const hostHint = document.getElementById('trunk-host-hint');
const receiveGuide = document.getElementById('trunk-receive-guide');
const regNotice = document.getElementById('trunk-reg-outbound-notice');
if (mode === 'receive') {
if (hostInput) {
hostInput.required = false;
hostInput.placeholder = 'Optionnel (vide = IP découverte dynamiquement au REGISTER de la passerelle)';
}
if (hostLabel) {
hostLabel.innerHTML = 'Serveur SIP / IP Passerelle (Optionnel en mode Réception) ';
}
hostHint?.classList.remove('hidden');
receiveGuide?.classList.remove('hidden');
regNotice?.classList.remove('hidden');
if (form.elements['register']) form.elements['register'].checked = false;
if (form.elements['line_support']) form.elements['line_support'].checked = false;
} else if (mode === 'none') {
if (hostInput) {
hostInput.required = true;
hostInput.placeholder = 'ex: 192.168.1.250 ou sbc.direct.net';
}
if (hostLabel) {
hostLabel.innerHTML = 'Serveur SIP / SBC / Passerelle * (Host / IP) ';
}
hostHint?.classList.add('hidden');
receiveGuide?.classList.add('hidden');
regNotice?.classList.remove('hidden');
if (form.elements['register']) form.elements['register'].checked = false;
} else {
// mode === 'send'
if (hostInput) {
hostInput.required = true;
hostInput.placeholder = 'ex: sbc.operateur.net ou siptrunk.ovh.net';
}
if (hostLabel) {
hostLabel.innerHTML = 'Serveur SIP / SBC / Passerelle * (Host / IP) ';
}
hostHint?.classList.add('hidden');
receiveGuide?.classList.add('hidden');
regNotice?.classList.add('hidden');
if (form.elements['register']) form.elements['register'].checked = true;
}
}
function applyTrunkArchitecture(arch) {
const form = document.getElementById('form-trunk');
if (!form) return;
const architectures = {
register_auth: {
label: 'Trunk avec Enregistrement (REGISTER + Auth)',
port: 5060,
transport: 'udp',
auth_mode: 'userpass',
registration_mode: 'send',
register: true,
line_support: true,
media_encryption: 'no',
nat_symmetric: true,
qualify_frequency: 60
},
gateway_receive: {
label: 'Passerelle FXO / Ligne Box (Grandstream HT813, Cisco SPA, AudioCodes, ATA)',
port: 5060,
transport: 'udp',
auth_mode: 'userpass',
registration_mode: 'receive',
register: false,
line_support: false,
media_encryption: 'no',
nat_symmetric: true,
qualify_frequency: 60
},
ip_peering: {
label: 'Peering IP / SBC / Passerelle (Sans REGISTER)',
port: 5060,
transport: 'udp',
auth_mode: 'ip',
registration_mode: 'none',
register: false,
line_support: false,
media_encryption: 'no',
nat_symmetric: true,
qualify_frequency: 30
},
tls_srtp: {
label: 'Trunk Sécurisé (TLS 5061 + SRTP SDES)',
port: 5061,
transport: 'tls',
auth_mode: 'userpass',
registration_mode: 'send',
register: true,
line_support: true,
media_encryption: 'sdes',
nat_symmetric: true,
qualify_frequency: 60
}
};
const a = architectures[arch];
if (!a) return;
// Ne jamais écraser le nom, l'hôte, les identifiants ou le from_domain de l'utilisateur !
form.elements['port'].value = a.port;
form.elements['transport'].value = a.transport;
form.querySelectorAll('input[name="auth_mode"]').forEach(r => { r.checked = (r.value === a.auth_mode); });
toggleTrunkAuthFields(a.auth_mode);
if (a.registration_mode) {
form.querySelectorAll('input[name="registration_mode"]').forEach(r => {
r.checked = (r.value === a.registration_mode);
});
onTrunkRegistrationModeChange(a.registration_mode);
}
form.elements['register'].checked = a.register;
form.elements['line_support'].checked = a.line_support;
form.elements['media_encryption'].value = a.media_encryption;
form.elements['nat_symmetric'].checked = a.nat_symmetric;
form.elements['qualify_frequency'].value = a.qualify_frequency;
showToast(`Architecture « ${a.label} » appliquée sans écraser votre serveur ni vos identifiants.`, 'info');
}
function openTrunkModal(trunk = null) {
const form = document.getElementById('form-trunk');
form.reset();
switchTrunkModalTab('general');
document.getElementById('freepbx-importer-box')?.classList.add('hidden');
document.getElementById('modal-trunk-title').textContent = trunk ? `Modifier le Trunk ${trunk.name}` : 'Nouveau Trunk SIP / PJSIP Universel';
form.elements['id'].value = trunk ? trunk.id : '';
form.elements['name'].value = trunk ? trunk.name : '';
form.elements['host'].value = trunk ? (trunk.host ?? '') : '';
form.elements['port'].value = trunk ? (trunk.port ?? 5060) : 5060;
form.elements['outbound_proxy'].value = trunk ? (trunk.outbound_proxy ?? '') : '';
form.elements['transport'].value = trunk ? (trunk.transport ?? 'udp') : 'udp';
const regMode = trunk ? (trunk.registration_mode || (Number(trunk.register) === 1 ? 'send' : 'none')) : 'send';
form.querySelectorAll('input[name="registration_mode"]').forEach(r => {
r.checked = (r.value === regMode);
});
onTrunkRegistrationModeChange(regMode);
form.elements['transport'].value = trunk ? (trunk.transport ?? 'udp') : 'udp';
const authMode = trunk ? (trunk.auth_mode || 'userpass') : 'userpass';
form.querySelectorAll('input[name="auth_mode"]').forEach(r => {
r.checked = (r.value === authMode);
});
toggleTrunkAuthFields(authMode);
form.elements['username'].value = trunk ? (trunk.username ?? '') : '';
form.elements['auth_username'].value = trunk ? (trunk.auth_username ?? '') : '';
form.elements['secret'].value = trunk ? (trunk.secret ?? '') : '';
form.elements['enabled'].checked = trunk ? Number(trunk.enabled ?? 1) === 1 : true;
const uInput = form.elements['username'];
const updateGuide = () => {
const val = uInput.value.trim() || 'Même valeur que Username';
const gUser = document.getElementById('ht813-guide-user');
const gAuth = document.getElementById('ht813-guide-auth');
if (gUser) gUser.textContent = val;
if (gAuth) gAuth.textContent = val;
};
uInput.oninput = updateGuide;
updateGuide();
// Onglet 2 : Registration & Identité SIP
form.elements['register'].checked = trunk ? Number(trunk.register ?? 1) === 1 : true;
form.elements['line_support'].checked = trunk ? Number(trunk.line_support ?? 1) === 1 : true;
form.elements['contact_user'].value = trunk ? (trunk.contact_user ?? '') : '';
form.elements['expiration'].value = trunk ? (trunk.expiration ?? 3600) : 3600;
form.elements['retry_interval'].value = trunk ? (trunk.retry_interval ?? 60) : 60;
form.elements['max_retries'].value = trunk ? (trunk.max_retries ?? 10000) : 10000;
form.elements['forbidden_retry_interval'].value = trunk ? (trunk.forbidden_retry_interval ?? 600) : 600;
form.elements['fatal_retry_interval'].value = trunk ? (trunk.fatal_retry_interval ?? 0) : 0;
form.elements['server_uri'].value = trunk ? (trunk.server_uri ?? '') : '';
form.elements['client_uri'].value = trunk ? (trunk.client_uri ?? '') : '';
form.elements['from_user'].value = trunk ? (trunk.from_user ?? '') : '';
form.elements['from_domain'].value = trunk ? (trunk.from_domain ?? '') : '';
form.elements['outbound_callerid'].value = trunk ? (trunk.outbound_callerid ?? '') : '';
form.elements['send_pai'].checked = trunk ? Number(trunk.send_pai ?? 1) === 1 : true;
form.elements['send_rpid'].checked = trunk ? Number(trunk.send_rpid ?? 0) === 1 : false;
form.elements['send_diversion'].checked = trunk ? Number(trunk.send_diversion ?? 1) === 1 : true;
form.elements['trust_id_inbound'].checked = trunk ? Number(trunk.trust_id_inbound ?? 1) === 1 : true;
// Onglet 3 : Codecs, NAT, Timers, T.38 & Identify
form.elements['codecs'].value = trunk ? (trunk.codecs || 'alaw,ulaw,g722') : 'alaw,ulaw,g722';
form.elements['dtmf_mode'].value = trunk ? (trunk.dtmf_mode || 'rfc4733') : 'rfc4733';
form.elements['media_encryption'].value = trunk ? (trunk.media_encryption || 'no') : 'no';
form.elements['qualify_frequency'].value = trunk ? (trunk.qualify_frequency ?? 60) : 60;
form.elements['rtp_keepalive'].value = trunk ? (trunk.rtp_keepalive ?? 15) : 15;
form.elements['max_channels'].value = trunk ? (trunk.max_channels ?? 0) : 0;
form.elements['timers'].value = trunk ? (trunk.timers || 'yes') : 'yes';
form.elements['t38_udptl'].value = trunk ? (trunk.t38_udptl || 'no') : 'no';
form.elements['language'].value = trunk ? (trunk.language || 'fr') : 'fr';
form.elements['nat_symmetric'].checked = trunk ? Number(trunk.nat_symmetric ?? 1) === 1 : true;
form.elements['direct_media'].checked = trunk ? Number(trunk.direct_media ?? 0) === 1 : false;
form.elements['inband_progress'].checked = trunk ? Number(trunk.inband_progress ?? 0) === 1 : false;
form.elements['fax_detect'].checked = trunk ? Number(trunk.fax_detect ?? 0) === 1 : false;
form.elements['match_ips'].value = trunk ? (trunk.match_ips ?? '') : '';
// Contexte & Destination entrante (Totalement libres + assistés)
form.elements['context'].value = trunk ? (trunk.context || 'from-pstn-toheader') : 'from-pstn-toheader';
if (form.elements['cid_name_prefix']) {
form.elements['cid_name_prefix'].value = trunk ? (trunk.cid_name_prefix || '') : '';
}
if (form.elements['dial_options']) {
form.elements['dial_options'].value = trunk ? (trunk.dial_options || 'tTR') : 'tTR';
}
populateInboundDestDropdown(trunk ? (trunk.inbound_dest || '101') : '101');
// Musique d'attente (MoH) spécifique à ce Trunk
const mohSelect = document.getElementById('trunk-moh-sound-id');
if (mohSelect) {
const mohSounds = (appState.sounds || []).filter(s => s.usage_type === 'moh');
const curMohId = trunk ? Number(trunk.moh_sound_id || 0) : 0;
let opts = `🌐 Hériter de la musique générale par défaut `;
mohSounds.forEach(s => {
const isSel = (Number(s.id) === curMohId) ? 'selected' : '';
opts += `🎵 ${escapeHtml(s.name)} (${escapeHtml(s.site_category || 'Global')}) `;
});
mohSelect.innerHTML = opts;
}
// Onglet 4 : Routage SDA & Directives PJSIP Natives ([endpoint], [registration], [aor])
form.elements['outbound_prefix'].value = trunk ? (trunk.outbound_prefix ?? '') : '';
form.elements['custom_endpoint_params'].value = trunk ? (trunk.custom_endpoint_params ?? '') : '';
form.elements['custom_reg_params'].value = trunk ? (trunk.custom_reg_params ?? '') : '';
form.elements['custom_aor_params'].value = trunk ? (trunk.custom_aor_params ?? '') : '';
const filterInput = document.getElementById('inbound-routes-filter');
if (filterInput) filterInput.value = '';
const didContainer = document.getElementById('trunk-did-container');
if (didContainer) {
didContainer.innerHTML = '';
const rawRoutes = trunk && trunk.did_routes ? trunk.did_routes : [];
let routesList = [];
if (Array.isArray(rawRoutes)) {
routesList = rawRoutes;
} else if (typeof rawRoutes === 'object' && rawRoutes !== null) {
routesList = Object.entries(rawRoutes).map(([did, destination]) => ({
cid: 'Any',
did,
description: `SDA ${did}`,
destination
}));
}
if (routesList.length === 0) {
const defDest = trunk ? (trunk.inbound_dest || 'ext-101') : 'ext-101';
const defDesc = trunk ? `Ligne ${trunk.name}` : 'Route Principale';
addTrunkInboundRouteRow('Any', 'Any', defDesc, defDest, false);
} else {
routesList.forEach(r => {
addTrunkInboundRouteRow(
r.cid || 'Any',
r.did || 'Any',
r.description || '',
r.destination || 'ext-101',
false,
{
time_condition_enabled: Boolean(Number(r.time_condition_enabled)),
time_start: r.time_start || '08:30',
time_finish: r.time_finish || '18:00',
weekdays: r.weekdays || 'mon-fri',
time_dest_closed: r.time_dest_closed || 'terminate-hangup',
cid_prefix: r.cid_prefix || ''
}
);
});
}
}
openModal('modal-trunk');
}
function editTrunk(id, initialTab = 'general') {
const trunk = (appState.trunks || []).find(t => Number(t.id) === Number(id));
if (trunk) {
openTrunkModal(trunk);
if (initialTab && initialTab !== 'general') {
switchTrunkModalTab(initialTab);
}
}
}
async function deleteTrunk(id, optName = null) {
const t = (appState.trunks || []).find(tr => Number(tr.id) === Number(id));
const name = optName || (t ? t.name : 'ce trunk');
if (!confirm(`Supprimer le Trunk SIP ${name} ?`)) return;
try {
const res = await apiCall('delete_trunk', { id: Number(id) });
showToast(res.message, 'success');
await loadState();
} catch (err) {
showToast(err.message, 'error');
}
}
// --- 3B. Rendu & CRUD Routes Sortantes (Outbound Routes & Failover Trunks) ---
function renderOutboundRoutesTable() {
const tbody = document.getElementById('outbound-routes-tbody');
if (!tbody) return;
const routes = appState.outbound_routes || [];
if (routes.length === 0) {
tbody.innerHTML = `Aucune route sortante configurée. Cliquez sur « + Ajout route sortante ». `;
return;
}
tbody.innerHTML = routes.map((r, idx) => {
const trunkSeq = Array.isArray(r.trunk_sequence) ? r.trunk_sequence : [];
const trunkBadges = trunkSeq.length > 0
? trunkSeq.map((t, tIdx) => `
${tIdx + 1}. ${escapeHtml(t)}
`).join(' → ')
: `⚠️ Aucun Trunk associé `;
const isRestricted = r.permission_mode === 'restricted';
const allowedGrps = Array.isArray(r.allowed_groups) ? r.allowed_groups : [];
const allowedExts = Array.isArray(r.allowed_extensions) ? r.allowed_extensions : [];
let permBadge = '';
if (!isRestricted) {
permBadge = `✓ Tous Postes & Groupes `;
} else {
const grpTags = allowedGrps.map(g => `Grp ${escapeHtml(g)} `).join(' ');
const extTags = allowedExts.length > 0 ? `${allowedExts.length} poste(s) ` : '';
permBadge = `
Restreint
${grpTags} ${extTags}
`;
}
const patterns = Array.isArray(r.dial_patterns) ? r.dial_patterns : [];
const patCount = patterns.length;
const patPreview = patterns.slice(0, 2).map(p => escapeHtml(p.match_pattern || '')).filter(Boolean).join(', ');
const rTypeBadge = r.route_type === 'emergency'
? `🚑 Urgence `
: (r.route_type === 'intracompany'
? `Intra `
: `Standard `);
const isFirst = idx === 0;
const isLast = idx === routes.length - 1;
return `
#${Number(r.priority)}
▲
▼
${escapeHtml(r.name)}
${rTypeBadge}
${r.outbound_callerid ? escapeHtml(r.outbound_callerid) : 'CID du poste ou du trunk '}
${Number(r.override_ext_cid) === 1 ? '(Force) ' : ''}
${trunkBadges}
${permBadge}
${patCount} masque(s)
${patPreview}${patCount > 2 ? '...' : ''}
Éditer
Supprimer
`;
}).join('');
}
let activeObAllowedExts = new Set();
let activeObBlockedExts = new Set();
function switchOutboundModalTab(tabKey) {
document.querySelectorAll('.ob-subpanel').forEach(p => p.classList.add('hidden'));
const target = document.getElementById(`ob-panel-${tabKey}`);
if (target) target.classList.remove('hidden');
document.querySelectorAll('.ob-tab-btn').forEach(btn => {
if (btn.dataset.obTab === tabKey) {
btn.className = 'ob-tab-btn px-4 py-2 text-xs font-bold rounded-t-xl bg-indigo-600 text-white transition flex items-center gap-1.5';
} else {
btn.className = 'ob-tab-btn px-4 py-2 text-xs font-semibold rounded-t-xl bg-anthracite-950 text-slate-300 hover:text-white transition flex items-center gap-1.5';
}
});
}
function openOutboundRouteModal(route = null) {
const form = document.getElementById('form-outbound');
form.reset();
document.getElementById('modal-outbound-title').textContent = route ? `Éditer la Route Sortante : ${route.name}` : 'Ajout Route Sortante';
form.elements['id'].value = route ? route.id : '';
form.elements['name'].value = route ? route.name : '';
form.elements['outbound_callerid'].value = route ? route.outbound_callerid : '';
form.elements['priority'].value = route ? route.priority : ((appState.outbound_routes || []).length + 1);
form.elements['route_password'].value = route ? route.route_password : '';
form.elements['enabled'].checked = route ? Number(route.enabled) === 1 : true;
form.elements['continue_next_route'].checked = route ? Number(route.continue_next_route) === 1 : true;
// Radios
const overrideCid = route ? String(route.override_ext_cid) : '0';
form.querySelectorAll('input[name="override_ext_cid"]').forEach(r => r.checked = (r.value === overrideCid));
const routeType = route ? route.route_type : 'normal';
form.querySelectorAll('input[name="route_type"]').forEach(r => r.checked = (r.value === routeType));
const permMode = route ? route.permission_mode : 'all';
form.querySelectorAll('input[name="permission_mode"]').forEach(r => r.checked = (r.value === permMode));
toggleOutboundPermissionMode(permMode);
const recMode = route ? route.record_mode : 'dontcare';
form.querySelectorAll('input[name="record_mode"]').forEach(r => r.checked = (r.value === recMode));
// Destination sur congestion / Trunks indisponibles
const congSel = document.getElementById('ob-congestion-dest-select');
if (congSel) {
const curCong = route ? route.congestion_dest : 'all-circuits-busy';
let congOpts = `
📢 Message vocal standard (« Toutes les lignes sont occupées »)
`;
const annSounds = (appState.sounds || []).filter(s => s.usage_type !== 'moh');
if (annSounds.length > 0) {
annSounds.forEach(s => {
const val = `sound-${s.id}`;
congOpts += `🎙️ Annonce : ${escapeHtml(s.name)} `;
});
}
congOpts += `
Tonalité de Congestion (Bip rapide réseau saturé)
Tonalité d'Occupation (Bip occupé)
Raccrocher immédiatement
`;
congSel.innerHTML = congOpts;
}
// Musique d'attente (MOH)
const mohSel = document.getElementById('ob-musicclass-select');
if (mohSel) {
const curMoh = route ? (route.moh_class || 'default') : 'default';
const mohSounds = (appState.sounds || []).filter(s => s.usage_type === 'moh');
let mohOpts = `🌐 Hériter du Trunk / Global (Recommandé) `;
mohSounds.forEach(s => {
const sSlug = slugifyTrunkName(s.site_category || 'global');
const cName = (sSlug === 'global' || sSlug === 'default') ? 'default' : `moh-${sSlug}`;
if (cName !== 'default') {
mohOpts += `${escapeHtml(s.name)} [${escapeHtml(s.site_category)}] `;
}
});
mohSel.innerHTML = mohOpts;
}
// Séquence Trunks
const tSeqContainer = document.getElementById('ob-trunk-seq-container');
tSeqContainer.innerHTML = '';
const initialTrunks = route && Array.isArray(route.trunk_sequence) && route.trunk_sequence.length > 0
? route.trunk_sequence
: [(appState.trunks?.[0]?.name || '')];
initialTrunks.forEach(tName => addOutboundTrunkSeqRow(tName));
// Dial Patterns
const pContainer = document.getElementById('ob-patterns-container');
pContainer.innerHTML = '';
const initialPatterns = route && Array.isArray(route.dial_patterns) && route.dial_patterns.length > 0
? route.dial_patterns
: [
{ prepend: '', prefix: '', match_pattern: '0[1-59]XXXXXXXX', callerid: '' },
{ prepend: '', prefix: '', match_pattern: '0[67]XXXXXXXX', callerid: '' }
];
initialPatterns.forEach(p => addOutboundPatternRow(p.prepend, p.prefix, p.match_pattern, p.callerid));
updateOutboundPatternsBadge();
// Groupes d'extensions autorisés (Sites & Droits Sortants)
const grpsContainer = document.getElementById('ob-allowed-groups-container');
const allowedGrpsSet = new Set(route && Array.isArray(route.allowed_groups) ? route.allowed_groups.map(String) : []);
const groups = appState.extension_groups || [];
if (groups.length === 0) {
grpsContainer.innerHTML = `Aucun groupe d'extensions configuré. Allez dans « Groupes » > « Groupes d'extensions » pour créer le Groupe Nice, Groupe Paris, etc. `;
} else {
grpsContainer.innerHTML = groups.map(g => {
const isChecked = allowedGrpsSet.has(String(g.id)) || allowedGrpsSet.has(String(g.name));
const mCount = Array.isArray(g.members) ? g.members.length : 0;
return `
${escapeHtml(g.name)}
${mCount} poste(s)${g.description ? ` • ${escapeHtml(g.description)}` : ''}
`;
}).join('');
}
// Extension Routing (Allowed Extensions <-> Blocked Extensions)
const allExts = appState.extensions || [];
const routeAllowedExts = route && Array.isArray(route.allowed_extensions) ? route.allowed_extensions.map(String) : null;
activeObAllowedExts = new Set();
activeObBlockedExts = new Set();
allExts.forEach(e => {
const eStr = String(e.extension);
if (routeAllowedExts === null) {
// Par défaut, tous autorisés
activeObAllowedExts.add(eStr);
} else if (routeAllowedExts.includes(eStr)) {
activeObAllowedExts.add(eStr);
} else {
activeObBlockedExts.add(eStr);
}
});
renderOutboundExtensionBoxes();
switchOutboundModalTab('settings');
openModal('modal-outbound');
}
function editOutboundRoute(id) {
const route = (appState.outbound_routes || []).find(r => Number(r.id) === Number(id));
if (route) openOutboundRouteModal(route);
}
async function deleteOutboundRoute(id, optName = null) {
const r = (appState.outbound_routes || []).find(rt => Number(rt.id) === Number(id));
const name = optName || (r ? r.name : 'cette route');
if (!confirm(`Supprimer définitivement la Route Sortante « ${name} » ?`)) return;
try {
const res = await apiCall('delete_outbound_route', { id: Number(id) });
showToast(res.message, 'success');
await loadState();
} catch (err) {
showToast(err.message, 'error');
}
}
async function reorderOutboundRoute(id, direction) {
try {
const res = await apiCall('reorder_outbound_route', { id, direction });
showToast(res.message, 'success');
await loadState();
} catch (err) {
showToast(err.message, 'error');
}
}
function addOutboundTrunkSeqRow(selectedTrunk = '') {
const container = document.getElementById('ob-trunk-seq-container');
if (!container) return;
const trunks = appState.trunks || [];
const row = document.createElement('div');
row.className = 'flex items-center gap-2 ob-trunk-seq-row';
const optsHtml = trunks.map(t => {
const isSel = (t.name === selectedTrunk) || (!selectedTrunk && trunks.indexOf(t) === 0);
return `${escapeHtml(t.name)} (${escapeHtml(t.host)}:${Number(t.port)}) `;
}).join('');
const currentCount = container.querySelectorAll('.ob-trunk-seq-row').length + 1;
row.innerHTML = `
${currentCount}
${optsHtml || 'Aucun Trunk disponible (Créez un Trunk) '}
🗑️
`;
row.querySelector('.btn-del-trunk-row').addEventListener('click', () => {
if (container.querySelectorAll('.ob-trunk-seq-row').length <= 1) {
showToast('Une route sortante doit avoir au moins un Trunk.', 'error');
return;
}
row.remove();
container.querySelectorAll('.ob-trunk-seq-row').forEach((r, idx) => {
r.querySelector('span').textContent = String(idx + 1);
});
});
container.appendChild(row);
}
function updateOutboundPatternsBadge() {
const count = document.querySelectorAll('#ob-patterns-container .ob-pat-row').length;
const badge = document.getElementById('ob-patterns-count-badge');
if (badge) badge.textContent = String(count);
}
function addOutboundPatternRow(prepend = '', prefix = '', match = '', cid = '') {
const container = document.getElementById('ob-patterns-container');
if (!container) return;
const row = document.createElement('div');
row.className = 'grid grid-cols-12 gap-2 px-4 py-2 items-center hover:bg-anthracite-800/60 transition ob-pat-row';
row.innerHTML = `
🗑️
`;
row.querySelector('.btn-del-pat').addEventListener('click', () => {
row.remove();
updateOutboundPatternsBadge();
});
container.appendChild(row);
updateOutboundPatternsBadge();
}
function applyOutboundPatternPreset(presetKey) {
if (presetKey === 'fr_national') {
addOutboundPatternRow('', '', '0[1-59]XXXXXXXX', '');
addOutboundPatternRow('', '', '0[67]XXXXXXXX', '');
} else if (presetKey === 'fr_free') {
addOutboundPatternRow('', '', '080[0-9]XXXXXX', '');
} else if (presetKey === 'fr_emergency') {
['15', '17', '18', '112', '114'].forEach(num => addOutboundPatternRow('', '', num, ''));
} else if (presetKey === 'international') {
addOutboundPatternRow('', '', '00.', '');
addOutboundPatternRow('', '', '+X.', '');
} else if (presetKey === 'all') {
addOutboundPatternRow('', '', 'X.', '');
}
}
function toggleOutboundPermissionMode(mode) {
const details = document.getElementById('ob-permissions-details');
if (!details) return;
if (mode === 'restricted') {
details.classList.remove('opacity-40', 'pointer-events-none');
} else {
details.classList.add('opacity-40', 'pointer-events-none');
}
}
function renderOutboundExtensionBoxes() {
const allowedBox = document.getElementById('ob-allowed-exts-box');
const blockedBox = document.getElementById('ob-blocked-exts-box');
if (!allowedBox || !blockedBox) return;
const allExts = appState.extensions || [];
let allowedHtml = '';
let blockedHtml = '';
allExts.forEach(e => {
const eStr = String(e.extension);
if (activeObAllowedExts.has(eStr)) {
allowedHtml += `
${escapeHtml(eStr)}
(${escapeHtml(e.name)})
→
`;
} else {
blockedHtml += `
←
${escapeHtml(eStr)}
(${escapeHtml(e.name)})
`;
}
});
allowedBox.innerHTML = allowedHtml || `Aucun poste individuel autorisé.
`;
blockedBox.innerHTML = blockedHtml || `Tous les postes sont autorisés sur cette route !
`;
document.getElementById('ob-allowed-exts-count').textContent = String(activeObAllowedExts.size);
document.getElementById('ob-blocked-exts-count').textContent = String(activeObBlockedExts.size);
}
function obToggleSingleExt(eStr) {
if (activeObAllowedExts.has(eStr)) {
activeObAllowedExts.delete(eStr);
activeObBlockedExts.add(eStr);
} else {
activeObBlockedExts.delete(eStr);
activeObAllowedExts.add(eStr);
}
renderOutboundExtensionBoxes();
}
function obTransferExtensions(action) {
const allExts = appState.extensions || [];
if (action === 'all_allow') {
allExts.forEach(e => {
activeObAllowedExts.add(String(e.extension));
activeObBlockedExts.delete(String(e.extension));
});
} else if (action === 'all_block') {
allExts.forEach(e => {
activeObBlockedExts.add(String(e.extension));
activeObAllowedExts.delete(String(e.extension));
});
} else if (action === 'invert') {
allExts.forEach(e => {
const eStr = String(e.extension);
if (activeObAllowedExts.has(eStr)) {
activeObAllowedExts.delete(eStr);
activeObBlockedExts.add(eStr);
} else {
activeObBlockedExts.delete(eStr);
activeObAllowedExts.add(eStr);
}
});
}
renderOutboundExtensionBoxes();
}
// --- 4. Rendu & CRUD SVI / IVR ---
function renderIvrsTable() {
const tbody = document.getElementById('ivrs-tbody');
const rows = appState.ivrs || [];
if (rows.length === 0) {
tbody.innerHTML = `Aucun serveur vocal interactif configuré. Cliquez sur « + Nouveau SVI / IVR » pour en créer un. `;
return;
}
tbody.innerHTML = rows.map(ivr => {
const opts = ivr.options || {};
const optEntries = Object.entries(opts);
const badges = optEntries.map(([k, v]) => {
let label = v;
if (v.startsWith('ext-')) label = `Poste ${v.replace('ext-', '')}`;
else if (v.startsWith('grp-')) label = `Groupe ${v.replace('grp-', '')}`;
else if (v.startsWith('sound-')) label = `Annonce`;
else if (v.startsWith('terminate-')) label = v.replace('terminate-', '');
return `
${escapeHtml(k)}
→
${escapeHtml(label)}
`;
}).join(' ');
const directDialBadge = Number(ivr.direct_dial) !== 0
? `
Activée
`
: `Désactivée `;
const testCode = '*8' + String(ivr.id).padStart(2, '0');
// Résolution du libellé de l'annonce audio
let audioName = escapeHtml(ivr.audio_file || 'demo-instruct');
const foundSound = (appState.sounds || []).find(s => `custom/${s.slug}` === ivr.audio_file || s.slug === ivr.audio_file);
if (foundSound) {
audioName = `${escapeHtml(foundSound.name)} (${escapeHtml(foundSound.site_category)}) `;
}
return `
${escapeHtml(ivr.name)}
${ivr.description ? `${escapeHtml(ivr.description)}
` : ''}
${directDialBadge}
${badges || 'Aucune touche '}
⏱️ Attente : ${Number(ivr.timeout)}s (${Number(ivr.timeout_retries || 2)} rép)
⚠️ Erreur : max ${Number(ivr.invalid_retries || 3)} essais
${testCode}
Modifier
Supprimer
`;
}).join('');
}
function addIvrOptionRow(digit = '', dest = '') {
const container = document.getElementById('ivr-options-container');
const row = document.createElement('div');
row.className = 'flex items-center gap-2.5 p-2 rounded-xl bg-anthracite-950/80 border border-anthracite-700/80 ivr-opt-row transition hover:border-indigo-500/50';
const digits = ['1', '2', '3', '4', '5', '6', '7', '8', '9', '0', '*', '#'];
let digitOptions = digits.map(d => `Touche ${d} `).join('');
row.innerHTML = `
Touche :
${digitOptions}
→
${buildDestinationOptionsHtml(dest || 'ext-101')}
`;
row.querySelector('button').addEventListener('click', () => row.remove());
container.appendChild(row);
}
function openIvrModal(ivr = null) {
const form = document.getElementById('form-ivr');
form.reset();
document.getElementById('modal-ivr-title').textContent = ivr ? `Modifier le SVI « ${ivr.name} »` : 'Nouveau Serveur Vocal Interactif (SVI / IVR)';
form.elements['id'].value = ivr ? ivr.id : '';
form.elements['name'].value = ivr ? ivr.name : '';
form.elements['description'].value = ivr && ivr.description ? ivr.description : '';
form.elements['timeout'].value = ivr ? Number(ivr.timeout) : 5;
form.elements['timeout_retries'].value = ivr ? Number(ivr.timeout_retries || 2) : 2;
form.elements['invalid_retries'].value = ivr ? Number(ivr.invalid_retries || 3) : 3;
// Numérotation directe (défaut activé si nouveau ou si direct_dial == 1)
const directDialCheck = document.getElementById('ivr-direct-dial-checkbox');
if (directDialCheck) {
directDialCheck.checked = ivr ? Boolean(Number(ivr.direct_dial)) : true;
}
// Sélecteur audio d'accueil (audio_file)
const audioSel = document.getElementById('ivr-audio-select');
if (audioSel) {
const curAudio = ivr ? String(ivr.audio_file || '') : '';
const sounds = appState.sounds || [];
let audioHtml = '';
if (sounds.length > 0) {
audioHtml += ``;
sounds.forEach(s => {
const val = `custom/${s.slug}`;
const isSel = curAudio === val || curAudio === s.slug || curAudio === String(s.id);
audioHtml += `${escapeHtml(s.name)} [${escapeHtml(s.site_category)} • ${escapeHtml(s.usage_type)}] `;
});
audioHtml += ` `;
}
audioHtml += `
demo-instruct (Instructions d'accueil)
hello-world (Message d'accueil basique)
beep (Simple bip sonore)
`;
audioSel.innerHTML = audioHtml;
}
// Sélecteur message d'erreur invalide (invalid_sound_id)
const invalidSoundSel = document.getElementById('ivr-invalid-sound-select');
if (invalidSoundSel) {
const curInvId = ivr ? Number(ivr.invalid_sound_id || 0) : 0;
let invSoundHtml = `Message par défaut (pbx-invalid : « Choix invalide ») `;
(appState.sounds || []).forEach(s => {
invSoundHtml += `${escapeHtml(s.name)} [${escapeHtml(s.site_category)}] `;
});
invalidSoundSel.innerHTML = invSoundHtml;
}
// Sélecteur destination d'échec invalide (invalid_dest)
const invalidDestSel = document.getElementById('ivr-invalid-dest-select');
if (invalidDestSel) {
invalidDestSel.innerHTML = buildDestinationOptionsHtml(ivr ? (ivr.invalid_dest || 'terminate-hangup') : 'terminate-hangup');
}
// Sélecteur message timeout (timeout_sound_id)
const timeoutSoundSel = document.getElementById('ivr-timeout-sound-select');
if (timeoutSoundSel) {
const curToutId = ivr ? Number(ivr.timeout_sound_id || 0) : 0;
let toutSoundHtml = `Aucun message supplémentaire (rejouer annonce) `;
(appState.sounds || []).forEach(s => {
toutSoundHtml += `${escapeHtml(s.name)} [${escapeHtml(s.site_category)}] `;
});
timeoutSoundSel.innerHTML = toutSoundHtml;
}
// Sélecteur destination d'échec timeout (timeout_dest)
const timeoutDestSel = document.getElementById('ivr-timeout-dest-select');
if (timeoutDestSel) {
timeoutDestSel.innerHTML = buildDestinationOptionsHtml(ivr ? (ivr.timeout_dest || 'terminate-hangup') : 'terminate-hangup');
}
// Conteneur des touches DTMF
const container = document.getElementById('ivr-options-container');
container.innerHTML = '';
const opts = ivr && ivr.options && Object.keys(ivr.options).length > 0
? ivr.options
: { '1': 'ext-101', '2': 'grp-600' };
Object.entries(opts).forEach(([d, dest]) => addIvrOptionRow(d, dest));
openModal('modal-ivr');
}
function editIvr(id) {
const ivr = (appState.ivrs || []).find(i => Number(i.id) === Number(id));
if (ivr) openIvrModal(ivr);
}
async function deleteIvr(id, optName = null) {
const ivr = (appState.ivrs || []).find(i => Number(i.id) === Number(id));
const name = optName || (ivr ? ivr.name : 'ce SVI');
if (!confirm(`Supprimer le SVI ${name} ?`)) return;
try {
const res = await apiCall('delete_ivr', { id: Number(id) });
showToast(res.message, 'success');
await loadState();
} catch (err) {
showToast(err.message, 'error');
}
}
// --- 4B. Rendu & CRUD Médiathèque & Fichiers Sons (MoH, Annonces, IVR, Congestion) ---
const USAGE_LABELS = {
moh: { label: "Musique d'attente (MoH)", color: "amber" },
announcement: { label: "Annonce d'accueil / Info", color: "indigo" },
ivr: { label: "Menu SVI / Guide interactif", color: "cyan" },
congestion: { label: "Ligne occupée / Congestion", color: "rose" },
custom: { label: "Autre / Personnalisé", color: "slate" }
};
function formatDurationSec(sec) {
const s = Math.max(0, parseInt(sec, 10) || 0);
const m = Math.floor(s / 60);
const rem = s % 60;
return `${m}:${rem.toString().padStart(2, '0')}`;
}
function formatBytes(bytes) {
const b = parseInt(bytes, 10) || 0;
if (b < 1024) return `${b} o`;
if (b < 1024 * 1024) return `${(b / 1024).toFixed(1)} Ko`;
return `${(b / (1024 * 1024)).toFixed(1)} Mo`;
}
function renderSoundsTable() {
const tbody = document.getElementById('sounds-tbody');
if (!tbody) return;
const allSounds = appState.sounds || [];
const soundSites = appState.sound_sites || [];
// Met à jour la liste des sites dans le filtre (sans icône)
const siteFilterEl = document.getElementById('filter-sound-site');
if (siteFilterEl) {
const curVal = siteFilterEl.value || 'all';
let siteOpts = `Tous les sites `;
soundSites.forEach(st => {
const isSel = (st.name === curVal) ? 'selected' : '';
siteOpts += `${escapeHtml(st.name)} `;
});
siteFilterEl.innerHTML = siteOpts;
}
// Met à jour le sélecteur de la Musique d'attente générale par défaut (MoH)
const defMohSelect = document.getElementById('global-default-moh-select');
if (defMohSelect) {
const mohSounds = allSounds.filter(s => s.usage_type === 'moh');
const curDefaultId = Number(appState.settings?.default_moh_sound_id || 0);
if (mohSounds.length === 0) {
defMohSelect.innerHTML = `Aucune musique d'attente disponible `;
} else {
defMohSelect.innerHTML = mohSounds.map(s => {
const isSel = (Number(s.id) === curDefaultId) ? 'selected' : '';
return `${escapeHtml(s.name)} [${escapeHtml(s.site_category || 'Global')}] `;
}).join('');
}
}
// Récupération des filtres actifs
const selSite = siteFilterEl ? siteFilterEl.value : 'all';
const selUsage = document.getElementById('filter-sound-usage')?.value || 'all';
const searchTxt = (document.getElementById('filter-sound-search')?.value || '').toLowerCase().trim();
const filtered = allSounds.filter(s => {
if (selSite !== 'all' && s.site_category !== selSite) return false;
if (selUsage !== 'all' && s.usage_type !== selUsage) return false;
if (searchTxt) {
const hay = `${s.name} ${s.description} ${s.slug} ${s.site_category} ${s.usage_type}`.toLowerCase();
if (!hay.includes(searchTxt)) return false;
}
return true;
});
if (filtered.length === 0) {
tbody.innerHTML = `Aucun fichier son trouvé pour les filtres sélectionnés. Cliquez sur « + Téléverser un Fichier Son ». `;
return;
}
const curDefaultMohId = Number(appState.settings?.default_moh_sound_id || 0);
tbody.innerHTML = filtered.map(snd => {
const usage = USAGE_LABELS[snd.usage_type] || USAGE_LABELS.custom;
const siteBadge = `${escapeHtml(snd.site_category || 'Global')} `;
const usageBadge = `${escapeHtml(usage.label || snd.usage_type)} `;
const defaultBadge = (Number(snd.id) === curDefaultMohId)
? `Par défaut `
: '';
let astRef = `custom/${escapeHtml(snd.slug)}`;
if (snd.usage_type === 'moh') {
const sSlug = slugifyTrunkName(snd.site_category || 'global');
astRef = (sSlug === 'global' || sSlug === 'default') ? 'default' : `moh-${sSlug}`;
}
const audioUrl = `/api.php?action=stream_sound&id=${Number(snd.id)}`;
return `
${escapeHtml(snd.name)}
${defaultBadge}
${escapeHtml(snd.description || '—')}
${siteBadge}
${usageBadge}
${formatDurationSec(snd.duration_sec)}
${formatBytes(snd.file_size)} • ${escapeHtml((snd.format || 'wav').toUpperCase())}
${astRef}
Copier
Modifier
Supprimer
`;
}).join('');
}
async function applyDefaultMohFromSelect() {
const sel = document.getElementById('global-default-moh-select');
if (!sel || !sel.value) {
showToast("Veuillez sélectionner un fichier son pour la musique d'attente par défaut.", 'error');
return;
}
try {
const res = await apiCall('set_default_moh', { sound_id: Number(sel.value) });
showToast(res.message, 'success');
await loadState();
} catch (err) {
showToast(err.message, 'error');
}
}
// --- Gestion des catégories de sites (Création, Modification, Suppression) ---
function openSoundSitesModal() {
const form = document.getElementById('form-sound-site');
if (form) form.reset();
const idInput = document.getElementById('sound-site-id');
if (idInput) idInput.value = '';
const btn = document.getElementById('btn-save-sound-site');
if (btn) btn.textContent = 'Enregistrer';
renderSoundSitesTable();
openModal('modal-sound-sites');
}
function renderSoundSitesTable() {
const tbody = document.getElementById('sound-sites-tbody');
if (!tbody) return;
const sites = appState.sound_sites || [];
const sounds = appState.sounds || [];
if (sites.length === 0) {
tbody.innerHTML = `Aucune catégorie de site définie. `;
return;
}
tbody.innerHTML = sites.map(st => {
const count = sounds.filter(s => (s.site_category || '').toLowerCase() === (st.name || '').toLowerCase()).length;
const isGlobal = (st.name || '').toLowerCase() === 'global';
const deleteBtn = isGlobal
? `Protégé `
: `Supprimer `;
return `
${escapeHtml(st.name)}
${isGlobal ? 'Système ' : ''}
${escapeHtml(st.description || '—')}
${count} fichier${count > 1 ? 's' : ''}
Modifier
${deleteBtn}
`;
}).join('');
}
function editSoundSite(id) {
const site = (appState.sound_sites || []).find(s => Number(s.id) === Number(id));
if (!site) return;
const idInput = document.getElementById('sound-site-id');
const nameInput = document.getElementById('sound-site-name');
const descInput = document.getElementById('sound-site-desc');
const btn = document.getElementById('btn-save-sound-site');
if (idInput) idInput.value = site.id;
if (nameInput) {
nameInput.value = site.name;
nameInput.focus();
}
if (descInput) descInput.value = site.description || '';
if (btn) btn.textContent = 'Mettre à jour';
}
async function deleteSoundSite(id, optName = null) {
const st = (appState.sound_sites || []).find(s => Number(s.id) === Number(id));
const name = optName || (st ? st.name : 'ce site');
if (!confirm(`Supprimer la catégorie de site « ${name} » ?\nTous les sons associés seront automatiquement réassignés à « Global ».`)) return;
try {
const res = await apiCall('delete_sound_site', { id: Number(id) });
showToast(res.message, 'success');
await loadState();
renderSoundSitesTable();
} catch (err) {
showToast(err.message, 'error');
}
}
function handleSoundFileSelected(input) {
const file = input.files?.[0];
const placeEl = document.getElementById('sound-upload-placeholder');
const prevEl = document.getElementById('sound-upload-preview');
const nameEl = document.getElementById('sound-upload-filename');
const sizeEl = document.getElementById('sound-upload-filesize');
const nameInput = document.getElementById('sound-input-name');
if (file) {
placeEl?.classList.add('hidden');
prevEl?.classList.remove('hidden');
if (nameEl) nameEl.textContent = file.name;
if (sizeEl) sizeEl.textContent = formatBytes(file.size);
if (nameInput && !nameInput.value.trim()) {
const cleanName = file.name.replace(/\.[^/.]+$/, '').replace(/[-_]+/g, ' ');
nameInput.value = cleanName.charAt(0).toUpperCase() + cleanName.slice(1);
}
} else {
placeEl?.classList.remove('hidden');
prevEl?.classList.add('hidden');
}
}
function openSoundModal(snd = null) {
const form = document.getElementById('form-sound');
form.reset();
document.getElementById('modal-sound-title').textContent = snd ? `Modifier le fichier « ${snd.name} »` : "Téléverser un Fichier Son";
document.getElementById('btn-submit-sound').textContent = snd ? "Enregistrer les Modifications" : "Téléverser & Enregistrer";
form.elements['id'].value = snd ? snd.id : '';
form.elements['name'].value = snd ? snd.name : '';
form.elements['usage_type'].value = snd ? snd.usage_type : 'announcement';
form.elements['description'].value = snd ? snd.description : '';
// Remplit la liste déroulante des catégories de sites
const siteSelect = document.getElementById('sound-input-site');
if (siteSelect) {
const sites = appState.sound_sites || [];
const curSite = snd ? snd.site_category : 'Global';
siteSelect.innerHTML = sites.map(st => {
const isSel = (st.name === curSite) ? 'selected' : '';
return `${escapeHtml(st.name)} `;
}).join('');
if (curSite && !sites.some(st => st.name === curSite)) {
siteSelect.insertAdjacentHTML('afterbegin', `${escapeHtml(curSite)} `);
}
}
const uploadBlock = document.getElementById('sound-file-upload-block');
const fileInput = document.getElementById('sound-audio-file-input');
const placeEl = document.getElementById('sound-upload-placeholder');
const prevEl = document.getElementById('sound-upload-preview');
if (snd) {
if (uploadBlock) uploadBlock.classList.add('hidden');
if (fileInput) fileInput.removeAttribute('required');
} else {
if (uploadBlock) uploadBlock.classList.remove('hidden');
if (fileInput) fileInput.setAttribute('required', 'required');
placeEl?.classList.remove('hidden');
prevEl?.classList.add('hidden');
}
openModal('modal-sound');
}
function editSound(id) {
const snd = (appState.sounds || []).find(s => Number(s.id) === Number(id));
if (snd) openSoundModal(snd);
}
async function deleteSound(id, optName = null) {
const snd = (appState.sounds || []).find(s => Number(s.id) === Number(id));
const name = optName || (snd ? snd.name : 'ce fichier son');
if (!confirm(`Supprimer définitivement le fichier son « ${name} » et ses encodages Asterisk ?`)) return;
try {
const res = await apiCall('delete_sound', { id: Number(id) });
showToast(res.message, 'success');
await loadState();
} catch (err) {
showToast(err.message, 'error');
}
}
// --- 5. Rendu Paramètres, Diagnostic Debian & Prévisualisations .conf ---
function renderSettingsAndDiagnostics() {
const s = appState.settings || {};
const form = document.getElementById('settings-form');
if (form) {
if (form.elements['company_name']) {
form.elements['company_name'].value = s.company_name || '';
}
if (form.elements['system_language']) {
form.elements['system_language'].value = s.system_language || 'fr';
}
form.elements['domain'].value = s.domain || 'pbx.local';
form.elements['letsencrypt_domain'].value = s.letsencrypt_domain || '';
form.elements['local_net'].value = s.local_net || '10.10.0.0/16';
form.elements['external_ip'].value = s.external_ip || '';
form.elements['udp_port'].value = s.udp_port || 5060;
form.elements['tls_port'].value = s.tls_port || 5061;
form.elements['ami_host'].value = s.ami_host || '127.0.0.1';
form.elements['ami_port'].value = s.ami_port || 5038;
form.elements['ami_user'].value = s.ami_user || 'pbxgui';
form.elements['ami_secret'].value = s.ami_secret || '';
form.elements['new_admin_password'].value = '';
}
const diagList = document.getElementById('diagnostics-list');
const checks = appState.diagnostics?.checks || [];
diagList.innerHTML = checks.map(c => `
${c.ok ? '✓' : '!'}
${escapeHtml(c.label)}
${escapeHtml(c.detail)}
`).join('');
document.getElementById('preview-pjsip').textContent = appState.previews?.pjsip || '';
document.getElementById('preview-extensions').textContent = appState.previews?.extensions || '';
const prevVm = document.getElementById('preview-voicemail');
if (prevVm) {
prevVm.textContent = appState.previews?.voicemail || '';
}
}
// --- Navigation Sidebar & Modales ---
const PAGE_TITLES = {
dashboard: 'Tableau de Bord & Supervision Temps Réel',
extensions: 'Postes Téléphoniques SIP (PJSIP)',
groups: "Groupes de Sonnerie (Ring Groups)",
extgroups: "Groupes d'Extensions (Sites & Droits Sortants)",
paging: 'Groupes de Téléavertissement & Interphonie (Paging)',
trunks: 'Trunks Opérateurs SIP',
outbound: 'Routes Sortantes (Outbound Routes & Séquence Trunks)',
ivrs: 'Serveurs Vocaux Interactifs (SVI / IVR)',
sounds: 'Fichiers sons',
featurecodes: 'Codes de Fonctions & Services Téléphoniques Asterisk',
sipsettings: 'Paramètres SIP Globaux & Configuration NAT (PJSIP & RTP)',
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)'
};
function switchTab(tabId) {
document.querySelectorAll('.tab-panel').forEach(p => p.classList.add('hidden'));
const target = document.getElementById(`tab-${tabId}`);
if (target) target.classList.remove('hidden');
const titleEl = document.getElementById('page-header-title');
if (titleEl && PAGE_TITLES[tabId]) {
titleEl.textContent = PAGE_TITLES[tabId];
}
document.querySelectorAll('.tab-btn').forEach(btn => {
const isSubBtn = btn.parentElement?.id === 'submenu-operators' || btn.parentElement?.id === 'submenu-groups' || btn.parentElement?.id === 'submenu-system';
if (btn.dataset.tab === tabId) {
if (isSubBtn) {
btn.className = 'tab-btn w-full flex items-center justify-between px-3 py-1.5 rounded-lg text-xs font-bold bg-indigo-600 text-white shadow-sm transition text-left';
} else {
btn.className = 'tab-btn w-full flex items-center justify-between px-3.5 py-2.5 rounded-xl text-sm font-semibold bg-indigo-600 text-white shadow-md shadow-indigo-600/20 transition';
}
} else {
if (isSubBtn) {
btn.className = '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';
} else {
btn.className = 'tab-btn w-full flex items-center justify-between px-3.5 py-2.5 rounded-xl text-sm font-medium text-slate-300 hover:bg-anthracite-800 hover:text-white transition';
}
}
});
// Si on clique sur un sous-menu Opérateurs, s'assurer que le menu parent est bien déplié
if (tabId === 'trunks' || tabId === 'outbound') {
const submenu = document.getElementById('submenu-operators');
const arrow = document.getElementById('arrow-menu-operators');
if (submenu && submenu.classList.contains('hidden')) {
submenu.classList.remove('hidden');
arrow?.classList.remove('-rotate-90');
}
}
// Si on clique sur un sous-menu Groupes, s'assurer que le menu parent est bien déplié
if (tabId === 'groups' || tabId === 'extgroups') {
const submenu = document.getElementById('submenu-groups');
const arrow = document.getElementById('arrow-menu-groups');
if (submenu && submenu.classList.contains('hidden')) {
submenu.classList.remove('hidden');
arrow?.classList.remove('-rotate-90');
}
}
// 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') {
const submenu = document.getElementById('submenu-system');
const arrow = document.getElementById('arrow-menu-system');
if (submenu && submenu.classList.contains('hidden')) {
submenu.classList.remove('hidden');
arrow?.classList.remove('-rotate-90');
}
}
// Si onglet Sauvegardes, charger la liste des sauvegardes et la configuration
if (tabId === 'backups') {
loadBackups();
}
// Si onglet Pare-feu, charger le statut en direct
if (tabId === 'firewall') {
loadFirewallStatus();
}
toggleMobileSidebar(false);
}
function toggleSystemMenu() {
const submenu = document.getElementById('submenu-system');
const arrow = document.getElementById('arrow-menu-system');
if (!submenu) return;
const isHidden = submenu.classList.toggle('hidden');
if (arrow) {
if (isHidden) {
arrow.classList.add('-rotate-90');
} else {
arrow.classList.remove('-rotate-90');
}
}
}
function toggleGroupsMenu() {
const submenu = document.getElementById('submenu-groups');
const arrow = document.getElementById('arrow-menu-groups');
if (!submenu) return;
const isHidden = submenu.classList.toggle('hidden');
if (arrow) {
if (isHidden) {
arrow.classList.add('-rotate-90');
} else {
arrow.classList.remove('-rotate-90');
}
}
}
function toggleOperatorsMenu() {
const submenu = document.getElementById('submenu-operators');
const arrow = document.getElementById('arrow-menu-operators');
if (!submenu) return;
const isHidden = submenu.classList.toggle('hidden');
if (arrow) {
if (isHidden) {
arrow.classList.add('-rotate-90');
} else {
arrow.classList.remove('-rotate-90');
}
}
}
function toggleMobileSidebar(open) {
const sidebar = document.getElementById('sidebar');
const backdrop = document.getElementById('sidebar-backdrop');
if (!sidebar || !backdrop) return;
if (open) {
sidebar.classList.remove('-translate-x-full');
backdrop.classList.remove('hidden');
} else {
sidebar.classList.add('-translate-x-full');
backdrop.classList.add('hidden');
}
}
function openModal(id) {
const el = document.getElementById(id);
if (el) {
el.classList.remove('hidden');
el.classList.add('flex');
}
}
function closeModal(id) {
const el = document.getElementById(id);
if (el) {
el.classList.add('hidden');
el.classList.remove('flex');
}
}
// --- Écouteurs d'événements DOM ---
document.addEventListener('DOMContentLoaded', () => {
// Login AJAX
document.getElementById('login-form')?.addEventListener('submit', async (e) => {
e.preventDefault();
const formData = Object.fromEntries(new FormData(e.target).entries());
try {
const res = await apiCall('login', formData);
showToast(res.message, 'success');
showAppScreen();
await loadState();
} catch (err) {
showToast(err.message, 'error');
}
});
// Logout AJAX
document.getElementById('btn-logout')?.addEventListener('click', async () => {
await apiCall('logout', {});
showLoginScreen();
showToast('Vous êtes déconnecté.', 'info');
});
// Form Extension
document.getElementById('form-extension')?.addEventListener('submit', async (e) => {
e.preventDefault();
const fd = new FormData(e.target);
const payload = Object.fromEntries(fd.entries());
payload.encryption = Number(document.getElementById('ext-encryption')?.value ?? 1);
payload.member_extension_groups = Array.from(e.target.querySelectorAll('input[name="member_extension_groups[]"]:checked')).map(c => c.value);
payload.voicemail_enabled = document.getElementById('ext-voicemail-enabled')?.checked ? 1 : 0;
payload.force_pin_change = (payload.pin_mode === 'first_call') ? 1 : 0;
payload.voicemail_skip_pin = document.getElementById('ext-skip-pin')?.checked ? 1 : 0;
payload.voicemail_attach = document.getElementById('ext-vm-attach')?.checked ? 1 : 0;
payload.voicemail_delete = document.getElementById('ext-vm-delete')?.checked ? 1 : 0;
payload.ring_time = Number(payload.ring_time || 20);
try {
const res = await apiCall('save_extension', payload);
showToast(res.message, 'success');
closeModal('modal-extension');
await loadState();
} catch (err) {
showToast(err.message, 'error');
}
});
// Form Groupe de Sonnerie (Ring Groups)
document.getElementById('form-group')?.addEventListener('submit', async (e) => {
e.preventDefault();
const fd = new FormData(e.target);
const payload = Object.fromEntries(fd.entries());
payload.members = Array.from(e.target.querySelectorAll('input[name="member_ext"]:checked')).map(c => c.value);
try {
const res = await apiCall('save_group', payload);
showToast(res.message, 'success');
closeModal('modal-group');
await loadState();
} catch (err) {
showToast(err.message, 'error');
}
});
// Form Groupe d'Extensions (Sites & Droits Sortants)
document.getElementById('form-extgroup')?.addEventListener('submit', async (e) => {
e.preventDefault();
const fd = new FormData(e.target);
const payload = Object.fromEntries(fd.entries());
payload.members = Array.from(activeExtGroupSelectedExts);
try {
const res = await apiCall('save_extension_group', payload);
showToast(res.message, 'success');
closeModal('modal-extgroup');
await loadState();
} catch (err) {
showToast(err.message, 'error');
}
});
// Form Groupe de Téléavertissement (Paging)
document.getElementById('form-paging')?.addEventListener('submit', async (e) => {
e.preventDefault();
const fd = new FormData(e.target);
const payload = Object.fromEntries(fd.entries());
payload.members = Array.from(activePagingSelectedExts);
payload.duplex = Number(payload.duplex || 0);
payload.timeout = Number(payload.timeout || 120);
payload.play_beep = e.target.querySelector('input[name="play_beep"]')?.checked ? 1 : 0;
payload.enabled = e.target.querySelector('input[name="enabled"]')?.checked ? 1 : 0;
try {
const res = await apiCall('save_paging_group', payload);
showToast(res.message, 'success');
closeModal('modal-paging');
await loadState();
} catch (err) {
showToast(err.message, 'error');
}
});
// Form Trunk (Configuration PJSIP complète)
document.getElementById('form-trunk')?.addEventListener('submit', async (e) => {
e.preventDefault();
const form = e.target;
const fd = new FormData(form);
const payload = Object.fromEntries(fd.entries());
payload.registration_mode = form.querySelector('input[name="registration_mode"]:checked')?.value || 'send';
payload.enabled = form.elements['enabled'].checked ? 1 : 0;
payload.register = form.elements['register'].checked ? 1 : 0;
payload.line_support = form.elements['line_support'].checked ? 1 : 0;
payload.send_pai = form.elements['send_pai'].checked ? 1 : 0;
payload.send_rpid = form.elements['send_rpid'].checked ? 1 : 0;
payload.send_diversion = form.elements['send_diversion'].checked ? 1 : 0;
payload.trust_id_inbound = form.elements['trust_id_inbound'].checked ? 1 : 0;
payload.nat_symmetric = form.elements['nat_symmetric'].checked ? 1 : 0;
payload.direct_media = form.elements['direct_media'].checked ? 1 : 0;
payload.inband_progress = form.elements['inband_progress'].checked ? 1 : 0;
payload.fax_detect = form.elements['fax_detect'].checked ? 1 : 0;
const didRoutes = [];
document.querySelectorAll('#trunk-did-container .trunk-did-row').forEach(row => {
const cid = row.querySelector('.trunk-did-cid')?.value.trim() || 'Any';
const did = row.querySelector('.trunk-did-num')?.value.trim() || 'Any';
const desc = row.querySelector('.trunk-did-desc')?.value.trim() || '';
const dest = row.querySelector('.trunk-did-dest')?.value.trim() || '';
const tcEnabled = row.querySelector('.trunk-tc-enabled')?.checked ? 1 : 0;
const tcStart = row.querySelector('.trunk-tc-start')?.value || '08:30';
const tcFinish = row.querySelector('.trunk-tc-finish')?.value || '18:00';
const weekdays = row.querySelector('.trunk-tc-weekdays')?.value || 'mon-fri';
const tcClosed = row.querySelector('.trunk-tc-closed-dest')?.value || 'terminate-hangup';
const cidPrefix = row.querySelector('.trunk-did-prefix')?.value.trim() || '';
if (dest) {
didRoutes.push({
cid,
did,
description: desc,
destination: dest,
cid_prefix: cidPrefix,
time_condition_enabled: tcEnabled,
time_start: tcStart,
time_finish: tcFinish,
weekdays: weekdays,
time_dest_closed: tcClosed
});
}
});
payload.did_routes = didRoutes;
try {
const res = await apiCall('save_trunk', payload);
showToast(res.message, 'success');
closeModal('modal-trunk');
await loadState();
} catch (err) {
showToast(err.message, 'error');
}
});
// Form Outbound Route
document.getElementById('form-outbound')?.addEventListener('submit', async (e) => {
e.preventDefault();
const form = e.target;
const fd = new FormData(form);
const payload = Object.fromEntries(fd.entries());
payload.enabled = form.elements['enabled'].checked ? 1 : 0;
payload.continue_next_route = form.elements['continue_next_route'].checked ? 1 : 0;
payload.override_ext_cid = form.querySelector('input[name="override_ext_cid"]:checked')?.value === '1' ? 1 : 0;
payload.route_type = form.querySelector('input[name="route_type"]:checked')?.value || 'normal';
payload.permission_mode = form.querySelector('input[name="permission_mode"]:checked')?.value || 'all';
payload.record_mode = form.querySelector('input[name="record_mode"]:checked')?.value || 'dontcare';
// Séquence de Trunks
const trunks = [];
document.querySelectorAll('#ob-trunk-seq-container .ob-trunk-select').forEach(sel => {
const val = sel.value.trim();
if (val && !trunks.includes(val)) trunks.push(val);
});
payload.trunk_sequence = trunks;
// Dial Patterns
const patterns = [];
document.querySelectorAll('#ob-patterns-container .ob-pat-row').forEach(row => {
const prep = row.querySelector('.ob-pat-prepend')?.value.trim() || '';
const pfx = row.querySelector('.ob-pat-prefix')?.value.trim() || '';
const mtch = row.querySelector('.ob-pat-match')?.value.trim() || '';
const cid = row.querySelector('.ob-pat-cid')?.value.trim() || '';
if (mtch) {
patterns.push({
prepend: prep,
prefix: pfx,
match_pattern: mtch,
callerid: cid
});
}
});
payload.dial_patterns = patterns;
// Groupes autorisés
payload.allowed_groups = Array.from(form.querySelectorAll('input[name="ob_allowed_groups[]"]:checked')).map(c => c.value);
// Extensions autorisées (depuis le drag/transfert interactif)
payload.allowed_extensions = Array.from(activeObAllowedExts);
try {
const res = await apiCall('save_outbound_route', payload);
showToast(res.message, 'success');
closeModal('modal-outbound');
await loadState();
} catch (err) {
showToast(err.message, 'error');
}
});
// Form IVR
document.getElementById('form-ivr')?.addEventListener('submit', async (e) => {
e.preventDefault();
const fd = new FormData(e.target);
const payload = Object.fromEntries(fd.entries());
payload.direct_dial = document.getElementById('ivr-direct-dial-checkbox')?.checked ? 1 : 0;
const options = {};
document.querySelectorAll('#ivr-options-container .ivr-opt-row').forEach(row => {
const d = (row.querySelector('.ivr-digit')?.value || '').trim();
const dest = (row.querySelector('.ivr-dest')?.value || '').trim();
if (d && dest) options[d] = dest;
});
payload.options = options;
try {
const res = await apiCall('save_ivr', payload);
showToast(res.message, 'success');
closeModal('modal-ivr');
await loadState();
} catch (err) {
showToast(err.message, 'error');
}
});
// Form Fichier Son (Upload multipart / Update)
document.getElementById('form-sound')?.addEventListener('submit', async (e) => {
e.preventDefault();
const form = e.target;
const fd = new FormData(form);
const id = form.elements['id'].value;
const submitBtn = document.getElementById('btn-submit-sound');
if (submitBtn) {
submitBtn.disabled = true;
submitBtn.textContent = "Traitement audio en cours...";
}
try {
let res;
if (id) {
const payload = Object.fromEntries(fd.entries());
res = await apiCall('update_sound', payload);
} else {
const response = await fetch('/api.php?action=upload_sound', {
method: 'POST',
body: fd
});
const cType = response.headers.get('content-type') || '';
if (cType.includes('application/json')) {
res = await response.json();
} else {
const rawText = await response.text();
if (response.status === 413) {
throw new Error("Le fichier audio est trop volumineux pour le serveur (Limite 64 Mo).");
}
throw new Error(`Erreur serveur (${response.status}) : ${rawText.replace(/<[^>]*>/g, '').trim().slice(0, 150) || 'Réponse inattendue'}`);
}
if (!response.ok || !res.success) {
throw new Error(res.message || 'Erreur lors du téléversement du fichier audio.');
}
}
showToast(res.message, 'success');
closeModal('modal-sound');
await loadState();
} catch (err) {
showToast(err.message, 'error');
} finally {
if (submitBtn) {
submitBtn.disabled = false;
submitBtn.textContent = id ? "Enregistrer les Modifications" : "Téléverser & Enregistrer";
}
}
});
// Form Catégories de sites
document.getElementById('form-sound-site')?.addEventListener('submit', async (e) => {
e.preventDefault();
const form = e.target;
const payload = Object.fromEntries(new FormData(form).entries());
try {
const res = await apiCall('save_sound_site', payload);
showToast(res.message, 'success');
form.reset();
const idInput = document.getElementById('sound-site-id');
if (idInput) idInput.value = '';
const btn = document.getElementById('btn-save-sound-site');
if (btn) btn.textContent = 'Enregistrer';
await loadState();
renderSoundSitesTable();
} catch (err) {
showToast(err.message, 'error');
}
});
// Form Settings
document.getElementById('settings-form')?.addEventListener('submit', async (e) => {
e.preventDefault();
const payload = Object.fromEntries(new FormData(e.target).entries());
payload.wizard_completed = 1;
try {
const res = await apiCall('save_settings', payload);
showToast(res.message, 'success');
await loadState();
} catch (err) {
showToast(err.message, 'error');
}
});
// Bouton Tester AMI
document.getElementById('btn-test-ami')?.addEventListener('click', async () => {
try {
const res = await apiCall('test_ami');
showToast(res.message, 'success');
} catch (err) {
showToast(err.message, 'error');
}
});
// Bouton Appliquer les configurations (Écriture /etc/asterisk + Reload AMI)
document.getElementById('btn-apply-config')?.addEventListener('click', async () => {
const icon = document.getElementById('apply-icon');
icon?.classList.add('animate-spin');
try {
const res = await apiCall('apply_config', {});
showToast(res.message, 'success');
await loadState();
} catch (err) {
showToast(err.message, 'error');
} finally {
icon?.classList.remove('animate-spin');
}
});
// Formulaire Let's Encrypt
document.getElementById('form-cert-letsencrypt')?.addEventListener('submit', async (e) => {
e.preventDefault();
const f = e.target;
const btn = document.getElementById('btn-submit-letsencrypt');
const spinner = document.getElementById('spinner-letsencrypt');
const btnText = document.getElementById('btn-text-letsencrypt');
const msgBox = document.getElementById('letsencrypt-msg-box');
const formData = new FormData(f);
const payload = {
name: (formData.get('name') || '').trim(),
domain: (formData.get('domain') || '').trim(),
email: (formData.get('email') || '').trim(),
is_default: formData.get('is_default') === '1' ? 1 : 0,
staging: formData.get('staging') === '1' ? 1 : 0
};
if (btn) btn.disabled = true;
if (spinner) spinner.classList.remove('hidden');
if (btnText) btnText.textContent = "Communication avec Let's Encrypt...";
if (msgBox) { msgBox.className = 'hidden'; msgBox.textContent = ''; }
try {
const res = await apiCall('request_letsencrypt_cert', payload);
showToast(res.message, 'success');
closeModal('modal-cert-letsencrypt');
await loadState();
} catch (err) {
if (msgBox) {
msgBox.className = 'p-3 rounded-xl text-xs font-mono bg-rose-950/60 border border-rose-500/40 text-rose-300';
msgBox.textContent = "Erreur Let's Encrypt :\n" + err.message;
}
showToast("Échec de l'émission Let's Encrypt", 'error');
} finally {
if (btn) btn.disabled = false;
if (spinner) spinner.classList.add('hidden');
if (btnText) btnText.textContent = "Lancer l'émission ACME";
}
});
// Formulaire Import Certificat Custom (supporte FormData multipart pour fichiers .crt, .key)
document.getElementById('form-cert-custom')?.addEventListener('submit', async (e) => {
e.preventDefault();
const f = e.target;
const btn = document.getElementById('btn-submit-custom-cert');
const spinner = document.getElementById('spinner-custom-cert');
const msgBox = document.getElementById('custom-cert-msg-box');
const formData = new FormData(f);
if (btn) btn.disabled = true;
if (spinner) spinner.classList.remove('hidden');
if (msgBox) { msgBox.className = 'hidden'; msgBox.textContent = ''; }
try {
const resp = await fetch('/api.php?action=save_custom_cert', {
method: 'POST',
body: formData
});
const data = await resp.json();
if (!data.success) {
throw new Error(data.message || 'Erreur lors de l\'enregistrement du certificat');
}
showToast(data.message, 'success');
closeModal('modal-cert-custom');
await loadState();
} catch (err) {
if (msgBox) {
msgBox.className = 'p-3 rounded-xl text-xs font-mono bg-rose-950/60 border border-rose-500/40 text-rose-300';
msgBox.textContent = "Erreur de validation du certificat :\n" + err.message;
}
showToast(err.message, 'error');
} finally {
if (btn) btn.disabled = false;
if (spinner) spinner.classList.add('hidden');
}
});
// Lecture automatique des fichiers .crt, .key dans les textareas lors de la sélection
const bindFileReader = (fileInputId, textareaId) => {
const input = document.getElementById(fileInputId);
const textarea = document.getElementById(textareaId);
if (input && textarea) {
input.addEventListener('change', (e) => {
const file = e.target.files[0];
if (file) {
const reader = new FileReader();
reader.onload = (re) => { textarea.value = re.target.result; };
reader.readAsText(file);
}
});
}
};
bindFileReader('file-cert-custom', 'textarea-cert-pem');
bindFileReader('file-key-custom', 'textarea-key-pem');
bindFileReader('file-chain-custom', 'textarea-chain-pem');
// Formulaire Paramètres SIP & NAT
document.getElementById('form-sip-settings')?.addEventListener('submit', async (e) => {
e.preventDefault();
const f = e.target;
const btn = document.getElementById('btn-save-sip-settings');
const spinner = document.getElementById('spinner-save-sip-settings');
const netInputs = f.querySelectorAll('input[name="local_networks[]"]');
const localNets = Array.from(netInputs).map(i => i.value.trim()).filter(v => v !== '');
const payload = {
external_ip: (document.getElementById('sip-external-ip')?.value || '').trim(),
external_udp_port: Number(document.getElementById('sip-external-udp-port')?.value || 0),
external_tls_port: Number(document.getElementById('sip-external-tls-port')?.value || 0),
udp_port: Number(document.getElementById('sip-udp-port')?.value || 5060),
tls_port: Number(document.getElementById('sip-tls-port')?.value || 5061),
rtp_start: Number(document.getElementById('sip-rtp-start')?.value || 10000),
rtp_end: Number(document.getElementById('sip-rtp-end')?.value || 20000),
rtp_strict: Number(document.getElementById('sip-rtp-strict')?.value || 1),
rtp_checksums: Number(document.getElementById('sip-rtp-checksums')?.value || 1),
rtp_keepalive: Number(document.getElementById('sip-rtp-keepalive')?.value || 15),
rtp_timeout: Number(document.getElementById('sip-rtp-timeout')?.value || 30),
allow_anonymous_sip: Number(document.getElementById('sip-allow-anonymous')?.value || 0),
local_networks: localNets
};
if (btn) btn.disabled = true;
if (spinner) spinner.classList.remove('hidden');
try {
const res = await apiCall('save_sip_settings', payload);
showToast(res.message, 'success');
const container = document.getElementById('local-networks-container');
if (container) delete container.dataset.initialized;
await loadState();
} catch (err) {
showToast(err.message, 'error');
} finally {
if (btn) btn.disabled = false;
if (spinner) spinner.classList.add('hidden');
}
});
// Formulaire Édition d'un Code de Fonction
document.getElementById('form-edit-feature-code')?.addEventListener('submit', async (e) => {
e.preventDefault();
const btn = document.getElementById('btn-submit-edit-fc');
const spinner = document.getElementById('spinner-edit-fc');
const id = Number(document.getElementById('edit-fc-id')?.value || 0);
const code = (document.getElementById('edit-fc-code')?.value || '').trim();
if (btn) btn.disabled = true;
if (spinner) spinner.classList.remove('hidden');
try {
const res = await apiCall('update_feature_code', { id, code });
showToast(res.message, 'success');
closeModal('modal-edit-feature-code');
await loadState();
} catch (err) {
showToast(err.message, 'error');
} finally {
if (btn) btn.disabled = false;
if (spinner) spinner.classList.add('hidden');
}
});
// Formulaire Paramètres de Sauvegarde & SSH
document.getElementById('backup-settings-form')?.addEventListener('submit', async (e) => {
e.preventDefault();
const f = e.target;
const autoEnabled = f.elements['backup_auto_enabled']?.checked ? 1 : 0;
const frequency = f.elements['backup_frequency']?.value || 'daily';
const retention = Number(f.elements['backup_retention']?.value || 7);
const storageType = f.querySelector('input[name="backup_storage_type"]:checked')?.value || 'local';
const sshHost = (f.elements['backup_ssh_host']?.value || '').trim();
const sshPort = Number(f.elements['backup_ssh_port']?.value || 22);
const sshUser = (f.elements['backup_ssh_user']?.value || '').trim();
const sshPassword = f.elements['backup_ssh_password']?.value || '';
const sshRemotePath = (f.elements['backup_ssh_remote_path']?.value || '/backups/nexus').trim();
const payload = {
backup_auto_enabled: autoEnabled,
backup_frequency: frequency,
backup_retention: retention,
backup_storage_type: storageType,
backup_ssh_host: sshHost,
backup_ssh_port: sshPort,
backup_ssh_user: sshUser,
backup_ssh_auth_type: 'password',
backup_ssh_password: sshPassword,
backup_ssh_remote_path: sshRemotePath
};
try {
const res = await apiCall('save_backup_settings', payload);
showToast(res.message, 'success');
await loadBackups();
} catch (err) {
showToast("Erreur enregistrement : " + err.message, 'error');
}
});
// Formulaire Paramètres Pare-feu & Fail2ban
document.getElementById('firewall-settings-form')?.addEventListener('submit', async (e) => {
e.preventDefault();
const f = e.target;
const btn = document.getElementById('btn-save-firewall-settings');
if (btn) btn.disabled = true;
const payload = {
firewall_enabled: f.elements['firewall_enabled']?.checked ? 1 : 0,
admin_https_port: Number(f.elements['admin_https_port']?.value || 8443),
certbot_strict_port80: f.elements['certbot_strict_port80']?.checked ? 1 : 0,
fail2ban_sip_maxretry: Number(f.elements['fail2ban_sip_maxretry']?.value || 3),
fail2ban_sip_bantime: Number(f.elements['fail2ban_sip_bantime']?.value || 3600),
fail2ban_whitelist_ips: (f.elements['fail2ban_whitelist_ips']?.value || '').trim()
};
try {
const res = await apiCall('save_firewall_settings', payload);
showToast(res.message, 'success');
await loadFirewallStatus();
} catch (err) {
showToast("Erreur application sécurité : " + err.message, 'error');
} finally {
if (btn) btn.disabled = false;
}
});
// Formulaire Bannissement Manuel Rapide
document.getElementById('form-manual-ban')?.addEventListener('submit', async (e) => {
e.preventDefault();
const ip = (document.getElementById('manual-ban-ip')?.value || '').trim();
const jail = document.getElementById('manual-ban-jail')?.value || 'asterisk';
if (!ip) return;
try {
const res = await apiCall('ban_ip', { ip, jail });
showToast(res.message, res.success ? 'success' : 'error');
document.getElementById('manual-ban-ip').value = '';
await loadFirewallStatus();
} catch (err) {
showToast("Erreur lors du bannissement : " + err.message, 'error');
}
});
// Si déjà authentifié au chargement initial
if (!document.getElementById('app-screen').classList.contains('hidden')) {
loadState();
}
});
// =========================================================================
// GESTIONNAIRE DE CERTIFICATS SSL / TLS (FONCTIONS GLOBALES)
// =========================================================================
function renderCertificates() {
const certs = appState.certificates || [];
const activeCert = appState.active_certificate || certs.find(c => Number(c.is_default) === 1) || certs[0];
// Badge sidebar
const badgeSidebar = document.getElementById('nav-badge-certificates');
if (badgeSidebar) {
badgeSidebar.textContent = certs.length;
}
const badgeTable = document.getElementById('certs-count-badge');
if (badgeTable) {
badgeTable.textContent = `${certs.length} certificat${certs.length > 1 ? 's' : ''}`;
}
// Carte Résumé Actif
if (activeCert) {
const nameEl = document.getElementById('active-cert-name');
const domEl = document.getElementById('active-cert-domain');
const issEl = document.getElementById('active-cert-issuer');
const typeEl = document.getElementById('active-cert-type-badge');
const daysEl = document.getElementById('active-cert-days');
const renewWrap = document.getElementById('active-cert-renew-wrapper');
if (nameEl) nameEl.textContent = activeCert.name || 'Certificat Actif';
if (domEl) domEl.textContent = activeCert.primary_domain || 'pbx.local';
if (issEl) issEl.textContent = activeCert.issuer || 'Inconnu';
if (typeEl) {
if (activeCert.type === 'letsencrypt') {
typeEl.className = 'px-2 py-0.5 rounded-full text-[10px] font-bold bg-emerald-500/20 text-emerald-300 border border-emerald-500/40';
typeEl.textContent = "Let's Encrypt (ACME Auto)";
} else if (activeCert.type === 'custom') {
typeEl.className = 'px-2 py-0.5 rounded-full text-[10px] font-bold bg-cyan-500/20 text-cyan-300 border border-cyan-500/40';
typeEl.textContent = "Certificat Entreprise (Import)";
} else {
typeEl.className = 'px-2 py-0.5 rounded-full text-[10px] font-bold bg-anthracite-700 text-slate-300 border border-anthracite-600';
typeEl.textContent = "Auto-signé Système";
}
}
if (daysEl) {
const days = Number(activeCert.days_remaining ?? 0);
if (days < 0) {
daysEl.className = 'text-xs font-bold text-rose-400 mt-0.5';
daysEl.textContent = `Expiré (${Math.abs(days)}j)`;
} else if (days <= 15) {
daysEl.className = 'text-xs font-bold text-amber-400 mt-0.5';
daysEl.textContent = `${days} j restants (À renouveler)`;
} else {
daysEl.className = 'text-xs font-bold text-emerald-400 mt-0.5';
daysEl.textContent = `${days} j restants`;
}
}
if (renewWrap) {
if (activeCert.type === 'letsencrypt') {
renewWrap.classList.remove('hidden');
} else {
renewWrap.classList.add('hidden');
}
}
}
// Statut Mutual TLS (mTLS) verify_client
const settings = appState.settings || {};
const mtlsVal = Number(settings.tls_verify_client ?? 0);
const selMtls = document.getElementById('select-tls-verify-client');
const badgeMtls = document.getElementById('badge-mtls-status');
if (selMtls) {
selMtls.value = String(mtlsVal);
}
if (badgeMtls) {
if (mtlsVal === 1) {
badgeMtls.className = 'px-2 py-0.5 rounded-full text-[10px] font-bold bg-amber-500/20 text-amber-300 border border-amber-500/40';
badgeMtls.textContent = 'Activé (Strict / mTLS)';
} else {
badgeMtls.className = 'px-2 py-0.5 rounded-full text-[10px] font-bold bg-anthracite-700 text-slate-300 border border-anthracite-600';
badgeMtls.textContent = 'Désactivé (Standard)';
}
}
// Tableau des certificats
const tbody = document.getElementById('certificates-tbody');
if (!tbody) return;
if (certs.length === 0) {
tbody.innerHTML = `
Aucun certificat enregistré. Cliquez sur « Générer via Let's Encrypt » ou « Importer un Certificat ».
`;
return;
}
tbody.innerHTML = certs.map(c => {
const isDefault = Number(c.is_default) === 1;
const days = Number(c.days_remaining ?? 0);
let typeBadge = '';
if (c.type === 'letsencrypt') {
typeBadge = `Let's Encrypt `;
} else if (c.type === 'custom') {
typeBadge = `Import Custom `;
} else {
typeBadge = `Auto-signé `;
}
let validityBadge = '';
if (days < 0) {
validityBadge = `Expiré (${c.valid_to ? c.valid_to.substring(0, 10) : ''}) `;
} else if (days <= 15) {
validityBadge = `${days}j restants (${c.valid_to ? c.valid_to.substring(0, 10) : ''}) `;
} else {
validityBadge = `${days}j restants (${c.valid_to ? c.valid_to.substring(0, 10) : ''}) `;
}
let defaultBadge = '';
if (isDefault) {
defaultBadge = `
ACTIF PAR DÉFAUT
`;
} else {
defaultBadge = `
Activer
`;
}
let actions = '';
if (c.type === 'letsencrypt') {
actions += `
`;
}
if (!isDefault) {
actions += `
`;
}
actions += '
';
const sanDisplay = c.alt_names ? `+ ${escapeHtml(c.alt_names)}
` : '';
return `
${escapeHtml(c.name)}
${isDefault ? '★ ' : ''}
${typeBadge}
${escapeHtml(c.primary_domain)}
${sanDisplay}
${escapeHtml(c.issuer || '-')}
${validityBadge}
${defaultBadge}
${actions}
`;
}).join('');
}
function openLetsEncryptModal() {
const f = document.getElementById('form-cert-letsencrypt');
if (f) f.reset();
const box = document.getElementById('letsencrypt-msg-box');
if (box) { box.className = 'hidden'; box.textContent = ''; }
if (appState.settings && appState.settings.domain && appState.settings.domain !== 'pbx.local') {
const domInput = f?.querySelector('input[name="domain"]');
if (domInput && !domInput.value) domInput.value = appState.settings.domain;
}
openModal('modal-cert-letsencrypt');
}
function openCustomCertModal() {
const f = document.getElementById('form-cert-custom');
if (f) f.reset();
const box = document.getElementById('custom-cert-msg-box');
if (box) { box.className = 'hidden'; box.textContent = ''; }
openModal('modal-cert-custom');
}
async function setDefaultCertificate(id) {
if (!confirm("Voulez-vous activer ce certificat comme certificat par défaut ?\n\nIl sera immédiatement utilisé pour le transport PJSIP TLS (port 5061) et le chiffrement des communications audio (SRTP).")) {
return;
}
try {
const res = await apiCall('set_default_cert', { id: Number(id) });
showToast(res.message, 'success');
await loadState();
} catch (err) {
showToast(err.message, 'error');
}
}
async function renewCertificate(id) {
if (!confirm("Lancer le renouvellement ACME Let's Encrypt pour ce certificat ?")) {
return;
}
try {
showToast("Renouvellement Let's Encrypt en cours...", 'info');
const res = await apiCall('renew_cert', { id: Number(id) });
showToast(res.message, 'success');
await loadState();
} catch (err) {
showToast("Erreur de renouvellement : " + err.message, 'error');
}
}
async function renewCertificateFromBanner() {
const certs = appState.certificates || [];
const activeCert = appState.active_certificate || certs.find(c => Number(c.is_default) === 1);
if (activeCert && activeCert.id) {
await renewCertificate(activeCert.id);
}
}
async function deleteCertificate(id, name) {
if (!confirm(`Confirmez-vous la suppression du certificat « ${name} » ?\nCette action est irréversible.`)) {
return;
}
try {
const res = await apiCall('delete_cert', { id: Number(id) });
showToast(res.message, 'success');
await loadState();
} catch (err) {
showToast(err.message, 'error');
}
}
async function saveTlsSecurityPolicy() {
const sel = document.getElementById('select-tls-verify-client');
const val = Number(sel?.value ?? 0);
const label = val === 1
? 'Activer le mode Mutual TLS strict (certificat client X.509 obligatoirement exigé sur chaque téléphone)'
: 'Désactiver le mode Mutual TLS (standard : authentification par mot de passe SIP dans le tunnel TLS)';
if (!confirm(`Confirmez-vous cette modification de la politique TLS ?\n\n${label}\n\nAsterisk rechargera immédiatement le transport TLS 5061.`)) {
return;
}
try {
const res = await apiCall('set_tls_security', { tls_verify_client: val });
showToast(res.message, 'success');
await loadState();
} catch (err) {
showToast(err.message, 'error');
}
}
// =========================================================================
// PARAMÈTRES SIP GLOBAUX & NAT (FONCTIONS GLOBALES)
// =========================================================================
function renderSipSettings() {
const s = appState.settings || {};
const extIpEl = document.getElementById('sip-external-ip');
if (extIpEl && document.activeElement !== extIpEl) {
extIpEl.value = s.external_ip || '';
}
const extUdpPortEl = document.getElementById('sip-external-udp-port');
if (extUdpPortEl && document.activeElement !== extUdpPortEl) {
extUdpPortEl.value = (s.external_udp_port && Number(s.external_udp_port) > 0) ? s.external_udp_port : '';
}
const extTlsPortEl = document.getElementById('sip-external-tls-port');
if (extTlsPortEl && document.activeElement !== extTlsPortEl) {
extTlsPortEl.value = (s.external_tls_port && Number(s.external_tls_port) > 0) ? s.external_tls_port : '';
}
const udpPortEl = document.getElementById('sip-udp-port');
if (udpPortEl && document.activeElement !== udpPortEl) {
udpPortEl.value = s.udp_port || 5060;
}
const tlsPortEl = document.getElementById('sip-tls-port');
if (tlsPortEl && document.activeElement !== tlsPortEl) {
tlsPortEl.value = s.tls_port || 5061;
}
const rtpStartEl = document.getElementById('sip-rtp-start');
if (rtpStartEl && document.activeElement !== rtpStartEl) {
rtpStartEl.value = s.rtp_start || 10000;
}
const rtpEndEl = document.getElementById('sip-rtp-end');
if (rtpEndEl && document.activeElement !== rtpEndEl) {
rtpEndEl.value = s.rtp_end || 20000;
}
const rtpStrictEl = document.getElementById('sip-rtp-strict');
if (rtpStrictEl) {
rtpStrictEl.value = (s.rtp_strict !== undefined && s.rtp_strict !== null) ? String(s.rtp_strict) : '1';
}
const rtpChecksumsEl = document.getElementById('sip-rtp-checksums');
if (rtpChecksumsEl) {
rtpChecksumsEl.value = (s.rtp_checksums !== undefined && s.rtp_checksums !== null) ? String(s.rtp_checksums) : '1';
}
const rtpKeepaliveEl = document.getElementById('sip-rtp-keepalive');
if (rtpKeepaliveEl && document.activeElement !== rtpKeepaliveEl) {
rtpKeepaliveEl.value = (s.rtp_keepalive !== undefined && s.rtp_keepalive !== null) ? s.rtp_keepalive : 15;
}
const rtpTimeoutEl = document.getElementById('sip-rtp-timeout');
if (rtpTimeoutEl && document.activeElement !== rtpTimeoutEl) {
rtpTimeoutEl.value = (s.rtp_timeout !== undefined && s.rtp_timeout !== null) ? s.rtp_timeout : 30;
}
const allowAnonEl = document.getElementById('sip-allow-anonymous');
if (allowAnonEl) {
allowAnonEl.value = (s.allow_anonymous_sip !== undefined && s.allow_anonymous_sip !== null) ? String(s.allow_anonymous_sip) : '0';
}
// Réseaux locaux (Local Networks list)
const container = document.getElementById('local-networks-container');
if (container && !container.dataset.initialized) {
container.innerHTML = '';
const nets = Array.isArray(s.local_networks) && s.local_networks.length > 0
? s.local_networks
: (s.local_net ? [s.local_net] : ['10.10.0.0/16']);
nets.forEach(net => addLocalNetworkField(net));
container.dataset.initialized = 'true';
}
}
function addLocalNetworkField(value = '') {
const container = document.getElementById('local-networks-container');
if (!container) return;
const row = document.createElement('div');
row.className = 'flex items-center gap-2 local-network-row';
row.innerHTML = `
`;
container.appendChild(row);
}
function removeLocalNetworkField(btn) {
const container = document.getElementById('local-networks-container');
if (!container) return;
const rows = container.querySelectorAll('.local-network-row');
if (rows.length <= 1) {
showToast("Au moins un réseau local doit être défini.", "error");
return;
}
btn.closest('.local-network-row')?.remove();
}
async function detectPublicIp() {
const btn = document.getElementById('btn-detect-ip');
const spinner = document.getElementById('spinner-detect-ip');
const input = document.getElementById('sip-external-ip');
if (btn) btn.disabled = true;
if (spinner) spinner.classList.remove('hidden');
try {
const res = await apiCall('detect_external_ip');
if (res.ip && input) {
input.value = res.ip;
showToast(`IP publique détectée avec succès : ${res.ip}`, 'success');
} else {
showToast("Impossible de déterminer l'adresse IP publique automatiquement.", "error");
}
} catch (err) {
showToast("Erreur de détection : " + err.message, "error");
} finally {
if (btn) btn.disabled = false;
if (spinner) spinner.classList.add('hidden');
}
}
// =========================================================================
// CODES DE FONCTIONS TÉLÉPHONIQUES & SERVICES (FONCTIONS GLOBALES)
// =========================================================================
let currentFeatureCategory = 'all';
function filterFeatureCodes(category) {
currentFeatureCategory = category;
document.querySelectorAll('.fc-filter-btn').forEach(btn => {
if (btn.dataset.category === category) {
btn.className = 'fc-filter-btn px-3 py-1.5 rounded-xl text-xs font-semibold bg-indigo-600 text-white transition';
} else {
btn.className = 'fc-filter-btn px-3 py-1.5 rounded-xl text-xs font-semibold bg-anthracite-800 text-slate-300 hover:text-white transition';
}
});
renderFeatureCodes();
}
function renderFeatureCodes() {
const list = appState.feature_codes || [];
const tbody = document.getElementById('feature-codes-table-body');
const badge = document.getElementById('nav-badge-featurecodes');
const countAll = document.getElementById('fc-count-all');
const activeCount = list.filter(f => Number(f.enabled) === 1).length;
if (badge) badge.textContent = String(activeCount);
if (countAll) countAll.textContent = String(list.length);
if (!tbody) return;
const filtered = (currentFeatureCategory === 'all')
? list
: list.filter(item => item.category === currentFeatureCategory);
if (filtered.length === 0) {
tbody.innerHTML = `
Aucun code de fonction trouvé dans cette catégorie.
`;
return;
}
const categoryBadges = {
forward: 'Renvois ',
pickup: 'BLF / Capture ',
dnd: 'Ne Pas Déranger ',
voicemail: 'Messagerie ',
security: 'Sécurité ',
tools: 'Outils '
};
tbody.innerHTML = filtered.map(fc => {
const isEnabled = Number(fc.enabled) === 1;
const catBadge = categoryBadges[fc.category] || `${fc.category} `;
const codeDisplay = escapeHtml(fc.code);
return `
${codeDisplay}
${escapeHtml(fc.name)}
Standard : ${escapeHtml(fc.default_code)}
${escapeHtml(fc.description)}
${catBadge}
`;
}).join('');
}
async function toggleFeatureCode(id, enabled) {
try {
const res = await apiCall('toggle_feature_code', { id: Number(id), enabled: enabled ? 1 : 0 });
showToast(res.message, 'success');
await loadState();
} catch (err) {
showToast(err.message, 'error');
await loadState();
}
}
function openEditFeatureCodeModal(id) {
const list = appState.feature_codes || [];
const item = list.find(f => Number(f.id) === Number(id));
if (!item) return;
document.getElementById('edit-fc-id').value = item.id;
document.getElementById('edit-fc-code').value = item.code;
document.getElementById('modal-fc-service-name').textContent = item.name;
document.getElementById('modal-fc-default-code').textContent = item.default_code;
openModal('modal-edit-feature-code');
}
async function resetFeatureCodes() {
if (!confirm("Voulez-vous réinitialiser tous les codes de fonctions aux valeurs standard FreePBX / Asterisk ?\n\n(ex: ** pour BLF, *8 pour groupe, *72/*73 pour renvois, *78/*79 pour DND, *97 pour boîte vocale)")) {
return;
}
try {
const res = await apiCall('reset_feature_codes');
showToast(res.message, 'success');
await loadState();
} catch (err) {
showToast(err.message, 'error');
}
}
// =========================================================================
// GESTIONNAIRE DE SAUVEGARDES & RESTAURATION (DISASTER RECOVERY)
// =========================================================================
let appBackupsState = {
settings: {},
backups: []
};
async function loadBackups() {
try {
const res = await apiCall('get_backup_data', {});
if (res.success) {
appBackupsState.settings = res.settings || {};
appBackupsState.backups = res.backups || [];
renderBackups();
}
} catch (err) {
showToast("Erreur chargement des sauvegardes : " + err.message, 'error');
}
}
function renderBackups() {
const s = appBackupsState.settings || {};
const backups = appBackupsState.backups || [];
// 1. Stats d'en-tête
const lastRunEl = document.getElementById('backup-stat-last-run');
if (lastRunEl) {
lastRunEl.textContent = s.backup_last_run ? s.backup_last_run : 'Jamais exécutée';
}
const storageEl = document.getElementById('backup-stat-storage');
if (storageEl) {
if (s.backup_storage_type === 'ssh') {
storageEl.textContent = `Distant SSH (${s.backup_ssh_host || 'non configuré'})`;
storageEl.className = 'text-sm font-bold text-purple-400 truncate';
} else {
storageEl.textContent = 'Stockage Local';
storageEl.className = 'text-sm font-bold text-emerald-400 truncate';
}
}
const countEl = document.getElementById('backup-stat-count');
if (countEl) {
countEl.textContent = `${backups.length} archive(s)`;
}
const badgeSidebar = document.getElementById('nav-badge-backups');
if (badgeSidebar) {
badgeSidebar.textContent = backups.length;
}
// 2. Remplissage du formulaire de configuration
const form = document.getElementById('backup-settings-form');
if (form) {
if (form.elements['backup_auto_enabled']) {
form.elements['backup_auto_enabled'].checked = Number(s.backup_auto_enabled) === 1;
}
if (form.elements['backup_frequency']) {
form.elements['backup_frequency'].value = s.backup_frequency || 'daily';
}
if (form.elements['backup_retention']) {
form.elements['backup_retention'].value = s.backup_retention || 7;
}
const storageRadios = form.querySelectorAll('input[name="backup_storage_type"]');
storageRadios.forEach(r => {
r.checked = (r.value === (s.backup_storage_type || 'local'));
});
if (form.elements['backup_ssh_host']) {
form.elements['backup_ssh_host'].value = s.backup_ssh_host || '';
}
if (form.elements['backup_ssh_port']) {
form.elements['backup_ssh_port'].value = s.backup_ssh_port || 22;
}
if (form.elements['backup_ssh_user']) {
form.elements['backup_ssh_user'].value = s.backup_ssh_user || '';
}
if (form.elements['backup_ssh_password']) {
form.elements['backup_ssh_password'].value = s.backup_ssh_password || '';
}
if (form.elements['backup_ssh_remote_path']) {
form.elements['backup_ssh_remote_path'].value = s.backup_ssh_remote_path || '/backups/nexus';
}
toggleSshBackupFields((s.backup_storage_type || 'local') === 'ssh');
}
// 3. Remplissage du tableau des archives
const tbody = document.getElementById('backups-table-body');
if (!tbody) return;
if (backups.length === 0) {
tbody.innerHTML = `
Aucune archive de sauvegarde disponible. Cliquez sur "Créer une Sauvegarde Immédiate" pour générer la première.
`;
return;
}
tbody.innerHTML = backups.map(b => {
const isLocal = b.is_local;
const locBadge = isLocal
? `Local `
: `Distant (SSH) `;
const versionBadge = `v${escapeHtml(b.version || '1.2.0')} `;
return `
${escapeHtml(b.filename)}
${versionBadge}
${escapeHtml(b.date_formatted)}
${escapeHtml(b.size_formatted)}
${locBadge}
`;
}).join('');
}
function toggleSshBackupFields(show) {
const fields = document.getElementById('backup-ssh-fields');
if (!fields) return;
if (show) {
fields.classList.remove('hidden');
} else {
fields.classList.add('hidden');
}
}
async function createBackupNow() {
const btn = document.getElementById('btn-create-backup-now');
const label = document.getElementById('btn-create-backup-label');
if (btn) btn.disabled = true;
if (label) label.textContent = 'Génération de l\'archive...';
try {
const res = await apiCall('create_backup', {});
showToast(res.message, 'success');
await loadBackups();
} catch (err) {
showToast("Échec de création : " + err.message, 'error');
} finally {
if (btn) btn.disabled = false;
if (label) label.textContent = 'Créer une Sauvegarde Immédiate';
}
}
let pendingRestoreAction = null;
let pendingRestoreInput = null;
function showRestoreModal({ title, filename, badgeText, onConfirm, inputEl = null }) {
const titleEl = document.getElementById('modal-restore-title');
if (titleEl) titleEl.textContent = title || "Restauration Complète du Système";
const nameEl = document.getElementById('restore-target-name');
if (nameEl) nameEl.textContent = filename || "Archive de sauvegarde";
const badgeEl = document.getElementById('restore-target-badge');
if (badgeEl) badgeEl.textContent = badgeText || "Archive";
const confirmBtn = document.getElementById('btn-confirm-restore');
const cancelBtn = document.getElementById('btn-cancel-restore');
if (confirmBtn) {
confirmBtn.disabled = false;
confirmBtn.innerHTML = `
Confirmer la Restauration
`;
}
if (cancelBtn) cancelBtn.disabled = false;
pendingRestoreAction = onConfirm;
pendingRestoreInput = inputEl;
openModal('modal-restore-backup');
}
function closeRestoreModal() {
closeModal('modal-restore-backup');
if (pendingRestoreInput) {
pendingRestoreInput.value = '';
pendingRestoreInput = null;
}
pendingRestoreAction = null;
}
async function executePendingRestore() {
if (!pendingRestoreAction) return;
const confirmBtn = document.getElementById('btn-confirm-restore');
const cancelBtn = document.getElementById('btn-cancel-restore');
if (confirmBtn) {
confirmBtn.disabled = true;
confirmBtn.innerHTML = `
Restauration en cours...
`;
}
if (cancelBtn) cancelBtn.disabled = true;
try {
await pendingRestoreAction();
} catch (err) {
showToast("Erreur inattendue : " + err.message, "error");
closeRestoreModal();
}
}
function confirmRestoreBackup(filename) {
showRestoreModal({
title: "Restauration du Système NexusPBX",
filename: filename,
badgeText: "Archive Locale",
onConfirm: async () => {
showToast("Restauration du système en cours... Veuillez patienter.", "info");
try {
const res = await apiCall('restore_backup', { filename });
closeRestoreModal();
showToast(res.message || "Restauration terminée avec succès !", 'success');
setTimeout(() => {
window.location.reload();
}, 2000);
} catch (err) {
closeRestoreModal();
showToast("Échec de la restauration : " + err.message, 'error');
}
}
});
}
function triggerBackupUpload() {
document.getElementById('backup-upload-input')?.click();
}
function handleBackupUpload(input) {
const file = input?.files?.[0];
if (!file) return;
const lowerName = file.name.toLowerCase();
if (!lowerName.endsWith('.tar.gz') && !lowerName.endsWith('.tgz')) {
showToast("Format de fichier non supporté. Veuillez sélectionner une archive de sauvegarde .tar.gz ou .tgz", 'error');
input.value = '';
return;
}
const fileSizeMb = (file.size / (1024 * 1024)).toFixed(2);
showRestoreModal({
title: "Disaster Recovery — Archive Téléversée",
filename: `${file.name} (${fileSizeMb} Mo)`,
badgeText: "Fichier Téléversé",
inputEl: input,
onConfirm: async () => {
showToast("Téléversement et restauration de l'archive en cours...", "info");
const fd = new FormData();
fd.append('backup_file', file);
try {
const response = await fetch('/api.php?action=upload_restore_backup', {
method: 'POST',
body: fd
});
const res = await response.json();
closeRestoreModal();
if (!res.success) {
throw new Error(res.message || "Erreur lors de la restauration");
}
showToast(res.message, 'success');
setTimeout(() => {
window.location.reload();
}, 2000);
} catch (err) {
closeRestoreModal();
showToast("Échec de la restauration : " + err.message, 'error');
}
}
});
}
function downloadBackup(filename) {
window.location.href = `/api.php?action=download_backup&filename=${encodeURIComponent(filename)}`;
}
async function deleteBackup(filename) {
if (!confirm(`Êtes-vous sûr de vouloir supprimer définitivement l'archive de sauvegarde suivante ?\n\n${filename}`)) {
return;
}
try {
const res = await apiCall('delete_backup', { filename });
showToast(res.message, res.success ? 'success' : 'error');
await loadBackups();
} catch (err) {
showToast("Erreur lors de la suppression : " + err.message, 'error');
}
}
async function testSshBackupConnection() {
const form = document.getElementById('backup-settings-form');
if (!form) return;
const payload = {
backup_ssh_host: (form.elements['backup_ssh_host']?.value || '').trim(),
backup_ssh_port: Number(form.elements['backup_ssh_port']?.value || 22),
backup_ssh_user: (form.elements['backup_ssh_user']?.value || '').trim(),
backup_ssh_password: form.elements['backup_ssh_password']?.value || '',
backup_ssh_remote_path: (form.elements['backup_ssh_remote_path']?.value || '/backups/nexus').trim(),
backup_ssh_auth_type: 'password'
};
showToast("Test de la connexion SSH vers " + payload.backup_ssh_host + "...", "info");
try {
const res = await apiCall('test_ssh_backup', payload);
if (res.success) {
showToast(res.message, 'success');
} else {
showToast(res.message, 'error');
}
} catch (err) {
showToast("Échec du test SSH : " + err.message, 'error');
}
}
// =========================================================================
// GESTIONNAIRE DE PARE-FEU & SÉCURITÉ (FAIL2BAN & NFTABLES)
// =========================================================================
let appFirewallState = {
is_running: false,
settings: {},
jails: {},
banned_ips: [],
total_banned: 0,
client_ip: ''
};
async function loadFirewallStatus() {
try {
const res = await apiCall('get_firewall_status', {});
if (res.success && res.data) {
appFirewallState = res.data;
renderFirewall();
}
} catch (err) {
showToast("Erreur chargement sécurité : " + err.message, 'error');
}
}
function renderFirewall() {
const d = appFirewallState;
const s = d.settings || {};
const banned = d.banned_ips || [];
// 1. Stat cards
const engineEl = document.getElementById('fw-stat-engine');
if (engineEl) {
engineEl.textContent = d.is_running ? 'En ligne (Actif)' : 'Arrêté (Inactif)';
engineEl.className = d.is_running ? 'text-sm font-bold text-emerald-400 truncate' : 'text-sm font-bold text-rose-400 truncate';
}
const sipJail = d.jails?.asterisk;
const sipJailEl = document.getElementById('fw-stat-sip-jail');
if (sipJailEl) {
sipJailEl.textContent = sipJail ? `Actif (${sipJail.banned_ips?.length || 0} banni)` : 'Inactif';
}
const webJail = d.jails?.['nexus-web'];
const webJailEl = document.getElementById('fw-stat-web-jail');
if (webJailEl) {
webJailEl.textContent = webJail ? `Protégé (${webJail.banned_ips?.length || 0} banni)` : 'Inactif';
}
const countEl = document.getElementById('fw-stat-banned-count');
if (countEl) {
countEl.textContent = `${d.total_banned} bannie(s)`;
countEl.className = d.total_banned > 0 ? 'text-sm font-bold text-rose-400 truncate' : 'text-sm font-bold text-emerald-400 truncate';
}
const badgeSidebar = document.getElementById('nav-badge-firewall');
if (badgeSidebar) {
badgeSidebar.textContent = d.total_banned;
badgeSidebar.className = d.total_banned > 0
? 'ml-2 shrink-0 px-1.5 py-0.2 text-[10px] rounded-full bg-rose-500/20 text-rose-300 border border-rose-500/30 font-bold'
: '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';
}
// 2. Table of banned IPs
const tbody = document.getElementById('firewall-banned-table-body');
if (tbody) {
if (banned.length === 0) {
tbody.innerHTML = `
Aucune adresse IP n'est actuellement bloquée.
Le pare-feu NFTables et Fail2ban surveillent les flux SIP et Web 24h/24.
`;
} else {
tbody.innerHTML = banned.map(b => {
const jailClass = b.jail === 'asterisk' ? 'bg-cyan-500/20 text-cyan-300 border border-cyan-500/30' : 'bg-purple-500/20 text-purple-300 border border-purple-500/30';
return `
⛔ ${escapeHtml(b.ip)}
${escapeHtml(b.jail_label || b.jail)}
DROP (Bloqué)
Débannir
`;
}).join('');
}
}
// 3. Form fields
const form = document.getElementById('firewall-settings-form');
if (form) {
if (form.elements['admin_https_port']) {
form.elements['admin_https_port'].value = s.admin_https_port || 8443;
}
if (form.elements['certbot_strict_port80']) {
form.elements['certbot_strict_port80'].checked = Number(s.certbot_strict_port80) === 1;
}
if (form.elements['firewall_enabled']) {
form.elements['firewall_enabled'].checked = Number(s.firewall_enabled) === 1;
}
if (form.elements['fail2ban_sip_maxretry']) {
form.elements['fail2ban_sip_maxretry'].value = s.fail2ban_sip_maxretry || 3;
}
if (form.elements['fail2ban_sip_bantime']) {
form.elements['fail2ban_sip_bantime'].value = s.fail2ban_sip_bantime || 3600;
}
if (form.elements['fail2ban_whitelist_ips'] && document.activeElement !== form.elements['fail2ban_whitelist_ips']) {
form.elements['fail2ban_whitelist_ips'].value = s.fail2ban_whitelist_ips || '';
}
}
}
async function unbanIp(ip, jail) {
if (!confirm(`Débannir l'adresse IP ${ip} et rétablir son accès immédiat ?`)) {
return;
}
try {
const res = await apiCall('unban_ip', { ip, jail });
showToast(res.message, res.success ? 'success' : 'error');
await loadFirewallStatus();
} catch (err) {
showToast("Erreur lors du débannissement : " + err.message, 'error');
}
}
function whitelistMyIp() {
const clientIp = appFirewallState.client_ip;
if (!clientIp) {
showToast("Impossible de déterminer votre adresse IP actuelle.", 'error');
return;
}
const textarea = document.getElementById('fail2ban_whitelist_ips');
if (!textarea) return;
const currentVal = textarea.value.trim();
const tokens = currentVal ? currentVal.split(/\s+/) : [];
if (tokens.includes(clientIp)) {
showToast(`Votre IP actuelle (${clientIp}) est déjà dans la liste blanche.`, 'info');
return;
}
tokens.push(clientIp);
textarea.value = tokens.join(' ');
showToast(`Votre IP actuelle (${clientIp}) a été ajoutée. Pensez à cliquer sur "Enregistrer" pour appliquer.`, 'success');
}
// Modal Journal des Événements de Sécurité
async function openSecurityEventsModal() {
const modal = document.getElementById('modal-security-events');
const tbody = document.getElementById('security-events-tbody');
const emptyState = document.getElementById('security-events-empty');
if (!modal) return;
modal.classList.remove('hidden');
if (tbody) tbody.innerHTML = 'Chargement des événements de sécurité en direct... ';
if (emptyState) emptyState.classList.add('hidden');
try {
const resp = await fetch('/api.php?action=get_security_events');
const data = await resp.json();
const events = data.events || [];
if (events.length === 0) {
if (tbody) tbody.innerHTML = '';
if (emptyState) emptyState.classList.remove('hidden');
return;
}
if (tbody) {
tbody.innerHTML = events.map(e => `
${escapeHtml(e.time)}
${escapeHtml(e.service)}
${escapeHtml(e.event)}
Échec mot de passe / Défi SIP
${escapeHtml(e.account)}
${escapeHtml(e.ip)}
`).join('');
}
} catch (err) {
if (tbody) {
tbody.innerHTML = `Erreur : ${escapeHtml(err.message)} `;
}
}
}
async function clearSecurityLog() {
if (!confirm("Voulez-vous vraiment vider le journal des événements de sécurité et réinitialiser les compteurs d'attaques à zéro ?")) {
return;
}
try {
const res = await apiCall('clear_security_log', {});
showToast(res.message, 'success');
await openSecurityEventsModal();
await pollMetrics();
} catch (err) {
showToast("Erreur lors de la purge du journal : " + err.message, 'error');
}
}
// =========================================================================
// 2C. ANNUAIRE D'ENTREPRISE & YEALINK REMOTE PHONEBOOK XML
// =========================================================================
let selectedPhonebookGroup = 'all';
function renderPhonebookTable() {
const tbody = document.getElementById('phonebook-tbody');
if (!tbody) return;
const contacts = appState.phonebook_contacts || [];
const groups = appState.phonebook_groups || [];
const searchVal = (document.getElementById('phonebook-search')?.value || '').trim().toLowerCase();
// 1. Rendu des Group Pills
const pillsContainer = document.getElementById('phonebook-group-pills');
if (pillsContainer) {
let pillsHtml = `
Tous les groupes (${contacts.length})
`;
groups.forEach(g => {
const count = contacts.filter(c => {
const cg = (c.group_name || '').toLowerCase();
const gl = g.toLowerCase();
return cg === gl || cg.includes(gl) || cg === 'tous' || cg === 'all';
}).length;
const isActive = selectedPhonebookGroup.toLowerCase() === g.toLowerCase();
pillsHtml += `
${escapeHtml(g)} (${count})
`;
});
pillsContainer.innerHTML = pillsHtml;
}
// 2. Filtrage des contacts
const filtered = contacts.filter(c => {
// Filtre de groupe
if (selectedPhonebookGroup !== 'all') {
const cg = (c.group_name || '').toLowerCase();
const sg = selectedPhonebookGroup.toLowerCase();
if (cg !== sg && !cg.includes(sg) && cg !== 'tous' && cg !== 'all') {
return false;
}
}
// Filtre de recherche
if (searchVal !== '') {
const matchName = (c.display_name || '').toLowerCase().includes(searchVal);
const matchInt = (c.internal_number || '').toLowerCase().includes(searchVal);
const matchMob = (c.mobile_number || '').toLowerCase().includes(searchVal);
const matchOth = (c.other_number || '').toLowerCase().includes(searchVal);
const matchMail = (c.email || '').toLowerCase().includes(searchVal);
const matchGrp = (c.group_name || '').toLowerCase().includes(searchVal);
if (!matchName && !matchInt && !matchMob && !matchOth && !matchMail && !matchGrp) {
return false;
}
}
return true;
});
// 3. Statistiques affichées
const statsEl = document.getElementById('phonebook-stats');
if (statsEl) {
const intCount = contacts.filter(c => c.is_internal).length;
const extCount = contacts.length - intCount;
statsEl.innerHTML = `${filtered.length} affiché(s) • ${intCount} interne(s) • ${extCount} externe(s) `;
}
if (filtered.length === 0) {
tbody.innerHTML = `Aucun contact trouvé dans l'annuaire pour ces critères. `;
return;
}
tbody.innerHTML = filtered.map(c => {
const initials = getInitials(c.display_name || '?');
const colorClass = getAvatarBgColor(c.display_name || '');
const avatarHtml = c.photo_url
? `${escapeHtml(initials)}
`
: `${escapeHtml(initials)}
`;
const typeBadge = c.is_internal
? `Poste ${escapeHtml(c.internal_number)} `
: `Contact Externe `;
return `
${avatarHtml}
${escapeHtml(c.display_name)}
${c.photo_url ? 'Photo active ' : ''}
${c.internal_number ? escapeHtml(c.internal_number) : '— '}
${c.mobile_number ? escapeHtml(c.mobile_number) : '— '}
${c.other_number ? escapeHtml(c.other_number) : '— '}
${c.email ? `${escapeHtml(c.email)} ` : '— '}
${escapeHtml(c.group_name || 'Général')}
${typeBadge}
Modifier
${!c.is_internal ? `Supprimer ` : ''}
`;
}).join('');
}
function getInitials(name) {
const parts = name.trim().split(/\s+/);
if (parts.length >= 2) {
return (parts[0][0] + parts[1][0]).toUpperCase();
}
return (name.substring(0, 2) || '?').toUpperCase();
}
function getAvatarBgColor(str) {
const colors = [
'bg-teal-600', 'bg-indigo-600', 'bg-emerald-600', 'bg-cyan-600',
'bg-purple-600', 'bg-amber-600', 'bg-rose-600', 'bg-blue-600'
];
let hash = 0;
for (let i = 0; i < str.length; i++) {
hash = str.charCodeAt(i) + ((hash << 5) - hash);
}
return colors[Math.abs(hash) % colors.length];
}
function filterPhonebook() {
renderPhonebookTable();
}
function selectPhonebookGroupFilter(grp) {
selectedPhonebookGroup = grp;
renderPhonebookTable();
}
function openPhonebookContactModal(contact = null) {
const form = document.getElementById('form-phonebook-contact');
if (!form) return;
form.reset();
const titleEl = document.getElementById('modal-phonebook-title');
if (titleEl) {
titleEl.textContent = contact ? `Modifier le Contact ${contact.display_name}` : 'Nouveau Contact Annuaire';
}
form.elements['id'].value = contact ? contact.id : '';
form.elements['extension_id'].value = contact ? (contact.extension_id || '') : '';
form.elements['is_internal'].value = contact ? (contact.is_internal ? 1 : 0) : 0;
form.elements['photo_url'].value = contact ? (contact.photo_url || '') : '';
form.elements['display_name'].value = contact ? contact.display_name : '';
form.elements['internal_number'].value = contact ? (contact.internal_number || '') : '';
form.elements['mobile_number'].value = contact ? (contact.mobile_number || '') : '';
form.elements['other_number'].value = contact ? (contact.other_number || '') : '';
form.elements['email'].value = contact ? (contact.email || '') : '';
form.elements['group_name'].value = contact ? (contact.group_name || 'Général') : (selectedPhonebookGroup !== 'all' ? selectedPhonebookGroup : 'Général');
// Datalist des groupes existants
const dl = document.getElementById('phonebook-groups-datalist');
if (dl) {
dl.innerHTML = (appState.phonebook_groups || []).map(g => ``).join('');
}
updateAvatarPreview(contact?.photo_url || '', contact?.display_name || '?');
openModal('modal-phonebook-contact');
}
function editPhonebookContact(id) {
const contact = (appState.phonebook_contacts || []).find(c => Number(c.id) === Number(id));
if (contact) {
openPhonebookContactModal(contact);
}
}
async function savePhonebookContact(e) {
e.preventDefault();
const form = e.target;
const payload = {
id: form.elements['id'].value ? Number(form.elements['id'].value) : null,
extension_id: form.elements['extension_id'].value ? Number(form.elements['extension_id'].value) : null,
is_internal: Number(form.elements['is_internal'].value) || 0,
display_name: form.elements['display_name'].value.trim(),
internal_number: form.elements['internal_number'].value.trim(),
mobile_number: form.elements['mobile_number'].value.trim(),
other_number: form.elements['other_number'].value.trim(),
email: form.elements['email'].value.trim(),
group_name: form.elements['group_name'].value.trim() || 'Général',
photo_url: form.elements['photo_url'].value.trim(),
};
try {
const res = await apiCall('save_phonebook_contact', payload);
showToast(res.message, 'success');
closeModal('modal-phonebook-contact');
if (res.contacts) appState.phonebook_contacts = res.contacts;
if (res.groups) appState.phonebook_groups = res.groups;
renderPhonebookTable();
const pbBadge = document.getElementById('nav-badge-phonebook');
if (pbBadge) pbBadge.textContent = appState.phonebook_contacts.length;
} catch (err) {
showToast(err.message, 'error');
}
}
async function deletePhonebookContact(id) {
const contact = (appState.phonebook_contacts || []).find(c => Number(c.id) === Number(id));
if (!contact) return;
if (!confirm(`Supprimer le contact « ${contact.display_name} » de l'annuaire ?`)) return;
try {
const res = await apiCall('delete_phonebook_contact', { id });
showToast(res.message, 'info');
if (res.contacts) appState.phonebook_contacts = res.contacts;
if (res.groups) appState.phonebook_groups = res.groups;
renderPhonebookTable();
const pbBadge = document.getElementById('nav-badge-phonebook');
if (pbBadge) pbBadge.textContent = appState.phonebook_contacts.length;
} catch (err) {
showToast(err.message, 'error');
}
}
async function syncPhonebookNow() {
try {
const res = await apiCall('sync_phonebook', {});
showToast(res.message, 'success');
if (res.contacts) appState.phonebook_contacts = res.contacts;
if (res.groups) appState.phonebook_groups = res.groups;
renderPhonebookTable();
const pbBadge = document.getElementById('nav-badge-phonebook');
if (pbBadge) pbBadge.textContent = appState.phonebook_contacts.length;
} catch (err) {
showToast(err.message, 'error');
}
}
async function uploadContactPhoto(input) {
if (!input.files || !input.files[0]) return;
const file = input.files[0];
const form = document.getElementById('form-phonebook-contact');
const contactId = form ? form.elements['id'].value : '';
const formData = new FormData();
formData.append('avatar', file);
formData.append('contact_id', contactId);
try {
const res = await fetch('/api.php?action=upload_phonebook_avatar', {
method: 'POST',
body: formData,
});
const data = await res.json();
if (!data.success) throw new Error(data.message || "Erreur d'envoi");
document.getElementById('phonebook-input-photo-url').value = data.photo_url;
updateAvatarPreview(data.photo_url, form.elements['display_name'].value || '?');
showToast(data.message, 'success');
} catch (err) {
showToast(err.message, 'error');
}
}
function clearContactPhoto() {
document.getElementById('phonebook-input-photo-url').value = '';
const form = document.getElementById('form-phonebook-contact');
updateAvatarPreview('', form?.elements['display_name']?.value || '?');
}
function updateAvatarPreview(photoUrl, name) {
const preview = document.getElementById('phonebook-avatar-preview');
const clearBtn = document.getElementById('phonebook-btn-clear-photo');
if (!preview) return;
const initials = getInitials(name || '?');
const colorClass = getAvatarBgColor(name || '');
if (photoUrl) {
preview.className = 'w-14 h-14 rounded-full overflow-hidden border border-teal-500/40 shrink-0 shadow relative flex items-center justify-center';
preview.innerHTML = `
${escapeHtml(initials)}
`;
clearBtn?.classList.remove('hidden');
} else {
preview.className = `w-14 h-14 rounded-full ${colorClass} text-white flex items-center justify-center font-bold text-lg overflow-hidden shrink-0 shadow-inner`;
preview.textContent = initials;
clearBtn?.classList.add('hidden');
}
}
// Modal Yealink Remote XML Phonebook
function openYealinkXmlModal() {
const sel = document.getElementById('yealink-modal-group-select');
if (sel) {
const groups = appState.phonebook_groups || [];
sel.innerHTML = ` Tous les groupes (Annuaire complet de l'entreprise) ` +
groups.map(g => `${escapeHtml(g)} `).join('');
if (selectedPhonebookGroup !== 'all' && groups.includes(selectedPhonebookGroup)) {
sel.value = selectedPhonebookGroup;
}
}
updateYealinkXmlUrls();
openModal('modal-yealink-xml');
}
function updateYealinkXmlUrls() {
const sel = document.getElementById('yealink-modal-group-select');
const grp = sel ? sel.value : 'all';
const host = window.location.hostname || '192.168.1.100';
const httpsPort = window.location.port || '8443';
const groupParam = grp !== 'all' ? `?group=${encodeURIComponent(grp)}` : `?group=all`;
const url8080 = `http://${host}:8080/phonebook.php${groupParam}`;
const urlHttps = `https://${host}:${httpsPort}/phonebook.php${groupParam}`;
const el8080 = document.getElementById('yealink-url-8080');
if (el8080) el8080.value = url8080;
const elHttps = document.getElementById('yealink-url-https');
if (elHttps) elHttps.value = urlHttps;
const openBtn = document.getElementById('yealink-open-browser-btn');
if (openBtn) openBtn.href = url8080;
const previewEl = document.getElementById('yealink-xml-preview');
if (previewEl) {
previewEl.textContent = 'Chargement du flux XML...';
fetch(`/phonebook.php${groupParam}`)
.then(r => r.text())
.then(xml => {
previewEl.textContent = xml;
})
.catch(() => {
previewEl.textContent = `\n\n Annuaire ${grp} \n `;
});
}
}
function copyYealinkUrl(inputId, btn) {
const el = document.getElementById(inputId);
if (!el) return;
el.select();
navigator.clipboard.writeText(el.value).then(() => {
showToast("URL Yealink copiée dans le presse-papiers !", 'success');
const origText = btn.innerHTML;
btn.innerHTML = 'Copié ! ';
setTimeout(() => { btn.innerHTML = origText; }, 2000);
}).catch(() => {
showToast("Sélectionnez l'URL et appuyez sur Ctrl+C.", 'info');
});
}
// =========================================================================
// GESTIONNAIRE DE MISES À JOUR DU SYSTÈME NEXUSPBX (OTA)
// =========================================================================
function openUpdatePanel() {
switchTab('system');
setTimeout(() => {
const el = document.getElementById('update-status-box');
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'center' });
}, 150);
}
async function checkSystemUpdate(notify = false) {
const btnCheck = document.getElementById('btn-check-update');
const btnRun = document.getElementById('btn-run-update');
const statusText = document.getElementById('update-status-text');
const lastChecked = document.getElementById('update-last-checked');
const changelogContainer = document.getElementById('update-changelog-container');
const changelogList = document.getElementById('update-changelog-list');
const pingDot = document.getElementById('update-ping');
const verTag = document.getElementById('system-version-tag');
const appBadge = document.getElementById('app-version-badge');
const headerUpdateBadge = document.getElementById('header-update-badge');
const headerUpdateText = document.getElementById('header-update-text');
const STORAGE_KEY_TIME = 'nexus_update_last_check_ts';
const STORAGE_KEY_DATA = 'nexus_update_cached_data';
const TWENTY_FOUR_HOURS = 24 * 60 * 60 * 1000;
// Si pas de clic explicite (notify === false), vérifier si un check a déjà été fait il y a moins de 24h
if (!notify) {
const lastTs = parseInt(localStorage.getItem(STORAGE_KEY_TIME) || '0', 10);
const cachedRaw = localStorage.getItem(STORAGE_KEY_DATA);
if (lastTs && (Date.now() - lastTs < TWENTY_FOUR_HOURS) && cachedRaw) {
try {
const cached = JSON.parse(cachedRaw);
applyUpdateUI(cached);
return;
} catch (_) {}
}
} else {
localStorage.removeItem(STORAGE_KEY_TIME);
localStorage.removeItem(STORAGE_KEY_DATA);
}
let origBtnHtml = null;
if (btnCheck) {
btnCheck.disabled = true;
btnCheck.classList.add('opacity-50');
origBtnHtml = btnCheck.innerHTML;
btnCheck.innerHTML = `Vérification en cours... `;
}
try {
const res = await fetch('/api.php?action=check_update');
const data = await res.json();
// Enregistrement du timestamp de vérification et des données en cache local (24h)
localStorage.setItem(STORAGE_KEY_TIME, Date.now().toString());
localStorage.setItem(STORAGE_KEY_DATA, JSON.stringify(data));
applyUpdateUI(data);
if (notify) {
if (data.has_update) {
showToast(`Nouvelle version NexusPBX v${data.latest_version} disponible !`, 'info');
} else {
showToast("Votre système NexusPBX est parfaitement à jour !", 'success');
}
}
} catch (e) {
if (notify) {
showToast("Erreur lors de la vérification des mises à jour : " + e.message, 'error');
}
} finally {
if (btnCheck) {
btnCheck.disabled = false;
btnCheck.classList.remove('opacity-50');
if (origBtnHtml) btnCheck.innerHTML = origBtnHtml;
}
}
function applyUpdateUI(data) {
if (lastChecked) {
const lastTs = parseInt(localStorage.getItem(STORAGE_KEY_TIME) || Date.now().toString(), 10);
const d = new Date(lastTs);
lastChecked.textContent = `Dernière vérification : ${d.toLocaleDateString()} à ${d.toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'})}`;
}
if (verTag && data.current_version) {
verTag.textContent = `v${data.current_version}`;
}
if (appBadge && data.current_version) {
appBadge.textContent = `v${data.current_version}`;
}
if (data.has_update) {
// Affichage du badge dans l'en-tête à côté de Tester AMI
if (headerUpdateBadge) {
headerUpdateBadge.style.display = 'inline-flex';
if (headerUpdateText) {
headerUpdateText.textContent = `v${escapeHtml(data.latest_version)} dispo`;
}
}
if (statusText) {
statusText.className = 'text-amber-400 font-bold';
statusText.innerHTML = `🚀 Nouvelle version disponible : v${escapeHtml(data.latest_version)} !`;
}
if (btnRun) {
btnRun.style.display = 'inline-flex';
btnRun.innerHTML = `Mettre à jour vers v${escapeHtml(data.latest_version)} (1-Clic) `;
}
if (pingDot) pingDot.classList.remove('hidden');
if (changelogList && Array.isArray(data.changelog) && data.changelog.length > 0) {
changelogList.innerHTML = data.changelog.map(item => `${escapeHtml(item)} `).join('');
if (changelogContainer) changelogContainer.classList.remove('hidden');
}
} else {
// Pas de mise à jour disponible : masquer le badge dans l'en-tête
if (headerUpdateBadge) {
headerUpdateBadge.style.display = 'none';
}
if (statusText) {
statusText.className = 'text-emerald-400 font-bold';
statusText.textContent = `Votre PBX est à jour avec la dernière version officielle (v${data.current_version}).`;
}
if (btnRun) btnRun.style.display = 'none';
if (pingDot) pingDot.classList.add('hidden');
if (changelogContainer) changelogContainer.classList.add('hidden');
}
}
}
async function runSystemUpdate() {
if (!confirm("Voulez-vous lancer la mise à jour de NexusPBX ?\n\nUne sauvegarde préventive automatique de vos configurations et de votre base de données sera créée.")) {
return;
}
const btnRun = document.getElementById('btn-run-update');
const statusText = document.getElementById('update-status-text');
if (btnRun) {
btnRun.disabled = true;
btnRun.innerHTML = '↻ Mise à jour en cours...';
}
if (statusText) {
statusText.className = 'text-cyan-300 font-bold';
statusText.textContent = 'Mise à jour en cours : sauvegarde, git pull, migrations SQLite et rechargement des services...';
}
showToast("Mise à jour en cours d'exécution en arrière-plan...", 'info');
try {
const res = await fetch('/api.php?action=run_update', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({})
});
const data = await res.json();
if (data.success) {
showToast(`NexusPBX a été mis à jour avec succès en v${data.new_version} ! Rechargement...`, 'success');
if (statusText) {
statusText.className = 'text-emerald-400 font-bold';
statusText.textContent = `Mise à jour terminée avec succès (v${data.new_version}) ! Rechargement de la page...`;
}
setTimeout(() => {
window.location.reload();
}, 2500);
} else {
alert("Erreur lors de la mise à jour :\n\n" + (data.output || data.message));
if (btnRun) {
btnRun.disabled = false;
btnRun.innerHTML = 'Réessayer la mise à jour';
}
}
} catch (e) {
alert("Erreur réseau lors de la mise à jour : " + e.message);
if (btnRun) {
btnRun.disabled = false;
btnRun.innerHTML = 'Réessayer la mise à jour';
}
}
}