Files
NexusPBX/public/assets/app.js
T

7051 lines
335 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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 = `
<div class="leading-snug">${escapeHtml(message)}</div>
<button type="button" class="text-white/70 hover:text-white font-bold">✕</button>
`;
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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
// --- 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();
renderUcpTab();
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');
loginBox.classList.add('flex');
if (logo && loginLogo) {
loginLogo.src = logo;
loginLogo.classList.remove('hidden');
} else if (loginLogo) {
loginLogo.src = '';
loginLogo.classList.add('hidden');
}
if (loginName) {
loginName.textContent = name;
loginName.classList.toggle('hidden', !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'); }
if (loginBox) {
loginBox.classList.add('hidden');
loginBox.classList.remove('flex');
}
}
}
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;
const ucpBadge = document.getElementById('nav-badge-ucp');
if (ucpBadge) ucpBadge.textContent = (appState.ucp_users || []).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 `
<div class="py-2.5 flex items-center justify-between text-xs">
<div class="flex items-center gap-2.5">
<span class="${dotClass}"></span>
<div>
<div class="font-bold text-white flex items-center gap-2">
<span>${escapeHtml(s.name)}</span>
<span class="text-[10px] font-mono text-slate-400 font-normal">(${escapeHtml(s.port)})</span>
</div>
<div class="text-[11px] text-slate-400">${escapeHtml(s.desc)}</div>
</div>
</div>
<div class="text-right">
<span class="${badgeClass}">${escapeHtml(s.status_text)}</span>
<div class="text-[10px] font-mono text-slate-400 mt-0.5">${escapeHtml(s.uptime)}</div>
</div>
</div>
`;
}).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 baselineY = padTop + plotH;
const safeMax = maxVal > 0 ? maxVal : 100;
const clampY = (y) => Math.max(padTop, Math.min(baselineY, y));
// Génération de courbes splines adoucies (Catmull-Rom / Bézier cubique) pour éliminer les angles pointus
const computeSmoothPaths = (arr, tension = 0.2) => {
if (!arr || arr.length === 0) return { line: '', area: '' };
const points = arr.map((val, idx) => {
const x = (idx / (arr.length - 1)) * width;
const ratio = Math.max(0, Math.min(1, val / safeMax));
const y = baselineY - (ratio * plotH);
return { x, y };
});
if (points.length === 1) {
return {
line: `M ${points[0].x.toFixed(1)},${points[0].y.toFixed(1)}`,
area: `M 0,${baselineY} L ${points[0].x.toFixed(1)},${points[0].y.toFixed(1)} L ${width},${baselineY} Z`
};
}
let line = `M ${points[0].x.toFixed(1)},${points[0].y.toFixed(1)}`;
for (let i = 0; i < points.length - 1; i++) {
const p0 = points[i === 0 ? i : i - 1];
const p1 = points[i];
const p2 = points[i + 1];
const p3 = points[i + 2 < points.length ? i + 2 : i + 1];
let cp1x = p1.x + (p2.x - p0.x) * tension;
let cp1y = p1.y + (p2.y - p0.y) * tension;
let cp2x = p2.x - (p3.x - p1.x) * tension;
let cp2y = p2.y - (p3.y - p1.y) * tension;
// Clamping vertical pour garantir que la courbe ne déborde pas sous l'axe ni au-dessus du max
cp1y = clampY(cp1y);
cp2y = clampY(cp2y);
line += ` C ${cp1x.toFixed(1)},${cp1y.toFixed(1)} ${cp2x.toFixed(1)},${cp2y.toFixed(1)} ${p2.x.toFixed(1)},${p2.y.toFixed(1)}`;
}
const area = `${line} L ${width},${baselineY} L 0,${baselineY} Z`;
return { line, area };
};
const pathA = computeSmoothPaths(seriesA);
const pathB = computeSmoothPaths(seriesB);
const gradIdA = `${svgId}-grad-a`;
const gradIdB = `${svgId}-grad-b`;
svg.innerHTML = `
<defs>
<linearGradient id="${gradIdA}" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="${colorA}" stop-opacity="0.38"/>
<stop offset="100%" stop-color="${colorA}" stop-opacity="0.02"/>
</linearGradient>
<linearGradient id="${gradIdB}" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="${colorB}" stop-opacity="0.32"/>
<stop offset="100%" stop-color="${colorB}" stop-opacity="0.02"/>
</linearGradient>
</defs>
<!-- Lignes de grille horizontales -->
<line x1="0" y1="${padTop}" x2="${width}" y2="${padTop}" stroke="#2c323f" stroke-dasharray="3,3" stroke-width="1"/>
<line x1="0" y1="${padTop + plotH / 2}" x2="${width}" y2="${padTop + plotH / 2}" stroke="#2c323f" stroke-dasharray="3,3" stroke-width="1"/>
<line x1="0" y1="${baselineY}" x2="${width}" y2="${baselineY}" stroke="#3d4657" stroke-width="1"/>
<text x="6" y="${padTop + 10}" fill="#94a3b8" font-size="10" font-family="monospace">${Math.round(safeMax)} ${unitLabel}</text>
<text x="6" y="${padTop + plotH / 2 - 4}" fill="#64748b" font-size="10" font-family="monospace">${Math.round(safeMax / 2)} ${unitLabel}</text>
<!-- Courbe B lissée avec dégradé -->
<path d="${pathB.area}" fill="url(#${gradIdB})"/>
<path d="${pathB.line}" fill="none" stroke="${colorB}" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/>
<!-- Courbe A lissée avec dégradé -->
<path d="${pathA.area}" fill="url(#${gradIdA})"/>
<path d="${pathA.line}" fill="none" stroke="${colorA}" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/>
`;
}
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 = `<tr><td colspan="6" class="py-8 text-center text-slate-400 text-xs">Aucun poste SIP configuré.</td></tr>`;
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 = `<span class="inline-flex items-center gap-1 px-1.5 py-0.2 rounded text-[10px] font-semibold bg-emerald-500/15 text-emerald-300 border border-emerald-500/30" title="Chiffrement média SRTP obligatoire (AES-128)">🛡️ SRTP Forcé</span>`;
} else if (encMode === 1) {
srtpBadge = `<span class="inline-flex items-center gap-1 px-1.5 py-0.2 rounded text-[10px] font-semibold bg-cyan-500/15 text-cyan-300 border border-cyan-500/30" title="Chiffrement média SRTP négociable si supporté par le poste">🛡️ SRTP Négociable</span>`;
} else {
srtpBadge = `<span class="inline-flex items-center gap-1 px-1.5 py-0.2 rounded text-[10px] font-medium bg-anthracite-900 text-slate-400 border border-anthracite-700" title="Voix en clair (RTP standard)">🔈 RTP Clair</span>`;
}
// 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 = `
<span class="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-bold bg-emerald-500/20 text-emerald-300 border border-emerald-500/40 shadow-sm shadow-emerald-500/10">
🔒 TLS Actif (Chiffré)
</span>
`;
profileNote = `<span class="${isTls ? 'text-emerald-300 font-semibold' : 'text-slate-300'}">${isTls ? 'TLS Strict' : 'UDP Interne'}</span>`;
} else if (actTr === 'wss') {
trPill = `
<span class="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-bold bg-cyan-500/20 text-cyan-300 border border-cyan-500/40">
🔒 WSS WebRTC
</span>
`;
profileNote = `<span class="text-cyan-300 font-semibold">WebRTC</span>`;
} else if (actTr === 'tcp') {
trPill = `
<span class="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-semibold bg-indigo-500/20 text-indigo-300 border border-indigo-500/35">
🔓 TCP Actif (Clair)
</span>
`;
profileNote = `<span class="text-slate-300">${isTls ? 'TLS Strict' : 'UDP Interne'}</span>`;
} else {
// UDP
trPill = `
<span class="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-semibold ${isTls ? 'bg-amber-500/20 text-amber-300 border border-amber-500/40' : 'bg-anthracite-750 text-slate-200 border border-anthracite-600'}">
🔓 UDP Actif (Clair)
</span>
`;
profileNote = isTls ? '<span class="text-amber-400 font-semibold">⚠️ Exige TLS</span>' : '<span class="text-slate-400">UDP Interne</span>';
}
modeBadge = `
<div class="space-y-1">
<div>${trPill}</div>
<div class="flex items-center gap-1.5 flex-wrap pl-0.5">
${srtpBadge}
<span class="text-[10px] text-slate-400 font-mono">Profil: ${profileNote}</span>
</div>
</div>
`;
} else {
modeBadge = `
<div class="space-y-1">
<div>
<span class="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-xs font-medium bg-anthracite-950 text-slate-400 border border-anthracite-700">
Inactif
</span>
</div>
<div class="flex items-center gap-1.5 flex-wrap pl-0.5">
${srtpBadge}
<span class="text-[10px] text-slate-500 font-mono">Profil: ${isTls ? 'TLS Strict' : 'UDP Interne'}</span>
</div>
</div>
`;
}
// Statut SIP
let statusBadge = '';
if (isOnline) {
statusBadge = `<span class="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-bold bg-emerald-500/20 text-emerald-300 border border-emerald-500/30"><i class="w-2 h-2 rounded-full bg-emerald-400 animate-pulse"></i>En ligne (${escapeHtml(c.status)})</span>`;
} else if (isUnavail) {
statusBadge = `<span class="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-bold bg-rose-500/20 text-rose-300 border border-rose-500/30" title="Contact injoignable (rejet de transport ou délai d'attente dépassé)"><i class="w-2 h-2 rounded-full bg-rose-400"></i>Injoignable (${escapeHtml(c.status)})</span>`;
} else {
statusBadge = `<span class="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-medium bg-anthracite-950 text-slate-400 border border-anthracite-700"><i class="w-2 h-2 rounded-full bg-slate-500"></i>Hors-ligne</span>`;
}
// 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 = `
<div>
<div class="font-mono text-xs text-slate-200 font-semibold">${escapeHtml(cleanIp)}</div>
${ua ? `<div class="text-[11px] text-indigo-300 font-medium truncate max-w-[240px]" title="${ua}">📱 ${ua}</div>` : ''}
</div>
`;
} else {
contactHtml = `<span class="text-xs text-slate-500 italic">Non enregistré</span>`;
}
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 `
<tr class="hover:bg-anthracite-750/60 transition">
<td class="py-3 px-4 font-mono font-bold text-indigo-300">${escapeHtml(ext.extension)}</td>
<td class="py-3 px-4 font-medium text-white">${escapeHtml(ext.name)}</td>
<td class="py-3 px-4">${modeBadge}</td>
<td class="py-3 px-4">${statusBadge}</td>
<td class="py-3 px-4">${contactHtml}</td>
<td class="py-3 px-4 text-right font-mono text-xs ${rttColor}">${rttText}</td>
</tr>
`;
}).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 = `<tr><td colspan="8" class="py-8 text-center text-slate-400 text-xs">Aucun poste configuré. Cliquez sur « + Nouveau Poste SIP ».</td></tr>`;
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 = '<span class="text-[10px] font-bold text-emerald-400">SRTP Forcé</span>';
} else if (encMode === 1) {
encLabel = '<span class="text-[10px] font-bold text-cyan-400">SRTP Négociable</span>';
} else {
encLabel = '<span class="text-[10px] text-slate-400">RTP Clair</span>';
}
const badge = isTls
? `<span class="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-semibold bg-emerald-500/15 text-emerald-300 border border-emerald-500/30">🔒 TLS (${encLabel})</span>`
: (encMode > 0
? `<span class="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-semibold bg-indigo-500/15 text-indigo-300 border border-indigo-500/30">🛡️ UDP (${encLabel})</span>`
: `<span class="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-semibold bg-anthracite-750 text-slate-200 border border-anthracite-600">🏢 UDP (RTP Clair)</span>`);
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
? `<span class="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-bold bg-emerald-500/20 text-emerald-300 border border-emerald-500/30" title="${escapeHtml(c.user_agent || '')}"><i class="w-1.5 h-1.5 rounded-full bg-emerald-400"></i>Connecté (${actTr}${c.rtt_ms !== null && !isNaN(c.rtt_ms) ? ` • ${c.rtt_ms}ms` : ''})</span>`
: `<span class="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-bold bg-rose-500/20 text-rose-300 border border-rose-500/30" title="Contact rejeté ou injoignable (rejet de transport ou délai dépassé)"><i class="w-1.5 h-1.5 rounded-full bg-rose-400"></i>Injoignable (${actTr})</span>`)
: `<span class="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-medium bg-anthracite-950 text-slate-400 border border-anthracite-700">Non connecté</span>`;
// 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 => `<span class="px-2 py-0.5 rounded-md bg-cyan-500/15 text-cyan-300 border border-cyan-500/30 text-[11px] font-semibold">${escapeHtml(eg.name)}</span>`).join(' ')
: `<span class="text-slate-500 text-xs italic">Aucun</span>`;
const vmBadge = Number(ext.voicemail_enabled) === 1
? `<span class="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[10px] font-semibold bg-amber-500/15 text-amber-300 border border-amber-500/30" title="Boîte vocale active (${ext.ring_time || 20}s)">📟 Boîte</span>`
: '';
return `
<tr class="hover:bg-anthracite-750/60 transition">
<td class="py-3.5 px-4 font-mono font-bold text-indigo-300">${escapeHtml(ext.extension)}</td>
<td class="py-3.5 px-4 font-medium text-white">
<div class="flex items-center gap-2">
<span>${escapeHtml(ext.name)}</span>
${vmBadge}
</div>
</td>
<td class="py-3.5 px-4"><div class="flex flex-wrap gap-1">${groupBadges}</div></td>
<td class="py-3.5 px-4">${regBadge}</td>
<td class="py-3.5 px-4">${badge}</td>
<td class="py-3.5 px-4 font-mono text-xs text-slate-200">${escapeHtml(ext.codecs)}</td>
<td class="py-3.5 px-4">
<code class="px-2 py-0.5 rounded bg-anthracite-950 border border-anthracite-700 text-xs font-mono text-slate-300">${escapeHtml(ext.secret)}</code>
</td>
<td class="py-3.5 px-4 text-right space-x-2 whitespace-nowrap">
<button type="button" onclick="promptRebootExtension(${Number(ext.id)})" class="text-xs font-semibold text-amber-400 hover:text-amber-300 transition">Redémarrer</button>
<button type="button" onclick="editExtension(${Number(ext.id)})" class="text-xs font-semibold text-indigo-300 hover:text-indigo-200 transition">Modifier</button>
<button type="button" onclick="deleteExtension(${Number(ext.id)})" class="text-xs font-semibold text-red-400 hover:text-red-300 transition">Supprimer</button>
</td>
</tr>
`;
}).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 = `<span class="text-slate-500 text-xs col-span-2">Aucun groupe d'extensions créé. Vous pouvez en créer dans « Groupes » → « Groupes d'extensions ».</span>`;
} 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 `
<label class="flex items-center gap-2 p-2 rounded-lg bg-anthracite-900 border border-anthracite-700 hover:border-cyan-500/50 cursor-pointer">
<input type="checkbox" name="member_extension_groups[]" value="${Number(eg.id)}" ${isMember ? 'checked' : ''} class="rounded bg-anthracite-950 border-anthracite-600">
<div class="leading-tight">
<span class="text-xs font-bold text-white">${escapeHtml(eg.name)}</span>
${eg.description ? `<span class="text-[10px] text-slate-400 block">${escapeHtml(eg.description)}</span>` : ''}
</div>
</label>
`;
}).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');
}
}
let pendingRebootExtension = null;
async function promptRebootExtension(id) {
const ext = (appState.extensions || []).find(e => Number(e.id) === Number(id));
if (!ext) return;
pendingRebootExtension = ext;
document.getElementById('reboot-modal-ext-num').textContent = ext.extension;
document.getElementById('reboot-modal-ext-name').textContent = ext.name || '';
const checkingEl = document.getElementById('reboot-modal-checking');
const warningEl = document.getElementById('reboot-modal-call-warning');
const normalEl = document.getElementById('reboot-modal-normal-info');
const btnAction = document.getElementById('btn-confirm-reboot-action');
const btnLabel = document.getElementById('reboot-confirm-btn-label');
checkingEl.classList.remove('hidden');
warningEl.classList.add('hidden');
normalEl.classList.add('hidden');
btnAction.disabled = true;
btnAction.className = 'px-5 py-2.5 rounded-xl text-xs font-bold bg-anthracite-700 text-slate-400 transition cursor-not-allowed flex items-center gap-2';
btnLabel.textContent = 'Vérification en cours...';
openModal('modal-confirm-reboot-extension');
try {
const res = await apiCall('check_extension_call_status', { extension: ext.extension });
checkingEl.classList.add('hidden');
btnAction.disabled = false;
if (res.in_call) {
warningEl.classList.remove('hidden');
normalEl.classList.add('hidden');
const detailsEl = document.getElementById('reboot-modal-call-details');
if (detailsEl) {
detailsEl.textContent = res.call_details || 'Communication active détectée sur ce combiné.';
}
btnAction.className = 'px-5 py-2.5 rounded-xl text-xs font-bold bg-rose-600 hover:bg-rose-500 text-white shadow-lg shadow-rose-600/30 transition flex items-center gap-2';
btnLabel.textContent = "Forcer le redémarrage (Couper l'appel)";
} else {
warningEl.classList.add('hidden');
normalEl.classList.remove('hidden');
btnAction.className = 'px-5 py-2.5 rounded-xl text-xs font-bold bg-amber-600 hover:bg-amber-500 text-white shadow-lg shadow-amber-600/30 transition flex items-center gap-2';
btnLabel.textContent = 'Confirmer le redémarrage';
}
} catch (err) {
checkingEl.classList.add('hidden');
normalEl.classList.remove('hidden');
btnAction.disabled = false;
btnAction.className = 'px-5 py-2.5 rounded-xl text-xs font-bold bg-amber-600 hover:bg-amber-500 text-white shadow-lg shadow-amber-600/30 transition flex items-center gap-2';
btnLabel.textContent = 'Confirmer le redémarrage';
}
}
function closeRebootModal() {
pendingRebootExtension = null;
closeModal('modal-confirm-reboot-extension');
}
async function confirmRebootExtension() {
if (!pendingRebootExtension) return;
const ext = pendingRebootExtension;
const btnAction = document.getElementById('btn-confirm-reboot-action');
const btnLabel = document.getElementById('reboot-confirm-btn-label');
if (btnAction) btnAction.disabled = true;
if (btnLabel) btnLabel.textContent = 'Envoi de l\'ordre...';
try {
const res = await apiCall('reboot_extension', { extension: ext.extension });
closeRebootModal();
showToast(res.message, 'success');
} catch (err) {
showToast(err.message, 'error');
} finally {
if (btnAction) btnAction.disabled = false;
}
}
function syncEncryptionToggle(transport, currentVal = null) {
const sel = document.getElementById('ext-encryption');
if (!sel) return;
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 = `<tr><td colspan="6" class="py-8 text-center text-slate-400 text-xs">Aucun groupe de sonnerie configuré. Cliquez sur « + Nouveau Groupe de Sonnerie ».</td></tr>`;
return;
}
tbody.innerHTML = rows.map(grp => {
const members = Array.isArray(grp.members) ? grp.members : [];
const badges = members.length
? members.map(m => `<span class="px-2 py-0.5 rounded bg-indigo-500/20 text-indigo-200 border border-indigo-500/30 font-mono text-xs font-semibold">${escapeHtml(m)}</span>`).join(' ')
: '<span class="text-slate-500 text-xs italic">Aucun membre</span>';
const failoverLabel = String(grp.failover_dest || '').startsWith('vm-')
? `<span class="px-2 py-0.5 rounded bg-violet-500/20 text-violet-200 border border-violet-500/30 font-mono text-xs font-semibold">📬 Répondeur ${escapeHtml(String(grp.failover_dest).slice(3))}</span>`
: `<code class="text-slate-300">${escapeHtml(grp.failover_dest)}</code>`;
return `
<tr class="hover:bg-anthracite-750/60 transition">
<td class="py-3.5 px-4 font-mono font-bold text-indigo-300">${escapeHtml(grp.extension)}</td>
<td class="py-3.5 px-4 font-bold text-white">${escapeHtml(grp.name)}</td>
<td class="py-3.5 px-4"><code class="text-xs text-cyan-300 font-mono">${escapeHtml(grp.strategy)}</code></td>
<td class="py-3.5 px-4"><div class="flex flex-wrap gap-1">${badges}</div></td>
<td class="py-3.5 px-4 text-xs text-slate-200">${Number(grp.ring_time)}s → ${failoverLabel}</td>
<td class="py-3.5 px-4 text-right space-x-2">
<button type="button" onclick="editGroup(${Number(grp.id)})" class="text-xs font-semibold text-indigo-300 hover:text-indigo-200">Modifier</button>
<button type="button" onclick="deleteGroup(${Number(grp.id)})" class="text-xs font-semibold text-red-400 hover:text-red-300">Supprimer</button>
</td>
</tr>
`;
}).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 += `
<button type="button" onclick="toggleGroupMemberExt('${escapeHtml(extStr)}')"
class="group inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg bg-emerald-500/20 hover:bg-red-500/20 border border-emerald-500/40 hover:border-red-500/40 text-xs font-semibold text-emerald-300 hover:text-red-300 transition cursor-pointer">
<span class="font-mono">${escapeHtml(extStr)}</span>
<span class="text-slate-300">(${escapeHtml(ext.name)})</span>
<span class="text-slate-400 group-hover:text-red-300 ml-1 font-bold">✕</span>
</button>
`;
hiddenHtml += `<input type="checkbox" name="member_ext" value="${escapeHtml(extStr)}" checked>`;
} else {
blockedCount++;
blockedHtml += `
<button type="button" onclick="toggleGroupMemberExt('${escapeHtml(extStr)}')"
class="group inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg bg-anthracite-950 hover:bg-emerald-500/20 border border-anthracite-700 hover:border-emerald-500/40 text-xs font-medium text-slate-300 hover:text-emerald-300 transition cursor-pointer">
<span class="font-mono text-indigo-300 font-bold">${escapeHtml(extStr)}</span>
<span class="text-slate-300">(${escapeHtml(ext.name)})</span>
<span class="text-slate-500 group-hover:text-emerald-300 ml-1 font-bold">+</span>
</button>
`;
}
});
allowedBox.innerHTML = allowedHtml || `<div class="text-slate-500 text-xs italic py-2">Aucun poste qui sonne. Cliquez sur un poste à droite pour l'ajouter.</div>`;
blockedBox.innerHTML = blockedHtml || `<div class="text-slate-500 text-xs italic py-2">Tous les postes sonnent dans ce groupe !</div>`;
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 = `<tr><td colspan="5" class="py-8 text-center text-slate-400 text-xs">Aucun groupe d'extensions configuré. Cliquez sur « + Nouveau Groupe d'Extensions ».</td></tr>`;
return;
}
tbody.innerHTML = rows.map(eg => {
const members = Array.isArray(eg.members) ? eg.members : [];
const badges = members.length
? members.map(m => `<span class="px-2 py-0.5 rounded bg-cyan-500/20 text-cyan-200 border border-cyan-500/30 font-mono text-xs font-semibold">${escapeHtml(m)}</span>`).join(' ')
: '<span class="text-slate-500 text-xs italic">Aucune extension</span>';
// 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 => `<span class="px-2 py-0.5 rounded bg-emerald-500/15 text-emerald-300 border border-emerald-500/30 text-[11px] font-semibold">${escapeHtml(r.name)}</span>`).join(' ')
: `<span class="px-2 py-0.5 rounded bg-amber-500/15 text-amber-300 border border-amber-500/30 text-[11px]">Aucune (Verrouillé)</span>`;
return `
<tr class="hover:bg-anthracite-750/60 transition">
<td class="py-3.5 px-4 font-bold text-white text-sm">
<div class="flex items-center gap-2">
<span class="w-2.5 h-2.5 rounded-full bg-cyan-400"></span>
<span>${escapeHtml(eg.name)}</span>
</div>
</td>
<td class="py-3.5 px-4 text-xs text-slate-300">${escapeHtml(eg.description || '—')}</td>
<td class="py-3.5 px-4"><div class="flex flex-wrap gap-1">${badges}</div></td>
<td class="py-3.5 px-4"><div class="flex flex-wrap gap-1">${routeBadges}</div></td>
<td class="py-3.5 px-4 text-right space-x-2">
<button type="button" onclick="editExtGroup(${Number(eg.id)})" class="text-xs font-semibold text-cyan-300 hover:text-cyan-200">Modifier</button>
<button type="button" onclick="deleteExtGroup(${Number(eg.id)})" class="text-xs font-semibold text-red-400 hover:text-red-300">Supprimer</button>
</td>
</tr>
`;
}).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 += `
<button type="button" onclick="toggleExtGroupMember('${escapeHtml(extStr)}')"
class="group inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg bg-cyan-500/20 hover:bg-red-500/20 border border-cyan-500/40 hover:border-red-500/40 text-xs font-semibold text-cyan-300 hover:text-red-300 transition cursor-pointer">
<span class="font-mono">${escapeHtml(extStr)}</span>
<span class="text-slate-300">(${escapeHtml(ext.name)})</span>
<span class="text-slate-400 group-hover:text-red-300 ml-1 font-bold">✕</span>
</button>
`;
} else {
blockedCount++;
blockedHtml += `
<button type="button" onclick="toggleExtGroupMember('${escapeHtml(extStr)}')"
class="group inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg bg-anthracite-950 hover:bg-cyan-500/20 border border-anthracite-700 hover:border-cyan-500/40 text-xs font-medium text-slate-300 hover:text-cyan-300 transition cursor-pointer">
<span class="font-mono text-indigo-300 font-bold">${escapeHtml(extStr)}</span>
<span class="text-slate-300">(${escapeHtml(ext.name)})</span>
<span class="text-slate-500 group-hover:text-cyan-300 ml-1 font-bold">+</span>
</button>
`;
}
});
allowedBox.innerHTML = allowedHtml || `<div class="text-slate-500 text-xs italic py-2">Aucune extension rattachée. Cliquez à droite pour en ajouter.</div>`;
blockedBox.innerHTML = blockedHtml || `<div class="text-slate-500 text-xs italic py-2">Toutes les extensions sont dans ce groupe !</div>`;
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 = `<tr><td colspan="8" class="py-8 text-center text-slate-400 text-xs">Aucun groupe de téléavertissement configuré. Cliquez sur « + Nouveau Groupe de Téléavertissement ».</td></tr>`;
return;
}
tbody.innerHTML = rows.map(pg => {
const members = Array.isArray(pg.members) ? pg.members : [];
const badges = members.length
? members.map(m => `<span class="px-2 py-0.5 rounded bg-purple-500/20 text-purple-200 border border-purple-500/30 font-mono text-xs font-semibold">${escapeHtml(m)}</span>`).join(' ')
: '<span class="text-slate-500 text-xs italic">Aucun membre</span>';
const isDuplex = Number(pg.duplex) === 1;
const typeBadge = isDuplex
? `<span class="px-2 py-0.5 rounded text-[11px] font-semibold bg-indigo-500/20 text-indigo-300 border border-indigo-500/30">Duplex (2 voies)</span>`
: `<span class="px-2 py-0.5 rounded text-[11px] font-semibold bg-purple-500/20 text-purple-300 border border-purple-500/30">Simplex (Haut-parleur)</span>`;
const busyLabel = pg.busy_mode === 'force'
? `<span class="text-rose-300 text-xs font-semibold">Forcer</span>`
: `<span class="text-emerald-300 text-xs font-semibold">Ignorer si occupé</span>`;
const beepLabel = Number(pg.play_beep) === 1
? `<span class="text-emerald-400 text-xs font-semibold">✓ Oui</span>`
: `<span class="text-slate-400 text-xs">Non</span>`;
const statusBadge = Number(pg.enabled) === 1
? `<span class="px-2 py-0.5 rounded text-[10px] font-bold bg-emerald-500/20 text-emerald-300 border border-emerald-500/30">Actif</span>`
: `<span class="px-2 py-0.5 rounded text-[10px] font-bold bg-slate-700 text-slate-300 border border-slate-600">Inactif</span>`;
return `
<tr class="hover:bg-anthracite-750/60 transition">
<td class="py-3.5 px-4 font-mono font-bold text-purple-300 text-base">${escapeHtml(pg.extension)}</td>
<td class="py-3.5 px-4">
<div class="font-bold text-white">${escapeHtml(pg.name)}</div>
${pg.description ? `<div class="text-xs text-slate-400">${escapeHtml(pg.description)}</div>` : ''}
</td>
<td class="py-3.5 px-4">${typeBadge}</td>
<td class="py-3.5 px-4"><div class="flex flex-wrap gap-1">${badges}</div></td>
<td class="py-3.5 px-4">${busyLabel}</td>
<td class="py-3.5 px-4">${beepLabel}</td>
<td class="py-3.5 px-4">${statusBadge}</td>
<td class="py-3.5 px-4 text-right space-x-2">
<button type="button" onclick="editPagingGroup(${Number(pg.id)})" class="text-xs font-semibold text-purple-300 hover:text-purple-200">Modifier</button>
<button type="button" onclick="deletePagingGroup(${Number(pg.id)})" class="text-xs font-semibold text-red-400 hover:text-red-300">Supprimer</button>
</td>
</tr>
`;
}).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 += `
<button type="button" onclick="togglePagingMemberExt('${escapeHtml(extStr)}')"
class="group inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg bg-purple-500/20 hover:bg-red-500/20 border border-purple-500/40 hover:border-red-500/40 text-xs font-semibold text-purple-300 hover:text-red-300 transition cursor-pointer">
<span class="font-mono font-bold">${escapeHtml(extStr)}</span>
<span class="text-slate-300">(${escapeHtml(ext.name)})</span>
<span class="text-slate-400 group-hover:text-red-300 ml-1 font-bold">✕</span>
</button>
`;
hiddenHtml += `<input type="checkbox" name="members[]" value="${escapeHtml(extStr)}" checked>`;
} else {
blockedCount++;
blockedHtml += `
<button type="button" onclick="togglePagingMemberExt('${escapeHtml(extStr)}')"
class="group inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg bg-anthracite-950 hover:bg-purple-500/20 border border-anthracite-700 hover:border-purple-500/40 text-xs font-medium text-slate-300 hover:text-purple-300 transition cursor-pointer">
<span class="font-mono text-purple-300 font-bold">${escapeHtml(extStr)}</span>
<span class="text-slate-300">(${escapeHtml(ext.name)})</span>
<span class="text-slate-500 group-hover:text-purple-300 ml-1 font-bold">+</span>
</button>
`;
}
});
allowedBox.innerHTML = allowedHtml || `<div class="text-slate-500 text-xs italic py-2">Aucun poste qui reçoit l'annonce. Cliquez sur un poste à droite pour l'ajouter.</div>`;
blockedBox.innerHTML = blockedHtml || `<div class="text-slate-500 text-xs italic py-2">Tous les postes reçoivent l'annonce !</div>`;
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 = `<tr><td colspan="7" class="py-8 text-center text-slate-400 text-xs">Aucun Trunk SIP opérateur configuré. Cliquez sur « + Nouveau Trunk SIP ».</td></tr>`;
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 = `<span class="px-2.5 py-0.5 rounded-full text-xs font-medium bg-anthracite-950 text-slate-400 border border-anthracite-700">Désactivé</span>`;
} 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 = `<span class="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-bold bg-emerald-500/20 text-emerald-300 border border-emerald-500/30"><i class="w-1.5 h-1.5 rounded-full bg-emerald-400 animate-pulse"></i>Passerelle En ligne</span>`;
} else {
statusBadge = `<span class="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-semibold bg-amber-500/20 text-amber-300 border border-amber-500/30">En attente passerelle (Receive)</span>`;
}
} else if (isIpAuth || regMode === 'none') {
statusBadge = `<span class="px-2.5 py-0.5 rounded-full text-xs font-semibold bg-purple-500/15 text-purple-300 border border-purple-500/30">Peering IP direct</span>`;
} else if (regInfo && /Registered/i.test(regInfo.status)) {
statusBadge = `<span class="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-bold bg-emerald-500/20 text-emerald-300 border border-emerald-500/30"><i class="w-1.5 h-1.5 rounded-full bg-emerald-400 animate-pulse"></i>Registered</span>`;
} else if (regInfo) {
statusBadge = `<span class="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-bold bg-amber-500/20 text-amber-300 border border-amber-500/30">${escapeHtml(regInfo.status)}</span>`;
} else {
statusBadge = `<span class="px-2.5 py-0.5 rounded-full text-xs font-medium bg-anthracite-950 text-slate-300 border border-anthracite-700">En attente Apply</span>`;
}
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 => `<span class="px-1.5 py-0.5 rounded bg-indigo-500/20 text-indigo-300 text-[10px] font-mono">${b}</span>`).join(' ');
const mohSound = (appState.sounds || []).find(s => Number(s.id) === Number(t.moh_sound_id));
const mohBadge = mohSound
? `<span class="inline-flex items-center gap-1 px-1.5 py-0.5 rounded bg-amber-500/15 text-amber-300 border border-amber-500/30 text-[10px] font-semibold">🎵 ${escapeHtml(mohSound.name)}</span>`
: `<span class="text-[10px] text-slate-500">MoH : Globale</span>`;
return `
<tr class="hover:bg-anthracite-750/60 transition">
<td class="py-3.5 px-4">
<div class="font-semibold text-white">${escapeHtml(t.name)}</div>
<div class="text-[11px] font-mono text-slate-400">[${escapeHtml(slug)}]</div>
</td>
<td class="py-3.5 px-4">${statusBadge}</td>
<td class="py-3.5 px-4">
<div class="font-mono text-xs ${t.host ? 'text-indigo-300' : 'text-emerald-300'}">
${t.host ? escapeHtml(t.host) + ':' + Number(t.port || 5060) : 'Passerelle locale (Dynamique)'}
</div>
${t.outbound_proxy ? `<div class="text-[11px] font-mono text-slate-400 truncate max-w-[180px]">Proxy: ${escapeHtml(t.outbound_proxy)}</div>` : ''}
</td>
<td class="py-3.5 px-4">
<div class="font-mono text-xs text-slate-200">${escapeHtml(isIpAuth ? 'Auth par IP' : (t.username || '—'))}</div>
<div class="text-[11px] text-slate-400">${escapeHtml(t.from_domain || t.host)} ${paiBadges}</div>
</td>
<td class="py-3.5 px-4 text-xs">
<span class="px-2 py-0.5 rounded bg-anthracite-950 text-slate-200 uppercase font-mono border border-anthracite-700">${escapeHtml(t.transport)}</span>
<span class="ml-1 font-mono text-slate-300">${escapeHtml(t.codecs)}</span>
</td>
<td class="py-3.5 px-4">
<div class="font-mono text-xs text-emerald-300">→ ${escapeHtml(t.inbound_dest)}</div>
<div class="text-[11px] text-slate-400">
${t.cid_name_prefix ? `<span class="px-1.5 py-0.5 rounded bg-emerald-500/20 text-emerald-300 text-[10px] font-mono mr-1 font-semibold">CID: ${escapeHtml(t.cid_name_prefix)}</span>` : ''}
${didCount > 0 ? `<span class="text-indigo-300 font-semibold">${didCount} règle(s) entrante(s)</span>` : 'Tous numéros (Any)'}
${blockCount > 0 ? `<span class="ml-1 px-1.5 py-0.5 rounded bg-amber-500/20 text-amber-300 text-[10px] font-semibold">${blockCount} blocage(s) CID</span>` : ''}
${tcCount > 0 ? `<span class="ml-1 px-1.5 py-0.5 rounded bg-amber-500/20 text-amber-300 text-[10px] font-semibold">⏱️ ${tcCount} horaire(s)</span>` : ''}
</div>
<div class="mt-1">${mohBadge}</div>
</td>
<td class="py-3.5 px-4 text-right space-x-2">
${wantsReg ? `<button type="button" onclick="forceRegisterTrunk('${escapeHtml(slug)}-reg')" title="Forcer l'envoi immédiat d'un SIP REGISTER vers l'opérateur pour reprendre la ligne" class="px-2 py-1 rounded-lg bg-cyan-500/15 hover:bg-cyan-500/25 border border-cyan-500/30 text-xs font-semibold text-cyan-300 transition">🔄 Re-Register</button>` : ''}
<button type="button" onclick="editTrunk(${Number(t.id)}, 'inbound')" class="text-xs font-semibold text-emerald-300 hover:text-emerald-200">Routes Entrantes</button>
<button type="button" onclick="editTrunk(${Number(t.id)})" class="text-xs font-semibold text-indigo-300 hover:text-indigo-200">Modifier</button>
<button type="button" onclick="deleteTrunk(${Number(t.id)})" class="text-xs font-semibold text-red-400 hover:text-red-300">Supprimer</button>
</td>
</tr>
`;
}).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 += `<optgroup label="📱 Postes SIP (Extensions)">`;
exts.forEach(e => {
const val = `ext-${e.extension}`;
html += `<option value="${escapeHtml(val)}" ${normVal === val ? 'selected' : ''}>Poste: ${escapeHtml(e.extension)} — ${escapeHtml(e.name)}</option>`;
});
html += `</optgroup>`;
}
if (grps.length > 0) {
html += `<optgroup label="👥 Ring Groups (Groupes d'appel)">`;
grps.forEach(g => {
const val = `grp-${g.extension}`;
html += `<option value="${escapeHtml(val)}" ${normVal === val ? 'selected' : ''}>Ring Group: ${escapeHtml(g.extension)} — ${escapeHtml(g.name)}</option>`;
});
html += `</optgroup>`;
}
if (paging.length > 0) {
html += `<optgroup label="📢 Téléavertissement & Interphonie (Paging)">`;
paging.forEach(pg => {
const val = `page-${pg.extension}`;
html += `<option value="${escapeHtml(val)}" ${normVal === val ? 'selected' : ''}>Paging: ${escapeHtml(pg.extension)} — ${escapeHtml(pg.name)}</option>`;
});
html += `</optgroup>`;
}
if (ivrs.length > 0) {
html += `<optgroup label="🎙️ Serveurs Vocaux (SVI / IVR)">`;
ivrs.forEach(i => {
const val = i.name;
html += `<option value="${escapeHtml(val)}" ${normVal === val ? 'selected' : ''}>SVI / IVR: ${escapeHtml(i.name)} ${i.description ? '(' + escapeHtml(i.description) + ')' : ''}</option>`;
});
html += `</optgroup>`;
}
const vmExts = exts.filter(e => Number(e.voicemail_enabled) === 1);
const hasMissingVm = normVal.startsWith('vm-') && !vmExts.some(e => `vm-${e.extension}` === normVal);
if (vmExts.length > 0 || hasMissingVm) {
html += `<optgroup label="📬 Messagerie Vocale Directe (Répondeur)">`;
if (hasMissingVm) {
const missingExt = normVal.slice(3);
html += `<option value="${escapeHtml(normVal)}" selected>Répondeur: ${escapeHtml(missingExt)} (messagerie désactivée)</option>`;
}
vmExts.forEach(e => {
const val = `vm-${e.extension}`;
html += `<option value="${escapeHtml(val)}" ${normVal === val ? 'selected' : ''}>Répondeur: ${escapeHtml(e.extension)} — ${escapeHtml(e.name)}</option>`;
});
html += `</optgroup>`;
}
// Piège à Spammeurs / Musique d'attente en boucle continue (MoH)
const mohSounds = sounds.filter(s => s.usage_type === 'moh');
html += `<optgroup label="🎵 Piège à Spammeurs (Musique d'attente en boucle infinie)">`;
html += `<option value="moh-default" ${normVal === 'moh-default' ? 'selected' : ''}>🎵 Musique MoH générale par défaut (en boucle infinie)</option>`;
mohSounds.forEach(s => {
const val = `moh-${s.id}`;
html += `<option value="${escapeHtml(val)}" ${normVal === val ? 'selected' : ''}>🎵 Musique MoH dédiée : ${escapeHtml(s.name)} [${escapeHtml(s.site_category)}]</option>`;
});
html += `</optgroup>`;
const announcements = sounds.filter(s => s.usage_type !== 'moh');
if (announcements.length > 0) {
html += `<optgroup label="📢 Annonces Vocales (Diffuser 1 fois puis raccrocher)">`;
announcements.forEach(s => {
const val = `sound-${s.id}`;
html += `<option value="${escapeHtml(val)}" ${normVal === val ? 'selected' : ''}>Annonce : ${escapeHtml(s.name)} [${escapeHtml(s.site_category)}]</option>`;
});
html += `</optgroup>`;
html += `<optgroup label="👵 Piège Scammer / Fausse Voix en boucle infinie (Anti-Spam Tarpit)">`;
announcements.forEach(s => {
const val = `sound-loop-${s.id}`;
html += `<option value="${escapeHtml(val)}" ${normVal === val ? 'selected' : ''}>👵 Voix en boucle infinie (ex: vieille dame) : ${escapeHtml(s.name)}</option>`;
});
html += `</optgroup>`;
}
html += `
<optgroup label="🚫 Terminer l'appel (Terminate Call)">
<option value="terminate-hangup" ${normVal === 'terminate-hangup' || normVal === 'hangup' ? 'selected' : ''}>Raccrocher immédiatement (Hangup)</option>
<option value="terminate-busy" ${normVal === 'terminate-busy' || normVal === 'busy' ? 'selected' : ''}>Tonalité d'occupation (Busy)</option>
<option value="terminate-congestion" ${normVal === 'terminate-congestion' || normVal === 'congestion' ? 'selected' : ''}>Tonalité de congestion / réseau saturé (Congestion)</option>
<option value="terminate-zapateller" ${normVal === 'terminate-zapateller' || normVal === 'zapateller' ? 'selected' : ''}>Tonalité SIT Numéro non attribué (Anti-Spam / Robot)</option>
<option value="terminate-ringforever" ${normVal === 'terminate-ringforever' || normVal === 'ringforever' ? 'selected' : ''}>Faire sonner indéfiniment (Wait Ringing)</option>
</optgroup>
`;
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 if (val.startsWith('vm-')) {
selectEl.className = 'trunk-did-dest w-full px-2.5 py-1.5 rounded-lg bg-violet-500/15 border border-violet-500/40 text-xs text-violet-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 = `
<div class="grid grid-cols-12 gap-3 items-center px-4 py-2.5">
<div class="col-span-3">
<input type="text" placeholder="Any ou _0162XXXXXX" value="${escapeHtml(cid || 'Any')}"
class="trunk-did-cid w-full px-3 py-1.5 rounded-lg bg-anthracite-950 border border-anthracite-700 text-xs text-white font-mono">
<input type="hidden" value="${escapeHtml(did || 'Any')}" class="trunk-did-num">
</div>
<div class="col-span-3">
<input type="text" placeholder="ex: Ligne Standard" value="${escapeHtml(description)}"
class="trunk-did-desc w-full px-3 py-1.5 rounded-lg bg-anthracite-950 border border-anthracite-700 text-xs text-white">
<div class="time-badge-container mt-1"></div>
</div>
<div class="col-span-4">
<select class="trunk-did-dest w-full px-2.5 py-1.5 rounded-lg bg-anthracite-950 border border-anthracite-700 text-xs text-white"
onchange="styleDestinationSelect(this)">
${buildDestinationOptionsHtml(dest)}
</select>
</div>
<div class="col-span-2 flex items-center justify-end gap-1.5">
<button type="button" class="btn-toggle-time px-2 py-1.5 rounded-lg text-xs font-semibold border transition" title="Configurer les horaires d'ouverture (Mode Jour / Nuit)">
⏱️ <span class="hidden sm:inline">Horaires</span>
</button>
<button type="button" title="Dupliquer cette règle" class="btn-dup-route p-1.5 rounded-lg bg-anthracite-950 hover:bg-indigo-500/20 text-slate-300 hover:text-indigo-300 border border-anthracite-700 text-xs">⧉</button>
<button type="button" title="Supprimer cette règle" class="btn-del-route p-1.5 rounded-lg bg-anthracite-950 hover:bg-red-500/20 text-slate-300 hover:text-red-300 border border-anthracite-700 text-xs">🗑️</button>
</div>
</div>
<!-- Tiroir rétractable des conditions horaires (Mode Jour / Nuit) -->
<div class="time-drawer hidden px-4 pb-3.5 pt-2 border-t border-anthracite-700/50 bg-anthracite-950/80 space-y-3">
<div class="flex flex-wrap items-center justify-between gap-2">
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" class="trunk-tc-enabled rounded bg-anthracite-900 border-anthracite-600 text-amber-500 focus:ring-0" ${tcEnabled ? 'checked' : ''}>
<span class="text-xs font-bold text-amber-300">Activer la condition horaire (Mode Jour / Nuit)</span>
</label>
<span class="text-[11px] text-slate-400">Si actif, l'appel bascule automatiquement selon l'heure de réception</span>
</div>
<div class="time-fields-grid grid grid-cols-1 sm:grid-cols-12 gap-3 items-center ${tcEnabled ? '' : 'opacity-50 pointer-events-none'}">
<div class="sm:col-span-3">
<label class="block text-[10px] uppercase font-bold text-slate-400 mb-1">Heures d'ouverture</label>
<div class="flex items-center gap-1.5">
<input type="time" class="trunk-tc-start px-2 py-1 rounded bg-anthracite-900 border border-anthracite-700 text-xs text-white" value="${escapeHtml(tcStart)}">
<span class="text-slate-400 text-xs">à</span>
<input type="time" class="trunk-tc-finish px-2 py-1 rounded bg-anthracite-900 border border-anthracite-700 text-xs text-white" value="${escapeHtml(tcFinish)}">
</div>
</div>
<div class="sm:col-span-4">
<label class="block text-[10px] uppercase font-bold text-slate-400 mb-1">Jours actifs</label>
<select class="trunk-tc-weekdays w-full px-2 py-1 rounded bg-anthracite-900 border border-anthracite-700 text-xs text-white">
<option value="mon-fri" ${tcWeekdays === 'mon-fri' ? 'selected' : ''}>Du Lundi au Vendredi (Semaine)</option>
<option value="mon-sat" ${tcWeekdays === 'mon-sat' ? 'selected' : ''}>Du Lundi au Samedi</option>
<option value="*" ${tcWeekdays === '*' ? 'selected' : ''}>Tous les jours (7j/7)</option>
<option value="sat-sun" ${tcWeekdays === 'sat-sun' ? 'selected' : ''}>Week-end uniquement (Sam-Dim)</option>
</select>
</div>
<div class="sm:col-span-5">
<label class="block text-[10px] uppercase font-bold text-red-400 mb-1">Destination si FERMÉ (Hors horaires)</label>
<select class="trunk-tc-closed-dest w-full px-2.5 py-1 rounded bg-anthracite-900 border border-anthracite-700 text-xs text-white">
${buildDestinationOptionsHtml(tcClosed)}
</select>
</div>
</div>
<div class="flex flex-wrap items-center gap-2 pt-2 border-t border-anthracite-800 text-xs">
<label class="text-[11px] font-semibold text-slate-300 flex items-center gap-1">
<span>🏷️ Préfixe CID spécifique à cette règle :</span>
</label>
<input type="text" placeholder="ex: [Ligne-1] (vide = hériter du Trunk)" value="${escapeHtml(tcCidPrefix)}"
class="trunk-did-prefix px-2.5 py-1 rounded bg-anthracite-900 border border-anthracite-700 text-xs text-white font-mono w-56 focus:outline-none focus:border-indigo-500">
<span class="text-[10px] text-slate-400">Ajouté devant le nom de l'appelant sur le téléphone (laisser vide pour utiliser le préfixe général du Trunk).</span>
</div>
</div>
`;
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 = `<span class="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[10px] font-semibold bg-amber-500/15 text-amber-300 border border-amber-500/30">⏱️ ${escapeHtml(tcStartInput.value)}-${escapeHtml(tcFinishInput.value)} (${escapeHtml(daysLabel)})</span>`;
} 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 <span class="text-emerald-400 font-semibold">(Optionnel en mode Réception)</span>';
}
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 * <span class="text-slate-400">(Host / IP)</span>';
}
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 * <span class="text-slate-400">(Host / IP)</span>';
}
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;
form.elements['trust_id_outbound'].checked = trunk ? Number(trunk.trust_id_outbound ?? 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 = `<option value="0" ${curMohId === 0 ? 'selected' : ''}>🌐 Hériter de la musique générale par défaut</option>`;
mohSounds.forEach(s => {
const isSel = (Number(s.id) === curMohId) ? 'selected' : '';
opts += `<option value="${s.id}" ${isSel}>🎵 ${escapeHtml(s.name)} (${escapeHtml(s.site_category || 'Global')})</option>`;
});
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 = `<tr><td colspan="7" class="py-8 text-center text-slate-400 text-xs">Aucune route sortante configurée. Cliquez sur « + Ajout route sortante ».</td></tr>`;
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) => `
<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded-lg ${tIdx === 0 ? 'bg-indigo-500/20 text-indigo-200 border border-indigo-500/30 font-bold' : 'bg-anthracite-950 text-slate-300 border border-anthracite-700'} text-xs font-mono">
<span class="text-[10px] text-slate-400">${tIdx + 1}.</span>${escapeHtml(t)}
</span>
`).join(' <span class="text-slate-500 text-xs">→</span> ')
: `<span class="text-amber-400 text-xs font-semibold">⚠️ Aucun Trunk associé</span>`;
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 = `<span class="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-xs font-semibold bg-emerald-500/15 text-emerald-300 border border-emerald-500/30">✓ Tous Postes & Groupes</span>`;
} else {
const grpTags = allowedGrps.map(g => `<span class="px-1.5 py-0.2 rounded bg-cyan-500/20 text-cyan-300 text-[11px] font-bold">Grp ${escapeHtml(g)}</span>`).join(' ');
const extTags = allowedExts.length > 0 ? `<span class="text-[11px] text-indigo-300 font-mono">${allowedExts.length} poste(s)</span>` : '';
permBadge = `
<div class="space-y-1">
<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[11px] font-bold bg-cyan-500/15 text-cyan-300 border border-cyan-500/30">Restreint</span>
<div class="flex flex-wrap gap-1">${grpTags} ${extTags}</div>
</div>
`;
}
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'
? `<span class="px-2 py-0.5 rounded-full text-[10px] font-bold bg-amber-500/20 text-amber-300 border border-amber-500/40">🚑 Urgence</span>`
: (r.route_type === 'intracompany'
? `<span class="px-2 py-0.5 rounded-full text-[10px] font-bold bg-cyan-500/20 text-cyan-300 border border-cyan-500/40">Intra</span>`
: `<span class="px-2 py-0.5 rounded-full text-[10px] font-medium bg-anthracite-950 text-slate-300 border border-anthracite-700">Standard</span>`);
const isFirst = idx === 0;
const isLast = idx === routes.length - 1;
return `
<tr class="hover:bg-anthracite-750/60 transition">
<td class="py-3.5 px-4">
<div class="flex items-center gap-1">
<span class="w-6 h-6 rounded-lg bg-indigo-600/30 border border-indigo-500/40 text-indigo-300 font-mono font-bold text-xs flex items-center justify-center">#${Number(r.priority)}</span>
<div class="flex flex-col gap-0.5">
<button type="button" onclick="reorderOutboundRoute(${Number(r.id)}, 'up')" ${isFirst ? 'disabled class="opacity-20 cursor-not-allowed"' : 'class="hover:text-white text-slate-400"'} title="Monter">▲</button>
<button type="button" onclick="reorderOutboundRoute(${Number(r.id)}, 'down')" ${isLast ? 'disabled class="opacity-20 cursor-not-allowed"' : 'class="hover:text-white text-slate-400"'} title="Descendre">▼</button>
</div>
</div>
</td>
<td class="py-3.5 px-4">
<div class="font-bold text-white text-sm flex items-center gap-2">
<span>${escapeHtml(r.name)}</span>
${rTypeBadge}
</div>
</td>
<td class="py-3.5 px-4 font-mono text-xs text-indigo-300">
${r.outbound_callerid ? escapeHtml(r.outbound_callerid) : '<span class="text-slate-500 italic">CID du poste ou du trunk</span>'}
${Number(r.override_ext_cid) === 1 ? '<span class="ml-1 text-[10px] text-amber-300 font-bold">(Force)</span>' : ''}
</td>
<td class="py-3.5 px-4">
<div class="flex flex-wrap items-center gap-1">${trunkBadges}</div>
</td>
<td class="py-3.5 px-4">${permBadge}</td>
<td class="py-3.5 px-4">
<div class="text-xs font-semibold text-slate-200">${patCount} masque(s)</div>
<div class="text-[11px] font-mono text-slate-400 truncate max-w-[160px]">${patPreview}${patCount > 2 ? '...' : ''}</div>
</td>
<td class="py-3.5 px-4 text-right space-x-2">
<button type="button" onclick="editOutboundRoute(${Number(r.id)})" class="text-xs font-semibold text-emerald-300 hover:text-emerald-200">Éditer</button>
<button type="button" onclick="deleteOutboundRoute(${Number(r.id)})" class="text-xs font-semibold text-red-400 hover:text-red-300">Supprimer</button>
</td>
</tr>
`;
}).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 = `
<optgroup label="📢 Messages Vocaux (Recommandé)">
<option value="all-circuits-busy" ${(curCong === 'all-circuits-busy' || (!route && curCong !== 'terminate-congestion')) ? 'selected' : ''}>📢 Message vocal standard (« Toutes les lignes sont occupées »)</option>
`;
const annSounds = (appState.sounds || []).filter(s => s.usage_type !== 'moh');
if (annSounds.length > 0) {
annSounds.forEach(s => {
const val = `sound-${s.id}`;
congOpts += `<option value="${val}" ${curCong === val ? 'selected' : ''}>🎙️ Annonce : ${escapeHtml(s.name)}</option>`;
});
}
congOpts += `
</optgroup>
<optgroup label="📴 Tonalités & Raccrochage">
<option value="terminate-congestion" ${curCong === 'terminate-congestion' ? 'selected' : ''}>Tonalité de Congestion (Bip rapide réseau saturé)</option>
<option value="terminate-busy" ${curCong === 'terminate-busy' ? 'selected' : ''}>Tonalité d'Occupation (Bip occupé)</option>
<option value="terminate-hangup" ${curCong === 'terminate-hangup' ? 'selected' : ''}>Raccrocher immédiatement</option>
</optgroup>
`;
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 = `<option value="default" ${curMoh === 'default' ? 'selected' : ''}>🌐 Hériter du Trunk / Global (Recommandé)</option>`;
mohSounds.forEach(s => {
const sSlug = slugifyTrunkName(s.site_category || 'global');
const cName = (sSlug === 'global' || sSlug === 'default') ? 'default' : `moh-${sSlug}`;
if (cName !== 'default') {
mohOpts += `<option value="${cName}" ${curMoh === cName ? 'selected' : ''}>${escapeHtml(s.name)} [${escapeHtml(s.site_category)}]</option>`;
}
});
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 = `<span class="text-slate-500 text-xs col-span-3">Aucun groupe d'extensions configuré. Allez dans « Groupes » > « Groupes d'extensions » pour créer le Groupe Nice, Groupe Paris, etc.</span>`;
} 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 `
<label class="flex items-center gap-2.5 p-2.5 rounded-xl bg-anthracite-900 border border-anthracite-700 hover:border-cyan-400 cursor-pointer">
<input type="checkbox" name="ob_allowed_groups[]" value="${escapeHtml(g.name)}" ${isChecked ? 'checked' : ''} class="rounded bg-anthracite-950 border-anthracite-600">
<div class="leading-tight">
<div class="text-xs font-bold text-white">${escapeHtml(g.name)}</div>
<div class="text-[10px] text-cyan-300 font-mono">${mCount} poste(s)${g.description ? ` • ${escapeHtml(g.description)}` : ''}</div>
</div>
</label>
`;
}).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 `<option value="${escapeHtml(t.name)}" ${isSel ? 'selected' : ''}>${escapeHtml(t.name)} (${escapeHtml(t.host)}:${Number(t.port)})</option>`;
}).join('');
const currentCount = container.querySelectorAll('.ob-trunk-seq-row').length + 1;
row.innerHTML = `
<span class="w-7 h-7 rounded-lg bg-indigo-500/20 text-indigo-300 border border-indigo-500/30 font-bold text-xs flex items-center justify-center shrink-0">
${currentCount}
</span>
<select class="ob-trunk-select flex-1 px-3 py-2 rounded-lg bg-anthracite-900 border border-anthracite-700 text-xs text-white font-medium">
${optsHtml || '<option value="">Aucun Trunk disponible (Créez un Trunk)</option>'}
</select>
<button type="button" class="btn-del-trunk-row p-2 rounded-lg bg-anthracite-900 hover:bg-red-500/20 text-slate-400 hover:text-red-300 border border-anthracite-700 text-xs">🗑️</button>
`;
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 = `
<div class="col-span-2">
<input type="text" placeholder="prepend" value="${escapeHtml(prepend)}"
class="ob-pat-prepend w-full px-2.5 py-1.5 rounded-lg bg-anthracite-950 border border-anthracite-700 text-xs text-white font-mono">
</div>
<div class="col-span-2">
<input type="text" placeholder="préfixe |" value="${escapeHtml(prefix)}"
class="ob-pat-prefix w-full px-2.5 py-1.5 rounded-lg bg-anthracite-950 border border-anthracite-700 text-xs text-white font-mono">
</div>
<div class="col-span-5">
<input type="text" placeholder="0[1-59]XXXXXXXX ou X." value="${escapeHtml(match)}" required
class="ob-pat-match w-full px-2.5 py-1.5 rounded-lg bg-anthracite-950 border border-anthracite-700 text-xs text-emerald-300 font-mono font-semibold">
</div>
<div class="col-span-2">
<input type="text" placeholder="/ CallerID" value="${escapeHtml(cid)}"
class="ob-pat-cid w-full px-2.5 py-1.5 rounded-lg bg-anthracite-950 border border-anthracite-700 text-xs text-indigo-300 font-mono">
</div>
<div class="col-span-1 text-right">
<button type="button" class="btn-del-pat p-1.5 rounded-lg bg-anthracite-950 hover:bg-red-500/20 text-slate-400 hover:text-red-300 border border-anthracite-700 text-xs">🗑️</button>
</div>
`;
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 += `
<button type="button" onclick="obToggleSingleExt('${escapeHtml(eStr)}')"
class="group px-3 py-1.5 rounded-xl bg-emerald-500/15 hover:bg-amber-500/20 border border-emerald-500/30 hover:border-amber-500/40 text-xs font-semibold text-emerald-300 hover:text-amber-300 transition cursor-pointer flex items-center gap-1.5">
<span class="font-mono">${escapeHtml(eStr)}</span>
<span class="text-slate-200">(${escapeHtml(e.name)})</span>
<span class="text-slate-400 group-hover:text-amber-300 ml-1">→</span>
</button>
`;
} else {
blockedHtml += `
<button type="button" onclick="obToggleSingleExt('${escapeHtml(eStr)}')"
class="group px-3 py-1.5 rounded-xl bg-anthracite-950 hover:bg-emerald-500/20 border border-anthracite-700 hover:border-emerald-500/40 text-xs font-medium text-slate-300 hover:text-emerald-300 transition cursor-pointer flex items-center gap-1.5">
<span class="text-slate-400 group-hover:text-emerald-300 mr-1">←</span>
<span class="font-mono text-indigo-300 font-bold">${escapeHtml(eStr)}</span>
<span class="text-slate-300">(${escapeHtml(e.name)})</span>
</button>
`;
}
});
allowedBox.innerHTML = allowedHtml || `<div class="text-slate-500 text-xs italic py-2">Aucun poste individuel autorisé.</div>`;
blockedBox.innerHTML = blockedHtml || `<div class="text-slate-500 text-xs italic py-2">Tous les postes sont autorisés sur cette route !</div>`;
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 = `<tr><td colspan="7" class="py-8 text-center text-slate-400 text-xs">Aucun serveur vocal interactif configuré. Cliquez sur « + Nouveau SVI / IVR » pour en créer un.</td></tr>`;
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 `<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded-lg bg-anthracite-950 border border-anthracite-700 text-xs font-mono shadow-sm">
<span class="w-4 h-4 rounded bg-indigo-500/20 text-indigo-300 font-bold flex items-center justify-center text-[10px]">${escapeHtml(k)}</span>
<span class="text-slate-400">→</span>
<span class="text-slate-200 font-semibold">${escapeHtml(label)}</span>
</span>`;
}).join(' ');
const directDialBadge = Number(ivr.direct_dial) !== 0
? `<span class="inline-flex items-center gap-1 px-2.5 py-1 rounded-full bg-emerald-500/10 text-emerald-400 border border-emerald-500/30 text-[11px] font-semibold">
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/></svg>
Activée
</span>`
: `<span class="inline-flex items-center gap-1 px-2.5 py-1 rounded-full bg-slate-500/10 text-slate-400 border border-slate-500/30 text-[11px] font-semibold">Désactivée</span>`;
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)} <span class="text-[10px] text-slate-400">(${escapeHtml(foundSound.site_category)})</span>`;
}
return `
<tr class="hover:bg-anthracite-750/60 transition">
<td class="py-3.5 px-4">
<div class="font-bold text-white flex items-center gap-2">
<span class="w-2 h-2 rounded-full bg-indigo-400"></span>
<span>${escapeHtml(ivr.name)}</span>
</div>
${ivr.description ? `<div class="text-[11px] text-slate-400 mt-0.5">${escapeHtml(ivr.description)}</div>` : ''}
</td>
<td class="py-3.5 px-4 text-xs font-mono text-slate-200">
<div class="flex items-center gap-1.5">
<svg class="w-3.5 h-3.5 text-amber-400 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.536 8.464a5 5 0 010 7.072m2.828-9.9a9 9 0 010 12.728M5.586 15H4a1 1 0 01-1-1v-4a1 1 0 011-1h1.586l4.707-4.707C10.923 3.663 12 4.109 12 5v14c0 .891-1.077 1.337-1.707.707L5.586 15z"/>
</svg>
<span>${audioName}</span>
</div>
</td>
<td class="py-3.5 px-4">${directDialBadge}</td>
<td class="py-3.5 px-4">
<div class="flex flex-wrap gap-1.5 max-w-xs">
${badges || '<span class="text-xs text-slate-400 italic">Aucune touche</span>'}
</div>
</td>
<td class="py-3.5 px-4 text-xs text-slate-300">
<div>⏱️ Attente : <strong class="text-white">${Number(ivr.timeout)}s</strong> <span class="text-slate-400">(${Number(ivr.timeout_retries || 2)} rép)</span></div>
<div class="text-[11px] text-slate-400 mt-0.5">⚠️ Erreur : max <strong class="text-slate-200">${Number(ivr.invalid_retries || 3)}</strong> essais</div>
</td>
<td class="py-3.5 px-4">
<span class="inline-flex items-center gap-1 px-2.5 py-1 rounded-lg bg-indigo-500/10 text-indigo-300 border border-indigo-500/30 text-xs font-mono font-bold" title="Composer ce code depuis un poste interne pour tester le SVI">
<svg class="w-3 h-3 text-indigo-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z"/></svg>
${testCode}
</span>
</td>
<td class="py-3.5 px-4 text-right space-x-2">
<button type="button" onclick="editIvr(${Number(ivr.id)})" class="text-xs font-semibold text-indigo-300 hover:text-indigo-200 transition">Modifier</button>
<button type="button" onclick="deleteIvr(${Number(ivr.id)})" class="text-xs font-semibold text-red-400 hover:text-red-300 transition">Supprimer</button>
</td>
</tr>
`;
}).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 => `<option value="${d}" ${String(digit) === d ? 'selected' : ''}>Touche ${d}</option>`).join('');
row.innerHTML = `
<div class="flex items-center gap-1.5 flex-shrink-0">
<span class="text-xs text-slate-400 font-mono">Touche :</span>
<select class="ivr-digit px-2.5 py-1.5 rounded-lg bg-anthracite-900 border border-anthracite-700 text-xs font-bold text-indigo-300 font-mono focus:outline-none focus:border-indigo-500">
${digitOptions}
</select>
</div>
<span class="text-slate-400 font-bold">→</span>
<div class="flex-1 min-w-0">
<select class="ivr-dest w-full px-2.5 py-1.5 rounded-lg bg-anthracite-900 border border-anthracite-700 text-xs text-white focus:outline-none focus:border-indigo-500">
${buildDestinationOptionsHtml(dest || 'ext-101')}
</select>
</div>
<button type="button" class="p-1.5 text-slate-400 hover:text-red-400 hover:bg-red-500/10 rounded-lg transition" title="Supprimer cette touche">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
`;
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 += `<optgroup label="📂 Fichiers sons de la médiathèque">`;
sounds.forEach(s => {
const val = `custom/${s.slug}`;
const isSel = curAudio === val || curAudio === s.slug || curAudio === String(s.id);
audioHtml += `<option value="${escapeHtml(val)}" ${isSel ? 'selected' : ''}>${escapeHtml(s.name)} [${escapeHtml(s.site_category)} • ${escapeHtml(s.usage_type)}]</option>`;
});
audioHtml += `</optgroup>`;
}
audioHtml += `
<optgroup label="🔔 Messages système Asterisk par défaut">
<option value="demo-instruct" ${curAudio === 'demo-instruct' || !curAudio ? 'selected' : ''}>demo-instruct (Instructions d'accueil)</option>
<option value="hello-world" ${curAudio === 'hello-world' ? 'selected' : ''}>hello-world (Message d'accueil basique)</option>
<option value="beep" ${curAudio === 'beep' ? 'selected' : ''}>beep (Simple bip sonore)</option>
</optgroup>
`;
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 = `<option value="0" ${curInvId === 0 ? 'selected' : ''}>Message par défaut (pbx-invalid : « Choix invalide »)</option>`;
(appState.sounds || []).forEach(s => {
invSoundHtml += `<option value="${s.id}" ${curInvId === Number(s.id) ? 'selected' : ''}>${escapeHtml(s.name)} [${escapeHtml(s.site_category)}]</option>`;
});
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 = `<option value="0" ${curToutId === 0 ? 'selected' : ''}>Aucun message supplémentaire (rejouer annonce)</option>`;
(appState.sounds || []).forEach(s => {
toutSoundHtml += `<option value="${s.id}" ${curToutId === Number(s.id) ? 'selected' : ''}>${escapeHtml(s.name)} [${escapeHtml(s.site_category)}]</option>`;
});
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 = `<option value="all">Tous les sites</option>`;
soundSites.forEach(st => {
const isSel = (st.name === curVal) ? 'selected' : '';
siteOpts += `<option value="${escapeHtml(st.name)}" ${isSel}>${escapeHtml(st.name)}</option>`;
});
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 = `<option value="">Aucune musique d'attente disponible</option>`;
} else {
defMohSelect.innerHTML = mohSounds.map(s => {
const isSel = (Number(s.id) === curDefaultId) ? 'selected' : '';
return `<option value="${s.id}" ${isSel}>${escapeHtml(s.name)} [${escapeHtml(s.site_category || 'Global')}]</option>`;
}).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 = `<tr><td colspan="7" class="py-8 text-center text-slate-400 text-xs">Aucun fichier son trouvé pour les filtres sélectionnés. Cliquez sur « + Téléverser un Fichier Son ».</td></tr>`;
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 = `<span class="px-2.5 py-1 rounded-lg text-xs font-semibold bg-amber-500/15 text-amber-300 border border-amber-500/30">${escapeHtml(snd.site_category || 'Global')}</span>`;
const usageBadge = `<span class="inline-flex items-center px-2.5 py-1 rounded-lg text-xs font-medium bg-anthracite-950 border border-anthracite-700 text-slate-200">${escapeHtml(usage.label || snd.usage_type)}</span>`;
const defaultBadge = (Number(snd.id) === curDefaultMohId)
? `<span class="ml-2 px-2 py-0.5 rounded text-[10px] font-bold bg-amber-500/20 text-amber-300 border border-amber-500/40">Par défaut</span>`
: '';
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 `
<tr class="hover:bg-anthracite-750/60 transition">
<td class="py-3.5 px-4">
<div class="font-bold text-white text-sm flex items-center">
<span>${escapeHtml(snd.name)}</span>
${defaultBadge}
</div>
<div class="text-xs text-slate-400 mt-0.5">${escapeHtml(snd.description || '—')}</div>
</td>
<td class="py-3.5 px-4">${siteBadge}</td>
<td class="py-3.5 px-4">${usageBadge}</td>
<td class="py-3.5 px-4">
<audio controls preload="none" class="h-8 w-48 sm:w-56 rounded bg-anthracite-900 border border-anthracite-700" src="${audioUrl}"></audio>
</td>
<td class="py-3.5 px-4 font-mono text-xs text-slate-300 whitespace-nowrap">
<div>${formatDurationSec(snd.duration_sec)}</div>
<div class="text-[10px] text-slate-500">${formatBytes(snd.file_size)} • ${escapeHtml((snd.format || 'wav').toUpperCase())}</div>
</td>
<td class="py-3.5 px-4">
<div class="inline-flex items-center gap-1.5 px-2 py-1 rounded bg-anthracite-950 border border-anthracite-700 font-mono text-xs text-amber-300">
<span>${astRef}</span>
<button type="button" onclick="navigator.clipboard.writeText('${astRef}'); showToast('Code copié : ${astRef}', 'info');" title="Copier la référence Asterisk" class="text-slate-400 hover:text-white text-[11px] font-sans underline">Copier</button>
</div>
</td>
<td class="py-3.5 px-4 text-right space-x-2 whitespace-nowrap">
<button type="button" onclick="editSound(${Number(snd.id)})" class="text-xs font-semibold text-amber-400 hover:text-amber-300">Modifier</button>
<button type="button" onclick="deleteSound(${Number(snd.id)})" class="text-xs font-semibold text-red-400 hover:text-red-300">Supprimer</button>
</td>
</tr>
`;
}).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 = `<tr><td colspan="4" class="py-6 text-center text-slate-400">Aucune catégorie de site définie.</td></tr>`;
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
? `<span class="text-slate-600 text-xs italic" title="Socle standard du système, non supprimable">Protégé</span>`
: `<button type="button" onclick="deleteSoundSite(${Number(st.id)})" class="text-xs font-semibold text-red-400 hover:text-red-300">Supprimer</button>`;
return `
<tr class="hover:bg-anthracite-900/50 transition">
<td class="py-2.5 px-4 font-semibold text-white">
${escapeHtml(st.name)}
${isGlobal ? '<span class="ml-1.5 px-1.5 py-0.5 rounded text-[10px] bg-slate-800 text-slate-300 border border-slate-700">Système</span>' : ''}
</td>
<td class="py-2.5 px-4 text-slate-300">${escapeHtml(st.description || '—')}</td>
<td class="py-2.5 px-4 text-slate-300">
<span class="px-2 py-0.5 rounded-full text-xs font-mono bg-anthracite-900 border border-anthracite-700">${count} fichier${count > 1 ? 's' : ''}</span>
</td>
<td class="py-2.5 px-4 text-right space-x-2 whitespace-nowrap">
<button type="button" onclick="editSoundSite(${Number(st.id)})" class="text-xs font-semibold text-amber-400 hover:text-amber-300">Modifier</button>
${deleteBtn}
</td>
</tr>
`;
}).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 `<option value="${escapeHtml(st.name)}" ${isSel}>${escapeHtml(st.name)}</option>`;
}).join('');
if (curSite && !sites.some(st => st.name === curSite)) {
siteSelect.insertAdjacentHTML('afterbegin', `<option value="${escapeHtml(curSite)}" selected>${escapeHtml(curSite)}</option>`);
}
}
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 => `
<div class="flex items-start gap-3 p-3 rounded-xl bg-anthracite-950 border ${c.ok ? 'border-anthracite-700' : 'border-amber-500/40'}">
<span class="mt-0.5 w-5 h-5 rounded-full flex items-center justify-center text-xs font-bold ${c.ok ? 'bg-emerald-500/20 text-emerald-300' : 'bg-amber-500/20 text-amber-300'}">
${c.ok ? '✓' : '!'}
</span>
<div class="flex-1 min-w-0">
<div class="text-xs font-semibold text-white">${escapeHtml(c.label)}</div>
<div class="text-[11px] text-slate-300 break-words mt-0.5">${escapeHtml(c.detail)}</div>
</div>
</div>
`).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',
cdr: 'Journal d\'Appels & Historique CDR (Call Detail Records)',
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)',
ucp: 'Portail Utilisateur (UCP) — Comptes & Droits',
cluster: 'Haute Disponibilité & Redondance (Cluster HA Keepalived)'
};
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' || tabId === 'cluster') {
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 Journal d'appels, charger l'historique CDR
if (tabId === 'cdr') {
loadCdr();
}
// Si onglet Pare-feu, charger le statut en direct
if (tabId === 'firewall') {
loadFirewallStatus();
}
// Si onglet Cluster HA, charger les interfaces et le statut en direct
if (tabId === 'cluster') {
loadCluster();
}
// Si onglet Portail Utilisateur (UCP), actualiser l'affichage
if (tabId === 'ucp') {
renderUcpTab();
}
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.trust_id_outbound = form.elements['trust_id_outbound'].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),
rtp_timeout_hold: Number(document.getElementById('sip-rtp-timeout-hold')?.value || 0),
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 rawUcpHttps = f.elements['ucp_https_port']?.value;
const rawUcpHttp = f.elements['ucp_http_port']?.value;
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(),
ucp_enabled: f.elements['ucp_enabled']?.checked ? 1 : 0,
ucp_https_port: rawUcpHttps !== '' && rawUcpHttps !== undefined ? Number(rawUcpHttps) : 8444,
ucp_http_port: rawUcpHttp !== '' && rawUcpHttp !== undefined ? Number(rawUcpHttp) : 8084,
fail2ban_ucp_maxretry: Number(f.elements['fail2ban_ucp_maxretry']?.value || 3),
fail2ban_ucp_bantime: Number(f.elements['fail2ban_ucp_bantime']?.value || 3600)
};
try {
const res = await apiCall('save_firewall_settings', payload);
showToast(res.message, 'success');
await loadFirewallStatus();
if (typeof loadState === 'function') {
await loadState();
}
} 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 = `
<tr>
<td colspan="7" class="py-8 text-center text-slate-400">
Aucun certificat enregistré. Cliquez sur « Générer via Let's Encrypt » ou « Importer un Certificat ».
</td>
</tr>
`;
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 = `<span class="px-2 py-0.5 rounded-md text-[10px] font-bold bg-emerald-500/20 text-emerald-300 border border-emerald-500/40">Let's Encrypt</span>`;
} else if (c.type === 'custom') {
typeBadge = `<span class="px-2 py-0.5 rounded-md text-[10px] font-bold bg-cyan-500/20 text-cyan-300 border border-cyan-500/40">Import Custom</span>`;
} else {
typeBadge = `<span class="px-2 py-0.5 rounded-md text-[10px] font-bold bg-anthracite-700 text-slate-300 border border-anthracite-600">Auto-signé</span>`;
}
let validityBadge = '';
if (days < 0) {
validityBadge = `<span class="px-2 py-0.5 rounded-md text-[10px] font-bold bg-rose-500/20 text-rose-300 border border-rose-500/40">Expiré (${c.valid_to ? c.valid_to.substring(0, 10) : ''})</span>`;
} else if (days <= 15) {
validityBadge = `<span class="px-2 py-0.5 rounded-md text-[10px] font-bold bg-amber-500/20 text-amber-300 border border-amber-500/40">${days}j restants (${c.valid_to ? c.valid_to.substring(0, 10) : ''})</span>`;
} else {
validityBadge = `<span class="px-2 py-0.5 rounded-md text-[10px] font-bold bg-emerald-500/20 text-emerald-300 border border-emerald-500/40">${days}j restants (${c.valid_to ? c.valid_to.substring(0, 10) : ''})</span>`;
}
let defaultBadge = '';
if (isDefault) {
defaultBadge = `
<div class="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg bg-emerald-500/20 text-emerald-300 border border-emerald-500/40 font-bold text-[11px]">
<span class="w-1.5 h-1.5 rounded-full bg-emerald-400 animate-pulse"></span>
<span>ACTIF PAR DÉFAUT</span>
</div>
`;
} else {
defaultBadge = `
<button type="button" onclick="setDefaultCertificate(${c.id})"
class="px-2.5 py-1 rounded-lg bg-anthracite-700 hover:bg-emerald-600 hover:text-white text-slate-300 border border-anthracite-600 font-semibold text-xs transition">
Activer
</button>
`;
}
let actions = '<div class="flex items-center justify-end gap-1.5">';
if (c.type === 'letsencrypt') {
actions += `
<button type="button" onclick="renewCertificate(${c.id})" title="Renouveler ce certificat Let's Encrypt"
class="p-1.5 rounded-lg text-emerald-400 hover:bg-emerald-500/20 border border-emerald-500/30 transition">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/></svg>
</button>
`;
}
if (!isDefault) {
actions += `
<button type="button" onclick="deleteCertificate(${c.id}, '${escapeHtml(c.name)}')" title="Supprimer ce certificat"
class="p-1.5 rounded-lg text-rose-400 hover:bg-rose-500/20 border border-rose-500/30 transition">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/></svg>
</button>
`;
}
actions += '</div>';
const sanDisplay = c.alt_names ? `<div class="text-[10px] text-slate-400 font-mono mt-0.5 truncate max-w-xs" title="${escapeHtml(c.alt_names)}">+ ${escapeHtml(c.alt_names)}</div>` : '';
return `
<tr class="hover:bg-anthracite-900/50 transition">
<td class="py-3 px-4 font-semibold text-white">
<div class="flex items-center gap-2">
<span>${escapeHtml(c.name)}</span>
${isDefault ? '<span class="text-emerald-400" title="Actif en production">★</span>' : ''}
</div>
</td>
<td class="py-3 px-4">${typeBadge}</td>
<td class="py-3 px-4 font-mono text-slate-200">
<div class="font-bold text-white">${escapeHtml(c.primary_domain)}</div>
${sanDisplay}
</td>
<td class="py-3 px-4 text-slate-300 font-medium">${escapeHtml(c.issuer || '-')}</td>
<td class="py-3 px-4">${validityBadge}</td>
<td class="py-3 px-4 text-center">${defaultBadge}</td>
<td class="py-3 px-4 text-right">${actions}</td>
</tr>
`;
}).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 rtpHoldTimeoutEl = document.getElementById('sip-rtp-timeout-hold');
if (rtpHoldTimeoutEl && document.activeElement !== rtpHoldTimeoutEl) {
rtpHoldTimeoutEl.value = (s.rtp_timeout_hold !== undefined && s.rtp_timeout_hold !== null) ? s.rtp_timeout_hold : 0;
}
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 = `
<div class="relative flex-1">
<input type="text" name="local_networks[]" value="${escapeHtml(value)}" placeholder="ex: 10.10.0.0/16 ou 192.168.1.0/24"
class="w-full px-3 py-2 rounded-xl bg-anthracite-950 border border-anthracite-700 text-xs text-white font-mono focus:ring-2 focus:ring-emerald-500 focus:outline-none">
</div>
<button type="button" onclick="removeLocalNetworkField(this)" title="Supprimer ce réseau"
class="p-2 rounded-xl bg-anthracite-900 border border-anthracite-700 text-slate-400 hover:text-rose-400 hover:border-rose-500/50 transition">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
`;
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 = `
<tr>
<td colspan="6" class="text-center py-8 text-slate-400 text-xs">
Aucun code de fonction trouvé dans cette catégorie.
</td>
</tr>
`;
return;
}
const categoryBadges = {
forward: '<span class="px-2 py-0.5 rounded-md text-[10px] font-bold bg-amber-500/20 text-amber-300 border border-amber-500/30">Renvois</span>',
pickup: '<span class="px-2 py-0.5 rounded-md text-[10px] font-bold bg-indigo-500/20 text-indigo-300 border border-indigo-500/30">BLF / Capture</span>',
dnd: '<span class="px-2 py-0.5 rounded-md text-[10px] font-bold bg-rose-500/20 text-rose-300 border border-rose-500/30">Ne Pas Déranger</span>',
voicemail: '<span class="px-2 py-0.5 rounded-md text-[10px] font-bold bg-purple-500/20 text-purple-300 border border-purple-500/30">Messagerie</span>',
security: '<span class="px-2 py-0.5 rounded-md text-[10px] font-bold bg-rose-500/20 text-rose-300 border border-rose-500/30">Sécurité</span>',
tools: '<span class="px-2 py-0.5 rounded-md text-[10px] font-bold bg-cyan-500/20 text-cyan-300 border border-cyan-500/30">Outils</span>'
};
tbody.innerHTML = filtered.map(fc => {
const isEnabled = Number(fc.enabled) === 1;
const catBadge = categoryBadges[fc.category] || `<span class="px-2 py-0.5 rounded-md text-[10px] font-bold bg-anthracite-700 text-slate-300">${fc.category}</span>`;
const codeDisplay = escapeHtml(fc.code);
return `
<tr class="hover:bg-anthracite-750 transition group">
<td class="py-3 px-4 font-mono font-bold text-sm text-indigo-300">
<span class="px-2.5 py-1 rounded-lg bg-indigo-500/15 border border-indigo-500/30 inline-block">${codeDisplay}</span>
</td>
<td class="py-3 px-4">
<div class="font-semibold text-white text-xs">${escapeHtml(fc.name)}</div>
<div class="text-[10px] text-slate-400 font-mono">Standard : ${escapeHtml(fc.default_code)}</div>
</td>
<td class="py-3 px-4 text-xs text-slate-300 max-w-xs hidden sm:table-cell leading-relaxed">
${escapeHtml(fc.description)}
</td>
<td class="py-3 px-4">
${catBadge}
</td>
<td class="py-3 px-4 text-center">
<label class="relative inline-flex items-center cursor-pointer">
<input type="checkbox" ${isEnabled ? 'checked' : ''} onchange="toggleFeatureCode(${fc.id}, this.checked)" class="sr-only peer">
<div class="w-9 h-5 bg-anthracite-700 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-emerald-600"></div>
</label>
</td>
<td class="py-3 px-4 text-right">
<button type="button" onclick="openEditFeatureCodeModal(${fc.id})"
class="p-1.5 rounded-lg bg-anthracite-700 hover:bg-indigo-600 text-slate-300 hover:text-white transition" title="Personnaliser ce code">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/>
</svg>
</button>
</td>
</tr>
`;
}).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 = `
<tr>
<td colspan="5" class="px-4 py-8 text-center text-slate-400">
<div class="flex flex-col items-center justify-center gap-2">
<svg class="w-8 h-8 text-slate-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M8 7H5a2 2 0 00-2 2v9a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-3m-1 4l-3 3m0 0l-3-3m3 3V4"/>
</svg>
<span>Aucune archive de sauvegarde disponible. Cliquez sur "Créer une Sauvegarde Immédiate" pour générer la première.</span>
</div>
</td>
</tr>
`;
return;
}
tbody.innerHTML = backups.map(b => {
const isLocal = b.is_local;
const locBadge = isLocal
? `<span class="px-2 py-0.5 rounded-full text-[10px] font-bold bg-emerald-500/20 text-emerald-300 border border-emerald-500/30">Local</span>`
: `<span class="px-2 py-0.5 rounded-full text-[10px] font-bold bg-purple-500/20 text-purple-300 border border-purple-500/30">Distant (SSH)</span>`;
const versionBadge = `<span class="px-1.5 py-0.2 rounded text-[10px] font-mono font-bold bg-indigo-500/20 text-indigo-300 border border-indigo-500/30">v${escapeHtml(b.version || '1.6.2')}</span>`;
let srcBadge = '';
if (b.source === 'pre-update') {
srcBadge = `<span class="px-1.5 py-0.2 rounded text-[10px] font-bold bg-amber-500/20 text-amber-300 border border-amber-500/30" title="Sauvegarde automatique pré-mise à jour">Pré-MàJ</span>`;
} else if (b.source === 'scheduled') {
srcBadge = `<span class="px-1.5 py-0.2 rounded text-[10px] font-bold bg-cyan-500/20 text-cyan-300 border border-cyan-500/30" title="Sauvegarde planifiée automatique">Auto</span>`;
}
return `
<tr class="hover:bg-anthracite-750 transition">
<td class="px-4 py-3 font-mono font-medium text-white">
<div class="flex items-center gap-2">
<svg class="w-4 h-4 text-purple-400 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
</svg>
<span class="truncate max-w-xs sm:max-w-md">${escapeHtml(b.filename)}</span>
${versionBadge}
${srcBadge}
</div>
</td>
<td class="px-4 py-3 text-slate-300 whitespace-nowrap">${escapeHtml(b.date_formatted)}</td>
<td class="px-4 py-3 font-mono text-emerald-300 whitespace-nowrap">${escapeHtml(b.size_formatted)}</td>
<td class="px-4 py-3 whitespace-nowrap">${locBadge}</td>
<td class="px-4 py-3 text-right whitespace-nowrap">
<div class="flex items-center justify-end gap-1.5">
<button type="button" onclick="downloadBackup('${escapeHtml(b.filename)}')"
class="px-2.5 py-1 rounded-lg bg-anthracite-700 hover:bg-indigo-600 text-slate-200 hover:text-white transition flex items-center gap-1 font-semibold text-xs" title="Télécharger l'archive">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"/>
</svg>
<span>Télécharger</span>
</button>
<button type="button" onclick="confirmRestoreBackup('${escapeHtml(b.filename)}')"
class="px-2.5 py-1 rounded-lg bg-anthracite-700 hover:bg-emerald-600 text-slate-200 hover:text-white transition flex items-center gap-1 font-semibold text-xs" title="Restaurer cette sauvegarde">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/>
</svg>
<span>Restaurer</span>
</button>
<button type="button" onclick="deleteBackup('${escapeHtml(b.filename)}')"
class="p-1.5 rounded-lg bg-anthracite-700 hover:bg-rose-600 text-slate-400 hover:text-white transition" title="Supprimer">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/>
</svg>
</button>
</div>
</td>
</tr>
`;
}).join('');
}
function toggleSshBackupFields(show) {
const fields = document.getElementById('backup-ssh-fields');
if (!fields) return;
if (show) {
fields.classList.remove('hidden');
} else {
fields.classList.add('hidden');
}
}
async function createBackupNow() {
const btn = document.getElementById('btn-create-backup-now');
const label = document.getElementById('btn-create-backup-label');
if (btn) btn.disabled = true;
if (label) label.textContent = 'Génération de l\'archive...';
try {
const res = await apiCall('create_backup', {});
showToast(res.message, 'success');
await loadBackups();
} catch (err) {
showToast("Échec de création : " + err.message, 'error');
} finally {
if (btn) btn.disabled = false;
if (label) label.textContent = 'Créer une Sauvegarde Immédiate';
}
}
let pendingRestoreAction = null;
let pendingRestoreInput = null;
function showRestoreModal({ title, filename, badgeText, onConfirm, inputEl = null }) {
const titleEl = document.getElementById('modal-restore-title');
if (titleEl) titleEl.textContent = title || "Restauration Complète du Système";
const nameEl = document.getElementById('restore-target-name');
if (nameEl) nameEl.textContent = filename || "Archive de sauvegarde";
const badgeEl = document.getElementById('restore-target-badge');
if (badgeEl) badgeEl.textContent = badgeText || "Archive";
const confirmBtn = document.getElementById('btn-confirm-restore');
const cancelBtn = document.getElementById('btn-cancel-restore');
if (confirmBtn) {
confirmBtn.disabled = false;
confirmBtn.innerHTML = `
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/></svg>
<span>Confirmer la Restauration</span>
`;
}
if (cancelBtn) cancelBtn.disabled = false;
pendingRestoreAction = onConfirm;
pendingRestoreInput = inputEl;
openModal('modal-restore-backup');
}
function closeRestoreModal() {
closeModal('modal-restore-backup');
if (pendingRestoreInput) {
pendingRestoreInput.value = '';
pendingRestoreInput = null;
}
pendingRestoreAction = null;
}
async function executePendingRestore() {
if (!pendingRestoreAction) return;
const confirmBtn = document.getElementById('btn-confirm-restore');
const cancelBtn = document.getElementById('btn-cancel-restore');
if (confirmBtn) {
confirmBtn.disabled = true;
confirmBtn.innerHTML = `
<svg class="animate-spin w-4 h-4" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z"></path></svg>
<span>Restauration en cours...</span>
`;
}
if (cancelBtn) cancelBtn.disabled = true;
try {
await pendingRestoreAction();
} catch (err) {
showToast("Erreur inattendue : " + err.message, "error");
closeRestoreModal();
}
}
function confirmRestoreBackup(filename) {
showRestoreModal({
title: "Restauration du Système NexusPBX",
filename: filename,
badgeText: "Archive Locale",
onConfirm: async () => {
showToast("Restauration du système en cours... Veuillez patienter.", "info");
try {
const res = await apiCall('restore_backup', { filename });
closeRestoreModal();
showToast(res.message || "Restauration terminée avec succès !", 'success');
setTimeout(() => {
window.location.reload();
}, 2000);
} catch (err) {
closeRestoreModal();
showToast("Échec de la restauration : " + err.message, 'error');
}
}
});
}
function triggerBackupUpload() {
document.getElementById('backup-upload-input')?.click();
}
function handleBackupUpload(input) {
const file = input?.files?.[0];
if (!file) return;
const lowerName = file.name.toLowerCase();
if (!lowerName.endsWith('.tar.gz') && !lowerName.endsWith('.tgz')) {
showToast("Format de fichier non supporté. Veuillez sélectionner une archive de sauvegarde .tar.gz ou .tgz", 'error');
input.value = '';
return;
}
const fileSizeMb = (file.size / (1024 * 1024)).toFixed(2);
showRestoreModal({
title: "Disaster Recovery — Archive Téléversée",
filename: `${file.name} (${fileSizeMb} Mo)`,
badgeText: "Fichier Téléversé",
inputEl: input,
onConfirm: async () => {
showToast("Téléversement et restauration de l'archive en cours...", "info");
const fd = new FormData();
fd.append('backup_file', file);
try {
const response = await fetch('/api.php?action=upload_restore_backup', {
method: 'POST',
body: fd
});
const res = await response.json();
closeRestoreModal();
if (!res.success) {
throw new Error(res.message || "Erreur lors de la restauration");
}
showToast(res.message, 'success');
setTimeout(() => {
window.location.reload();
}, 2000);
} catch (err) {
closeRestoreModal();
showToast("Échec de la restauration : " + err.message, 'error');
}
}
});
}
function downloadBackup(filename) {
window.location.href = `/api.php?action=download_backup&filename=${encodeURIComponent(filename)}`;
}
async function deleteBackup(filename) {
if (!confirm(`Êtes-vous sûr de vouloir supprimer définitivement l'archive de sauvegarde suivante ?\n\n${filename}`)) {
return;
}
try {
const res = await apiCall('delete_backup', { filename });
showToast(res.message, res.success ? 'success' : 'error');
await loadBackups();
} catch (err) {
showToast("Erreur lors de la suppression : " + err.message, 'error');
}
}
async function testSshBackupConnection() {
const form = document.getElementById('backup-settings-form');
if (!form) return;
const payload = {
backup_ssh_host: (form.elements['backup_ssh_host']?.value || '').trim(),
backup_ssh_port: Number(form.elements['backup_ssh_port']?.value || 22),
backup_ssh_user: (form.elements['backup_ssh_user']?.value || '').trim(),
backup_ssh_password: form.elements['backup_ssh_password']?.value || '',
backup_ssh_remote_path: (form.elements['backup_ssh_remote_path']?.value || '/backups/nexus').trim(),
backup_ssh_auth_type: 'password'
};
showToast("Test de la connexion SSH vers " + payload.backup_ssh_host + "...", "info");
try {
const res = await apiCall('test_ssh_backup', payload);
if (res.success) {
showToast(res.message, 'success');
} else {
showToast(res.message, 'error');
}
} catch (err) {
showToast("Échec du test SSH : " + err.message, 'error');
}
}
// =========================================================================
// GESTIONNAIRE DE PARE-FEU & SÉCURITÉ (FAIL2BAN & NFTABLES)
// =========================================================================
let appFirewallState = {
is_running: false,
settings: {},
jails: {},
banned_ips: [],
total_banned: 0,
client_ip: ''
};
async function loadFirewallStatus() {
try {
const res = await apiCall('get_firewall_status', {});
if (res.success && res.data) {
appFirewallState = res.data;
renderFirewall();
}
} catch (err) {
showToast("Erreur chargement sécurité : " + err.message, 'error');
}
}
function renderFirewall() {
const d = appFirewallState;
const s = d.settings || {};
const banned = d.banned_ips || [];
// 1. Stat cards
const engineEl = document.getElementById('fw-stat-engine');
if (engineEl) {
engineEl.textContent = d.is_running ? 'En ligne (Actif)' : 'Arrêté (Inactif)';
engineEl.className = d.is_running ? 'text-sm font-bold text-emerald-400 truncate' : 'text-sm font-bold text-rose-400 truncate';
}
const sipJail = d.jails?.asterisk;
const sipJailEl = document.getElementById('fw-stat-sip-jail');
if (sipJailEl) {
sipJailEl.textContent = sipJail ? `Actif (${sipJail.banned_ips?.length || 0} banni)` : 'Inactif';
}
const webJail = d.jails?.['nexus-web'];
const webJailEl = document.getElementById('fw-stat-web-jail');
if (webJailEl) {
webJailEl.textContent = webJail ? `Protégé (${webJail.banned_ips?.length || 0} banni)` : 'Inactif';
}
const countEl = document.getElementById('fw-stat-banned-count');
if (countEl) {
countEl.textContent = `${d.total_banned} bannie(s)`;
countEl.className = d.total_banned > 0 ? 'text-sm font-bold text-rose-400 truncate' : 'text-sm font-bold text-emerald-400 truncate';
}
const badgeSidebar = document.getElementById('nav-badge-firewall');
if (badgeSidebar) {
badgeSidebar.textContent = d.total_banned;
badgeSidebar.className = d.total_banned > 0
? 'ml-2 shrink-0 px-1.5 py-0.2 text-[10px] rounded-full bg-rose-500/20 text-rose-300 border border-rose-500/30 font-bold'
: 'ml-2 shrink-0 px-1.5 py-0.2 text-[10px] rounded-full bg-anthracite-800 text-emerald-300 border border-anthracite-700 font-bold';
}
// 2. Table of banned IPs
const tbody = document.getElementById('firewall-banned-table-body');
if (tbody) {
if (banned.length === 0) {
tbody.innerHTML = `
<tr>
<td colspan="4" class="px-4 py-8 text-center text-slate-400">
<div class="flex flex-col items-center justify-center gap-2">
<svg class="w-8 h-8 text-emerald-500/60" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"/>
</svg>
<span class="text-slate-300 font-medium">Aucune adresse IP n'est actuellement bloquée.</span>
<span class="text-[11px] text-slate-500">Le pare-feu NFTables et Fail2ban surveillent les flux SIP et Web 24h/24.</span>
</div>
</td>
</tr>
`;
} else {
tbody.innerHTML = banned.map(b => {
let jailClass = 'bg-purple-500/20 text-purple-300 border border-purple-500/30';
if (b.jail === 'asterisk') {
jailClass = 'bg-cyan-500/20 text-cyan-300 border border-cyan-500/30';
} else if (b.jail === 'nexus-ucp') {
jailClass = 'bg-sky-500/20 text-sky-300 border border-sky-500/30';
}
return `
<tr class="hover:bg-anthracite-750 transition">
<td class="px-4 py-3 font-mono font-bold text-white text-sm">
<span class="text-rose-400">⛔ ${escapeHtml(b.ip)}</span>
</td>
<td class="px-4 py-3">
<span class="px-2 py-0.5 rounded-full text-[10px] font-bold ${jailClass}">${escapeHtml(b.jail_label || b.jail)}</span>
</td>
<td class="px-4 py-3">
<span class="px-2 py-0.5 rounded text-[10px] font-bold bg-rose-950/80 text-rose-300 border border-rose-600/40">DROP (Bloqué)</span>
</td>
<td class="px-4 py-3 text-right">
<button type="button" onclick="unbanIp('${escapeHtml(b.ip)}', '${escapeHtml(b.jail)}')"
class="px-2.5 py-1 rounded-lg bg-emerald-600/20 hover:bg-emerald-600 text-emerald-300 hover:text-white border border-emerald-500/40 text-xs font-semibold transition">
Débannir
</button>
</td>
</tr>
`;
}).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 || '';
}
if (form.elements['ucp_enabled']) {
form.elements['ucp_enabled'].checked = Number(s.ucp_enabled ?? 1) === 1;
}
if (form.elements['ucp_https_port'] && document.activeElement !== form.elements['ucp_https_port']) {
form.elements['ucp_https_port'].value = s.ucp_https_port !== undefined ? s.ucp_https_port : 8444;
}
if (form.elements['ucp_http_port'] && document.activeElement !== form.elements['ucp_http_port']) {
form.elements['ucp_http_port'].value = s.ucp_http_port !== undefined ? s.ucp_http_port : 8084;
}
if (form.elements['fail2ban_ucp_maxretry']) {
form.elements['fail2ban_ucp_maxretry'].value = s.fail2ban_ucp_maxretry || 3;
}
if (form.elements['fail2ban_ucp_bantime']) {
form.elements['fail2ban_ucp_bantime'].value = s.fail2ban_ucp_bantime || 3600;
}
}
}
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 = '<tr><td colspan="5" class="py-8 text-center text-slate-400">Chargement des événements de sécurité en direct...</td></tr>';
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 => `
<tr class="hover:bg-anthracite-900/50 transition border-b border-anthracite-800">
<td class="py-2.5 px-3 font-mono text-[11px] text-slate-300 whitespace-nowrap">${escapeHtml(e.time)}</td>
<td class="py-2.5 px-3">
<span class="px-2 py-0.5 rounded text-[10px] font-bold bg-indigo-500/15 text-indigo-300 border border-indigo-500/30 font-mono">
${escapeHtml(e.service)}
</span>
</td>
<td class="py-2.5 px-3">
<span class="text-rose-400 font-semibold font-mono text-xs">${escapeHtml(e.event)}</span>
<div class="text-[10px] text-slate-400">Échec mot de passe / Défi SIP</div>
</td>
<td class="py-2.5 px-3 font-mono text-xs text-amber-300 font-bold">${escapeHtml(e.account)}</td>
<td class="py-2.5 px-3 font-mono text-xs text-cyan-300 font-bold">${escapeHtml(e.ip)}</td>
</tr>
`).join('');
}
} catch (err) {
if (tbody) {
tbody.innerHTML = `<tr><td colspan="5" class="py-4 text-center text-rose-400">Erreur : ${escapeHtml(err.message)}</td></tr>`;
}
}
}
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 = `
<button type="button" onclick="selectPhonebookGroupFilter('all')"
class="phonebook-grp-pill ${selectedPhonebookGroup === 'all' ? 'active bg-indigo-600 text-white' : 'bg-anthracite-950 text-slate-400 hover:text-white'} px-3 py-1 rounded-lg text-xs font-semibold border border-anthracite-700 transition">
Tous les groupes (${contacts.length})
</button>
`;
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 += `
<button type="button" onclick="selectPhonebookGroupFilter('${escapeHtml(g)}')"
class="phonebook-grp-pill ${isActive ? 'active bg-teal-600 text-white border-teal-500' : 'bg-anthracite-950 text-slate-400 hover:text-white border-anthracite-700'} px-3 py-1 rounded-lg text-xs font-semibold border transition">
${escapeHtml(g)} (${count})
</button>
`;
});
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 = `<span><strong>${filtered.length}</strong> affiché(s) • ${intCount} interne(s) • ${extCount} externe(s)</span>`;
}
if (filtered.length === 0) {
tbody.innerHTML = `<tr><td colspan="9" class="py-10 text-center text-slate-400 text-xs">Aucun contact trouvé dans l'annuaire pour ces critères.</td></tr>`;
return;
}
tbody.innerHTML = filtered.map(c => {
const initials = getInitials(c.display_name || '?');
const colorClass = getAvatarBgColor(c.display_name || '');
const avatarHtml = c.photo_url
? `<img src="${escapeHtml(c.photo_url)}" alt="${escapeHtml(c.display_name)}" class="w-8 h-8 rounded-full object-cover border border-anthracite-700 shadow" onerror="this.style.display='none'; if(this.nextElementSibling) this.nextElementSibling.style.display='flex';"><div class="w-8 h-8 rounded-full ${colorClass} text-white items-center justify-center font-bold text-xs shadow-inner" style="display:none;">${escapeHtml(initials)}</div>`
: `<div class="w-8 h-8 rounded-full ${colorClass} text-white flex items-center justify-center font-bold text-xs shadow-inner">${escapeHtml(initials)}</div>`;
const typeBadge = c.is_internal
? `<span class="px-2 py-0.5 rounded-full text-[10px] font-bold bg-indigo-500/15 text-indigo-300 border border-indigo-500/30">Poste ${escapeHtml(c.internal_number)}</span>`
: `<span class="px-2 py-0.5 rounded-full text-[10px] font-bold bg-slate-700/60 text-slate-300 border border-slate-600">Contact Externe</span>`;
return `
<tr class="hover:bg-anthracite-750/60 transition">
<td class="py-3 px-4">${avatarHtml}</td>
<td class="py-3 px-4 font-semibold text-white">
<div>${escapeHtml(c.display_name)}</div>
${c.photo_url ? '<span class="text-[10px] text-teal-400 font-mono">Photo active</span>' : ''}
</td>
<td class="py-3 px-4 font-mono font-bold text-indigo-300">${c.internal_number ? escapeHtml(c.internal_number) : '<span class="text-slate-600">—</span>'}</td>
<td class="py-3 px-4 font-mono text-xs text-slate-200">${c.mobile_number ? escapeHtml(c.mobile_number) : '<span class="text-slate-600">—</span>'}</td>
<td class="py-3 px-4 font-mono text-xs text-slate-400">${c.other_number ? escapeHtml(c.other_number) : '<span class="text-slate-600">—</span>'}</td>
<td class="py-3 px-4 text-xs text-slate-300">${c.email ? `<a href="mailto:${escapeHtml(c.email)}" class="hover:text-teal-300 underline">${escapeHtml(c.email)}</a>` : '<span class="text-slate-600">—</span>'}</td>
<td class="py-3 px-4">
<span class="px-2 py-0.5 rounded text-[11px] font-semibold bg-teal-500/15 text-teal-300 border border-teal-500/30">${escapeHtml(c.group_name || 'Général')}</span>
</td>
<td class="py-3 px-4">${typeBadge}</td>
<td class="py-3 px-4 text-right space-x-2">
<button type="button" onclick="editPhonebookContact(${c.id})" class="text-xs font-semibold text-indigo-300 hover:text-indigo-200">Modifier</button>
${!c.is_internal ? `<button type="button" onclick="deletePhonebookContact(${c.id})" class="text-xs font-semibold text-rose-400 hover:text-rose-300">Supprimer</button>` : ''}
</td>
</tr>
`;
}).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 => `<option value="${escapeHtml(g)}">`).join('');
}
updateAvatarPreview(contact?.photo_url || '', contact?.display_name || '?');
openModal('modal-phonebook-contact');
}
function editPhonebookContact(id) {
const contact = (appState.phonebook_contacts || []).find(c => Number(c.id) === Number(id));
if (contact) {
openPhonebookContactModal(contact);
}
}
async function savePhonebookContact(e) {
e.preventDefault();
const form = e.target;
const payload = {
id: form.elements['id'].value ? Number(form.elements['id'].value) : null,
extension_id: form.elements['extension_id'].value ? Number(form.elements['extension_id'].value) : null,
is_internal: Number(form.elements['is_internal'].value) || 0,
display_name: form.elements['display_name'].value.trim(),
internal_number: form.elements['internal_number'].value.trim(),
mobile_number: form.elements['mobile_number'].value.trim(),
other_number: form.elements['other_number'].value.trim(),
email: form.elements['email'].value.trim(),
group_name: form.elements['group_name'].value.trim() || 'Général',
photo_url: form.elements['photo_url'].value.trim(),
};
try {
const res = await apiCall('save_phonebook_contact', payload);
showToast(res.message, 'success');
closeModal('modal-phonebook-contact');
if (res.contacts) appState.phonebook_contacts = res.contacts;
if (res.groups) appState.phonebook_groups = res.groups;
renderPhonebookTable();
const pbBadge = document.getElementById('nav-badge-phonebook');
if (pbBadge) pbBadge.textContent = appState.phonebook_contacts.length;
} catch (err) {
showToast(err.message, 'error');
}
}
async function deletePhonebookContact(id) {
const contact = (appState.phonebook_contacts || []).find(c => Number(c.id) === Number(id));
if (!contact) return;
if (!confirm(`Supprimer le contact « ${contact.display_name} » de l'annuaire ?`)) return;
try {
const res = await apiCall('delete_phonebook_contact', { id });
showToast(res.message, 'info');
if (res.contacts) appState.phonebook_contacts = res.contacts;
if (res.groups) appState.phonebook_groups = res.groups;
renderPhonebookTable();
const pbBadge = document.getElementById('nav-badge-phonebook');
if (pbBadge) pbBadge.textContent = appState.phonebook_contacts.length;
} catch (err) {
showToast(err.message, 'error');
}
}
async function syncPhonebookNow() {
try {
const res = await apiCall('sync_phonebook', {});
showToast(res.message, 'success');
if (res.contacts) appState.phonebook_contacts = res.contacts;
if (res.groups) appState.phonebook_groups = res.groups;
renderPhonebookTable();
const pbBadge = document.getElementById('nav-badge-phonebook');
if (pbBadge) pbBadge.textContent = appState.phonebook_contacts.length;
} catch (err) {
showToast(err.message, 'error');
}
}
async function uploadContactPhoto(input) {
if (!input.files || !input.files[0]) return;
const file = input.files[0];
const form = document.getElementById('form-phonebook-contact');
const contactId = form ? form.elements['id'].value : '';
const formData = new FormData();
formData.append('avatar', file);
formData.append('contact_id', contactId);
try {
const res = await fetch('/api.php?action=upload_phonebook_avatar', {
method: 'POST',
body: formData,
});
const data = await res.json();
if (!data.success) throw new Error(data.message || "Erreur d'envoi");
document.getElementById('phonebook-input-photo-url').value = data.photo_url;
updateAvatarPreview(data.photo_url, form.elements['display_name'].value || '?');
showToast(data.message, 'success');
} catch (err) {
showToast(err.message, 'error');
}
}
function clearContactPhoto() {
document.getElementById('phonebook-input-photo-url').value = '';
const form = document.getElementById('form-phonebook-contact');
updateAvatarPreview('', form?.elements['display_name']?.value || '?');
}
function updateAvatarPreview(photoUrl, name) {
const preview = document.getElementById('phonebook-avatar-preview');
const clearBtn = document.getElementById('phonebook-btn-clear-photo');
if (!preview) return;
const initials = getInitials(name || '?');
const colorClass = getAvatarBgColor(name || '');
if (photoUrl) {
preview.className = 'w-14 h-14 rounded-full overflow-hidden border border-teal-500/40 shrink-0 shadow relative flex items-center justify-center';
preview.innerHTML = `
<img src="${escapeHtml(photoUrl)}" class="w-full h-full object-cover" onerror="this.style.display='none'; if(this.nextElementSibling) this.nextElementSibling.style.display='flex';">
<div class="w-full h-full ${colorClass} text-white items-center justify-center font-bold text-lg shadow-inner" style="display:none;">${escapeHtml(initials)}</div>
`;
clearBtn?.classList.remove('hidden');
} else {
preview.className = `w-14 h-14 rounded-full ${colorClass} text-white flex items-center justify-center font-bold text-lg overflow-hidden shrink-0 shadow-inner`;
preview.textContent = initials;
clearBtn?.classList.add('hidden');
}
}
// Modal Yealink Remote XML Phonebook
function openYealinkXmlModal() {
const sel = document.getElementById('yealink-modal-group-select');
if (sel) {
const groups = appState.phonebook_groups || [];
sel.innerHTML = `<option value="all">Tous les groupes (Annuaire complet de l'entreprise)</option>` +
groups.map(g => `<option value="${escapeHtml(g)}">${escapeHtml(g)}</option>`).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 = `<?xml version="1.0" encoding="UTF-8"?>\n<YealinkIPPhoneDirectory>\n <Title>Annuaire ${grp}</Title>\n</YealinkIPPhoneDirectory>`;
});
}
}
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 = '<span>Copié !</span>';
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
const installedVer = (appState && appState.version) ? appState.version : (document.querySelector('meta[name="nexus-app-version"]')?.content || '');
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);
// Si la version locale correspond et qu'on n'a pas un vieux flag "mise à jour dispo" vers la version déjà installée
if (installedVer && (cached.current_version !== installedVer || (cached.has_update && cached.latest_version === installedVer))) {
// Cache périmé suite à une mise à jour : on purge pour rafraîchir en direct
localStorage.removeItem(STORAGE_KEY_TIME);
localStorage.removeItem(STORAGE_KEY_DATA);
} else {
applyUpdateUI(cached);
return;
}
} catch (_) {
localStorage.removeItem(STORAGE_KEY_TIME);
localStorage.removeItem(STORAGE_KEY_DATA);
}
}
} else {
localStorage.removeItem(STORAGE_KEY_TIME);
localStorage.removeItem(STORAGE_KEY_DATA);
}
let origBtnHtml = null;
if (btnCheck) {
btnCheck.disabled = true;
btnCheck.classList.add('opacity-50');
origBtnHtml = btnCheck.innerHTML;
btnCheck.innerHTML = `<svg class="animate-spin w-4 h-4 text-cyan-400" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z"></path></svg><span>Vérification en cours...</span>`;
}
try {
const res = await fetch('/api.php?action=check_update&_t=' + Date.now());
const data = await res.json();
// Enregistrement du timestamp de vérification et des données en cache local (24h)
localStorage.setItem(STORAGE_KEY_TIME, Date.now().toString());
localStorage.setItem(STORAGE_KEY_DATA, JSON.stringify(data));
applyUpdateUI(data);
if (notify) {
if (data.has_update) {
showToast(`Nouvelle version NexusPBX v${data.latest_version} disponible !`, 'info');
} else {
showToast("Votre système NexusPBX est parfaitement à jour !", 'success');
}
}
} catch (e) {
if (notify) {
showToast("Erreur lors de la vérification des mises à jour : " + e.message, 'error');
}
} finally {
if (btnCheck) {
btnCheck.disabled = false;
btnCheck.classList.remove('opacity-50');
if (origBtnHtml) btnCheck.innerHTML = origBtnHtml;
}
}
function applyUpdateUI(data) {
if (lastChecked) {
const lastTs = parseInt(localStorage.getItem(STORAGE_KEY_TIME) || Date.now().toString(), 10);
const d = new Date(lastTs);
lastChecked.textContent = `Dernière vérification : ${d.toLocaleDateString()} à ${d.toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'})}`;
}
if (verTag && data.current_version) {
verTag.textContent = `v${data.current_version}`;
}
if (appBadge && data.current_version) {
appBadge.textContent = `v${data.current_version}`;
}
if (data.has_update) {
// Affichage du badge dans l'en-tête à côté de Tester AMI
if (headerUpdateBadge) {
headerUpdateBadge.style.display = 'inline-flex';
if (headerUpdateText) {
headerUpdateText.textContent = `v${escapeHtml(data.latest_version)} dispo`;
}
}
if (statusText) {
statusText.className = 'text-amber-400 font-bold';
statusText.innerHTML = `🚀 Nouvelle version disponible : <span class="underline">v${escapeHtml(data.latest_version)}</span> !`;
}
if (btnRun) {
btnRun.style.display = 'inline-flex';
btnRun.innerHTML = `<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"/></svg><span>Mettre à jour vers v${escapeHtml(data.latest_version)} (1-Clic)</span>`;
}
if (pingDot) pingDot.classList.remove('hidden');
if (changelogList && Array.isArray(data.changelog) && data.changelog.length > 0) {
changelogList.innerHTML = data.changelog.map(item => `<li>${escapeHtml(item)}</li>`).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');
}
}
}
function openUpdateConfirmModal() {
const modal = document.getElementById('modal-confirm-update');
if (!modal) return;
let targetVer = '';
let currVer = (appState && appState.version) ? appState.version : (document.querySelector('meta[name="nexus-app-version"]')?.content || '');
try {
const cachedRaw = localStorage.getItem('nexus_update_cached_data');
if (cachedRaw) {
const cached = JSON.parse(cachedRaw);
if (cached.latest_version) targetVer = cached.latest_version;
if (cached.current_version) currVer = cached.current_version;
}
} catch (_) {}
const elTarget = document.getElementById('update-modal-target-version');
const elCurrent = document.getElementById('update-modal-current-version');
const elBadge = document.getElementById('update-modal-target-badge');
if (elTarget) elTarget.textContent = targetVer ? `v${targetVer}` : 'Dernière version';
if (elCurrent) elCurrent.textContent = currVer ? `v${currVer}` : 'vActuelle';
if (elBadge && targetVer) elBadge.textContent = `v${targetVer} disponible`;
modal.classList.remove('hidden');
modal.classList.add('flex');
}
function closeUpdateConfirmModal() {
const modal = document.getElementById('modal-confirm-update');
if (modal) {
modal.classList.add('hidden');
modal.classList.remove('flex');
}
}
function runSystemUpdate() {
openUpdateConfirmModal();
}
async function executeSystemUpdate() {
const btnModalConfirm = document.getElementById('btn-confirm-update-action');
const btnRun = document.getElementById('btn-run-update');
const statusText = document.getElementById('update-status-text');
if (btnModalConfirm) {
btnModalConfirm.disabled = true;
btnModalConfirm.classList.add('opacity-75', 'cursor-not-allowed');
btnModalConfirm.innerHTML = '<span class="animate-spin mr-2">↻</span> Déploiement en cours...';
}
if (btnRun) {
btnRun.disabled = true;
btnRun.innerHTML = '<span class="animate-spin mr-1.5">↻</span> 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');
// Purge préventive du cache local
localStorage.removeItem('nexus_update_last_check_ts');
localStorage.removeItem('nexus_update_cached_data');
function finalizeUpdateSuccess(newVer) {
newVer = String(newVer || '').replace(/^v/, '');
localStorage.setItem('nexus_update_last_check_ts', Date.now().toString());
localStorage.setItem('nexus_update_cached_data', JSON.stringify({
success: true,
current_version: newVer,
latest_version: newVer,
has_update: false,
changelog: [],
release_date: ''
}));
const verTag = document.getElementById('system-version-tag');
const appBadge = document.getElementById('app-version-badge');
const headerUpdateBadge = document.getElementById('header-update-badge');
const pingDot = document.getElementById('update-ping-dot');
if (verTag && newVer) verTag.textContent = `v${newVer}`;
if (appBadge && newVer) appBadge.textContent = `v${newVer}`;
if (headerUpdateBadge) headerUpdateBadge.style.display = 'none';
if (btnRun) btnRun.style.display = 'none';
if (pingDot) pingDot.classList.add('hidden');
showToast(`NexusPBX a été mis à jour avec succès en v${newVer} ! Rechargement...`, 'success');
if (statusText) {
statusText.className = 'text-emerald-400 font-bold';
statusText.textContent = `Mise à jour terminée avec succès (v${newVer}) ! Rechargement...`;
}
if (btnModalConfirm) {
btnModalConfirm.className = 'px-5 py-2.5 rounded-xl text-xs font-bold bg-emerald-600 text-white flex items-center gap-2';
btnModalConfirm.innerHTML = '✓ Mise à jour terminée ! Rechargement...';
}
setTimeout(() => {
closeUpdateConfirmModal();
window.location.href = window.location.origin + window.location.pathname + '?_t=' + Date.now();
}, 1800);
}
try {
const res = await fetch('/api.php?action=run_update&_t=' + Date.now(), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({})
});
const rawText = await res.text();
let data = null;
try {
data = JSON.parse(rawText);
} catch (_) {
data = null;
}
if (data && data.success) {
finalizeUpdateSuccess(data.new_version);
return;
}
// Si la réponse n'était pas un JSON valide ou a été interrompue par le rechargement de php-fpm/nginx,
// on sonde le serveur pour vérifier si la mise à jour a en réalité bien été appliquée sur le disque
await new Promise(r => setTimeout(r, 2000));
try {
const probeRes = await fetch('/api.php?action=check_update&_t=' + Date.now());
const probeData = await probeRes.json();
if (probeData && probeData.success && probeData.current_version) {
finalizeUpdateSuccess(probeData.current_version);
return;
}
} catch (_) {}
// En cas d'échec réel avéré
closeUpdateConfirmModal();
const errMsg = (data && (data.output || data.message)) ? (data.output || data.message) : (rawText.slice(0, 150) || "Erreur inconnue");
alert("Erreur lors de la mise à jour :\n\n" + errMsg);
if (btnRun) {
btnRun.disabled = false;
btnRun.innerHTML = 'Réessayer la mise à jour';
}
if (btnModalConfirm) {
btnModalConfirm.disabled = false;
btnModalConfirm.classList.remove('opacity-75', 'cursor-not-allowed');
btnModalConfirm.innerHTML = '<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"/></svg><span>Lancer la mise à jour (1-Clic)</span>';
}
} catch (e) {
// En cas d'interruption réseau du socket (Safari WebKit DOMException ou timeout),
// on vérifie avant d'alerter si le serveur a quand même réussi à déployer les fichiers
await new Promise(r => setTimeout(r, 2000));
try {
const probeRes = await fetch('/api.php?action=check_update&_t=' + Date.now());
const probeData = await probeRes.json();
if (probeData && probeData.success && probeData.current_version) {
finalizeUpdateSuccess(probeData.current_version);
return;
}
} catch (_) {}
closeUpdateConfirmModal();
alert("Erreur réseau lors de la mise à jour : " + (e.message || "Connexion interrompue"));
if (btnRun) {
btnRun.disabled = false;
btnRun.innerHTML = 'Réessayer la mise à jour';
}
if (btnModalConfirm) {
btnModalConfirm.disabled = false;
btnModalConfirm.classList.remove('opacity-75', 'cursor-not-allowed');
btnModalConfirm.innerHTML = '<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"/></svg><span>Lancer la mise à jour (1-Clic)</span>';
}
}
}
// =========================================================================
// JOURNAL D'APPELS (CDR) & HISTORIQUE DES COMMUNICATIONS
// =========================================================================
let cdrDebounceTimer = null;
function debounceCdrLoad() {
clearTimeout(cdrDebounceTimer);
cdrDebounceTimer = setTimeout(() => {
loadCdr();
}, 300);
}
async function loadCdr() {
const tbody = document.getElementById('cdr-table-body');
const info = document.getElementById('cdr-status-info');
const spinner = document.getElementById('spinner-refresh-cdr');
if (spinner) spinner.classList.add('animate-spin');
const search = document.getElementById('cdr-filter-search')?.value || '';
const period = document.getElementById('cdr-filter-period')?.value || 'today';
const direction = document.getElementById('cdr-filter-direction')?.value || 'all';
const disposition = document.getElementById('cdr-filter-disposition')?.value || 'all';
const limit = document.getElementById('cdr-filter-limit')?.value || 100;
try {
const res = await apiCall('get_cdr', {
search,
period,
direction,
disposition,
limit
});
if (!res || !res.success) throw new Error(res?.message || 'Erreur lors de la récupération des CDR');
renderCdrStats(res.stats || {});
renderCdrTable(res.calls || []);
if (info) {
const count = (res.calls || []).length;
const total = res.total_records || 0;
info.textContent = `Affichage de ${count} appel(s) sur ${total} analysé(s) pour la période sélectionnée.`;
}
} catch (err) {
if (tbody) {
tbody.innerHTML = `
<tr>
<td colspan="7" class="py-8 text-center text-rose-400">
Erreur de chargement du journal d'appels : ${escapeHtml(err.message)}
</td>
</tr>
`;
}
} finally {
if (spinner) spinner.classList.remove('animate-spin');
}
}
function renderCdrStats(stats) {
const elTotal = document.getElementById('cdr-stat-total');
const elAns = document.getElementById('cdr-stat-answered');
const elRate = document.getElementById('cdr-stat-rate');
const elMiss = document.getElementById('cdr-stat-missed');
const elOut = document.getElementById('cdr-stat-outbound');
const elIn = document.getElementById('cdr-stat-inbound');
const total = stats.total || 0;
const answered = stats.answered || 0;
const missed = stats.missed || 0;
const rate = total > 0 ? Math.round((answered / total) * 100) : 0;
if (elTotal) elTotal.textContent = total;
if (elAns) elAns.textContent = answered;
if (elRate) elRate.textContent = `${rate}% de réponse`;
if (elMiss) elMiss.textContent = missed;
if (elOut) elOut.textContent = stats.outbound || 0;
if (elIn) elIn.textContent = stats.inbound || 0;
}
function renderCdrTable(calls) {
const tbody = document.getElementById('cdr-table-body');
if (!tbody) return;
if (!calls || calls.length === 0) {
tbody.innerHTML = `
<tr>
<td colspan="7" class="py-8 text-center text-slate-400">
Aucun appel enregistré pour les filtres sélectionnés.
</td>
</tr>
`;
return;
}
tbody.innerHTML = calls.map(c => {
// Direction badge & icon
let dirBadge = '';
if (c.direction === 'inbound') {
dirBadge = `<span class="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-[11px] font-bold bg-teal-500/15 text-teal-300 border border-teal-500/30">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 14l-7 7m0 0l-7-7m7 7V3"/></svg>
<span>Entrant</span>
</span>`;
} else if (c.direction === 'outbound') {
dirBadge = `<span class="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-[11px] font-bold bg-cyan-500/15 text-cyan-300 border border-cyan-500/30">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 10l7-7m0 0l7 7m-7-7v18"/></svg>
<span>Sortant</span>
</span>`;
} else if (c.direction === 'service') {
dirBadge = `<span class="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-[11px] font-bold bg-amber-500/15 text-amber-300 border border-amber-500/30">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/></svg>
<span>Service</span>
</span>`;
} else if (c.direction === 'group') {
dirBadge = `<span class="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-[11px] font-bold bg-purple-500/15 text-purple-300 border border-purple-500/30">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z"/></svg>
<span>Groupe</span>
</span>`;
} else if (c.direction === 'ivr') {
dirBadge = `<span class="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-[11px] font-bold bg-pink-500/15 text-pink-300 border border-pink-500/30">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11a7 7 0 01-7 7m0 0a7 7 0 01-7-7m7 7v4m0 0H8m4 0h4m-4-8a3 3 0 01-3-3V5a3 3 0 116 0v6a3 3 0 01-3 3z"/></svg>
<span>SVI</span>
</span>`;
} else {
dirBadge = `<span class="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-[11px] font-bold bg-indigo-500/15 text-indigo-300 border border-indigo-500/30">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4"/></svg>
<span>Interne</span>
</span>`;
}
// Status badge
let statusBadge = '';
if (c.disposition === 'ANSWERED') {
statusBadge = `<span class="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-[11px] font-bold bg-emerald-500/15 text-emerald-300 border border-emerald-500/30">
<span class="w-1.5 h-1.5 rounded-full bg-emerald-400"></span>
<span>Décroché</span>
</span>`;
} else if (c.disposition === 'NO ANSWER') {
statusBadge = `<span class="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-[11px] font-bold bg-amber-500/15 text-amber-300 border border-amber-500/30">
<span class="w-1.5 h-1.5 rounded-full bg-amber-400"></span>
<span>Non répondu</span>
</span>`;
} else if (c.disposition === 'BUSY') {
statusBadge = `<span class="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-[11px] font-bold bg-purple-500/15 text-purple-300 border border-purple-500/30">
<span class="w-1.5 h-1.5 rounded-full bg-purple-400"></span>
<span>Occupé</span>
</span>`;
} else {
statusBadge = `<span class="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-[11px] font-bold bg-rose-500/15 text-rose-300 border border-rose-500/30">
<span class="w-1.5 h-1.5 rounded-full bg-rose-400"></span>
<span>Échec</span>
</span>`;
}
// Trunk badge
let trunkBadge = '';
if (c.trunk_name) {
trunkBadge = `<span class="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-xl text-xs font-semibold bg-emerald-950/60 text-emerald-300 border border-emerald-500/40 shadow-sm">
<svg class="w-3.5 h-3.5 text-emerald-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9"/></svg>
<span>${escapeHtml(c.trunk_name)}</span>
</span>`;
} else {
trunkBadge = `<span class="text-slate-500 font-mono text-xs">—</span>`;
}
// Caller display
const callerNameHtml = c.caller_name ? `<div class="font-bold text-white text-xs">${escapeHtml(c.caller_name)}</div>` : '';
const callerNumHtml = `<div class="font-mono text-slate-300 text-xs">${escapeHtml(c.caller_number || 'Inconnu')}</div>`;
// Callee display
const calleeNameHtml = c.callee_name ? `<div class="font-bold text-white text-xs">${escapeHtml(c.callee_name)}</div>` : '';
const calleeNumHtml = `<div class="font-mono text-slate-300 text-xs">${escapeHtml(c.callee_number || '—')}</div>`;
// Talked vs Total duration
const durationHtml = c.billsec > 0
? `<div><span class="font-bold text-emerald-300 font-mono text-xs">${c.billsec_formatted}</span> <span class="text-[10px] text-slate-500 font-sans">(${c.duration_formatted} tot.)</span></div>`
: `<span class="font-mono text-slate-400 text-xs">${c.duration_formatted}</span>`;
return `
<tr class="hover:bg-anthracite-750/50 transition">
<td class="py-3 px-4 whitespace-nowrap text-slate-300 font-mono text-xs">
<div class="font-semibold text-slate-200">${escapeHtml(c.datetime_formatted)}</div>
<div class="text-[10px] text-slate-500 font-sans">${escapeHtml(c.datetime)}</div>
</td>
<td class="py-3 px-3 whitespace-nowrap">
${dirBadge}
</td>
<td class="py-3 px-4">
${callerNameHtml}
${callerNumHtml}
</td>
<td class="py-3 px-4">
${calleeNameHtml}
${calleeNumHtml}
</td>
<td class="py-3 px-4 whitespace-nowrap">
${trunkBadge}
</td>
<td class="py-3 px-3 whitespace-nowrap">
${durationHtml}
</td>
<td class="py-3 px-3 whitespace-nowrap">
${statusBadge}
</td>
</tr>
`;
}).join('');
}
function exportCdrCsv() {
const search = encodeURIComponent(document.getElementById('cdr-filter-search')?.value || '');
const period = encodeURIComponent(document.getElementById('cdr-filter-period')?.value || 'all');
const direction = encodeURIComponent(document.getElementById('cdr-filter-direction')?.value || 'all');
const disposition = encodeURIComponent(document.getElementById('cdr-filter-disposition')?.value || 'all');
window.location.href = `/api.php?action=export_cdr_csv&search=${search}&period=${period}&direction=${direction}&disposition=${disposition}`;
}
async function confirmClearCdr() {
if (!confirm("Êtes-vous sûr de vouloir purger l'historique complet du journal d'appels Asterisk ? Cette action est irréversible.")) {
return;
}
try {
const res = await apiCall('clear_cdr', {});
showToast(res.message || "Journal d'appels réinitialisé.", 'success');
loadCdr();
} catch (e) {
showToast(e.message || "Erreur lors de la purge du journal.", 'error');
}
}
/* ==============================================================================
MODULE HAUTE DISPONIBILITÉ (CLUSTER HA ACTIF / PASSIF KEEPALIVED)
============================================================================== */
let currentClusterData = null;
async function loadCluster() {
await loadClusterInterfaces();
await refreshClusterStatus();
}
async function loadClusterInterfaces() {
try {
const res = await apiCall('cluster_interfaces');
const select = document.getElementById('cluster-interface');
if (!select || !res.interfaces) return;
const currentVal = select.value;
select.innerHTML = res.interfaces.map(iface => {
const ipDisplay = iface.primary ? `(${iface.primary}/${iface.netmask})` : '(pas d\'IP)';
return `<option value="${iface.name}">${iface.name} ${ipDisplay}</option>`;
}).join('');
if (currentVal && res.interfaces.some(i => i.name === currentVal)) {
select.value = currentVal;
}
} catch (e) {
console.error("Erreur lors du chargement des interfaces réseau :", e);
}
}
async function refreshClusterStatus() {
try {
const res = await apiCall('cluster_status');
if (!res.success || !res.status) return;
const st = res.status;
currentClusterData = st;
// Formulaire - Mise à jour des valeurs
const chkEnabled = document.getElementById('cluster-enabled');
if (chkEnabled) chkEnabled.checked = Boolean(st.enabled);
const radioRole = document.querySelector(`input[name="role"][value="${st.configured_role || 'master'}"]`);
if (radioRole) radioRole.checked = true;
const ifaceSelect = document.getElementById('cluster-interface');
if (ifaceSelect && st.interface) ifaceSelect.value = st.interface;
const vipInput = document.getElementById('cluster-virtual-ip');
if (vipInput) vipInput.value = st.virtual_ip || '';
const vmaskInput = document.getElementById('cluster-virtual-netmask');
if (vmaskInput && st.virtual_netmask) vmaskInput.value = st.virtual_netmask;
const vridInput = document.getElementById('cluster-vrid');
if (vridInput && st.vrid) vridInput.value = st.vrid;
const peerIpInput = document.getElementById('cluster-peer-ip');
if (peerIpInput) peerIpInput.value = st.peer_ip || '';
const peerPortInput = document.getElementById('cluster-peer-port');
if (peerPortInput && st.peer_port) peerPortInput.value = st.peer_port;
const autoSyncChk = document.getElementById('cluster-auto-sync');
if (autoSyncChk && st.auto_sync !== undefined) autoSyncChk.checked = Boolean(st.auto_sync);
const secretInput = document.getElementById('cluster-secret');
if (secretInput && st.cluster_secret) secretInput.value = st.cluster_secret;
const blfExpiryContainer = document.getElementById('cluster-blf-expiry-container');
const blfExpiryInput = document.getElementById('cluster-blf-expiry');
if (blfExpiryContainer && blfExpiryInput) {
if (st.enabled) {
blfExpiryContainer.classList.remove('hidden');
blfExpiryInput.value = st.blf_expiry || 120;
} else {
blfExpiryContainer.classList.add('hidden');
blfExpiryInput.value = st.blf_expiry || 1800;
}
}
if (chkEnabled && !chkEnabled.dataset.blfBound) {
chkEnabled.dataset.blfBound = '1';
chkEnabled.addEventListener('change', () => {
const c = document.getElementById('cluster-blf-expiry-container');
const inp = document.getElementById('cluster-blf-expiry');
if (chkEnabled.checked) {
if (c) c.classList.remove('hidden');
if (inp && (!inp.value || inp.value === '1800')) inp.value = 120;
} else {
if (c) c.classList.add('hidden');
if (inp) inp.value = 1800;
}
});
}
// Badges & Cartes
const headerBadge = document.getElementById('cluster-header-state-badge');
const navBadge = document.getElementById('nav-badge-cluster');
const cardRoleBadge = document.getElementById('cluster-card-role-badge');
const cardState = document.getElementById('cluster-card-state');
const cardVipInd = document.getElementById('cluster-card-vip-indicator');
const cardIface = document.getElementById('cluster-card-iface');
const cardVip = document.getElementById('cluster-card-vip');
const cardVrid = document.getElementById('cluster-card-vrid');
const cardPeerStatus = document.getElementById('cluster-card-peer-status');
const cardPeerIp = document.getElementById('cluster-card-peer-ip');
const cardPeerPing = document.getElementById('cluster-card-peer-ping');
const cardSyncStatus = document.getElementById('cluster-card-sync-status');
const cardSyncTime = document.getElementById('cluster-card-sync-time');
if (cardVrid) cardVrid.textContent = st.vrid || '51';
if (cardIface) cardIface.textContent = `Interface : ${st.interface || 'eth0'}`;
if (cardVip) cardVip.textContent = st.virtual_ip || 'Non configurée';
if (cardRoleBadge) {
cardRoleBadge.textContent = (st.configured_role || 'master').toUpperCase();
}
if (st.enabled) {
if (st.effective_state === 'MASTER') {
if (headerBadge) {
headerBadge.className = 'px-2.5 py-0.5 rounded-full text-xs font-bold bg-emerald-500/20 text-emerald-300 border border-emerald-500/30';
headerBadge.textContent = 'ACTIF (MASTER)';
}
if (navBadge) {
navBadge.className = 'ml-2 shrink-0 px-1.5 py-0.2 text-[10px] rounded-full bg-emerald-500/20 text-emerald-300 border border-emerald-500/30 font-bold';
navBadge.textContent = 'MASTER';
}
if (cardState) {
cardState.className = 'text-lg font-bold text-emerald-400 tracking-wide';
cardState.textContent = 'MASTER (ACTIF)';
}
if (cardVipInd) {
cardVipInd.className = 'text-[10px] font-mono px-1.5 py-0.5 rounded bg-emerald-950 text-emerald-300 border border-emerald-800';
cardVipInd.textContent = 'VIP: Portée';
}
} else {
if (headerBadge) {
headerBadge.className = 'px-2.5 py-0.5 rounded-full text-xs font-bold bg-blue-500/20 text-blue-300 border border-blue-500/30';
headerBadge.textContent = 'STANDBY (EN VEILLE)';
}
if (navBadge) {
navBadge.className = 'ml-2 shrink-0 px-1.5 py-0.2 text-[10px] rounded-full bg-blue-500/20 text-blue-300 border border-blue-500/30 font-bold';
navBadge.textContent = 'STANDBY';
}
if (cardState) {
cardState.className = 'text-lg font-bold text-blue-400 tracking-wide';
cardState.textContent = 'STANDBY (VEILLE)';
}
if (cardVipInd) {
cardVipInd.className = 'text-[10px] font-mono px-1.5 py-0.5 rounded bg-anthracite-900 text-slate-400';
cardVipInd.textContent = 'VIP: Écoute';
}
}
} else {
if (headerBadge) {
headerBadge.className = 'px-2.5 py-0.5 rounded-full text-xs font-bold bg-slate-700 text-slate-300';
headerBadge.textContent = 'DÉSACTIVÉ';
}
if (navBadge) {
navBadge.className = 'ml-2 shrink-0 px-1.5 py-0.2 text-[10px] rounded-full bg-anthracite-800 text-slate-400 border border-anthracite-700 font-bold';
navBadge.textContent = 'OFF';
}
if (cardState) {
cardState.className = 'text-lg font-bold text-slate-400 tracking-wide';
cardState.textContent = 'STANDALONE';
}
if (cardVipInd) {
cardVipInd.className = 'text-[10px] font-mono px-1.5 py-0.5 rounded bg-anthracite-900 text-slate-400';
cardVipInd.textContent = 'VIP: Inactive';
}
}
// Nœud Distant (Peer)
if (cardPeerIp) cardPeerIp.textContent = st.peer_ip || 'Non configuré';
if (cardPeerStatus) {
if (!st.peer_ip) {
cardPeerStatus.className = 'px-2 py-0.5 rounded text-[10px] font-bold bg-slate-700 text-slate-300';
cardPeerStatus.textContent = 'NON DÉFINI';
} else if (st.peer_reachable) {
cardPeerStatus.className = 'px-2 py-0.5 rounded text-[10px] font-bold bg-emerald-500/20 text-emerald-300 border border-emerald-500/30';
cardPeerStatus.textContent = 'JOIGNABLE';
} else {
cardPeerStatus.className = 'px-2 py-0.5 rounded text-[10px] font-bold bg-red-500/20 text-red-300 border border-red-500/30';
cardPeerStatus.textContent = 'INJOIGNABLE';
}
}
if (cardPeerPing) {
cardPeerPing.textContent = (st.peer_ping_ms !== null) ? `Latence ping : ${st.peer_ping_ms.toFixed(1)} ms` : 'Latence ping : Inaccessible';
}
// Synchronisation
if (cardSyncStatus) {
if (st.last_sync_status === 'success' || st.last_sync_status === 'synced') {
cardSyncStatus.className = 'text-sm font-bold text-emerald-400 truncate';
cardSyncStatus.textContent = 'Synchronisation OK';
} else if (st.last_sync_status === 'error') {
cardSyncStatus.className = 'text-sm font-bold text-red-400 truncate';
cardSyncStatus.textContent = 'Erreur réplication';
} else {
cardSyncStatus.className = 'text-sm font-bold text-slate-400 truncate';
cardSyncStatus.textContent = 'En attente';
}
}
if (cardSyncTime) {
cardSyncTime.textContent = st.last_sync_at ? `Dernière synchro : ${st.last_sync_at}` : 'Dernière synchro : Jamais';
}
// Services Système (Diagnostic)
const svcKeepalived = document.getElementById('cluster-svc-keepalived');
if (svcKeepalived) {
if (!st.keepalived_installed) {
svcKeepalived.className = 'font-mono text-amber-400 font-bold';
svcKeepalived.textContent = '⚠️ Non installé';
} else if (st.keepalived_running) {
svcKeepalived.className = 'font-mono text-emerald-400 font-bold';
svcKeepalived.textContent = '🟢 Actif (En cours)';
} else {
svcKeepalived.className = 'font-mono text-slate-400';
svcKeepalived.textContent = '⚪ Arrêté';
}
}
const svcAsterisk = document.getElementById('cluster-svc-asterisk');
if (svcAsterisk) {
if (st.asterisk_healthy) {
svcAsterisk.className = 'font-mono text-emerald-400 font-bold';
svcAsterisk.textContent = '🟢 En ligne (Ping Pong OK)';
} else if (st.asterisk_running) {
svcAsterisk.className = 'font-mono text-amber-400 font-bold';
svcAsterisk.textContent = '🟡 En cours (Ping en attente)';
} else {
svcAsterisk.className = 'font-mono text-red-400 font-bold';
svcAsterisk.textContent = '🔴 Arrêté';
}
}
const svcPeer = document.getElementById('cluster-svc-peer');
if (svcPeer) {
if (!st.peer_ip) {
svcPeer.textContent = 'Non configuré';
svcPeer.className = 'font-mono text-slate-500';
} else if (st.peer_reachable) {
svcPeer.textContent = `🟢 Joignable (${st.peer_ping_ms !== null ? st.peer_ping_ms.toFixed(1) + 'ms' : 'OK'})`;
svcPeer.className = 'font-mono text-emerald-400 font-bold';
} else {
svcPeer.textContent = '🔴 Injoignable (Timeout)';
svcPeer.className = 'font-mono text-red-400 font-bold';
}
}
// Journal de logs
const logsContainer = document.getElementById('cluster-logs-container');
if (logsContainer && st.recent_logs) {
if (st.recent_logs.length > 0) {
logsContainer.innerHTML = st.recent_logs.map(line => {
let color = 'text-slate-300';
if (line.includes('MASTER')) color = 'text-emerald-300 font-bold';
if (line.includes('BACKUP') || line.includes('STANDBY')) color = 'text-blue-300';
if (line.includes('FAULT') || line.includes('ALERTE')) color = 'text-red-400 font-bold';
return `<div class="${color}">${escapeHtml(line)}</div>`;
}).join('');
} else {
logsContainer.innerHTML = '<p class="text-slate-500">Aucun événement enregistré.</p>';
}
}
} catch (e) {
console.error("Erreur lors du rafraîchissement du cluster :", e);
}
}
async function saveClusterSettings(e) {
e.preventDefault();
const form = e.target;
const payload = {
enabled: form.elements['enabled'].checked ? 1 : 0,
role: form.elements['role'].value,
interface: form.elements['interface'].value,
virtual_ip: form.elements['virtual_ip'].value.trim(),
virtual_netmask: form.elements['virtual_netmask'].value.trim() || '24',
peer_ip: form.elements['peer_ip'].value.trim(),
peer_port: parseInt(form.elements['peer_port'] ? form.elements['peer_port'].value : 8443, 10) || 8443,
cluster_secret: form.elements['cluster_secret'].value.trim(),
vrid: parseInt(form.elements['vrid'].value, 10) || 51,
blf_expiry: parseInt(form.elements['blf_expiry'] ? form.elements['blf_expiry'].value : 120, 10) || 120,
auto_sync: form.elements['auto_sync'].checked ? 1 : 0
};
if (payload.enabled && !payload.virtual_ip) {
showToast("L'adresse IP Virtuelle Flottante (VIP) est requise pour activer le cluster.", 'error');
return;
}
try {
const res = await apiCall('cluster_save', payload);
showToast(res.message || "Paramètres du cluster enregistrés.", 'success');
refreshClusterStatus();
} catch (err) {
showToast(err.message || "Erreur lors de l'enregistrement du cluster.", 'error');
}
}
async function triggerClusterSyncNow() {
if (!currentClusterData || !currentClusterData.peer_ip) {
showToast("Veuillez d'abord renseigner et enregistrer l'adresse IP du Nœud Pair (Peer IP).", 'warning');
return;
}
showToast("Déclenchement de la réplication vers le pair...", 'info');
try {
const res = await apiCall('cluster_sync_now', {});
showToast(res.message || "Synchronisation réussie avec le nœud pair !", 'success');
refreshClusterStatus();
} catch (e) {
showToast(e.message || "Erreur lors de la synchronisation.", 'error');
}
}
async function triggerClusterSwitchover() {
if (!currentClusterData || !currentClusterData.enabled) {
showToast("Le cluster doit être activé pour effectuer une bascule.", 'warning');
return;
}
const currentRole = currentClusterData.configured_role || 'master';
const targetRole = (currentRole === 'master') ? 'STANDBY' : 'MASTER';
if (!confirm(`Confirmez-vous la bascule manuelle de rôle (Switchover) vers l'état ${targetRole} ?\nL'adresse IP flottante va être transférée.`)) {
return;
}
try {
const res = await apiCall('cluster_switchover', {});
showToast(res.message || `Bascule effectuée. Nouveau rôle : ${targetRole}`, 'success');
refreshClusterStatus();
} catch (e) {
showToast(e.message || "Erreur lors de la bascule.", 'error');
}
}
function regenerateClusterSecret() {
const secretInput = document.getElementById('cluster-secret');
if (!secretInput) return;
const array = new Uint8Array(16);
window.crypto.getRandomValues(array);
const newSecret = Array.from(array, byte => byte.toString(16).padStart(2, '0')).join('');
secretInput.value = newSecret;
showToast("Nouveau jeton généré. N'oubliez pas d'enregistrer et de copier cette même clé sur le second serveur !", 'info');
}
function copyClusterSecret() {
const secretInput = document.getElementById('cluster-secret');
if (!secretInput || !secretInput.value) return;
navigator.clipboard.writeText(secretInput.value).then(() => {
showToast("Jeton secret copié dans le presse-papier !", 'success');
}).catch(() => {
secretInput.select();
document.execCommand('copy');
showToast("Jeton secret copié !", 'success');
});
}
// =========================================================================
// GESTION DES COMPTES PORTAIL UTILISATEUR (UCP)
// =========================================================================
function renderUcpTab() {
const s = appState.settings || {};
const ucpEnabled = Number(s.ucp_enabled ?? 1) === 1;
const httpsPort = Number(s.ucp_https_port ?? 8444);
const httpPort = Number(s.ucp_http_port ?? 8084);
const host = window.location.hostname || 'localhost';
const statusBadge = document.getElementById('ucp-service-status-badge');
if (statusBadge) {
if (!ucpEnabled || (httpsPort <= 0 && httpPort <= 0)) {
statusBadge.textContent = 'Portail désactivé';
statusBadge.className = 'px-2 py-0.5 rounded-full text-[11px] font-semibold bg-rose-500/15 text-rose-300 border border-rose-500/30';
} else {
statusBadge.textContent = 'En ligne & Isolé';
statusBadge.className = 'px-2 py-0.5 rounded-full text-[11px] font-semibold bg-emerald-500/15 text-emerald-300 border border-emerald-500/30';
}
}
const urlsBox = document.getElementById('ucp-access-urls');
if (urlsBox) {
if (!ucpEnabled) {
urlsBox.innerHTML = '<span class="text-slate-500 italic">Activez l’UCP dans Pare-feu & Sécurité pour exposer le portail.</span>';
} else {
const links = [];
if (httpsPort > 0) {
const url = `https://${host}:${httpsPort}/`;
links.push(`<a href="${escapeHtml(url)}" target="_blank" rel="noopener" class="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg bg-emerald-500/10 hover:bg-emerald-500/20 text-emerald-300 border border-emerald-500/30 font-mono transition">🔒 ${escapeHtml(url)}</a>`);
}
if (httpPort > 0) {
const url = `http://${host}:${httpPort}/`;
links.push(`<a href="${escapeHtml(url)}" target="_blank" rel="noopener" class="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg bg-sky-500/10 hover:bg-sky-500/20 text-sky-300 border border-sky-500/30 font-mono transition">🌐 ${escapeHtml(url)} (Reverse Proxy)</a>`);
}
urlsBox.innerHTML = links.length ? links.join('') : '<span class="text-amber-400">Aucun port d’écoute configuré.</span>';
}
}
renderUcpUsersTable();
}
function renderUcpUsersTable() {
const tbody = document.getElementById('ucp-users-table-body');
if (!tbody) return;
const users = appState.ucp_users || [];
const extMap = new Map((appState.extensions || []).map(e => [String(e.extension), e]));
if (users.length === 0) {
tbody.innerHTML = `
<tr>
<td colspan="6" class="px-5 py-10 text-center text-slate-400">
<div class="flex flex-col items-center gap-2">
<span class="text-sm font-medium text-slate-300">Aucun utilisateur UCP créé pour le moment.</span>
<span class="text-xs text-slate-500">Cliquez sur « Nouvel Utilisateur UCP » pour ouvrir un accès employé à sa messagerie vocale, ses renvois et l’annuaire.</span>
</div>
</td>
</tr>
`;
return;
}
tbody.innerHTML = users.map(u => {
const exts = Array.isArray(u.extensions) ? u.extensions : [];
const extChips = exts.length
? exts.map(num => {
const info = extMap.get(String(num));
const title = info ? `${num} — ${info.caller_id_name || ''}` : String(num);
return `<span title="${escapeHtml(title)}" class="px-2 py-0.5 rounded-md bg-sky-500/15 text-sky-300 border border-sky-500/30 font-mono text-[11px] font-semibold">${escapeHtml(String(num))}</span>`;
}).join(' ')
: '<span class="text-slate-500 italic">Aucune extension</span>';
const isEnabled = Number(u.enabled) === 1;
const statusPill = isEnabled
? '<span class="px-2 py-0.5 rounded-full text-[11px] font-semibold bg-emerald-500/15 text-emerald-300 border border-emerald-500/30">Actif</span>'
: '<span class="px-2 py-0.5 rounded-full text-[11px] font-semibold bg-rose-500/15 text-rose-300 border border-rose-500/30">Désactivé</span>';
const lastLogin = u.last_login_at ? escapeHtml(u.last_login_at) : '<span class="text-slate-500">Jamais connecté</span>';
return `
<tr class="hover:bg-anthracite-800/40 transition">
<td class="px-5 py-3.5 font-mono font-bold text-white">${escapeHtml(u.username || '')}</td>
<td class="px-5 py-3.5">
<div class="font-semibold text-slate-200">${escapeHtml(u.display_name || u.username || '')}</div>
<div class="text-[11px] text-slate-400">${u.email ? escapeHtml(u.email) : '<span class="text-slate-600">—</span>'}</div>
</td>
<td class="px-5 py-3.5">
<div class="flex flex-wrap gap-1.5">${extChips}</div>
</td>
<td class="px-5 py-3.5">${statusPill}</td>
<td class="px-5 py-3.5 text-slate-400">${lastLogin}</td>
<td class="px-5 py-3.5 text-right whitespace-nowrap">
<button type="button" onclick="openUcpUserModal(${Number(u.id)})"
class="px-2.5 py-1 rounded-lg bg-anthracite-800 hover:bg-sky-600/20 text-slate-300 hover:text-sky-300 border border-anthracite-700 hover:border-sky-500/40 text-xs font-semibold transition mr-1.5">
Modifier
</button>
<button type="button" onclick="deleteUcpUser(${Number(u.id)}, '${escapeHtml(u.username || '')}')"
class="px-2.5 py-1 rounded-lg bg-rose-500/10 hover:bg-rose-600 text-rose-300 hover:text-white border border-rose-500/30 text-xs font-semibold transition">
Supprimer
</button>
</td>
</tr>
`;
}).join('');
}
function openUcpUserModal(userId = 0) {
const modal = document.getElementById('modal-ucp-user');
if (!modal) return;
const users = appState.ucp_users || [];
const user = userId ? users.find(u => Number(u.id) === Number(userId)) : null;
document.getElementById('ucp-user-id').value = user ? Number(user.id) : 0;
document.getElementById('ucp-modal-title').textContent = user ? `Modifier l'utilisateur UCP (${user.username})` : 'Nouvel utilisateur UCP';
document.getElementById('ucp-user-username').value = user ? (user.username || '') : '';
document.getElementById('ucp-user-display-name').value = user ? (user.display_name || '') : '';
document.getElementById('ucp-user-email').value = user ? (user.email || '') : '';
document.getElementById('ucp-user-password').value = '';
document.getElementById('ucp-user-password').required = !user;
document.getElementById('ucp-password-hint').textContent = user ? '(laisser vide pour conserver)' : '(min. 6 car., requis)';
document.getElementById('ucp-user-enabled').checked = user ? Number(user.enabled) === 1 : true;
const selectedExts = new Set((user?.extensions || []).map(String));
const container = document.getElementById('ucp-user-extensions-container');
const exts = [...(appState.extensions || [])].sort((a, b) => Number(a.extension) - Number(b.extension));
if (container) {
if (exts.length === 0) {
container.innerHTML = '<div class="col-span-full text-xs text-slate-500 py-2">Aucune extension SIP configurée sur le PBX.</div>';
} else {
container.innerHTML = exts.map(ext => {
const num = String(ext.extension);
const checked = selectedExts.has(num) ? 'checked' : '';
return `
<label class="flex items-center gap-2.5 p-2 rounded-lg bg-anthracite-900 border border-anthracite-800 hover:border-sky-500/40 cursor-pointer select-none transition">
<input type="checkbox" name="ucp_ext_choice" value="${escapeHtml(num)}" ${checked}
class="w-4 h-4 rounded border-anthracite-600 bg-anthracite-950 text-sky-500 focus:ring-sky-500">
<div class="min-w-0">
<div class="text-xs font-bold text-white font-mono">${escapeHtml(num)}</div>
<div class="text-[11px] text-slate-400 truncate">${escapeHtml(ext.caller_id_name || 'Sans nom')}</div>
</div>
</label>
`;
}).join('');
}
}
modal.classList.remove('hidden');
modal.classList.add('flex');
}
function closeUcpUserModal() {
const modal = document.getElementById('modal-ucp-user');
if (!modal) return;
modal.classList.add('hidden');
modal.classList.remove('flex');
}
async function saveUcpUser(event) {
event.preventDefault();
const btn = document.getElementById('btn-save-ucp-user');
if (btn) btn.disabled = true;
const checkedExts = Array.from(document.querySelectorAll('#ucp-user-extensions-container input[name="ucp_ext_choice"]:checked'))
.map(el => el.value);
const payload = {
id: Number(document.getElementById('ucp-user-id').value || 0),
username: (document.getElementById('ucp-user-username').value || '').trim(),
display_name: (document.getElementById('ucp-user-display-name').value || '').trim(),
email: (document.getElementById('ucp-user-email').value || '').trim(),
password: document.getElementById('ucp-user-password').value || '',
enabled: document.getElementById('ucp-user-enabled').checked ? 1 : 0,
extensions: checkedExts
};
try {
const res = await apiCall('save_ucp_user', payload);
appState.ucp_users = res.ucp_users || [];
renderUcpTab();
const ucpBadge = document.getElementById('nav-badge-ucp');
if (ucpBadge) ucpBadge.textContent = appState.ucp_users.length;
closeUcpUserModal();
showToast(res.message || 'Utilisateur UCP enregistré.', 'success');
} catch (err) {
showToast(err.message || "Erreur lors de l'enregistrement de l'utilisateur UCP.", 'error');
} finally {
if (btn) btn.disabled = false;
}
}
async function deleteUcpUser(userId, username) {
if (!confirm(`Supprimer définitivement le compte UCP « ${username} » ?`)) {
return;
}
try {
const res = await apiCall('delete_ucp_user', { id: Number(userId) });
appState.ucp_users = res.ucp_users || [];
renderUcpTab();
const ucpBadge = document.getElementById('nav-badge-ucp');
if (ucpBadge) ucpBadge.textContent = appState.ucp_users.length;
showToast(res.message || 'Utilisateur UCP supprimé.', 'success');
} catch (err) {
showToast(err.message || "Erreur lors de la suppression de l'utilisateur UCP.", 'error');
}
}