'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)} `; }).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 ` `; }).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)} `; }).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 += ` `; hiddenHtml += ``; } else { blockedCount++; blockedHtml += ` `; } }); 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}
`; }).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 += ` `; } else { blockedCount++; blockedHtml += ` `; } }); 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} `; }).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 += ` `; hiddenHtml += ``; } else { blockedCount++; blockedHtml += ` `; } }); 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 ? `` : ''} `; }).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 += ``; }); html += ``; } if (grps.length > 0) { html += ``; grps.forEach(g => { const val = `grp-${g.extension}`; html += ``; }); html += ``; } if (paging.length > 0) { html += ``; paging.forEach(pg => { const val = `page-${pg.extension}`; html += ``; }); html += ``; } if (ivrs.length > 0) { html += ``; ivrs.forEach(i => { const val = i.name; html += ``; }); html += ``; } // Piège à Spammeurs / Musique d'attente en boucle continue (MoH) const mohSounds = sounds.filter(s => s.usage_type === 'moh'); html += ``; html += ``; mohSounds.forEach(s => { const val = `moh-${s.id}`; html += ``; }); html += ``; const announcements = sounds.filter(s => s.usage_type !== 'moh'); if (announcements.length > 0) { html += ``; announcements.forEach(s => { const val = `sound-${s.id}`; html += ``; }); html += ``; html += ``; announcements.forEach(s => { const val = `sound-loop-${s.id}`; html += ``; }); html += ``; } html += ` `; 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 = `
`; 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 = ``; mohSounds.forEach(s => { const isSel = (Number(s.id) === curMohId) ? 'selected' : ''; opts += ``; }); 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 ? '...' : ''}
`; }).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 = ` `; const annSounds = (appState.sounds || []).filter(s => s.usage_type !== 'moh'); if (annSounds.length > 0) { annSounds.forEach(s => { const val = `sound-${s.id}`; congOpts += ``; }); } congOpts += ` `; 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 = ``; mohSounds.forEach(s => { const sSlug = slugifyTrunkName(s.site_category || 'global'); const cName = (sSlug === 'global' || sSlug === 'default') ? 'default' : `moh-${sSlug}`; if (cName !== 'default') { mohOpts += ``; } }); 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 ` `; }).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 ``; }).join(''); const currentCount = container.querySelectorAll('.ob-trunk-seq-row').length + 1; row.innerHTML = ` ${currentCount} `; 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 += ` `; } else { blockedHtml += ` `; } }); 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)}
` : ''}
${audioName}
${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} `; }).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 => ``).join(''); row.innerHTML = `
Touche :
→
`; 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 += ``; }); audioHtml += ``; } audioHtml += ` `; 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 = ``; (appState.sounds || []).forEach(s => { invSoundHtml += ``; }); 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 = ``; (appState.sounds || []).forEach(s => { toutSoundHtml += ``; }); 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 = ``; soundSites.forEach(st => { const isSel = (st.name === curVal) ? 'selected' : ''; siteOpts += ``; }); 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 = ``; } else { defMohSelect.innerHTML = mohSounds.map(s => { const isSel = (Number(s.id) === curDefaultId) ? 'selected' : ''; return ``; }).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}
`; }).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é` : ``; return ` ${escapeHtml(st.name)} ${isGlobal ? 'Système' : ''} ${escapeHtml(st.description || '—')} ${count} fichier${count > 1 ? 's' : ''} ${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 ``; }).join(''); if (curSite && !sites.some(st => st.name === curSite)) { siteSelect.insertAdjacentHTML('afterbegin', ``); } } 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 = ` `; } 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'; } } async function confirmRestoreBackup(filename) { const ok = confirm(`⚠️ ATTENTION — RESTAURATION COMPLÈTE DU SYSTÈME NEXUSPBX ⚠️\n\nVoulez-vous restaurer le système depuis l'archive :\n${filename} ?\n\nCette action va :\n1. Remplacer la base de données SQLite\n2. Restaurer tous les fichiers sons, annonces et musiques\n3. Restaurer toutes les boîtes vocales, salutations et PINs\n4. Recharger l'intégralité du PBX Asterisk\n\nConfirmez-vous la restauration immédiate ?`); if (!ok) return; showToast("Restauration du système en cours... Veuillez patienter.", "info"); try { const res = await apiCall('restore_backup', { filename }); showToast(res.message || "Restauration terminée avec succès !", 'success'); setTimeout(() => { window.location.reload(); }, 2000); } catch (err) { showToast("Échec de la restauration : " + err.message, 'error'); } } function triggerBackupUpload() { document.getElementById('backup-upload-input')?.click(); } async 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 ok = confirm(`⚠️ RESTAURATION DISASTER RECOVERY VIA FICHIER TÉLÉVERSÉ ⚠️\n\nFichier sélectionné : ${file.name} (${(file.size / (1024 * 1024)).toFixed(2)} Mo)\n\nVoulez-vous restaurer immédiatement le système NexusPBX avec cette archive ?\nToutes les données actuelles seront remplacées.`); if (!ok) { input.value = ''; return; } 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(); if (!res.success) { throw new Error(res.message || "Erreur lors de la restauration"); } showToast(res.message, 'success'); setTimeout(() => { window.location.reload(); }, 2000); } catch (err) { showToast("Échec de la restauration : " + err.message, 'error'); } finally { input.value = ''; } } 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é) `; }).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 = ` `; 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 += ` `; }); 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(c.display_name)}` : `
${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} ${!c.is_internal ? `` : ''} `; }).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 => `` + groups.map(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 (_) {} } } if (btnCheck) { btnCheck.disabled = true; btnCheck.classList.add('opacity-50'); } 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'); } } 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'; } } }