6076 lines
287 KiB
JavaScript
6076 lines
287 KiB
JavaScript
'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, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"')
|
|
.replace(/'/g, ''');
|
|
}
|
|
|
|
// --- Bascule propre des écrans Login / Dashboard ---
|
|
function showLoginScreen() {
|
|
stopMetricsPolling();
|
|
const appScreen = document.getElementById('app-screen');
|
|
const loginScreen = document.getElementById('login-screen');
|
|
if (appScreen) {
|
|
appScreen.classList.add('hidden');
|
|
appScreen.classList.remove('flex');
|
|
}
|
|
if (loginScreen) {
|
|
loginScreen.classList.remove('hidden');
|
|
loginScreen.classList.add('flex');
|
|
}
|
|
window.scrollTo(0, 0);
|
|
}
|
|
|
|
function showAppScreen() {
|
|
const loginScreen = document.getElementById('login-screen');
|
|
const appScreen = document.getElementById('app-screen');
|
|
if (loginScreen) {
|
|
loginScreen.classList.add('hidden');
|
|
loginScreen.classList.remove('flex');
|
|
}
|
|
if (appScreen) {
|
|
appScreen.classList.remove('hidden');
|
|
appScreen.classList.add('flex');
|
|
}
|
|
window.scrollTo(0, 0);
|
|
}
|
|
|
|
// --- Client AJAX Fetch API ---
|
|
async function apiCall(action, payload = null) {
|
|
const options = {
|
|
method: payload ? 'POST' : 'GET',
|
|
headers: { 'Accept': 'application/json' }
|
|
};
|
|
if (payload) {
|
|
options.headers['Content-Type'] = 'application/json';
|
|
options.body = JSON.stringify(payload);
|
|
}
|
|
|
|
const res = await fetch(`/api.php?action=${encodeURIComponent(action)}`, options);
|
|
const cType = res.headers.get('content-type') || '';
|
|
let data;
|
|
if (cType.includes('application/json')) {
|
|
data = await res.json();
|
|
} else {
|
|
const rawText = await res.text();
|
|
if (res.status === 413) {
|
|
throw new Error("Le contenu envoyé est trop volumineux pour le serveur (Limite 64 Mo).");
|
|
}
|
|
throw new Error(`Erreur serveur (${res.status}) : ${rawText.replace(/<[^>]*>/g, '').trim().slice(0, 150) || 'Réponse inattendue'}`);
|
|
}
|
|
|
|
if (res.status === 401 && data.unauthenticated) {
|
|
showLoginScreen();
|
|
throw new Error(data.message || 'Session expirée');
|
|
}
|
|
|
|
if (!res.ok || !data.success) {
|
|
throw new Error(data.message || 'Erreur lors de la requête AJAX');
|
|
}
|
|
|
|
return data;
|
|
}
|
|
|
|
// --- Chargement global de l'état ---
|
|
async function loadState() {
|
|
try {
|
|
const data = await apiCall('get_state');
|
|
appState = data;
|
|
if (data.metrics) {
|
|
pushMetricsToHistory(data.metrics);
|
|
}
|
|
renderAll();
|
|
loadBackups();
|
|
checkSystemUpdate(false);
|
|
startMetricsPolling();
|
|
} catch (err) {
|
|
if (err.message !== 'Session expirée') {
|
|
showToast(err.message, 'error');
|
|
}
|
|
}
|
|
}
|
|
|
|
async function pollMetrics() {
|
|
try {
|
|
const data = await apiCall('get_metrics');
|
|
if (data.metrics) {
|
|
appState.metrics = data.metrics;
|
|
pushMetricsToHistory(data.metrics);
|
|
renderDashboardMetrics();
|
|
renderExtensionsTable();
|
|
renderTrunksTable();
|
|
}
|
|
} catch (_) {
|
|
// Silencieux en arrière-plan
|
|
}
|
|
}
|
|
|
|
function startMetricsPolling() {
|
|
if (!metricsTimer) {
|
|
metricsTimer = setInterval(pollMetrics, 3000);
|
|
}
|
|
}
|
|
|
|
function stopMetricsPolling() {
|
|
if (metricsTimer) {
|
|
clearInterval(metricsTimer);
|
|
metricsTimer = null;
|
|
}
|
|
}
|
|
|
|
function pushMetricsToHistory(m) {
|
|
chartHistory.cpu.push(Number(m.cpu?.percent || 0));
|
|
chartHistory.cpu.shift();
|
|
|
|
chartHistory.ram.push(Number(m.memory?.percent || 0));
|
|
chartHistory.ram.shift();
|
|
|
|
chartHistory.rx.push(Number(m.network?.rx_kbps || 0));
|
|
chartHistory.rx.shift();
|
|
|
|
chartHistory.tx.push(Number(m.network?.tx_kbps || 0));
|
|
chartHistory.tx.shift();
|
|
}
|
|
|
|
function renderAll() {
|
|
renderCompanyBranding();
|
|
renderSummaryCards();
|
|
renderDashboardMetrics();
|
|
renderExtensionsTable();
|
|
renderGroupsTable();
|
|
renderExtensionGroupsTable();
|
|
renderPagingTable();
|
|
renderPhonebookTable();
|
|
renderTrunksTable();
|
|
renderOutboundRoutesTable();
|
|
renderIvrsTable();
|
|
renderSoundsTable();
|
|
renderFeatureCodes();
|
|
renderSipSettings();
|
|
renderCertificates();
|
|
renderSettingsAndDiagnostics();
|
|
}
|
|
|
|
function renderCompanyBranding() {
|
|
const s = appState.settings || {};
|
|
const logo = (s.company_logo || '').trim();
|
|
const name = (s.company_name || '').trim();
|
|
|
|
const emptyBox = document.getElementById('sidebar-company-empty');
|
|
const filledBox = document.getElementById('sidebar-company-filled');
|
|
const logoImg = document.getElementById('sidebar-company-logo');
|
|
const nameEl = document.getElementById('sidebar-company-name');
|
|
const slot = document.getElementById('sidebar-company-slot');
|
|
|
|
const loginBox = document.getElementById('login-company-box');
|
|
const loginLogo = document.getElementById('login-company-logo');
|
|
const loginName = document.getElementById('login-company-name');
|
|
|
|
// Aperçu dans l'administration (Onglet Système & Paramètres)
|
|
const settingsPreviewImg = document.getElementById('settings-company-preview-img');
|
|
const settingsPreviewEmpty = document.getElementById('settings-company-preview-empty');
|
|
if (settingsPreviewImg && settingsPreviewEmpty) {
|
|
if (logo) {
|
|
settingsPreviewImg.src = logo;
|
|
settingsPreviewImg.classList.remove('hidden');
|
|
settingsPreviewEmpty.classList.add('hidden');
|
|
} else {
|
|
settingsPreviewImg.src = '';
|
|
settingsPreviewImg.classList.add('hidden');
|
|
settingsPreviewEmpty.classList.remove('hidden');
|
|
}
|
|
}
|
|
|
|
if (logo || name) {
|
|
emptyBox?.classList.add('hidden');
|
|
filledBox?.classList.remove('hidden');
|
|
|
|
if (logo && logoImg) {
|
|
logoImg.src = logo;
|
|
logoImg.classList.remove('hidden');
|
|
} else if (logoImg) {
|
|
logoImg.src = '';
|
|
logoImg.classList.add('hidden');
|
|
}
|
|
|
|
if (name && nameEl) {
|
|
nameEl.textContent = name;
|
|
nameEl.classList.remove('hidden');
|
|
} else if (nameEl) {
|
|
nameEl.textContent = '';
|
|
nameEl.classList.add('hidden');
|
|
}
|
|
|
|
if (loginBox) {
|
|
loginBox.classList.remove('hidden');
|
|
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;
|
|
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">
|
|
<button type="button" onclick="editExtension(${Number(ext.id)})" class="text-xs font-semibold text-indigo-300 hover:text-indigo-200">Modifier</button>
|
|
<button type="button" onclick="deleteExtension(${Number(ext.id)})" class="text-xs font-semibold text-red-400 hover:text-red-300">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');
|
|
}
|
|
}
|
|
|
|
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>';
|
|
|
|
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 → <code class="text-slate-300">${escapeHtml(grp.failover_dest)}</code></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>`;
|
|
}
|
|
|
|
// 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 {
|
|
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;
|
|
|
|
// 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',
|
|
extensions: 'Postes Téléphoniques SIP (PJSIP)',
|
|
groups: "Groupes de Sonnerie (Ring Groups)",
|
|
extgroups: "Groupes d'Extensions (Sites & Droits Sortants)",
|
|
paging: 'Groupes de Téléavertissement & Interphonie (Paging)',
|
|
trunks: 'Trunks Opérateurs SIP',
|
|
outbound: 'Routes Sortantes (Outbound Routes & Séquence Trunks)',
|
|
ivrs: 'Serveurs Vocaux Interactifs (SVI / IVR)',
|
|
sounds: 'Fichiers sons',
|
|
featurecodes: 'Codes de Fonctions & Services Téléphoniques Asterisk',
|
|
sipsettings: 'Paramètres SIP Globaux & Configuration NAT (PJSIP & RTP)',
|
|
certificates: 'Gestionnaire de Certificats SSL / TLS (Let\'s Encrypt & Imports)',
|
|
system: 'Paramètres Globaux, Diagnostic Debian & Fichiers .conf',
|
|
backups: 'Sauvegardes & Restauration Système (Disaster Recovery)',
|
|
firewall: 'Pare-feu Réseau & Sécurité Téléphonie (Fail2ban & NFTables)'
|
|
};
|
|
|
|
function switchTab(tabId) {
|
|
document.querySelectorAll('.tab-panel').forEach(p => p.classList.add('hidden'));
|
|
const target = document.getElementById(`tab-${tabId}`);
|
|
if (target) target.classList.remove('hidden');
|
|
|
|
const titleEl = document.getElementById('page-header-title');
|
|
if (titleEl && PAGE_TITLES[tabId]) {
|
|
titleEl.textContent = PAGE_TITLES[tabId];
|
|
}
|
|
|
|
document.querySelectorAll('.tab-btn').forEach(btn => {
|
|
const isSubBtn = btn.parentElement?.id === 'submenu-operators' || btn.parentElement?.id === 'submenu-groups' || btn.parentElement?.id === 'submenu-system';
|
|
if (btn.dataset.tab === tabId) {
|
|
if (isSubBtn) {
|
|
btn.className = 'tab-btn w-full flex items-center justify-between px-3 py-1.5 rounded-lg text-xs font-bold bg-indigo-600 text-white shadow-sm transition text-left';
|
|
} else {
|
|
btn.className = 'tab-btn w-full flex items-center justify-between px-3.5 py-2.5 rounded-xl text-sm font-semibold bg-indigo-600 text-white shadow-md shadow-indigo-600/20 transition';
|
|
}
|
|
} else {
|
|
if (isSubBtn) {
|
|
btn.className = 'tab-btn w-full flex items-center justify-between px-3 py-1.5 rounded-lg text-xs font-medium text-slate-300 hover:bg-anthracite-800 hover:text-white transition text-left';
|
|
} else {
|
|
btn.className = 'tab-btn w-full flex items-center justify-between px-3.5 py-2.5 rounded-xl text-sm font-medium text-slate-300 hover:bg-anthracite-800 hover:text-white transition';
|
|
}
|
|
}
|
|
});
|
|
|
|
// Si on clique sur un sous-menu Opérateurs, s'assurer que le menu parent est bien déplié
|
|
if (tabId === 'trunks' || tabId === 'outbound') {
|
|
const submenu = document.getElementById('submenu-operators');
|
|
const arrow = document.getElementById('arrow-menu-operators');
|
|
if (submenu && submenu.classList.contains('hidden')) {
|
|
submenu.classList.remove('hidden');
|
|
arrow?.classList.remove('-rotate-90');
|
|
}
|
|
}
|
|
|
|
// Si on clique sur un sous-menu Groupes, s'assurer que le menu parent est bien déplié
|
|
if (tabId === 'groups' || tabId === 'extgroups') {
|
|
const submenu = document.getElementById('submenu-groups');
|
|
const arrow = document.getElementById('arrow-menu-groups');
|
|
if (submenu && submenu.classList.contains('hidden')) {
|
|
submenu.classList.remove('hidden');
|
|
arrow?.classList.remove('-rotate-90');
|
|
}
|
|
}
|
|
|
|
// Si on clique sur un sous-menu Système, s'assurer que le menu parent est bien déplié
|
|
if (tabId === 'system' || tabId === 'backups' || tabId === 'firewall') {
|
|
const submenu = document.getElementById('submenu-system');
|
|
const arrow = document.getElementById('arrow-menu-system');
|
|
if (submenu && submenu.classList.contains('hidden')) {
|
|
submenu.classList.remove('hidden');
|
|
arrow?.classList.remove('-rotate-90');
|
|
}
|
|
}
|
|
|
|
// Si onglet Sauvegardes, charger la liste des sauvegardes et la configuration
|
|
if (tabId === 'backups') {
|
|
loadBackups();
|
|
}
|
|
|
|
// Si onglet Pare-feu, charger le statut en direct
|
|
if (tabId === 'firewall') {
|
|
loadFirewallStatus();
|
|
}
|
|
|
|
toggleMobileSidebar(false);
|
|
}
|
|
|
|
function toggleSystemMenu() {
|
|
const submenu = document.getElementById('submenu-system');
|
|
const arrow = document.getElementById('arrow-menu-system');
|
|
if (!submenu) return;
|
|
const isHidden = submenu.classList.toggle('hidden');
|
|
if (arrow) {
|
|
if (isHidden) {
|
|
arrow.classList.add('-rotate-90');
|
|
} else {
|
|
arrow.classList.remove('-rotate-90');
|
|
}
|
|
}
|
|
}
|
|
|
|
function toggleGroupsMenu() {
|
|
const submenu = document.getElementById('submenu-groups');
|
|
const arrow = document.getElementById('arrow-menu-groups');
|
|
if (!submenu) return;
|
|
const isHidden = submenu.classList.toggle('hidden');
|
|
if (arrow) {
|
|
if (isHidden) {
|
|
arrow.classList.add('-rotate-90');
|
|
} else {
|
|
arrow.classList.remove('-rotate-90');
|
|
}
|
|
}
|
|
}
|
|
|
|
function toggleOperatorsMenu() {
|
|
const submenu = document.getElementById('submenu-operators');
|
|
const arrow = document.getElementById('arrow-menu-operators');
|
|
if (!submenu) return;
|
|
const isHidden = submenu.classList.toggle('hidden');
|
|
if (arrow) {
|
|
if (isHidden) {
|
|
arrow.classList.add('-rotate-90');
|
|
} else {
|
|
arrow.classList.remove('-rotate-90');
|
|
}
|
|
}
|
|
}
|
|
|
|
function toggleMobileSidebar(open) {
|
|
const sidebar = document.getElementById('sidebar');
|
|
const backdrop = document.getElementById('sidebar-backdrop');
|
|
if (!sidebar || !backdrop) return;
|
|
|
|
if (open) {
|
|
sidebar.classList.remove('-translate-x-full');
|
|
backdrop.classList.remove('hidden');
|
|
} else {
|
|
sidebar.classList.add('-translate-x-full');
|
|
backdrop.classList.add('hidden');
|
|
}
|
|
}
|
|
|
|
function openModal(id) {
|
|
const el = document.getElementById(id);
|
|
if (el) {
|
|
el.classList.remove('hidden');
|
|
el.classList.add('flex');
|
|
}
|
|
}
|
|
|
|
function closeModal(id) {
|
|
const el = document.getElementById(id);
|
|
if (el) {
|
|
el.classList.add('hidden');
|
|
el.classList.remove('flex');
|
|
}
|
|
}
|
|
|
|
// --- Écouteurs d'événements DOM ---
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
// Login AJAX
|
|
document.getElementById('login-form')?.addEventListener('submit', async (e) => {
|
|
e.preventDefault();
|
|
const formData = Object.fromEntries(new FormData(e.target).entries());
|
|
try {
|
|
const res = await apiCall('login', formData);
|
|
showToast(res.message, 'success');
|
|
showAppScreen();
|
|
await loadState();
|
|
} catch (err) {
|
|
showToast(err.message, 'error');
|
|
}
|
|
});
|
|
|
|
// Logout AJAX
|
|
document.getElementById('btn-logout')?.addEventListener('click', async () => {
|
|
await apiCall('logout', {});
|
|
showLoginScreen();
|
|
showToast('Vous êtes déconnecté.', 'info');
|
|
});
|
|
|
|
// Form Extension
|
|
document.getElementById('form-extension')?.addEventListener('submit', async (e) => {
|
|
e.preventDefault();
|
|
const fd = new FormData(e.target);
|
|
const payload = Object.fromEntries(fd.entries());
|
|
payload.encryption = Number(document.getElementById('ext-encryption')?.value ?? 1);
|
|
payload.member_extension_groups = Array.from(e.target.querySelectorAll('input[name="member_extension_groups[]"]:checked')).map(c => c.value);
|
|
payload.voicemail_enabled = document.getElementById('ext-voicemail-enabled')?.checked ? 1 : 0;
|
|
payload.force_pin_change = (payload.pin_mode === 'first_call') ? 1 : 0;
|
|
payload.voicemail_skip_pin = document.getElementById('ext-skip-pin')?.checked ? 1 : 0;
|
|
payload.voicemail_attach = document.getElementById('ext-vm-attach')?.checked ? 1 : 0;
|
|
payload.voicemail_delete = document.getElementById('ext-vm-delete')?.checked ? 1 : 0;
|
|
payload.ring_time = Number(payload.ring_time || 20);
|
|
|
|
try {
|
|
const res = await apiCall('save_extension', payload);
|
|
showToast(res.message, 'success');
|
|
closeModal('modal-extension');
|
|
await loadState();
|
|
} catch (err) {
|
|
showToast(err.message, 'error');
|
|
}
|
|
});
|
|
|
|
// Form Groupe de Sonnerie (Ring Groups)
|
|
document.getElementById('form-group')?.addEventListener('submit', async (e) => {
|
|
e.preventDefault();
|
|
const fd = new FormData(e.target);
|
|
const payload = Object.fromEntries(fd.entries());
|
|
payload.members = Array.from(e.target.querySelectorAll('input[name="member_ext"]:checked')).map(c => c.value);
|
|
|
|
try {
|
|
const res = await apiCall('save_group', payload);
|
|
showToast(res.message, 'success');
|
|
closeModal('modal-group');
|
|
await loadState();
|
|
} catch (err) {
|
|
showToast(err.message, 'error');
|
|
}
|
|
});
|
|
|
|
// Form Groupe d'Extensions (Sites & Droits Sortants)
|
|
document.getElementById('form-extgroup')?.addEventListener('submit', async (e) => {
|
|
e.preventDefault();
|
|
const fd = new FormData(e.target);
|
|
const payload = Object.fromEntries(fd.entries());
|
|
payload.members = Array.from(activeExtGroupSelectedExts);
|
|
|
|
try {
|
|
const res = await apiCall('save_extension_group', payload);
|
|
showToast(res.message, 'success');
|
|
closeModal('modal-extgroup');
|
|
await loadState();
|
|
} catch (err) {
|
|
showToast(err.message, 'error');
|
|
}
|
|
});
|
|
|
|
// Form Groupe de Téléavertissement (Paging)
|
|
document.getElementById('form-paging')?.addEventListener('submit', async (e) => {
|
|
e.preventDefault();
|
|
const fd = new FormData(e.target);
|
|
const payload = Object.fromEntries(fd.entries());
|
|
payload.members = Array.from(activePagingSelectedExts);
|
|
payload.duplex = Number(payload.duplex || 0);
|
|
payload.timeout = Number(payload.timeout || 120);
|
|
payload.play_beep = e.target.querySelector('input[name="play_beep"]')?.checked ? 1 : 0;
|
|
payload.enabled = e.target.querySelector('input[name="enabled"]')?.checked ? 1 : 0;
|
|
|
|
try {
|
|
const res = await apiCall('save_paging_group', payload);
|
|
showToast(res.message, 'success');
|
|
closeModal('modal-paging');
|
|
await loadState();
|
|
} catch (err) {
|
|
showToast(err.message, 'error');
|
|
}
|
|
});
|
|
|
|
// Form Trunk (Configuration PJSIP complète)
|
|
document.getElementById('form-trunk')?.addEventListener('submit', async (e) => {
|
|
e.preventDefault();
|
|
const form = e.target;
|
|
const fd = new FormData(form);
|
|
const payload = Object.fromEntries(fd.entries());
|
|
|
|
payload.registration_mode = form.querySelector('input[name="registration_mode"]:checked')?.value || 'send';
|
|
payload.enabled = form.elements['enabled'].checked ? 1 : 0;
|
|
payload.register = form.elements['register'].checked ? 1 : 0;
|
|
payload.line_support = form.elements['line_support'].checked ? 1 : 0;
|
|
payload.send_pai = form.elements['send_pai'].checked ? 1 : 0;
|
|
payload.send_rpid = form.elements['send_rpid'].checked ? 1 : 0;
|
|
payload.send_diversion = form.elements['send_diversion'].checked ? 1 : 0;
|
|
payload.trust_id_inbound = form.elements['trust_id_inbound'].checked ? 1 : 0;
|
|
payload.nat_symmetric = form.elements['nat_symmetric'].checked ? 1 : 0;
|
|
payload.direct_media = form.elements['direct_media'].checked ? 1 : 0;
|
|
payload.inband_progress = form.elements['inband_progress'].checked ? 1 : 0;
|
|
payload.fax_detect = form.elements['fax_detect'].checked ? 1 : 0;
|
|
|
|
const didRoutes = [];
|
|
document.querySelectorAll('#trunk-did-container .trunk-did-row').forEach(row => {
|
|
const cid = row.querySelector('.trunk-did-cid')?.value.trim() || 'Any';
|
|
const did = row.querySelector('.trunk-did-num')?.value.trim() || 'Any';
|
|
const desc = row.querySelector('.trunk-did-desc')?.value.trim() || '';
|
|
const dest = row.querySelector('.trunk-did-dest')?.value.trim() || '';
|
|
const tcEnabled = row.querySelector('.trunk-tc-enabled')?.checked ? 1 : 0;
|
|
const tcStart = row.querySelector('.trunk-tc-start')?.value || '08:30';
|
|
const tcFinish = row.querySelector('.trunk-tc-finish')?.value || '18:00';
|
|
const weekdays = row.querySelector('.trunk-tc-weekdays')?.value || 'mon-fri';
|
|
const tcClosed = row.querySelector('.trunk-tc-closed-dest')?.value || 'terminate-hangup';
|
|
const cidPrefix = row.querySelector('.trunk-did-prefix')?.value.trim() || '';
|
|
|
|
if (dest) {
|
|
didRoutes.push({
|
|
cid,
|
|
did,
|
|
description: desc,
|
|
destination: dest,
|
|
cid_prefix: cidPrefix,
|
|
time_condition_enabled: tcEnabled,
|
|
time_start: tcStart,
|
|
time_finish: tcFinish,
|
|
weekdays: weekdays,
|
|
time_dest_closed: tcClosed
|
|
});
|
|
}
|
|
});
|
|
payload.did_routes = didRoutes;
|
|
|
|
try {
|
|
const res = await apiCall('save_trunk', payload);
|
|
showToast(res.message, 'success');
|
|
closeModal('modal-trunk');
|
|
await loadState();
|
|
} catch (err) {
|
|
showToast(err.message, 'error');
|
|
}
|
|
});
|
|
|
|
// Form Outbound Route
|
|
document.getElementById('form-outbound')?.addEventListener('submit', async (e) => {
|
|
e.preventDefault();
|
|
const form = e.target;
|
|
const fd = new FormData(form);
|
|
const payload = Object.fromEntries(fd.entries());
|
|
|
|
payload.enabled = form.elements['enabled'].checked ? 1 : 0;
|
|
payload.continue_next_route = form.elements['continue_next_route'].checked ? 1 : 0;
|
|
payload.override_ext_cid = form.querySelector('input[name="override_ext_cid"]:checked')?.value === '1' ? 1 : 0;
|
|
payload.route_type = form.querySelector('input[name="route_type"]:checked')?.value || 'normal';
|
|
payload.permission_mode = form.querySelector('input[name="permission_mode"]:checked')?.value || 'all';
|
|
payload.record_mode = form.querySelector('input[name="record_mode"]:checked')?.value || 'dontcare';
|
|
|
|
// Séquence de Trunks
|
|
const trunks = [];
|
|
document.querySelectorAll('#ob-trunk-seq-container .ob-trunk-select').forEach(sel => {
|
|
const val = sel.value.trim();
|
|
if (val && !trunks.includes(val)) trunks.push(val);
|
|
});
|
|
payload.trunk_sequence = trunks;
|
|
|
|
// Dial Patterns
|
|
const patterns = [];
|
|
document.querySelectorAll('#ob-patterns-container .ob-pat-row').forEach(row => {
|
|
const prep = row.querySelector('.ob-pat-prepend')?.value.trim() || '';
|
|
const pfx = row.querySelector('.ob-pat-prefix')?.value.trim() || '';
|
|
const mtch = row.querySelector('.ob-pat-match')?.value.trim() || '';
|
|
const cid = row.querySelector('.ob-pat-cid')?.value.trim() || '';
|
|
if (mtch) {
|
|
patterns.push({
|
|
prepend: prep,
|
|
prefix: pfx,
|
|
match_pattern: mtch,
|
|
callerid: cid
|
|
});
|
|
}
|
|
});
|
|
payload.dial_patterns = patterns;
|
|
|
|
// Groupes autorisés
|
|
payload.allowed_groups = Array.from(form.querySelectorAll('input[name="ob_allowed_groups[]"]:checked')).map(c => c.value);
|
|
|
|
// Extensions autorisées (depuis le drag/transfert interactif)
|
|
payload.allowed_extensions = Array.from(activeObAllowedExts);
|
|
|
|
try {
|
|
const res = await apiCall('save_outbound_route', payload);
|
|
showToast(res.message, 'success');
|
|
closeModal('modal-outbound');
|
|
await loadState();
|
|
} catch (err) {
|
|
showToast(err.message, 'error');
|
|
}
|
|
});
|
|
|
|
// Form IVR
|
|
document.getElementById('form-ivr')?.addEventListener('submit', async (e) => {
|
|
e.preventDefault();
|
|
const fd = new FormData(e.target);
|
|
const payload = Object.fromEntries(fd.entries());
|
|
payload.direct_dial = document.getElementById('ivr-direct-dial-checkbox')?.checked ? 1 : 0;
|
|
|
|
const options = {};
|
|
document.querySelectorAll('#ivr-options-container .ivr-opt-row').forEach(row => {
|
|
const d = (row.querySelector('.ivr-digit')?.value || '').trim();
|
|
const dest = (row.querySelector('.ivr-dest')?.value || '').trim();
|
|
if (d && dest) options[d] = dest;
|
|
});
|
|
payload.options = options;
|
|
|
|
try {
|
|
const res = await apiCall('save_ivr', payload);
|
|
showToast(res.message, 'success');
|
|
closeModal('modal-ivr');
|
|
await loadState();
|
|
} catch (err) {
|
|
showToast(err.message, 'error');
|
|
}
|
|
});
|
|
|
|
// Form Fichier Son (Upload multipart / Update)
|
|
document.getElementById('form-sound')?.addEventListener('submit', async (e) => {
|
|
e.preventDefault();
|
|
const form = e.target;
|
|
const fd = new FormData(form);
|
|
const id = form.elements['id'].value;
|
|
const submitBtn = document.getElementById('btn-submit-sound');
|
|
|
|
if (submitBtn) {
|
|
submitBtn.disabled = true;
|
|
submitBtn.textContent = "Traitement audio en cours...";
|
|
}
|
|
|
|
try {
|
|
let res;
|
|
if (id) {
|
|
const payload = Object.fromEntries(fd.entries());
|
|
res = await apiCall('update_sound', payload);
|
|
} else {
|
|
const response = await fetch('/api.php?action=upload_sound', {
|
|
method: 'POST',
|
|
body: fd
|
|
});
|
|
const cType = response.headers.get('content-type') || '';
|
|
if (cType.includes('application/json')) {
|
|
res = await response.json();
|
|
} else {
|
|
const rawText = await response.text();
|
|
if (response.status === 413) {
|
|
throw new Error("Le fichier audio est trop volumineux pour le serveur (Limite 64 Mo).");
|
|
}
|
|
throw new Error(`Erreur serveur (${response.status}) : ${rawText.replace(/<[^>]*>/g, '').trim().slice(0, 150) || 'Réponse inattendue'}`);
|
|
}
|
|
if (!response.ok || !res.success) {
|
|
throw new Error(res.message || 'Erreur lors du téléversement du fichier audio.');
|
|
}
|
|
}
|
|
showToast(res.message, 'success');
|
|
closeModal('modal-sound');
|
|
await loadState();
|
|
} catch (err) {
|
|
showToast(err.message, 'error');
|
|
} finally {
|
|
if (submitBtn) {
|
|
submitBtn.disabled = false;
|
|
submitBtn.textContent = id ? "Enregistrer les Modifications" : "Téléverser & Enregistrer";
|
|
}
|
|
}
|
|
});
|
|
|
|
// Form Catégories de sites
|
|
document.getElementById('form-sound-site')?.addEventListener('submit', async (e) => {
|
|
e.preventDefault();
|
|
const form = e.target;
|
|
const payload = Object.fromEntries(new FormData(form).entries());
|
|
try {
|
|
const res = await apiCall('save_sound_site', payload);
|
|
showToast(res.message, 'success');
|
|
form.reset();
|
|
const idInput = document.getElementById('sound-site-id');
|
|
if (idInput) idInput.value = '';
|
|
const btn = document.getElementById('btn-save-sound-site');
|
|
if (btn) btn.textContent = 'Enregistrer';
|
|
await loadState();
|
|
renderSoundSitesTable();
|
|
} catch (err) {
|
|
showToast(err.message, 'error');
|
|
}
|
|
});
|
|
|
|
// Form Settings
|
|
document.getElementById('settings-form')?.addEventListener('submit', async (e) => {
|
|
e.preventDefault();
|
|
const payload = Object.fromEntries(new FormData(e.target).entries());
|
|
payload.wizard_completed = 1;
|
|
try {
|
|
const res = await apiCall('save_settings', payload);
|
|
showToast(res.message, 'success');
|
|
await loadState();
|
|
} catch (err) {
|
|
showToast(err.message, 'error');
|
|
}
|
|
});
|
|
|
|
// Bouton Tester AMI
|
|
document.getElementById('btn-test-ami')?.addEventListener('click', async () => {
|
|
try {
|
|
const res = await apiCall('test_ami');
|
|
showToast(res.message, 'success');
|
|
} catch (err) {
|
|
showToast(err.message, 'error');
|
|
}
|
|
});
|
|
|
|
// Bouton Appliquer les configurations (Écriture /etc/asterisk + Reload AMI)
|
|
document.getElementById('btn-apply-config')?.addEventListener('click', async () => {
|
|
const icon = document.getElementById('apply-icon');
|
|
icon?.classList.add('animate-spin');
|
|
try {
|
|
const res = await apiCall('apply_config', {});
|
|
showToast(res.message, 'success');
|
|
await loadState();
|
|
} catch (err) {
|
|
showToast(err.message, 'error');
|
|
} finally {
|
|
icon?.classList.remove('animate-spin');
|
|
}
|
|
});
|
|
|
|
// Formulaire Let's Encrypt
|
|
document.getElementById('form-cert-letsencrypt')?.addEventListener('submit', async (e) => {
|
|
e.preventDefault();
|
|
const f = e.target;
|
|
const btn = document.getElementById('btn-submit-letsencrypt');
|
|
const spinner = document.getElementById('spinner-letsencrypt');
|
|
const btnText = document.getElementById('btn-text-letsencrypt');
|
|
const msgBox = document.getElementById('letsencrypt-msg-box');
|
|
|
|
const formData = new FormData(f);
|
|
const payload = {
|
|
name: (formData.get('name') || '').trim(),
|
|
domain: (formData.get('domain') || '').trim(),
|
|
email: (formData.get('email') || '').trim(),
|
|
is_default: formData.get('is_default') === '1' ? 1 : 0,
|
|
staging: formData.get('staging') === '1' ? 1 : 0
|
|
};
|
|
|
|
if (btn) btn.disabled = true;
|
|
if (spinner) spinner.classList.remove('hidden');
|
|
if (btnText) btnText.textContent = "Communication avec Let's Encrypt...";
|
|
if (msgBox) { msgBox.className = 'hidden'; msgBox.textContent = ''; }
|
|
|
|
try {
|
|
const res = await apiCall('request_letsencrypt_cert', payload);
|
|
showToast(res.message, 'success');
|
|
closeModal('modal-cert-letsencrypt');
|
|
await loadState();
|
|
} catch (err) {
|
|
if (msgBox) {
|
|
msgBox.className = 'p-3 rounded-xl text-xs font-mono bg-rose-950/60 border border-rose-500/40 text-rose-300';
|
|
msgBox.textContent = "Erreur Let's Encrypt :\n" + err.message;
|
|
}
|
|
showToast("Échec de l'émission Let's Encrypt", 'error');
|
|
} finally {
|
|
if (btn) btn.disabled = false;
|
|
if (spinner) spinner.classList.add('hidden');
|
|
if (btnText) btnText.textContent = "Lancer l'émission ACME";
|
|
}
|
|
});
|
|
|
|
// Formulaire Import Certificat Custom (supporte FormData multipart pour fichiers .crt, .key)
|
|
document.getElementById('form-cert-custom')?.addEventListener('submit', async (e) => {
|
|
e.preventDefault();
|
|
const f = e.target;
|
|
const btn = document.getElementById('btn-submit-custom-cert');
|
|
const spinner = document.getElementById('spinner-custom-cert');
|
|
const msgBox = document.getElementById('custom-cert-msg-box');
|
|
|
|
const formData = new FormData(f);
|
|
|
|
if (btn) btn.disabled = true;
|
|
if (spinner) spinner.classList.remove('hidden');
|
|
if (msgBox) { msgBox.className = 'hidden'; msgBox.textContent = ''; }
|
|
|
|
try {
|
|
const resp = await fetch('/api.php?action=save_custom_cert', {
|
|
method: 'POST',
|
|
body: formData
|
|
});
|
|
const data = await resp.json();
|
|
if (!data.success) {
|
|
throw new Error(data.message || 'Erreur lors de l\'enregistrement du certificat');
|
|
}
|
|
showToast(data.message, 'success');
|
|
closeModal('modal-cert-custom');
|
|
await loadState();
|
|
} catch (err) {
|
|
if (msgBox) {
|
|
msgBox.className = 'p-3 rounded-xl text-xs font-mono bg-rose-950/60 border border-rose-500/40 text-rose-300';
|
|
msgBox.textContent = "Erreur de validation du certificat :\n" + err.message;
|
|
}
|
|
showToast(err.message, 'error');
|
|
} finally {
|
|
if (btn) btn.disabled = false;
|
|
if (spinner) spinner.classList.add('hidden');
|
|
}
|
|
});
|
|
|
|
// Lecture automatique des fichiers .crt, .key dans les textareas lors de la sélection
|
|
const bindFileReader = (fileInputId, textareaId) => {
|
|
const input = document.getElementById(fileInputId);
|
|
const textarea = document.getElementById(textareaId);
|
|
if (input && textarea) {
|
|
input.addEventListener('change', (e) => {
|
|
const file = e.target.files[0];
|
|
if (file) {
|
|
const reader = new FileReader();
|
|
reader.onload = (re) => { textarea.value = re.target.result; };
|
|
reader.readAsText(file);
|
|
}
|
|
});
|
|
}
|
|
};
|
|
bindFileReader('file-cert-custom', 'textarea-cert-pem');
|
|
bindFileReader('file-key-custom', 'textarea-key-pem');
|
|
bindFileReader('file-chain-custom', 'textarea-chain-pem');
|
|
|
|
// Formulaire Paramètres SIP & NAT
|
|
document.getElementById('form-sip-settings')?.addEventListener('submit', async (e) => {
|
|
e.preventDefault();
|
|
const f = e.target;
|
|
const btn = document.getElementById('btn-save-sip-settings');
|
|
const spinner = document.getElementById('spinner-save-sip-settings');
|
|
|
|
const netInputs = f.querySelectorAll('input[name="local_networks[]"]');
|
|
const localNets = Array.from(netInputs).map(i => i.value.trim()).filter(v => v !== '');
|
|
|
|
const payload = {
|
|
external_ip: (document.getElementById('sip-external-ip')?.value || '').trim(),
|
|
external_udp_port: Number(document.getElementById('sip-external-udp-port')?.value || 0),
|
|
external_tls_port: Number(document.getElementById('sip-external-tls-port')?.value || 0),
|
|
udp_port: Number(document.getElementById('sip-udp-port')?.value || 5060),
|
|
tls_port: Number(document.getElementById('sip-tls-port')?.value || 5061),
|
|
rtp_start: Number(document.getElementById('sip-rtp-start')?.value || 10000),
|
|
rtp_end: Number(document.getElementById('sip-rtp-end')?.value || 20000),
|
|
rtp_strict: Number(document.getElementById('sip-rtp-strict')?.value || 1),
|
|
rtp_checksums: Number(document.getElementById('sip-rtp-checksums')?.value || 1),
|
|
rtp_keepalive: Number(document.getElementById('sip-rtp-keepalive')?.value || 15),
|
|
rtp_timeout: Number(document.getElementById('sip-rtp-timeout')?.value || 30),
|
|
allow_anonymous_sip: Number(document.getElementById('sip-allow-anonymous')?.value || 0),
|
|
local_networks: localNets
|
|
};
|
|
|
|
if (btn) btn.disabled = true;
|
|
if (spinner) spinner.classList.remove('hidden');
|
|
|
|
try {
|
|
const res = await apiCall('save_sip_settings', payload);
|
|
showToast(res.message, 'success');
|
|
const container = document.getElementById('local-networks-container');
|
|
if (container) delete container.dataset.initialized;
|
|
await loadState();
|
|
} catch (err) {
|
|
showToast(err.message, 'error');
|
|
} finally {
|
|
if (btn) btn.disabled = false;
|
|
if (spinner) spinner.classList.add('hidden');
|
|
}
|
|
});
|
|
|
|
// Formulaire Édition d'un Code de Fonction
|
|
document.getElementById('form-edit-feature-code')?.addEventListener('submit', async (e) => {
|
|
e.preventDefault();
|
|
const btn = document.getElementById('btn-submit-edit-fc');
|
|
const spinner = document.getElementById('spinner-edit-fc');
|
|
const id = Number(document.getElementById('edit-fc-id')?.value || 0);
|
|
const code = (document.getElementById('edit-fc-code')?.value || '').trim();
|
|
|
|
if (btn) btn.disabled = true;
|
|
if (spinner) spinner.classList.remove('hidden');
|
|
|
|
try {
|
|
const res = await apiCall('update_feature_code', { id, code });
|
|
showToast(res.message, 'success');
|
|
closeModal('modal-edit-feature-code');
|
|
await loadState();
|
|
} catch (err) {
|
|
showToast(err.message, 'error');
|
|
} finally {
|
|
if (btn) btn.disabled = false;
|
|
if (spinner) spinner.classList.add('hidden');
|
|
}
|
|
});
|
|
|
|
// Formulaire Paramètres de Sauvegarde & SSH
|
|
document.getElementById('backup-settings-form')?.addEventListener('submit', async (e) => {
|
|
e.preventDefault();
|
|
const f = e.target;
|
|
const autoEnabled = f.elements['backup_auto_enabled']?.checked ? 1 : 0;
|
|
const frequency = f.elements['backup_frequency']?.value || 'daily';
|
|
const retention = Number(f.elements['backup_retention']?.value || 7);
|
|
const storageType = f.querySelector('input[name="backup_storage_type"]:checked')?.value || 'local';
|
|
const sshHost = (f.elements['backup_ssh_host']?.value || '').trim();
|
|
const sshPort = Number(f.elements['backup_ssh_port']?.value || 22);
|
|
const sshUser = (f.elements['backup_ssh_user']?.value || '').trim();
|
|
const sshPassword = f.elements['backup_ssh_password']?.value || '';
|
|
const sshRemotePath = (f.elements['backup_ssh_remote_path']?.value || '/backups/nexus').trim();
|
|
|
|
const payload = {
|
|
backup_auto_enabled: autoEnabled,
|
|
backup_frequency: frequency,
|
|
backup_retention: retention,
|
|
backup_storage_type: storageType,
|
|
backup_ssh_host: sshHost,
|
|
backup_ssh_port: sshPort,
|
|
backup_ssh_user: sshUser,
|
|
backup_ssh_auth_type: 'password',
|
|
backup_ssh_password: sshPassword,
|
|
backup_ssh_remote_path: sshRemotePath
|
|
};
|
|
|
|
try {
|
|
const res = await apiCall('save_backup_settings', payload);
|
|
showToast(res.message, 'success');
|
|
await loadBackups();
|
|
} catch (err) {
|
|
showToast("Erreur enregistrement : " + err.message, 'error');
|
|
}
|
|
});
|
|
|
|
// Formulaire Paramètres Pare-feu & Fail2ban
|
|
document.getElementById('firewall-settings-form')?.addEventListener('submit', async (e) => {
|
|
e.preventDefault();
|
|
const f = e.target;
|
|
const btn = document.getElementById('btn-save-firewall-settings');
|
|
if (btn) btn.disabled = true;
|
|
|
|
const payload = {
|
|
firewall_enabled: f.elements['firewall_enabled']?.checked ? 1 : 0,
|
|
admin_https_port: Number(f.elements['admin_https_port']?.value || 8443),
|
|
certbot_strict_port80: f.elements['certbot_strict_port80']?.checked ? 1 : 0,
|
|
fail2ban_sip_maxretry: Number(f.elements['fail2ban_sip_maxretry']?.value || 3),
|
|
fail2ban_sip_bantime: Number(f.elements['fail2ban_sip_bantime']?.value || 3600),
|
|
fail2ban_whitelist_ips: (f.elements['fail2ban_whitelist_ips']?.value || '').trim()
|
|
};
|
|
|
|
try {
|
|
const res = await apiCall('save_firewall_settings', payload);
|
|
showToast(res.message, 'success');
|
|
await loadFirewallStatus();
|
|
} catch (err) {
|
|
showToast("Erreur application sécurité : " + err.message, 'error');
|
|
} finally {
|
|
if (btn) btn.disabled = false;
|
|
}
|
|
});
|
|
|
|
// Formulaire Bannissement Manuel Rapide
|
|
document.getElementById('form-manual-ban')?.addEventListener('submit', async (e) => {
|
|
e.preventDefault();
|
|
const ip = (document.getElementById('manual-ban-ip')?.value || '').trim();
|
|
const jail = document.getElementById('manual-ban-jail')?.value || 'asterisk';
|
|
if (!ip) return;
|
|
|
|
try {
|
|
const res = await apiCall('ban_ip', { ip, jail });
|
|
showToast(res.message, res.success ? 'success' : 'error');
|
|
document.getElementById('manual-ban-ip').value = '';
|
|
await loadFirewallStatus();
|
|
} catch (err) {
|
|
showToast("Erreur lors du bannissement : " + err.message, 'error');
|
|
}
|
|
});
|
|
|
|
// Si déjà authentifié au chargement initial
|
|
if (!document.getElementById('app-screen').classList.contains('hidden')) {
|
|
loadState();
|
|
}
|
|
});
|
|
|
|
// =========================================================================
|
|
// GESTIONNAIRE DE CERTIFICATS SSL / TLS (FONCTIONS GLOBALES)
|
|
// =========================================================================
|
|
function renderCertificates() {
|
|
const certs = appState.certificates || [];
|
|
const activeCert = appState.active_certificate || certs.find(c => Number(c.is_default) === 1) || certs[0];
|
|
|
|
// Badge sidebar
|
|
const badgeSidebar = document.getElementById('nav-badge-certificates');
|
|
if (badgeSidebar) {
|
|
badgeSidebar.textContent = certs.length;
|
|
}
|
|
|
|
const badgeTable = document.getElementById('certs-count-badge');
|
|
if (badgeTable) {
|
|
badgeTable.textContent = `${certs.length} certificat${certs.length > 1 ? 's' : ''}`;
|
|
}
|
|
|
|
// Carte Résumé Actif
|
|
if (activeCert) {
|
|
const nameEl = document.getElementById('active-cert-name');
|
|
const domEl = document.getElementById('active-cert-domain');
|
|
const issEl = document.getElementById('active-cert-issuer');
|
|
const typeEl = document.getElementById('active-cert-type-badge');
|
|
const daysEl = document.getElementById('active-cert-days');
|
|
const renewWrap = document.getElementById('active-cert-renew-wrapper');
|
|
|
|
if (nameEl) nameEl.textContent = activeCert.name || 'Certificat Actif';
|
|
if (domEl) domEl.textContent = activeCert.primary_domain || 'pbx.local';
|
|
if (issEl) issEl.textContent = activeCert.issuer || 'Inconnu';
|
|
|
|
if (typeEl) {
|
|
if (activeCert.type === 'letsencrypt') {
|
|
typeEl.className = 'px-2 py-0.5 rounded-full text-[10px] font-bold bg-emerald-500/20 text-emerald-300 border border-emerald-500/40';
|
|
typeEl.textContent = "Let's Encrypt (ACME Auto)";
|
|
} else if (activeCert.type === 'custom') {
|
|
typeEl.className = 'px-2 py-0.5 rounded-full text-[10px] font-bold bg-cyan-500/20 text-cyan-300 border border-cyan-500/40';
|
|
typeEl.textContent = "Certificat Entreprise (Import)";
|
|
} else {
|
|
typeEl.className = 'px-2 py-0.5 rounded-full text-[10px] font-bold bg-anthracite-700 text-slate-300 border border-anthracite-600';
|
|
typeEl.textContent = "Auto-signé Système";
|
|
}
|
|
}
|
|
|
|
if (daysEl) {
|
|
const days = Number(activeCert.days_remaining ?? 0);
|
|
if (days < 0) {
|
|
daysEl.className = 'text-xs font-bold text-rose-400 mt-0.5';
|
|
daysEl.textContent = `Expiré (${Math.abs(days)}j)`;
|
|
} else if (days <= 15) {
|
|
daysEl.className = 'text-xs font-bold text-amber-400 mt-0.5';
|
|
daysEl.textContent = `${days} j restants (À renouveler)`;
|
|
} else {
|
|
daysEl.className = 'text-xs font-bold text-emerald-400 mt-0.5';
|
|
daysEl.textContent = `${days} j restants`;
|
|
}
|
|
}
|
|
|
|
if (renewWrap) {
|
|
if (activeCert.type === 'letsencrypt') {
|
|
renewWrap.classList.remove('hidden');
|
|
} else {
|
|
renewWrap.classList.add('hidden');
|
|
}
|
|
}
|
|
}
|
|
|
|
// Statut Mutual TLS (mTLS) verify_client
|
|
const settings = appState.settings || {};
|
|
const mtlsVal = Number(settings.tls_verify_client ?? 0);
|
|
const selMtls = document.getElementById('select-tls-verify-client');
|
|
const badgeMtls = document.getElementById('badge-mtls-status');
|
|
if (selMtls) {
|
|
selMtls.value = String(mtlsVal);
|
|
}
|
|
if (badgeMtls) {
|
|
if (mtlsVal === 1) {
|
|
badgeMtls.className = 'px-2 py-0.5 rounded-full text-[10px] font-bold bg-amber-500/20 text-amber-300 border border-amber-500/40';
|
|
badgeMtls.textContent = 'Activé (Strict / mTLS)';
|
|
} else {
|
|
badgeMtls.className = 'px-2 py-0.5 rounded-full text-[10px] font-bold bg-anthracite-700 text-slate-300 border border-anthracite-600';
|
|
badgeMtls.textContent = 'Désactivé (Standard)';
|
|
}
|
|
}
|
|
|
|
// Tableau des certificats
|
|
const tbody = document.getElementById('certificates-tbody');
|
|
if (!tbody) return;
|
|
|
|
if (certs.length === 0) {
|
|
tbody.innerHTML = `
|
|
<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 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.2.0')}</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}
|
|
</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 => {
|
|
const jailClass = b.jail === 'asterisk' ? 'bg-cyan-500/20 text-cyan-300 border border-cyan-500/30' : 'bg-purple-500/20 text-purple-300 border border-purple-500/30';
|
|
return `
|
|
<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 || '';
|
|
}
|
|
}
|
|
}
|
|
|
|
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>';
|
|
}
|
|
}
|
|
}
|