7070 lines
337 KiB
JavaScript
7070 lines
337 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();
|
||
renderUcpTab();
|
||
renderSettingsAndDiagnostics();
|
||
}
|
||
|
||
function renderCompanyBranding() {
|
||
const s = appState.settings || {};
|
||
const logo = (s.company_logo || '').trim();
|
||
const name = (s.company_name || '').trim();
|
||
|
||
const emptyBox = document.getElementById('sidebar-company-empty');
|
||
const filledBox = document.getElementById('sidebar-company-filled');
|
||
const logoImg = document.getElementById('sidebar-company-logo');
|
||
const nameEl = document.getElementById('sidebar-company-name');
|
||
const slot = document.getElementById('sidebar-company-slot');
|
||
|
||
const loginBox = document.getElementById('login-company-box');
|
||
const loginLogo = document.getElementById('login-company-logo');
|
||
const loginName = document.getElementById('login-company-name');
|
||
|
||
// Aperçu dans l'administration (Onglet Système & Paramètres)
|
||
const settingsPreviewImg = document.getElementById('settings-company-preview-img');
|
||
const settingsPreviewEmpty = document.getElementById('settings-company-preview-empty');
|
||
if (settingsPreviewImg && settingsPreviewEmpty) {
|
||
if (logo) {
|
||
settingsPreviewImg.src = logo;
|
||
settingsPreviewImg.classList.remove('hidden');
|
||
settingsPreviewEmpty.classList.add('hidden');
|
||
} else {
|
||
settingsPreviewImg.src = '';
|
||
settingsPreviewImg.classList.add('hidden');
|
||
settingsPreviewEmpty.classList.remove('hidden');
|
||
}
|
||
}
|
||
|
||
if (logo || name) {
|
||
emptyBox?.classList.add('hidden');
|
||
filledBox?.classList.remove('hidden');
|
||
|
||
if (logo && logoImg) {
|
||
logoImg.src = logo;
|
||
logoImg.classList.remove('hidden');
|
||
} else if (logoImg) {
|
||
logoImg.src = '';
|
||
logoImg.classList.add('hidden');
|
||
}
|
||
|
||
if (name && nameEl) {
|
||
nameEl.textContent = name;
|
||
nameEl.classList.remove('hidden');
|
||
} else if (nameEl) {
|
||
nameEl.textContent = '';
|
||
nameEl.classList.add('hidden');
|
||
}
|
||
|
||
if (loginBox) {
|
||
loginBox.classList.remove('hidden');
|
||
loginBox.classList.add('flex');
|
||
if (logo && loginLogo) {
|
||
loginLogo.src = logo;
|
||
loginLogo.classList.remove('hidden');
|
||
} else if (loginLogo) {
|
||
loginLogo.src = '';
|
||
loginLogo.classList.add('hidden');
|
||
}
|
||
if (loginName) {
|
||
loginName.textContent = name;
|
||
loginName.classList.toggle('hidden', !name);
|
||
}
|
||
}
|
||
} else {
|
||
emptyBox?.classList.remove('hidden');
|
||
filledBox?.classList.add('hidden');
|
||
if (logoImg) { logoImg.src = ''; logoImg.classList.add('hidden'); }
|
||
if (nameEl) { nameEl.textContent = ''; nameEl.classList.add('hidden'); }
|
||
if (loginBox) {
|
||
loginBox.classList.add('hidden');
|
||
loginBox.classList.remove('flex');
|
||
}
|
||
}
|
||
}
|
||
|
||
function triggerCompanyLogoPicker() {
|
||
document.getElementById('company-logo-file-input')?.click();
|
||
}
|
||
|
||
function handleCompanyLogoUpload(input) {
|
||
const file = input?.files?.[0];
|
||
if (!file) return;
|
||
|
||
if (file.size > 1500000) {
|
||
showToast('Le fichier logo ne doit pas dépasser 1.5 Mo.', 'error');
|
||
input.value = '';
|
||
return;
|
||
}
|
||
|
||
const reader = new FileReader();
|
||
reader.onload = async (ev) => {
|
||
const dataUrl = String(ev.target?.result || '');
|
||
try {
|
||
const res = await apiCall('save_company_branding', { company_logo: dataUrl });
|
||
showToast(res.message, 'success');
|
||
await loadState();
|
||
} catch (err) {
|
||
showToast(err.message, 'error');
|
||
} finally {
|
||
input.value = '';
|
||
}
|
||
};
|
||
reader.readAsDataURL(file);
|
||
}
|
||
|
||
async function clearCompanyLogo() {
|
||
try {
|
||
const res = await apiCall('save_company_branding', { company_logo: '' });
|
||
showToast(res.message, 'info');
|
||
await loadState();
|
||
} catch (err) {
|
||
showToast(err.message, 'error');
|
||
}
|
||
}
|
||
|
||
function renderSummaryCards() {
|
||
const exts = appState.extensions || [];
|
||
const grps = appState.groups || [];
|
||
const trks = appState.trunks || [];
|
||
const ivrs = appState.ivrs || [];
|
||
const obRoutes = appState.outbound_routes || [];
|
||
|
||
const tlsCount = exts.filter(e => e.transport === 'tls').length;
|
||
const udpCount = exts.length - tlsCount;
|
||
|
||
document.getElementById('stat-extensions').textContent = exts.length;
|
||
document.getElementById('stat-extensions-sub').textContent = `${udpCount} Interne UDP • ${tlsCount} TLS/SRTP`;
|
||
document.getElementById('stat-groups').textContent = grps.length;
|
||
document.getElementById('stat-trunks').textContent = trks.length;
|
||
document.getElementById('stat-ivrs').textContent = ivrs.length;
|
||
|
||
// Badges menu gauche
|
||
document.getElementById('nav-badge-extensions').textContent = exts.length;
|
||
document.getElementById('nav-badge-groups').textContent = grps.length;
|
||
const egBadge = document.getElementById('nav-badge-extgroups');
|
||
if (egBadge) egBadge.textContent = (appState.extension_groups || []).length;
|
||
const pagingBadge = document.getElementById('nav-badge-paging');
|
||
if (pagingBadge) pagingBadge.textContent = (appState.paging_groups || []).length;
|
||
const pbBadge = document.getElementById('nav-badge-phonebook');
|
||
if (pbBadge) pbBadge.textContent = (appState.phonebook_contacts || []).length;
|
||
const ucpBadge = document.getElementById('nav-badge-ucp');
|
||
if (ucpBadge) ucpBadge.textContent = (appState.ucp_users || []).length;
|
||
document.getElementById('nav-badge-trunks').textContent = trks.length;
|
||
const obBadge = document.getElementById('nav-badge-outbound');
|
||
if (obBadge) obBadge.textContent = obRoutes.length;
|
||
document.getElementById('nav-badge-ivrs').textContent = ivrs.length;
|
||
const soundsBadge = document.getElementById('nav-badge-sounds');
|
||
if (soundsBadge) soundsBadge.textContent = (appState.sounds || []).length;
|
||
const backupsBadge = document.getElementById('nav-badge-backups');
|
||
if (backupsBadge && typeof appState.backups_count !== 'undefined') {
|
||
backupsBadge.textContent = appState.backups_count;
|
||
}
|
||
|
||
const diag = appState.diagnostics || { healthy: false, checks: [] };
|
||
const healthEl = document.getElementById('stat-health');
|
||
const amiDot = document.getElementById('ami-dot');
|
||
const sideBadge = document.getElementById('sidebar-health-badge');
|
||
|
||
if (diag.healthy) {
|
||
healthEl.textContent = 'AMI & PJSIP OK';
|
||
healthEl.className = 'text-[11px] font-bold text-emerald-400';
|
||
amiDot.className = 'w-2 h-2 rounded-full bg-emerald-400';
|
||
sideBadge.textContent = 'EN LIGNE';
|
||
sideBadge.className = 'px-2 py-0.5 rounded-full text-[10px] font-bold bg-emerald-500/20 text-emerald-300 border border-emerald-500/30';
|
||
} else {
|
||
healthEl.textContent = 'Vérifier Config';
|
||
healthEl.className = 'text-[11px] font-bold text-amber-400';
|
||
amiDot.className = 'w-2 h-2 rounded-full bg-amber-400';
|
||
sideBadge.textContent = 'ATTENTION';
|
||
sideBadge.className = 'px-2 py-0.5 rounded-full text-[10px] font-bold bg-amber-500/20 text-amber-300 border border-amber-500/30';
|
||
}
|
||
|
||
const lastApplied = appState.settings?.last_applied_at;
|
||
document.getElementById('last-applied-label').textContent = lastApplied
|
||
? `Dernière application Asterisk : ${lastApplied} UTC`
|
||
: `Cliquez sur « Appliquer les configurations » pour synchroniser Asterisk`;
|
||
}
|
||
|
||
// --- Rendu Dashboard Métriques & Graphes SVG ---
|
||
function renderDashboardMetrics() {
|
||
const m = appState.metrics;
|
||
if (!m) return;
|
||
|
||
// Sidebar infos
|
||
const osVerEl = document.getElementById('sidebar-os-version');
|
||
if (osVerEl && m.os_version) osVerEl.textContent = m.os_version;
|
||
const astVerEl = document.getElementById('sidebar-ast-version');
|
||
if (astVerEl && m.asterisk?.version) astVerEl.textContent = m.asterisk.version;
|
||
document.getElementById('sidebar-ip').textContent = m.network?.ip || '192.168.1.100';
|
||
document.getElementById('sidebar-uptime').textContent = m.uptime || '—';
|
||
document.getElementById('sidebar-online-phones').textContent = `${m.asterisk?.online_contacts ?? 0} / ${(appState.extensions || []).length}`;
|
||
|
||
// 1. CPU
|
||
const cpuPct = Number(m.cpu?.percent || 0);
|
||
document.getElementById('metric-cpu-pct').textContent = `${cpuPct.toFixed(1)}%`;
|
||
document.getElementById('metric-cpu-cores').textContent = `${m.cpu?.cores || 1} cœur(s)`;
|
||
document.getElementById('metric-cpu-model').textContent = m.cpu?.model || 'Processeur Debian';
|
||
document.getElementById('metric-cpu-load').textContent = `${m.cpu?.load_1 ?? 0} / ${m.cpu?.load_5 ?? 0} / ${m.cpu?.load_15 ?? 0}`;
|
||
document.getElementById('bar-cpu').style.width = `${Math.max(3, Math.min(100, cpuPct))}%`;
|
||
|
||
// 2. RAM
|
||
const ramPct = Number(m.memory?.percent || 0);
|
||
document.getElementById('metric-ram-pct').textContent = `${ramPct.toFixed(1)}%`;
|
||
document.getElementById('metric-ram-used').textContent = `${m.memory?.used_mb || 0} / ${m.memory?.total_mb || 0} Mo`;
|
||
document.getElementById('metric-ram-free').textContent = `Disponible : ${m.memory?.free_mb || 0} Mo`;
|
||
document.getElementById('metric-swap').textContent = `${m.memory?.swap_used_mb || 0} / ${m.memory?.swap_total_mb || 0} Mo`;
|
||
document.getElementById('bar-ram').style.width = `${Math.max(3, Math.min(100, ramPct))}%`;
|
||
|
||
// 3. Réseau
|
||
document.getElementById('metric-net-rx').textContent = `${Number(m.network?.rx_kbps || 0).toFixed(1)} Ko/s`;
|
||
document.getElementById('metric-net-tx').textContent = `${Number(m.network?.tx_kbps || 0).toFixed(1)} Ko/s`;
|
||
document.getElementById('metric-net-iface').textContent = `${m.network?.interface || 'eth0'} (${m.network?.ip || ''})`;
|
||
document.getElementById('metric-net-totals').textContent = `Total : ↓ ${m.network?.rx_total_mb || 0} Mo • ↑ ${m.network?.tx_total_mb || 0} Mo`;
|
||
|
||
// 4. Disque HDD
|
||
const diskPct = Number(m.disk?.percent || 0);
|
||
document.getElementById('metric-disk-pct').textContent = `${diskPct.toFixed(1)}%`;
|
||
document.getElementById('metric-disk-used').textContent = `${m.disk?.used_gb || 0} / ${m.disk?.total_gb || 0} Go`;
|
||
document.getElementById('metric-disk-free').textContent = `Espace libre sur / : ${m.disk?.free_gb || 0} Go`;
|
||
document.getElementById('bar-disk').style.width = `${Math.max(3, Math.min(100, diskPct))}%`;
|
||
|
||
// 5. Jauges Anneaux (Donuts)
|
||
updateDonut('donut-cpu', 'donut-cpu-label', cpuPct);
|
||
updateDonut('donut-ram', 'donut-ram-label', ramPct);
|
||
updateDonut('donut-disk', 'donut-disk-label', diskPct);
|
||
|
||
document.getElementById('dash-active-calls').textContent = m.asterisk?.active_calls ?? 0;
|
||
document.getElementById('dash-online-contacts').textContent = `${m.asterisk?.online_contacts ?? 0} / ${(appState.extensions || []).length}`;
|
||
|
||
// 6. Graphes SVG Temps Réel
|
||
drawDualAreaChart('chart-cpu-ram', chartHistory.cpu, chartHistory.ram, 100, '#818cf8', '#34d399', '%');
|
||
const maxNet = Math.max(10, ...chartHistory.rx, ...chartHistory.tx) * 1.25;
|
||
drawDualAreaChart('chart-network', chartHistory.rx, chartHistory.tx, maxNet, '#22d3ee', '#c084fc', 'Ko/s');
|
||
|
||
// 7. Table des téléphones SIP en direct sur le Dashboard
|
||
renderDashboardPhonesTable();
|
||
|
||
// 8. Sécurité, Pare-feu, Fail2ban & IPs bloquées
|
||
if (m.security) {
|
||
const sec = m.security;
|
||
const fwDot = document.getElementById('dash-fw-dot');
|
||
const fwStatus = document.getElementById('dash-fw-status');
|
||
const fwDetail = document.getElementById('dash-fw-detail');
|
||
|
||
const isFwActive = Boolean(sec.firewall_enabled);
|
||
if (isFwActive) {
|
||
if (fwDot) fwDot.className = 'w-2 h-2 rounded-full bg-emerald-400 shadow-sm shadow-emerald-500/50';
|
||
if (fwStatus) {
|
||
fwStatus.textContent = 'Actif';
|
||
fwStatus.className = 'text-sm font-extrabold text-emerald-400 mt-1';
|
||
}
|
||
if (fwDetail) fwDetail.textContent = sec.strict_port80 ? 'Mode Strict (Port 80 Certbot)' : `Port HTTPS : ${sec.admin_port || 8443}`;
|
||
} else {
|
||
if (fwDot) fwDot.className = 'w-2 h-2 rounded-full bg-slate-500';
|
||
if (fwStatus) {
|
||
fwStatus.textContent = 'Inactif';
|
||
fwStatus.className = 'text-sm font-extrabold text-slate-400 mt-1';
|
||
}
|
||
if (fwDetail) fwDetail.textContent = 'Tout le trafic autorisé';
|
||
}
|
||
|
||
const f2bDot = document.getElementById('dash-f2b-dot');
|
||
const f2bStatus = document.getElementById('dash-f2b-status');
|
||
const f2bDetail = document.getElementById('dash-f2b-detail');
|
||
|
||
if (sec.fail2ban_running) {
|
||
if (f2bDot) f2bDot.className = 'w-2 h-2 rounded-full bg-emerald-400 animate-pulse';
|
||
if (f2bStatus) {
|
||
f2bStatus.textContent = 'En ligne';
|
||
f2bStatus.className = 'text-sm font-extrabold text-emerald-400 mt-1';
|
||
}
|
||
const activeJailCount = Object.keys(sec.jails || {}).length;
|
||
if (f2bDetail) f2bDetail.textContent = activeJailCount > 0 ? `${activeJailCount} prison(s) active(s)` : 'Surveillance active';
|
||
} else {
|
||
if (f2bDot) f2bDot.className = 'w-2 h-2 rounded-full bg-rose-500';
|
||
if (f2bStatus) {
|
||
f2bStatus.textContent = 'Arrêté';
|
||
f2bStatus.className = 'text-sm font-extrabold text-rose-400 mt-1';
|
||
}
|
||
if (f2bDetail) f2bDetail.textContent = 'Service non démarré';
|
||
}
|
||
|
||
const bannedCountEl = document.getElementById('dash-banned-count');
|
||
if (bannedCountEl) bannedCountEl.textContent = sec.total_banned || 0;
|
||
|
||
let totalFailedAll = 0;
|
||
let totalBannedAll = 0;
|
||
Object.values(sec.jails || {}).forEach(j => {
|
||
totalFailedAll += (j.total_failed || 0);
|
||
totalBannedAll += (j.total_banned || 0);
|
||
});
|
||
|
||
const blockedBadge = document.getElementById('dash-total-blocked-badge');
|
||
if (blockedBadge) {
|
||
blockedBadge.textContent = `${totalFailedAll} attaque(s) contrée(s)`;
|
||
if (totalFailedAll > 0) {
|
||
blockedBadge.className = 'px-2.5 py-1 rounded-full text-[11px] font-bold bg-amber-500/15 text-amber-300 border border-amber-500/30 font-mono';
|
||
} else {
|
||
blockedBadge.className = 'px-2.5 py-1 rounded-full text-[11px] font-bold bg-emerald-500/15 text-emerald-300 border border-emerald-500/30 font-mono';
|
||
}
|
||
}
|
||
|
||
const latestBannedEl = document.getElementById('dash-latest-banned');
|
||
if (latestBannedEl) {
|
||
if (sec.banned_ips && sec.banned_ips.length > 0) {
|
||
latestBannedEl.textContent = `Bannie : ${sec.banned_ips[0].ip}`;
|
||
latestBannedEl.className = 'text-[10px] text-rose-400 mt-1 truncate font-mono font-bold';
|
||
} else {
|
||
latestBannedEl.textContent = 'Aucun ban actif';
|
||
latestBannedEl.className = 'text-[10px] text-emerald-400 mt-1 truncate';
|
||
}
|
||
}
|
||
|
||
const clientIpEl = document.getElementById('dash-client-ip');
|
||
if (clientIpEl && m.network?.ip) {
|
||
clientIpEl.textContent = `IP locale PBX : ${m.network.ip}`;
|
||
}
|
||
|
||
// Graphique Répartition Jails
|
||
const astJail = sec.jails?.asterisk || {};
|
||
const webJail = sec.jails?.['nexus-web'] || {};
|
||
const sshJail = sec.jails?.sshd || {};
|
||
|
||
const astFailed = astJail.total_failed || 0;
|
||
const webFailed = webJail.total_failed || 0;
|
||
const sshFailed = sshJail.total_failed || 0;
|
||
const totalThreats = astFailed + webFailed + sshFailed;
|
||
|
||
const astPct = totalThreats > 0 ? Math.round((astFailed / totalThreats) * 100) : 0;
|
||
const webPct = totalThreats > 0 ? Math.round((webFailed / totalThreats) * 100) : 0;
|
||
const sshPct = totalThreats > 0 ? Math.round((sshFailed / totalThreats) * 100) : 0;
|
||
|
||
const astStats = document.getElementById('dash-jail-ast-stats');
|
||
const astBar = document.getElementById('dash-jail-ast-bar');
|
||
if (astStats) astStats.textContent = `${astFailed} contrée(s) (${astPct}%) • ${astJail.currently_banned || 0} ban actif`;
|
||
if (astBar) astBar.style.width = `${astPct}%`;
|
||
|
||
const webStats = document.getElementById('dash-jail-web-stats');
|
||
const webBar = document.getElementById('dash-jail-web-bar');
|
||
if (webStats) webStats.textContent = `${webFailed} contrée(s) (${webPct}%) • ${webJail.currently_banned || 0} ban actif`;
|
||
if (webBar) webBar.style.width = `${webPct}%`;
|
||
|
||
const sshStats = document.getElementById('dash-jail-ssh-stats');
|
||
const sshBar = document.getElementById('dash-jail-ssh-bar');
|
||
if (sshStats) sshStats.textContent = `${sshFailed} contrée(s) (${sshPct}%) • ${sshJail.currently_banned || 0} ban actif`;
|
||
if (sshBar) sshBar.style.width = `${sshPct}%`;
|
||
}
|
||
|
||
// 9. Rendu de la liste des Services Système
|
||
if (m.services && Array.isArray(m.services)) {
|
||
renderDashboardServicesList(m.services);
|
||
}
|
||
}
|
||
|
||
function renderDashboardServicesList(services) {
|
||
const container = document.getElementById('dash-services-list');
|
||
if (!container) return;
|
||
|
||
let allOk = true;
|
||
const html = services.map(s => {
|
||
if (!s.active && s.id !== 'firewall') allOk = false;
|
||
const dotClass = s.active
|
||
? 'w-2 h-2 rounded-full bg-emerald-400 shadow-sm shadow-emerald-400/50'
|
||
: 'w-2 h-2 rounded-full bg-rose-500';
|
||
const badgeClass = s.active
|
||
? 'px-2 py-0.5 rounded text-[10px] font-bold bg-emerald-500/15 text-emerald-300 border border-emerald-500/30'
|
||
: 'px-2 py-0.5 rounded text-[10px] font-bold bg-rose-500/15 text-rose-300 border border-rose-500/30';
|
||
|
||
return `
|
||
<div class="py-2.5 flex items-center justify-between text-xs">
|
||
<div class="flex items-center gap-2.5">
|
||
<span class="${dotClass}"></span>
|
||
<div>
|
||
<div class="font-bold text-white flex items-center gap-2">
|
||
<span>${escapeHtml(s.name)}</span>
|
||
<span class="text-[10px] font-mono text-slate-400 font-normal">(${escapeHtml(s.port)})</span>
|
||
</div>
|
||
<div class="text-[11px] text-slate-400">${escapeHtml(s.desc)}</div>
|
||
</div>
|
||
</div>
|
||
<div class="text-right">
|
||
<span class="${badgeClass}">${escapeHtml(s.status_text)}</span>
|
||
<div class="text-[10px] font-mono text-slate-400 mt-0.5">${escapeHtml(s.uptime)}</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}).join('');
|
||
|
||
container.innerHTML = html;
|
||
|
||
const allBadge = document.getElementById('dash-services-all-badge');
|
||
if (allBadge) {
|
||
if (allOk) {
|
||
allBadge.textContent = 'TOUS OPÉRATIONNELS';
|
||
allBadge.className = 'px-2 py-0.5 rounded-full text-[10px] font-bold bg-emerald-500/20 text-emerald-300 border border-emerald-500/30';
|
||
} else {
|
||
allBadge.textContent = 'ATTENTION DÉMON';
|
||
allBadge.className = 'px-2 py-0.5 rounded-full text-[10px] font-bold bg-amber-500/20 text-amber-300 border border-amber-500/30';
|
||
}
|
||
}
|
||
}
|
||
|
||
function updateDonut(circleId, labelId, pct) {
|
||
const circle = document.getElementById(circleId);
|
||
const label = document.getElementById(labelId);
|
||
const clamped = Math.max(0, Math.min(100, Math.round(pct)));
|
||
if (circle) {
|
||
circle.setAttribute('stroke-dasharray', `${clamped}, 100`);
|
||
}
|
||
if (label) {
|
||
label.textContent = `${clamped}%`;
|
||
}
|
||
}
|
||
|
||
function drawDualAreaChart(svgId, seriesA, seriesB, maxVal, colorA, colorB, unitLabel) {
|
||
const svg = document.getElementById(svgId);
|
||
if (!svg) return;
|
||
|
||
const width = 500;
|
||
const height = 180;
|
||
const padTop = 14;
|
||
const padBottom = 18;
|
||
const plotH = height - padTop - padBottom;
|
||
const baselineY = padTop + plotH;
|
||
const safeMax = maxVal > 0 ? maxVal : 100;
|
||
|
||
const clampY = (y) => Math.max(padTop, Math.min(baselineY, y));
|
||
|
||
// Génération de courbes splines adoucies (Catmull-Rom / Bézier cubique) pour éliminer les angles pointus
|
||
const computeSmoothPaths = (arr, tension = 0.2) => {
|
||
if (!arr || arr.length === 0) return { line: '', area: '' };
|
||
|
||
const points = arr.map((val, idx) => {
|
||
const x = (idx / (arr.length - 1)) * width;
|
||
const ratio = Math.max(0, Math.min(1, val / safeMax));
|
||
const y = baselineY - (ratio * plotH);
|
||
return { x, y };
|
||
});
|
||
|
||
if (points.length === 1) {
|
||
return {
|
||
line: `M ${points[0].x.toFixed(1)},${points[0].y.toFixed(1)}`,
|
||
area: `M 0,${baselineY} L ${points[0].x.toFixed(1)},${points[0].y.toFixed(1)} L ${width},${baselineY} Z`
|
||
};
|
||
}
|
||
|
||
let line = `M ${points[0].x.toFixed(1)},${points[0].y.toFixed(1)}`;
|
||
|
||
for (let i = 0; i < points.length - 1; i++) {
|
||
const p0 = points[i === 0 ? i : i - 1];
|
||
const p1 = points[i];
|
||
const p2 = points[i + 1];
|
||
const p3 = points[i + 2 < points.length ? i + 2 : i + 1];
|
||
|
||
let cp1x = p1.x + (p2.x - p0.x) * tension;
|
||
let cp1y = p1.y + (p2.y - p0.y) * tension;
|
||
|
||
let cp2x = p2.x - (p3.x - p1.x) * tension;
|
||
let cp2y = p2.y - (p3.y - p1.y) * tension;
|
||
|
||
// Clamping vertical pour garantir que la courbe ne déborde pas sous l'axe ni au-dessus du max
|
||
cp1y = clampY(cp1y);
|
||
cp2y = clampY(cp2y);
|
||
|
||
line += ` C ${cp1x.toFixed(1)},${cp1y.toFixed(1)} ${cp2x.toFixed(1)},${cp2y.toFixed(1)} ${p2.x.toFixed(1)},${p2.y.toFixed(1)}`;
|
||
}
|
||
|
||
const area = `${line} L ${width},${baselineY} L 0,${baselineY} Z`;
|
||
return { line, area };
|
||
};
|
||
|
||
const pathA = computeSmoothPaths(seriesA);
|
||
const pathB = computeSmoothPaths(seriesB);
|
||
|
||
const gradIdA = `${svgId}-grad-a`;
|
||
const gradIdB = `${svgId}-grad-b`;
|
||
|
||
svg.innerHTML = `
|
||
<defs>
|
||
<linearGradient id="${gradIdA}" x1="0" y1="0" x2="0" y2="1">
|
||
<stop offset="0%" stop-color="${colorA}" stop-opacity="0.38"/>
|
||
<stop offset="100%" stop-color="${colorA}" stop-opacity="0.02"/>
|
||
</linearGradient>
|
||
<linearGradient id="${gradIdB}" x1="0" y1="0" x2="0" y2="1">
|
||
<stop offset="0%" stop-color="${colorB}" stop-opacity="0.32"/>
|
||
<stop offset="100%" stop-color="${colorB}" stop-opacity="0.02"/>
|
||
</linearGradient>
|
||
</defs>
|
||
|
||
<!-- Lignes de grille horizontales -->
|
||
<line x1="0" y1="${padTop}" x2="${width}" y2="${padTop}" stroke="#2c323f" stroke-dasharray="3,3" stroke-width="1"/>
|
||
<line x1="0" y1="${padTop + plotH / 2}" x2="${width}" y2="${padTop + plotH / 2}" stroke="#2c323f" stroke-dasharray="3,3" stroke-width="1"/>
|
||
<line x1="0" y1="${baselineY}" x2="${width}" y2="${baselineY}" stroke="#3d4657" stroke-width="1"/>
|
||
|
||
<text x="6" y="${padTop + 10}" fill="#94a3b8" font-size="10" font-family="monospace">${Math.round(safeMax)} ${unitLabel}</text>
|
||
<text x="6" y="${padTop + plotH / 2 - 4}" fill="#64748b" font-size="10" font-family="monospace">${Math.round(safeMax / 2)} ${unitLabel}</text>
|
||
|
||
<!-- Courbe B lissée avec dégradé -->
|
||
<path d="${pathB.area}" fill="url(#${gradIdB})"/>
|
||
<path d="${pathB.line}" fill="none" stroke="${colorB}" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/>
|
||
|
||
<!-- Courbe A lissée avec dégradé -->
|
||
<path d="${pathA.area}" fill="url(#${gradIdA})"/>
|
||
<path d="${pathA.line}" fill="none" stroke="${colorA}" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||
`;
|
||
}
|
||
|
||
function getContactMap() {
|
||
const map = {};
|
||
const contacts = appState.metrics?.asterisk?.contacts || [];
|
||
contacts.forEach(c => {
|
||
map[String(c.extension)] = c;
|
||
});
|
||
return map;
|
||
}
|
||
|
||
function extractCleanIp(uri) {
|
||
if (!uri) return '';
|
||
const m = uri.match(/@([0-9a-zA-Z\.\-]+(?::\d+)?)/);
|
||
if (m) return m[1];
|
||
const m2 = uri.match(/^sip:([0-9a-zA-Z\.\-]+(?::\d+)?)/);
|
||
if (m2) return m2[1];
|
||
return uri.split(';')[0].replace(/^sip:/, '');
|
||
}
|
||
|
||
function renderDashboardPhonesTable() {
|
||
const tbody = document.getElementById('dashboard-phones-tbody');
|
||
if (!tbody) return;
|
||
|
||
const exts = appState.extensions || [];
|
||
const contactMap = getContactMap();
|
||
|
||
if (exts.length === 0) {
|
||
tbody.innerHTML = `<tr><td colspan="6" class="py-8 text-center text-slate-400 text-xs">Aucun poste SIP configuré.</td></tr>`;
|
||
return;
|
||
}
|
||
|
||
tbody.innerHTML = exts.map(ext => {
|
||
const c = contactMap[String(ext.extension)];
|
||
const isTls = ext.transport === 'tls';
|
||
const st = c ? String(c.status).toLowerCase() : '';
|
||
const isUnavail = c && st.includes('unavail');
|
||
const isOnline = c && !isUnavail && (st.startsWith('avail') || st.includes('nonqual'));
|
||
|
||
// Chiffrement Voix (SRTP) configuré pour l'extension
|
||
const encMode = Number(ext.encryption ?? (isTls ? 1 : 0));
|
||
let srtpBadge = '';
|
||
if (encMode === 2) {
|
||
srtpBadge = `<span class="inline-flex items-center gap-1 px-1.5 py-0.2 rounded text-[10px] font-semibold bg-emerald-500/15 text-emerald-300 border border-emerald-500/30" title="Chiffrement média SRTP obligatoire (AES-128)">🛡️ SRTP Forcé</span>`;
|
||
} else if (encMode === 1) {
|
||
srtpBadge = `<span class="inline-flex items-center gap-1 px-1.5 py-0.2 rounded text-[10px] font-semibold bg-cyan-500/15 text-cyan-300 border border-cyan-500/30" title="Chiffrement média SRTP négociable si supporté par le poste">🛡️ SRTP Négociable</span>`;
|
||
} else {
|
||
srtpBadge = `<span class="inline-flex items-center gap-1 px-1.5 py-0.2 rounded text-[10px] font-medium bg-anthracite-900 text-slate-400 border border-anthracite-700" title="Voix en clair (RTP standard)">🔈 RTP Clair</span>`;
|
||
}
|
||
|
||
// Détection dynamique du mode réel négocié en direct par le téléphone
|
||
let modeBadge = '';
|
||
if (c) {
|
||
const actTr = (c.active_transport || 'udp').toLowerCase();
|
||
let trPill = '';
|
||
let profileNote = '';
|
||
|
||
if (actTr === 'tls') {
|
||
trPill = `
|
||
<span class="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-bold bg-emerald-500/20 text-emerald-300 border border-emerald-500/40 shadow-sm shadow-emerald-500/10">
|
||
🔒 TLS Actif (Chiffré)
|
||
</span>
|
||
`;
|
||
profileNote = `<span class="${isTls ? 'text-emerald-300 font-semibold' : 'text-slate-300'}">${isTls ? 'TLS Strict' : 'UDP Interne'}</span>`;
|
||
} else if (actTr === 'wss') {
|
||
trPill = `
|
||
<span class="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-bold bg-cyan-500/20 text-cyan-300 border border-cyan-500/40">
|
||
🔒 WSS WebRTC
|
||
</span>
|
||
`;
|
||
profileNote = `<span class="text-cyan-300 font-semibold">WebRTC</span>`;
|
||
} else if (actTr === 'tcp') {
|
||
trPill = `
|
||
<span class="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-semibold bg-indigo-500/20 text-indigo-300 border border-indigo-500/35">
|
||
🔓 TCP Actif (Clair)
|
||
</span>
|
||
`;
|
||
profileNote = `<span class="text-slate-300">${isTls ? 'TLS Strict' : 'UDP Interne'}</span>`;
|
||
} else {
|
||
// UDP
|
||
trPill = `
|
||
<span class="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-semibold ${isTls ? 'bg-amber-500/20 text-amber-300 border border-amber-500/40' : 'bg-anthracite-750 text-slate-200 border border-anthracite-600'}">
|
||
🔓 UDP Actif (Clair)
|
||
</span>
|
||
`;
|
||
profileNote = isTls ? '<span class="text-amber-400 font-semibold">⚠️ Exige TLS</span>' : '<span class="text-slate-400">UDP Interne</span>';
|
||
}
|
||
|
||
modeBadge = `
|
||
<div class="space-y-1">
|
||
<div>${trPill}</div>
|
||
<div class="flex items-center gap-1.5 flex-wrap pl-0.5">
|
||
${srtpBadge}
|
||
<span class="text-[10px] text-slate-400 font-mono">Profil: ${profileNote}</span>
|
||
</div>
|
||
</div>
|
||
`;
|
||
} else {
|
||
modeBadge = `
|
||
<div class="space-y-1">
|
||
<div>
|
||
<span class="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-xs font-medium bg-anthracite-950 text-slate-400 border border-anthracite-700">
|
||
Inactif
|
||
</span>
|
||
</div>
|
||
<div class="flex items-center gap-1.5 flex-wrap pl-0.5">
|
||
${srtpBadge}
|
||
<span class="text-[10px] text-slate-500 font-mono">Profil: ${isTls ? 'TLS Strict' : 'UDP Interne'}</span>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
// Statut SIP
|
||
let statusBadge = '';
|
||
if (isOnline) {
|
||
statusBadge = `<span class="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-bold bg-emerald-500/20 text-emerald-300 border border-emerald-500/30"><i class="w-2 h-2 rounded-full bg-emerald-400 animate-pulse"></i>En ligne (${escapeHtml(c.status)})</span>`;
|
||
} else if (isUnavail) {
|
||
statusBadge = `<span class="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-bold bg-rose-500/20 text-rose-300 border border-rose-500/30" title="Contact injoignable (rejet de transport ou délai d'attente dépassé)"><i class="w-2 h-2 rounded-full bg-rose-400"></i>Injoignable (${escapeHtml(c.status)})</span>`;
|
||
} else {
|
||
statusBadge = `<span class="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-medium bg-anthracite-950 text-slate-400 border border-anthracite-700"><i class="w-2 h-2 rounded-full bg-slate-500"></i>Hors-ligne</span>`;
|
||
}
|
||
|
||
// Contact / IP & Modèle Téléphone (User-Agent)
|
||
let contactHtml = '';
|
||
if (c) {
|
||
const cleanIp = extractCleanIp(c.uri);
|
||
const ua = c.user_agent ? escapeHtml(c.user_agent) : '';
|
||
contactHtml = `
|
||
<div>
|
||
<div class="font-mono text-xs text-slate-200 font-semibold">${escapeHtml(cleanIp)}</div>
|
||
${ua ? `<div class="text-[11px] text-indigo-300 font-medium truncate max-w-[240px]" title="${ua}">📱 ${ua}</div>` : ''}
|
||
</div>
|
||
`;
|
||
} else {
|
||
contactHtml = `<span class="text-xs text-slate-500 italic">Non enregistré</span>`;
|
||
}
|
||
|
||
let rttText = '—';
|
||
let rttColor = 'text-slate-500';
|
||
if (isOnline && c && c.rtt_ms !== null && !isNaN(c.rtt_ms)) {
|
||
rttText = `${c.rtt_ms} ms`;
|
||
rttColor = c.rtt_ms < 30 ? 'text-emerald-300 font-bold' : (c.rtt_ms < 100 ? 'text-cyan-300' : 'text-amber-300');
|
||
} else if (isUnavail) {
|
||
rttText = 'Inaccessible';
|
||
rttColor = 'text-rose-400/90 font-semibold italic';
|
||
}
|
||
|
||
return `
|
||
<tr class="hover:bg-anthracite-750/60 transition">
|
||
<td class="py-3 px-4 font-mono font-bold text-indigo-300">${escapeHtml(ext.extension)}</td>
|
||
<td class="py-3 px-4 font-medium text-white">${escapeHtml(ext.name)}</td>
|
||
<td class="py-3 px-4">${modeBadge}</td>
|
||
<td class="py-3 px-4">${statusBadge}</td>
|
||
<td class="py-3 px-4">${contactHtml}</td>
|
||
<td class="py-3 px-4 text-right font-mono text-xs ${rttColor}">${rttText}</td>
|
||
</tr>
|
||
`;
|
||
}).join('');
|
||
}
|
||
|
||
// --- 1. Rendu & CRUD Extensions ---
|
||
function renderExtensionsTable() {
|
||
const tbody = document.getElementById('extensions-tbody');
|
||
if (!tbody) return;
|
||
const rows = appState.extensions || [];
|
||
const contactMap = getContactMap();
|
||
const groups = appState.groups || [];
|
||
|
||
if (rows.length === 0) {
|
||
tbody.innerHTML = `<tr><td colspan="8" class="py-8 text-center text-slate-400 text-xs">Aucun poste configuré. Cliquez sur « + Nouveau Poste SIP ».</td></tr>`;
|
||
return;
|
||
}
|
||
|
||
tbody.innerHTML = rows.map(ext => {
|
||
const c = contactMap[String(ext.extension)];
|
||
const isTls = ext.transport === 'tls';
|
||
const encMode = Number(ext.encryption ?? (isTls ? 1 : 0));
|
||
let encLabel = '';
|
||
if (encMode === 2) {
|
||
encLabel = '<span class="text-[10px] font-bold text-emerald-400">SRTP Forcé</span>';
|
||
} else if (encMode === 1) {
|
||
encLabel = '<span class="text-[10px] font-bold text-cyan-400">SRTP Négociable</span>';
|
||
} else {
|
||
encLabel = '<span class="text-[10px] text-slate-400">RTP Clair</span>';
|
||
}
|
||
const badge = isTls
|
||
? `<span class="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-semibold bg-emerald-500/15 text-emerald-300 border border-emerald-500/30">🔒 TLS (${encLabel})</span>`
|
||
: (encMode > 0
|
||
? `<span class="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-semibold bg-indigo-500/15 text-indigo-300 border border-indigo-500/30">🛡️ UDP (${encLabel})</span>`
|
||
: `<span class="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-semibold bg-anthracite-750 text-slate-200 border border-anthracite-600">🏢 UDP (RTP Clair)</span>`);
|
||
|
||
const actTr = c ? (c.active_transport || 'udp').toUpperCase() : '';
|
||
const stLower = c ? String(c.status).toLowerCase() : '';
|
||
const isCUnavail = c && stLower.includes('unavail');
|
||
const isCOnline = c && !isCUnavail && (stLower.startsWith('avail') || stLower.includes('nonqual'));
|
||
const regBadge = c
|
||
? (isCOnline
|
||
? `<span class="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-bold bg-emerald-500/20 text-emerald-300 border border-emerald-500/30" title="${escapeHtml(c.user_agent || '')}"><i class="w-1.5 h-1.5 rounded-full bg-emerald-400"></i>Connecté (${actTr}${c.rtt_ms !== null && !isNaN(c.rtt_ms) ? ` • ${c.rtt_ms}ms` : ''})</span>`
|
||
: `<span class="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-bold bg-rose-500/20 text-rose-300 border border-rose-500/30" title="Contact rejeté ou injoignable (rejet de transport ou délai dépassé)"><i class="w-1.5 h-1.5 rounded-full bg-rose-400"></i>Injoignable (${actTr})</span>`)
|
||
: `<span class="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-medium bg-anthracite-950 text-slate-400 border border-anthracite-700">Non connecté</span>`;
|
||
|
||
// Trouve à quels groupes d'extensions (Sites/Droits) ce poste appartient (ex: Nice, Paris)
|
||
const extGroups = appState.extension_groups || [];
|
||
const myExtGroups = extGroups.filter(eg => {
|
||
const mList = Array.isArray(eg.members) ? eg.members.map(String) : [];
|
||
return mList.includes(String(ext.extension));
|
||
});
|
||
const groupBadges = myExtGroups.length > 0
|
||
? myExtGroups.map(eg => `<span class="px-2 py-0.5 rounded-md bg-cyan-500/15 text-cyan-300 border border-cyan-500/30 text-[11px] font-semibold">${escapeHtml(eg.name)}</span>`).join(' ')
|
||
: `<span class="text-slate-500 text-xs italic">Aucun</span>`;
|
||
|
||
const vmBadge = Number(ext.voicemail_enabled) === 1
|
||
? `<span class="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[10px] font-semibold bg-amber-500/15 text-amber-300 border border-amber-500/30" title="Boîte vocale active (${ext.ring_time || 20}s)">📟 Boîte</span>`
|
||
: '';
|
||
const gwBadge = Number(ext.groundwire_enabled) === 1
|
||
? `<span class="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[10px] font-semibold bg-cyan-500/15 text-cyan-300 border border-cyan-500/30" title="Provisionnement XML GroundWire activé dans l'UCP">GroundWire</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 flex-wrap">
|
||
<span>${escapeHtml(ext.name)}</span>
|
||
${vmBadge}
|
||
${gwBadge}
|
||
</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 whitespace-nowrap">
|
||
<div class="inline-flex items-center justify-end gap-1.5">
|
||
<button type="button" onclick="promptRebootExtension(${Number(ext.id)})"
|
||
class="px-2.5 py-1 rounded-lg bg-amber-500/10 hover:bg-amber-500/20 text-amber-300 hover:text-amber-200 border border-amber-500/30 text-xs font-semibold transition">
|
||
Redémarrer
|
||
</button>
|
||
<button type="button" onclick="editExtension(${Number(ext.id)})"
|
||
class="px-2.5 py-1 rounded-lg bg-anthracite-800 hover:bg-sky-600/20 text-slate-300 hover:text-sky-300 border border-anthracite-700 hover:border-sky-500/40 text-xs font-semibold transition">
|
||
Modifier
|
||
</button>
|
||
<button type="button" onclick="deleteExtension(${Number(ext.id)})"
|
||
class="px-2.5 py-1 rounded-lg bg-rose-500/10 hover:bg-rose-600 text-rose-300 hover:text-white border border-rose-500/30 text-xs font-semibold transition">
|
||
Supprimer
|
||
</button>
|
||
</div>
|
||
</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));
|
||
|
||
const gwCheckbox = document.getElementById('ext-groundwire-enabled');
|
||
if (gwCheckbox) gwCheckbox.checked = ext ? (Number(ext.groundwire_enabled) === 1) : false;
|
||
|
||
// Réinitialisation de l'onglet actif vers "Général"
|
||
switchExtModalTab('general');
|
||
|
||
// Initialisation des champs Messagerie Vocale
|
||
const vmEnabled = ext ? (Number(ext.voicemail_enabled) === 1) : false;
|
||
const vmCheckbox = document.getElementById('ext-voicemail-enabled');
|
||
if (vmCheckbox) vmCheckbox.checked = vmEnabled;
|
||
|
||
const isFirstCall = ext ? (Number(ext.force_pin_change) === 1) : false;
|
||
form.querySelectorAll('input[name="pin_mode"]').forEach(r => {
|
||
r.checked = isFirstCall ? (r.value === 'first_call') : (r.value === 'manual');
|
||
});
|
||
syncPinMode(isFirstCall ? 'first_call' : 'manual');
|
||
|
||
if (form.elements['voicemail_pin']) {
|
||
form.elements['voicemail_pin'].value = ext ? (ext.voicemail_pin || '') : '';
|
||
}
|
||
if (form.elements['ring_time']) {
|
||
form.elements['ring_time'].value = ext ? (ext.ring_time || 20) : 20;
|
||
}
|
||
|
||
const skipPinCb = document.getElementById('ext-skip-pin');
|
||
if (skipPinCb) {
|
||
skipPinCb.checked = ext ? (Number(ext.voicemail_skip_pin) === 1) : true;
|
||
}
|
||
|
||
if (form.elements['voicemail_email']) {
|
||
form.elements['voicemail_email'].value = ext ? (ext.voicemail_email || '') : '';
|
||
}
|
||
|
||
const vmAttachCb = document.getElementById('ext-vm-attach');
|
||
if (vmAttachCb) {
|
||
vmAttachCb.checked = ext ? (Number(ext.voicemail_attach) === 1) : true;
|
||
}
|
||
|
||
const vmDeleteCb = document.getElementById('ext-vm-delete');
|
||
if (vmDeleteCb) {
|
||
vmDeleteCb.checked = ext ? (Number(ext.voicemail_delete) === 1) : false;
|
||
}
|
||
|
||
syncVoicemailToggle(vmEnabled);
|
||
|
||
// Rendu des cases d'appartenance aux Groupes d'Extensions (Sites: Nice, Paris...)
|
||
const grpBox = document.getElementById('extension-groups-checkboxes');
|
||
if (grpBox) {
|
||
const extGroups = appState.extension_groups || [];
|
||
if (extGroups.length === 0) {
|
||
grpBox.innerHTML = `<span class="text-slate-500 text-xs col-span-2">Aucun groupe d'extensions créé. Vous pouvez en créer dans « Groupes » → « Groupes d'extensions ».</span>`;
|
||
} else {
|
||
const currentExt = ext ? String(ext.extension) : '';
|
||
grpBox.innerHTML = extGroups.map(eg => {
|
||
const isMember = currentExt && Array.isArray(eg.members) && eg.members.map(String).includes(currentExt);
|
||
return `
|
||
<label class="flex items-center gap-2 p-2 rounded-lg bg-anthracite-900 border border-anthracite-700 hover:border-cyan-500/50 cursor-pointer">
|
||
<input type="checkbox" name="member_extension_groups[]" value="${Number(eg.id)}" ${isMember ? 'checked' : ''} class="rounded bg-anthracite-950 border-anthracite-600">
|
||
<div class="leading-tight">
|
||
<span class="text-xs font-bold text-white">${escapeHtml(eg.name)}</span>
|
||
${eg.description ? `<span class="text-[10px] text-slate-400 block">${escapeHtml(eg.description)}</span>` : ''}
|
||
</div>
|
||
</label>
|
||
`;
|
||
}).join('');
|
||
}
|
||
}
|
||
|
||
openModal('modal-extension');
|
||
}
|
||
|
||
function editExtension(id) {
|
||
const ext = (appState.extensions || []).find(e => Number(e.id) === Number(id));
|
||
if (ext) openExtensionModal(ext);
|
||
}
|
||
|
||
async function deleteExtension(id, optNum = null) {
|
||
const ext = (appState.extensions || []).find(e => Number(e.id) === Number(id));
|
||
const num = optNum || (ext ? ext.extension : 'ce poste');
|
||
if (!confirm(`Supprimer définitivement le poste SIP ${num} ?`)) return;
|
||
try {
|
||
const res = await apiCall('delete_extension', { id: Number(id) });
|
||
showToast(res.message, 'success');
|
||
await loadState();
|
||
} catch (err) {
|
||
showToast(err.message, 'error');
|
||
}
|
||
}
|
||
|
||
let pendingRebootExtension = null;
|
||
|
||
async function promptRebootExtension(id) {
|
||
const ext = (appState.extensions || []).find(e => Number(e.id) === Number(id));
|
||
if (!ext) return;
|
||
|
||
pendingRebootExtension = ext;
|
||
|
||
document.getElementById('reboot-modal-ext-num').textContent = ext.extension;
|
||
document.getElementById('reboot-modal-ext-name').textContent = ext.name || '';
|
||
|
||
const checkingEl = document.getElementById('reboot-modal-checking');
|
||
const warningEl = document.getElementById('reboot-modal-call-warning');
|
||
const normalEl = document.getElementById('reboot-modal-normal-info');
|
||
const btnAction = document.getElementById('btn-confirm-reboot-action');
|
||
const btnLabel = document.getElementById('reboot-confirm-btn-label');
|
||
|
||
checkingEl.classList.remove('hidden');
|
||
warningEl.classList.add('hidden');
|
||
normalEl.classList.add('hidden');
|
||
|
||
btnAction.disabled = true;
|
||
btnAction.className = 'px-5 py-2.5 rounded-xl text-xs font-bold bg-anthracite-700 text-slate-400 transition cursor-not-allowed flex items-center gap-2';
|
||
btnLabel.textContent = 'Vérification en cours...';
|
||
|
||
openModal('modal-confirm-reboot-extension');
|
||
|
||
try {
|
||
const res = await apiCall('check_extension_call_status', { extension: ext.extension });
|
||
checkingEl.classList.add('hidden');
|
||
btnAction.disabled = false;
|
||
|
||
if (res.in_call) {
|
||
warningEl.classList.remove('hidden');
|
||
normalEl.classList.add('hidden');
|
||
|
||
const detailsEl = document.getElementById('reboot-modal-call-details');
|
||
if (detailsEl) {
|
||
detailsEl.textContent = res.call_details || 'Communication active détectée sur ce combiné.';
|
||
}
|
||
|
||
btnAction.className = 'px-5 py-2.5 rounded-xl text-xs font-bold bg-rose-600 hover:bg-rose-500 text-white shadow-lg shadow-rose-600/30 transition flex items-center gap-2';
|
||
btnLabel.textContent = "Forcer le redémarrage (Couper l'appel)";
|
||
} else {
|
||
warningEl.classList.add('hidden');
|
||
normalEl.classList.remove('hidden');
|
||
|
||
btnAction.className = 'px-5 py-2.5 rounded-xl text-xs font-bold bg-amber-600 hover:bg-amber-500 text-white shadow-lg shadow-amber-600/30 transition flex items-center gap-2';
|
||
btnLabel.textContent = 'Confirmer le redémarrage';
|
||
}
|
||
} catch (err) {
|
||
checkingEl.classList.add('hidden');
|
||
normalEl.classList.remove('hidden');
|
||
btnAction.disabled = false;
|
||
btnAction.className = 'px-5 py-2.5 rounded-xl text-xs font-bold bg-amber-600 hover:bg-amber-500 text-white shadow-lg shadow-amber-600/30 transition flex items-center gap-2';
|
||
btnLabel.textContent = 'Confirmer le redémarrage';
|
||
}
|
||
}
|
||
|
||
function closeRebootModal() {
|
||
pendingRebootExtension = null;
|
||
closeModal('modal-confirm-reboot-extension');
|
||
}
|
||
|
||
async function confirmRebootExtension() {
|
||
if (!pendingRebootExtension) return;
|
||
const ext = pendingRebootExtension;
|
||
const btnAction = document.getElementById('btn-confirm-reboot-action');
|
||
const btnLabel = document.getElementById('reboot-confirm-btn-label');
|
||
|
||
if (btnAction) btnAction.disabled = true;
|
||
if (btnLabel) btnLabel.textContent = 'Envoi de l\'ordre...';
|
||
|
||
try {
|
||
const res = await apiCall('reboot_extension', { extension: ext.extension });
|
||
closeRebootModal();
|
||
showToast(res.message, 'success');
|
||
} catch (err) {
|
||
showToast(err.message, 'error');
|
||
} finally {
|
||
if (btnAction) btnAction.disabled = false;
|
||
}
|
||
}
|
||
|
||
function syncEncryptionToggle(transport, currentVal = null) {
|
||
const sel = document.getElementById('ext-encryption');
|
||
if (!sel) return;
|
||
if (currentVal !== null) {
|
||
sel.value = String(currentVal);
|
||
} else if (transport === 'tls' && sel.value === '0') {
|
||
sel.value = '1';
|
||
}
|
||
}
|
||
|
||
function generateSecretString() {
|
||
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789!@#$%';
|
||
let out = '';
|
||
for (let i = 0; i < 14; i++) {
|
||
out += chars.charAt(Math.floor(Math.random() * chars.length));
|
||
}
|
||
return out;
|
||
}
|
||
|
||
function generateRandomSecret() {
|
||
const form = document.getElementById('form-extension');
|
||
form.elements['secret'].value = generateSecretString();
|
||
}
|
||
|
||
// --- 2A. Rendu & CRUD Groupes de Sonnerie (Ring Groups) ---
|
||
function renderGroupsTable() {
|
||
const tbody = document.getElementById('groups-tbody');
|
||
if (!tbody) return;
|
||
const rows = appState.groups || [];
|
||
|
||
if (rows.length === 0) {
|
||
tbody.innerHTML = `<tr><td colspan="6" class="py-8 text-center text-slate-400 text-xs">Aucun groupe de sonnerie configuré. Cliquez sur « + Nouveau Groupe de Sonnerie ».</td></tr>`;
|
||
return;
|
||
}
|
||
|
||
tbody.innerHTML = rows.map(grp => {
|
||
const members = Array.isArray(grp.members) ? grp.members : [];
|
||
const badges = members.length
|
||
? members.map(m => `<span class="px-2 py-0.5 rounded bg-indigo-500/20 text-indigo-200 border border-indigo-500/30 font-mono text-xs font-semibold">${escapeHtml(m)}</span>`).join(' ')
|
||
: '<span class="text-slate-500 text-xs italic">Aucun membre</span>';
|
||
const failoverLabel = String(grp.failover_dest || '').startsWith('vm-')
|
||
? `<span class="px-2 py-0.5 rounded bg-violet-500/20 text-violet-200 border border-violet-500/30 font-mono text-xs font-semibold">📬 Répondeur ${escapeHtml(String(grp.failover_dest).slice(3))}</span>`
|
||
: `<code class="text-slate-300">${escapeHtml(grp.failover_dest)}</code>`;
|
||
|
||
return `
|
||
<tr class="hover:bg-anthracite-750/60 transition">
|
||
<td class="py-3.5 px-4 font-mono font-bold text-indigo-300">${escapeHtml(grp.extension)}</td>
|
||
<td class="py-3.5 px-4 font-bold text-white">${escapeHtml(grp.name)}</td>
|
||
<td class="py-3.5 px-4"><code class="text-xs text-cyan-300 font-mono">${escapeHtml(grp.strategy)}</code></td>
|
||
<td class="py-3.5 px-4"><div class="flex flex-wrap gap-1">${badges}</div></td>
|
||
<td class="py-3.5 px-4 text-xs text-slate-200">${Number(grp.ring_time)}s → ${failoverLabel}</td>
|
||
<td class="py-3.5 px-4 text-right space-x-2">
|
||
<button type="button" onclick="editGroup(${Number(grp.id)})" class="text-xs font-semibold text-indigo-300 hover:text-indigo-200">Modifier</button>
|
||
<button type="button" onclick="deleteGroup(${Number(grp.id)})" class="text-xs font-semibold text-red-400 hover:text-red-300">Supprimer</button>
|
||
</td>
|
||
</tr>
|
||
`;
|
||
}).join('');
|
||
}
|
||
|
||
let activeGroupSelectedExts = new Set();
|
||
|
||
function openGroupModal(grp = null) {
|
||
const form = document.getElementById('form-group');
|
||
form.reset();
|
||
document.getElementById('modal-group-title').textContent = grp ? `Modifier le groupe de sonnerie ${grp.name} (${grp.extension})` : "Nouveau Groupe de Sonnerie";
|
||
|
||
form.elements['id'].value = grp ? grp.id : '';
|
||
form.elements['extension'].value = grp ? grp.extension : '';
|
||
form.elements['name'].value = grp ? grp.name : '';
|
||
form.elements['strategy'].value = grp ? grp.strategy : 'ringall';
|
||
form.elements['ring_time'].value = grp ? grp.ring_time : 20;
|
||
|
||
// Failover destinations
|
||
const failoverSel = document.getElementById('group-failover-select');
|
||
if (failoverSel) {
|
||
failoverSel.innerHTML = buildDestinationOptionsHtml(grp ? grp.failover_dest : 'hangup');
|
||
}
|
||
|
||
activeGroupSelectedExts = new Set(grp && Array.isArray(grp.members) ? grp.members.map(String) : []);
|
||
renderGroupInteractiveBoxes();
|
||
|
||
openModal('modal-group');
|
||
}
|
||
|
||
function renderGroupInteractiveBoxes() {
|
||
const allowedBox = document.getElementById('group-members-allowed-box');
|
||
const blockedBox = document.getElementById('group-members-blocked-box');
|
||
const hiddenBox = document.getElementById('group-members-checkboxes');
|
||
if (!allowedBox || !blockedBox) return;
|
||
|
||
const allExts = appState.extensions || [];
|
||
let allowedHtml = '';
|
||
let blockedHtml = '';
|
||
let hiddenHtml = '';
|
||
let allowedCount = 0;
|
||
let blockedCount = 0;
|
||
|
||
allExts.forEach(ext => {
|
||
const extStr = String(ext.extension);
|
||
const isSel = activeGroupSelectedExts.has(extStr);
|
||
if (isSel) {
|
||
allowedCount++;
|
||
allowedHtml += `
|
||
<button type="button" onclick="toggleGroupMemberExt('${escapeHtml(extStr)}')"
|
||
class="group inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg bg-emerald-500/20 hover:bg-red-500/20 border border-emerald-500/40 hover:border-red-500/40 text-xs font-semibold text-emerald-300 hover:text-red-300 transition cursor-pointer">
|
||
<span class="font-mono">${escapeHtml(extStr)}</span>
|
||
<span class="text-slate-300">(${escapeHtml(ext.name)})</span>
|
||
<span class="text-slate-400 group-hover:text-red-300 ml-1 font-bold">✕</span>
|
||
</button>
|
||
`;
|
||
hiddenHtml += `<input type="checkbox" name="member_ext" value="${escapeHtml(extStr)}" checked>`;
|
||
} else {
|
||
blockedCount++;
|
||
blockedHtml += `
|
||
<button type="button" onclick="toggleGroupMemberExt('${escapeHtml(extStr)}')"
|
||
class="group inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg bg-anthracite-950 hover:bg-emerald-500/20 border border-anthracite-700 hover:border-emerald-500/40 text-xs font-medium text-slate-300 hover:text-emerald-300 transition cursor-pointer">
|
||
<span class="font-mono text-indigo-300 font-bold">${escapeHtml(extStr)}</span>
|
||
<span class="text-slate-300">(${escapeHtml(ext.name)})</span>
|
||
<span class="text-slate-500 group-hover:text-emerald-300 ml-1 font-bold">+</span>
|
||
</button>
|
||
`;
|
||
}
|
||
});
|
||
|
||
allowedBox.innerHTML = allowedHtml || `<div class="text-slate-500 text-xs italic py-2">Aucun poste qui sonne. Cliquez sur un poste à droite pour l'ajouter.</div>`;
|
||
blockedBox.innerHTML = blockedHtml || `<div class="text-slate-500 text-xs italic py-2">Tous les postes sonnent dans ce groupe !</div>`;
|
||
if (hiddenBox) hiddenBox.innerHTML = hiddenHtml;
|
||
|
||
const countA = document.getElementById('group-selected-count');
|
||
const countB = document.getElementById('group-unselected-count');
|
||
if (countA) countA.textContent = String(allowedCount);
|
||
if (countB) countB.textContent = String(blockedCount);
|
||
}
|
||
|
||
function toggleGroupMemberExt(extStr) {
|
||
if (activeGroupSelectedExts.has(extStr)) {
|
||
activeGroupSelectedExts.delete(extStr);
|
||
} else {
|
||
activeGroupSelectedExts.add(extStr);
|
||
}
|
||
renderGroupInteractiveBoxes();
|
||
}
|
||
|
||
function groupTransferAllExtensions(includeAll) {
|
||
const allExts = appState.extensions || [];
|
||
if (includeAll) {
|
||
allExts.forEach(e => activeGroupSelectedExts.add(String(e.extension)));
|
||
} else {
|
||
activeGroupSelectedExts.clear();
|
||
}
|
||
renderGroupInteractiveBoxes();
|
||
}
|
||
|
||
function editGroup(id) {
|
||
const grp = (appState.groups || []).find(g => Number(g.id) === Number(id));
|
||
if (grp) openGroupModal(grp);
|
||
}
|
||
|
||
async function deleteGroup(id, optNum = null) {
|
||
const grp = (appState.groups || []).find(g => Number(g.id) === Number(id));
|
||
const num = optNum || (grp ? grp.extension : 'ce groupe');
|
||
if (!confirm(`Supprimer le groupe de sonnerie ${num} ?`)) return;
|
||
try {
|
||
const res = await apiCall('delete_group', { id: Number(id) });
|
||
showToast(res.message, 'success');
|
||
await loadState();
|
||
} catch (err) {
|
||
showToast(err.message, 'error');
|
||
}
|
||
}
|
||
|
||
// --- 2B. Rendu & CRUD Groupes d'Extensions (Sites & Droits Routes Sortantes) ---
|
||
function renderExtensionGroupsTable() {
|
||
const tbody = document.getElementById('extgroups-tbody');
|
||
if (!tbody) return;
|
||
const rows = appState.extension_groups || [];
|
||
const obRoutes = appState.outbound_routes || [];
|
||
|
||
if (rows.length === 0) {
|
||
tbody.innerHTML = `<tr><td colspan="5" class="py-8 text-center text-slate-400 text-xs">Aucun groupe d'extensions configuré. Cliquez sur « + Nouveau Groupe d'Extensions ».</td></tr>`;
|
||
return;
|
||
}
|
||
|
||
tbody.innerHTML = rows.map(eg => {
|
||
const members = Array.isArray(eg.members) ? eg.members : [];
|
||
const badges = members.length
|
||
? members.map(m => `<span class="px-2 py-0.5 rounded bg-cyan-500/20 text-cyan-200 border border-cyan-500/30 font-mono text-xs font-semibold">${escapeHtml(m)}</span>`).join(' ')
|
||
: '<span class="text-slate-500 text-xs italic">Aucune extension</span>';
|
||
|
||
// Routes sortantes où ce groupe d'extensions a accès
|
||
const egIdStr = String(eg.id);
|
||
const egNameStr = String(eg.name);
|
||
const accessibleRoutes = obRoutes.filter(r => {
|
||
if (r.permission_mode === 'all') return true;
|
||
const ag = Array.isArray(r.allowed_groups) ? r.allowed_groups.map(String) : [];
|
||
return ag.includes(egIdStr) || ag.includes(egNameStr);
|
||
});
|
||
const routeBadges = accessibleRoutes.length > 0
|
||
? accessibleRoutes.map(r => `<span class="px-2 py-0.5 rounded bg-emerald-500/15 text-emerald-300 border border-emerald-500/30 text-[11px] font-semibold">${escapeHtml(r.name)}</span>`).join(' ')
|
||
: `<span class="px-2 py-0.5 rounded bg-amber-500/15 text-amber-300 border border-amber-500/30 text-[11px]">Aucune (Verrouillé)</span>`;
|
||
|
||
return `
|
||
<tr class="hover:bg-anthracite-750/60 transition">
|
||
<td class="py-3.5 px-4 font-bold text-white text-sm">
|
||
<div class="flex items-center gap-2">
|
||
<span class="w-2.5 h-2.5 rounded-full bg-cyan-400"></span>
|
||
<span>${escapeHtml(eg.name)}</span>
|
||
</div>
|
||
</td>
|
||
<td class="py-3.5 px-4 text-xs text-slate-300">${escapeHtml(eg.description || '—')}</td>
|
||
<td class="py-3.5 px-4"><div class="flex flex-wrap gap-1">${badges}</div></td>
|
||
<td class="py-3.5 px-4"><div class="flex flex-wrap gap-1">${routeBadges}</div></td>
|
||
<td class="py-3.5 px-4 text-right space-x-2">
|
||
<button type="button" onclick="editExtGroup(${Number(eg.id)})" class="text-xs font-semibold text-cyan-300 hover:text-cyan-200">Modifier</button>
|
||
<button type="button" onclick="deleteExtGroup(${Number(eg.id)})" class="text-xs font-semibold text-red-400 hover:text-red-300">Supprimer</button>
|
||
</td>
|
||
</tr>
|
||
`;
|
||
}).join('');
|
||
}
|
||
|
||
let activeExtGroupSelectedExts = new Set();
|
||
|
||
function openExtGroupModal(eg = null) {
|
||
const form = document.getElementById('form-extgroup');
|
||
form.reset();
|
||
document.getElementById('modal-extgroup-title').textContent = eg ? `Modifier le groupe d'extensions ${eg.name}` : "Nouveau Groupe d'Extensions";
|
||
|
||
form.elements['id'].value = eg ? eg.id : '';
|
||
form.elements['name'].value = eg ? eg.name : '';
|
||
form.elements['description'].value = eg ? eg.description : '';
|
||
|
||
activeExtGroupSelectedExts = new Set(eg && Array.isArray(eg.members) ? eg.members.map(String) : []);
|
||
renderExtGroupInteractiveBoxes();
|
||
|
||
openModal('modal-extgroup');
|
||
}
|
||
|
||
function renderExtGroupInteractiveBoxes() {
|
||
const allowedBox = document.getElementById('extgroup-members-allowed-box');
|
||
const blockedBox = document.getElementById('extgroup-members-blocked-box');
|
||
if (!allowedBox || !blockedBox) return;
|
||
|
||
const allExts = appState.extensions || [];
|
||
let allowedHtml = '';
|
||
let blockedHtml = '';
|
||
let allowedCount = 0;
|
||
let blockedCount = 0;
|
||
|
||
allExts.forEach(ext => {
|
||
const extStr = String(ext.extension);
|
||
const isSel = activeExtGroupSelectedExts.has(extStr);
|
||
if (isSel) {
|
||
allowedCount++;
|
||
allowedHtml += `
|
||
<button type="button" onclick="toggleExtGroupMember('${escapeHtml(extStr)}')"
|
||
class="group inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg bg-cyan-500/20 hover:bg-red-500/20 border border-cyan-500/40 hover:border-red-500/40 text-xs font-semibold text-cyan-300 hover:text-red-300 transition cursor-pointer">
|
||
<span class="font-mono">${escapeHtml(extStr)}</span>
|
||
<span class="text-slate-300">(${escapeHtml(ext.name)})</span>
|
||
<span class="text-slate-400 group-hover:text-red-300 ml-1 font-bold">✕</span>
|
||
</button>
|
||
`;
|
||
} else {
|
||
blockedCount++;
|
||
blockedHtml += `
|
||
<button type="button" onclick="toggleExtGroupMember('${escapeHtml(extStr)}')"
|
||
class="group inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg bg-anthracite-950 hover:bg-cyan-500/20 border border-anthracite-700 hover:border-cyan-500/40 text-xs font-medium text-slate-300 hover:text-cyan-300 transition cursor-pointer">
|
||
<span class="font-mono text-indigo-300 font-bold">${escapeHtml(extStr)}</span>
|
||
<span class="text-slate-300">(${escapeHtml(ext.name)})</span>
|
||
<span class="text-slate-500 group-hover:text-cyan-300 ml-1 font-bold">+</span>
|
||
</button>
|
||
`;
|
||
}
|
||
});
|
||
|
||
allowedBox.innerHTML = allowedHtml || `<div class="text-slate-500 text-xs italic py-2">Aucune extension rattachée. Cliquez à droite pour en ajouter.</div>`;
|
||
blockedBox.innerHTML = blockedHtml || `<div class="text-slate-500 text-xs italic py-2">Toutes les extensions sont dans ce groupe !</div>`;
|
||
|
||
const countA = document.getElementById('extgroup-selected-count');
|
||
const countB = document.getElementById('extgroup-unselected-count');
|
||
if (countA) countA.textContent = String(allowedCount);
|
||
if (countB) countB.textContent = String(blockedCount);
|
||
}
|
||
|
||
function toggleExtGroupMember(extStr) {
|
||
if (activeExtGroupSelectedExts.has(extStr)) {
|
||
activeExtGroupSelectedExts.delete(extStr);
|
||
} else {
|
||
activeExtGroupSelectedExts.add(extStr);
|
||
}
|
||
renderExtGroupInteractiveBoxes();
|
||
}
|
||
|
||
function extGroupTransferAll(includeAll) {
|
||
const allExts = appState.extensions || [];
|
||
if (includeAll) {
|
||
allExts.forEach(e => activeExtGroupSelectedExts.add(String(e.extension)));
|
||
} else {
|
||
activeExtGroupSelectedExts.clear();
|
||
}
|
||
renderExtGroupInteractiveBoxes();
|
||
}
|
||
|
||
function editExtGroup(id) {
|
||
const eg = (appState.extension_groups || []).find(g => Number(g.id) === Number(id));
|
||
if (eg) openExtGroupModal(eg);
|
||
}
|
||
|
||
async function deleteExtGroup(id, optName = null) {
|
||
const eg = (appState.extension_groups || []).find(g => Number(g.id) === Number(id));
|
||
const name = optName || (eg ? eg.name : 'ce groupe');
|
||
if (!confirm(`Supprimer définitivement le groupe d'extensions « ${name} » ?`)) return;
|
||
try {
|
||
const res = await apiCall('delete_extension_group', { id: Number(id) });
|
||
showToast(res.message, 'success');
|
||
await loadState();
|
||
} catch (err) {
|
||
showToast(err.message, 'error');
|
||
}
|
||
}
|
||
|
||
// --- 2C. Rendu & CRUD Groupes de Téléavertissement & Interphonie (Paging) ---
|
||
function renderPagingTable() {
|
||
const tbody = document.getElementById('paging-tbody');
|
||
if (!tbody) return;
|
||
const rows = appState.paging_groups || [];
|
||
|
||
if (rows.length === 0) {
|
||
tbody.innerHTML = `<tr><td colspan="8" class="py-8 text-center text-slate-400 text-xs">Aucun groupe de téléavertissement configuré. Cliquez sur « + Nouveau Groupe de Téléavertissement ».</td></tr>`;
|
||
return;
|
||
}
|
||
|
||
tbody.innerHTML = rows.map(pg => {
|
||
const members = Array.isArray(pg.members) ? pg.members : [];
|
||
const badges = members.length
|
||
? members.map(m => `<span class="px-2 py-0.5 rounded bg-purple-500/20 text-purple-200 border border-purple-500/30 font-mono text-xs font-semibold">${escapeHtml(m)}</span>`).join(' ')
|
||
: '<span class="text-slate-500 text-xs italic">Aucun membre</span>';
|
||
|
||
const isDuplex = Number(pg.duplex) === 1;
|
||
const typeBadge = isDuplex
|
||
? `<span class="px-2 py-0.5 rounded text-[11px] font-semibold bg-indigo-500/20 text-indigo-300 border border-indigo-500/30">Duplex (2 voies)</span>`
|
||
: `<span class="px-2 py-0.5 rounded text-[11px] font-semibold bg-purple-500/20 text-purple-300 border border-purple-500/30">Simplex (Haut-parleur)</span>`;
|
||
|
||
const busyLabel = pg.busy_mode === 'force'
|
||
? `<span class="text-rose-300 text-xs font-semibold">Forcer</span>`
|
||
: `<span class="text-emerald-300 text-xs font-semibold">Ignorer si occupé</span>`;
|
||
|
||
const beepLabel = Number(pg.play_beep) === 1
|
||
? `<span class="text-emerald-400 text-xs font-semibold">✓ Oui</span>`
|
||
: `<span class="text-slate-400 text-xs">Non</span>`;
|
||
|
||
const statusBadge = Number(pg.enabled) === 1
|
||
? `<span class="px-2 py-0.5 rounded text-[10px] font-bold bg-emerald-500/20 text-emerald-300 border border-emerald-500/30">Actif</span>`
|
||
: `<span class="px-2 py-0.5 rounded text-[10px] font-bold bg-slate-700 text-slate-300 border border-slate-600">Inactif</span>`;
|
||
|
||
return `
|
||
<tr class="hover:bg-anthracite-750/60 transition">
|
||
<td class="py-3.5 px-4 font-mono font-bold text-purple-300 text-base">${escapeHtml(pg.extension)}</td>
|
||
<td class="py-3.5 px-4">
|
||
<div class="font-bold text-white">${escapeHtml(pg.name)}</div>
|
||
${pg.description ? `<div class="text-xs text-slate-400">${escapeHtml(pg.description)}</div>` : ''}
|
||
</td>
|
||
<td class="py-3.5 px-4">${typeBadge}</td>
|
||
<td class="py-3.5 px-4"><div class="flex flex-wrap gap-1">${badges}</div></td>
|
||
<td class="py-3.5 px-4">${busyLabel}</td>
|
||
<td class="py-3.5 px-4">${beepLabel}</td>
|
||
<td class="py-3.5 px-4">${statusBadge}</td>
|
||
<td class="py-3.5 px-4 text-right space-x-2">
|
||
<button type="button" onclick="editPagingGroup(${Number(pg.id)})" class="text-xs font-semibold text-purple-300 hover:text-purple-200">Modifier</button>
|
||
<button type="button" onclick="deletePagingGroup(${Number(pg.id)})" class="text-xs font-semibold text-red-400 hover:text-red-300">Supprimer</button>
|
||
</td>
|
||
</tr>
|
||
`;
|
||
}).join('');
|
||
}
|
||
|
||
let activePagingSelectedExts = new Set();
|
||
|
||
function openPagingModal(pg = null) {
|
||
const form = document.getElementById('form-paging');
|
||
if (!form) return;
|
||
form.reset();
|
||
document.getElementById('modal-paging-title').textContent = pg
|
||
? `Modifier le téléavertissement ${pg.name} (${pg.extension})`
|
||
: "Nouveau Groupe de Téléavertissement";
|
||
|
||
form.elements['id'].value = pg ? pg.id : '';
|
||
form.elements['extension'].value = pg ? pg.extension : '';
|
||
form.elements['name'].value = pg ? pg.name : '';
|
||
form.elements['description'].value = pg ? (pg.description || '') : '';
|
||
form.elements['duplex'].value = pg ? String(pg.duplex) : '0';
|
||
form.elements['busy_mode'].value = pg ? (pg.busy_mode || 'skip') : 'skip';
|
||
form.elements['timeout'].value = pg ? (pg.timeout || 120) : 120;
|
||
if (form.elements['play_beep']) {
|
||
form.elements['play_beep'].checked = pg ? Number(pg.play_beep) === 1 : true;
|
||
}
|
||
if (form.elements['enabled']) {
|
||
form.elements['enabled'].checked = pg ? Number(pg.enabled) === 1 : true;
|
||
}
|
||
|
||
activePagingSelectedExts = new Set(pg && Array.isArray(pg.members) ? pg.members.map(String) : []);
|
||
renderPagingInteractiveBoxes();
|
||
|
||
openModal('modal-paging');
|
||
}
|
||
|
||
function renderPagingInteractiveBoxes() {
|
||
const allowedBox = document.getElementById('paging-members-allowed-box');
|
||
const blockedBox = document.getElementById('paging-members-blocked-box');
|
||
const hiddenBox = document.getElementById('paging-members-checkboxes');
|
||
if (!allowedBox || !blockedBox) return;
|
||
|
||
const allExts = appState.extensions || [];
|
||
let allowedHtml = '';
|
||
let blockedHtml = '';
|
||
let hiddenHtml = '';
|
||
let allowedCount = 0;
|
||
let blockedCount = 0;
|
||
|
||
allExts.forEach(ext => {
|
||
const extStr = String(ext.extension);
|
||
const isSel = activePagingSelectedExts.has(extStr);
|
||
if (isSel) {
|
||
allowedCount++;
|
||
allowedHtml += `
|
||
<button type="button" onclick="togglePagingMemberExt('${escapeHtml(extStr)}')"
|
||
class="group inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg bg-purple-500/20 hover:bg-red-500/20 border border-purple-500/40 hover:border-red-500/40 text-xs font-semibold text-purple-300 hover:text-red-300 transition cursor-pointer">
|
||
<span class="font-mono font-bold">${escapeHtml(extStr)}</span>
|
||
<span class="text-slate-300">(${escapeHtml(ext.name)})</span>
|
||
<span class="text-slate-400 group-hover:text-red-300 ml-1 font-bold">✕</span>
|
||
</button>
|
||
`;
|
||
hiddenHtml += `<input type="checkbox" name="members[]" value="${escapeHtml(extStr)}" checked>`;
|
||
} else {
|
||
blockedCount++;
|
||
blockedHtml += `
|
||
<button type="button" onclick="togglePagingMemberExt('${escapeHtml(extStr)}')"
|
||
class="group inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg bg-anthracite-950 hover:bg-purple-500/20 border border-anthracite-700 hover:border-purple-500/40 text-xs font-medium text-slate-300 hover:text-purple-300 transition cursor-pointer">
|
||
<span class="font-mono text-purple-300 font-bold">${escapeHtml(extStr)}</span>
|
||
<span class="text-slate-300">(${escapeHtml(ext.name)})</span>
|
||
<span class="text-slate-500 group-hover:text-purple-300 ml-1 font-bold">+</span>
|
||
</button>
|
||
`;
|
||
}
|
||
});
|
||
|
||
allowedBox.innerHTML = allowedHtml || `<div class="text-slate-500 text-xs italic py-2">Aucun poste qui reçoit l'annonce. Cliquez sur un poste à droite pour l'ajouter.</div>`;
|
||
blockedBox.innerHTML = blockedHtml || `<div class="text-slate-500 text-xs italic py-2">Tous les postes reçoivent l'annonce !</div>`;
|
||
if (hiddenBox) hiddenBox.innerHTML = hiddenHtml;
|
||
|
||
const countA = document.getElementById('paging-selected-count');
|
||
const countB = document.getElementById('paging-unselected-count');
|
||
if (countA) countA.textContent = String(allowedCount);
|
||
if (countB) countB.textContent = String(blockedCount);
|
||
}
|
||
|
||
function togglePagingMemberExt(extStr) {
|
||
if (activePagingSelectedExts.has(extStr)) {
|
||
activePagingSelectedExts.delete(extStr);
|
||
} else {
|
||
activePagingSelectedExts.add(extStr);
|
||
}
|
||
renderPagingInteractiveBoxes();
|
||
}
|
||
|
||
function pagingTransferAllExtensions(includeAll) {
|
||
const allExts = appState.extensions || [];
|
||
if (includeAll) {
|
||
allExts.forEach(e => activePagingSelectedExts.add(String(e.extension)));
|
||
} else {
|
||
activePagingSelectedExts.clear();
|
||
}
|
||
renderPagingInteractiveBoxes();
|
||
}
|
||
|
||
function editPagingGroup(id) {
|
||
const pg = (appState.paging_groups || []).find(g => Number(g.id) === Number(id));
|
||
if (pg) openPagingModal(pg);
|
||
}
|
||
|
||
async function deletePagingGroup(id) {
|
||
const pg = (appState.paging_groups || []).find(g => Number(g.id) === Number(id));
|
||
const num = pg ? pg.extension : 'ce groupe';
|
||
if (!confirm(`Supprimer le groupe de téléavertissement ${num} ?`)) return;
|
||
try {
|
||
const res = await apiCall('delete_paging_group', { id: Number(id) });
|
||
showToast(res.message, 'success');
|
||
await loadState();
|
||
} catch (err) {
|
||
showToast(err.message, 'error');
|
||
}
|
||
}
|
||
|
||
// --- 3. Rendu & CRUD Trunks SIP (Configuration PJSIP Avancée) ---
|
||
function slugifyTrunkName(text) {
|
||
return String(text || '')
|
||
.toLowerCase()
|
||
.trim()
|
||
.replace(/^ivr-/, '')
|
||
.replace(/[^a-z0-9]+/g, '-')
|
||
.replace(/^-+|-+$/g, '') || 'default';
|
||
}
|
||
|
||
function renderTrunksTable() {
|
||
const tbody = document.getElementById('trunks-tbody');
|
||
if (!tbody) return;
|
||
const rows = appState.trunks || [];
|
||
const regs = appState.metrics?.asterisk?.registrations || {};
|
||
|
||
if (rows.length === 0) {
|
||
tbody.innerHTML = `<tr><td colspan="7" class="py-8 text-center text-slate-400 text-xs">Aucun Trunk SIP opérateur configuré. Cliquez sur « + Nouveau Trunk SIP ».</td></tr>`;
|
||
return;
|
||
}
|
||
|
||
tbody.innerHTML = rows.map(t => {
|
||
const slug = `trunk-${slugifyTrunkName(t.name)}`;
|
||
const regInfo = regs[slug];
|
||
const regMode = t.registration_mode || (Number(t.register) === 1 ? 'send' : 'none');
|
||
const isIpAuth = t.auth_mode === 'ip';
|
||
const wantsReg = regMode === 'send' && !isIpAuth;
|
||
|
||
let statusBadge = '';
|
||
if (Number(t.enabled ?? 1) === 0) {
|
||
statusBadge = `<span class="px-2.5 py-0.5 rounded-full text-xs font-medium bg-anthracite-950 text-slate-400 border border-anthracite-700">Désactivé</span>`;
|
||
} else if (regMode === 'receive') {
|
||
const contacts = appState.metrics?.asterisk?.contacts || [];
|
||
const isOnline = contacts.some(c =>
|
||
(c.extension === slug || c.extension === t.username || (t.username && c.uri && c.uri.includes(t.username)))
|
||
&& String(c.status || '').toLowerCase().includes('avail')
|
||
);
|
||
if (isOnline) {
|
||
statusBadge = `<span class="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-bold bg-emerald-500/20 text-emerald-300 border border-emerald-500/30"><i class="w-1.5 h-1.5 rounded-full bg-emerald-400 animate-pulse"></i>Passerelle En ligne</span>`;
|
||
} else {
|
||
statusBadge = `<span class="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-semibold bg-amber-500/20 text-amber-300 border border-amber-500/30">En attente passerelle (Receive)</span>`;
|
||
}
|
||
} else if (isIpAuth || regMode === 'none') {
|
||
statusBadge = `<span class="px-2.5 py-0.5 rounded-full text-xs font-semibold bg-purple-500/15 text-purple-300 border border-purple-500/30">Peering IP direct</span>`;
|
||
} else if (regInfo && /Registered/i.test(regInfo.status)) {
|
||
statusBadge = `<span class="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-bold bg-emerald-500/20 text-emerald-300 border border-emerald-500/30"><i class="w-1.5 h-1.5 rounded-full bg-emerald-400 animate-pulse"></i>Registered</span>`;
|
||
} else if (regInfo) {
|
||
statusBadge = `<span class="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-bold bg-amber-500/20 text-amber-300 border border-amber-500/30">${escapeHtml(regInfo.status)}</span>`;
|
||
} else {
|
||
statusBadge = `<span class="px-2.5 py-0.5 rounded-full text-xs font-medium bg-anthracite-950 text-slate-300 border border-anthracite-700">En attente Apply</span>`;
|
||
}
|
||
|
||
const routesArr = Array.isArray(t.did_routes) ? t.did_routes : Object.entries(t.did_routes || {}).map(([did, destination]) => ({ cid: 'Any', did, description: '', destination }));
|
||
const didCount = routesArr.length;
|
||
const blockCount = routesArr.filter(r => String(r.destination || '').startsWith('terminate-')).length;
|
||
const tcCount = routesArr.filter(r => Number(r.time_condition_enabled) === 1).length;
|
||
const paiBadges = [
|
||
Number(t.send_pai ?? 1) === 1 ? 'PAI' : null,
|
||
Number(t.send_rpid ?? 0) === 1 ? 'RPID' : null,
|
||
t.media_encryption && t.media_encryption !== 'no' ? t.media_encryption.toUpperCase() : null
|
||
].filter(Boolean).map(b => `<span class="px-1.5 py-0.5 rounded bg-indigo-500/20 text-indigo-300 text-[10px] font-mono">${b}</span>`).join(' ');
|
||
|
||
const mohSound = (appState.sounds || []).find(s => Number(s.id) === Number(t.moh_sound_id));
|
||
const mohBadge = mohSound
|
||
? `<span class="inline-flex items-center gap-1 px-1.5 py-0.5 rounded bg-amber-500/15 text-amber-300 border border-amber-500/30 text-[10px] font-semibold">🎵 ${escapeHtml(mohSound.name)}</span>`
|
||
: `<span class="text-[10px] text-slate-500">MoH : Globale</span>`;
|
||
|
||
return `
|
||
<tr class="hover:bg-anthracite-750/60 transition">
|
||
<td class="py-3.5 px-4">
|
||
<div class="font-semibold text-white">${escapeHtml(t.name)}</div>
|
||
<div class="text-[11px] font-mono text-slate-400">[${escapeHtml(slug)}]</div>
|
||
</td>
|
||
<td class="py-3.5 px-4">${statusBadge}</td>
|
||
<td class="py-3.5 px-4">
|
||
<div class="font-mono text-xs ${t.host ? 'text-indigo-300' : 'text-emerald-300'}">
|
||
${t.host ? escapeHtml(t.host) + ':' + Number(t.port || 5060) : 'Passerelle locale (Dynamique)'}
|
||
</div>
|
||
${t.outbound_proxy ? `<div class="text-[11px] font-mono text-slate-400 truncate max-w-[180px]">Proxy: ${escapeHtml(t.outbound_proxy)}</div>` : ''}
|
||
</td>
|
||
<td class="py-3.5 px-4">
|
||
<div class="font-mono text-xs text-slate-200">${escapeHtml(isIpAuth ? 'Auth par IP' : (t.username || '—'))}</div>
|
||
<div class="text-[11px] text-slate-400">${escapeHtml(t.from_domain || t.host)} ${paiBadges}</div>
|
||
</td>
|
||
<td class="py-3.5 px-4 text-xs">
|
||
<span class="px-2 py-0.5 rounded bg-anthracite-950 text-slate-200 uppercase font-mono border border-anthracite-700">${escapeHtml(t.transport)}</span>
|
||
<span class="ml-1 font-mono text-slate-300">${escapeHtml(t.codecs)}</span>
|
||
</td>
|
||
<td class="py-3.5 px-4">
|
||
<div class="font-mono text-xs text-emerald-300">→ ${escapeHtml(t.inbound_dest)}</div>
|
||
<div class="text-[11px] text-slate-400">
|
||
${t.cid_name_prefix ? `<span class="px-1.5 py-0.5 rounded bg-emerald-500/20 text-emerald-300 text-[10px] font-mono mr-1 font-semibold">CID: ${escapeHtml(t.cid_name_prefix)}</span>` : ''}
|
||
${didCount > 0 ? `<span class="text-indigo-300 font-semibold">${didCount} règle(s) entrante(s)</span>` : 'Tous numéros (Any)'}
|
||
${blockCount > 0 ? `<span class="ml-1 px-1.5 py-0.5 rounded bg-amber-500/20 text-amber-300 text-[10px] font-semibold">${blockCount} blocage(s) CID</span>` : ''}
|
||
${tcCount > 0 ? `<span class="ml-1 px-1.5 py-0.5 rounded bg-amber-500/20 text-amber-300 text-[10px] font-semibold">⏱️ ${tcCount} horaire(s)</span>` : ''}
|
||
</div>
|
||
<div class="mt-1">${mohBadge}</div>
|
||
</td>
|
||
<td class="py-3.5 px-4 text-right space-x-2">
|
||
${wantsReg ? `<button type="button" onclick="forceRegisterTrunk('${escapeHtml(slug)}-reg')" title="Forcer l'envoi immédiat d'un SIP REGISTER vers l'opérateur pour reprendre la ligne" class="px-2 py-1 rounded-lg bg-cyan-500/15 hover:bg-cyan-500/25 border border-cyan-500/30 text-xs font-semibold text-cyan-300 transition">🔄 Re-Register</button>` : ''}
|
||
<button type="button" onclick="editTrunk(${Number(t.id)}, 'inbound')" class="text-xs font-semibold text-emerald-300 hover:text-emerald-200">Routes Entrantes</button>
|
||
<button type="button" onclick="editTrunk(${Number(t.id)})" class="text-xs font-semibold text-indigo-300 hover:text-indigo-200">Modifier</button>
|
||
<button type="button" onclick="deleteTrunk(${Number(t.id)})" class="text-xs font-semibold text-red-400 hover:text-red-300">Supprimer</button>
|
||
</td>
|
||
</tr>
|
||
`;
|
||
}).join('');
|
||
}
|
||
|
||
async function forceRegisterTrunk(regName = '*all') {
|
||
try {
|
||
const res = await apiCall('force_register_trunk', { reg_name: regName });
|
||
showToast(res.message, 'success');
|
||
await loadState(true);
|
||
} catch (err) {
|
||
showToast(err.message, 'error');
|
||
}
|
||
}
|
||
|
||
function switchTrunkModalTab(subTab) {
|
||
document.querySelectorAll('.trunk-subpanel').forEach(p => p.classList.add('hidden'));
|
||
const target = document.getElementById(`trunk-panel-${subTab}`);
|
||
if (target) target.classList.remove('hidden');
|
||
|
||
document.querySelectorAll('.trunk-tab-btn').forEach(btn => {
|
||
if (btn.dataset.trunkTab === subTab) {
|
||
btn.className = 'trunk-tab-btn px-3.5 py-2 text-xs font-bold rounded-t-xl bg-indigo-600 text-white transition flex items-center gap-1.5';
|
||
} else {
|
||
btn.className = 'trunk-tab-btn px-3.5 py-2 text-xs font-semibold rounded-t-xl bg-anthracite-950 text-slate-300 hover:text-white transition flex items-center gap-1.5';
|
||
}
|
||
});
|
||
}
|
||
|
||
function toggleTrunkAuthFields(mode) {
|
||
const container = document.getElementById('trunk-auth-fields');
|
||
const form = document.getElementById('form-trunk');
|
||
if (!container || !form) return;
|
||
if (mode === 'ip') {
|
||
container.classList.add('opacity-40', 'pointer-events-none');
|
||
form.elements['register'].checked = false;
|
||
} else {
|
||
container.classList.remove('opacity-40', 'pointer-events-none');
|
||
}
|
||
}
|
||
|
||
function normalizeDestinationValue(rawVal) {
|
||
const exts = appState.extensions || [];
|
||
const grps = appState.groups || [];
|
||
const paging = appState.paging_groups || [];
|
||
let normVal = String(rawVal || '').trim();
|
||
if (!normVal) return 'ext-101';
|
||
if (normVal === 'busy') return 'terminate-busy';
|
||
if (normVal === 'congestion') return 'terminate-congestion';
|
||
if (normVal === 'zapateller') return 'terminate-zapateller';
|
||
if (normVal === 'hangup') return 'terminate-hangup';
|
||
if (/^\d+$/.test(normVal)) {
|
||
if (exts.some(e => String(e.extension) === normVal)) return `ext-${normVal}`;
|
||
if (grps.some(g => String(g.extension) === normVal)) return `grp-${normVal}`;
|
||
if (paging.some(p => String(p.extension) === normVal)) return `page-${normVal}`;
|
||
}
|
||
return normVal;
|
||
}
|
||
|
||
function buildDestinationOptionsHtml(selectedValue = 'ext-101') {
|
||
const exts = appState.extensions || [];
|
||
const grps = appState.groups || [];
|
||
const paging = appState.paging_groups || [];
|
||
const ivrs = appState.ivrs || [];
|
||
const sounds = appState.sounds || [];
|
||
const normVal = normalizeDestinationValue(selectedValue);
|
||
|
||
let html = '';
|
||
if (exts.length > 0) {
|
||
html += `<optgroup label="📱 Postes SIP (Extensions)">`;
|
||
exts.forEach(e => {
|
||
const val = `ext-${e.extension}`;
|
||
html += `<option value="${escapeHtml(val)}" ${normVal === val ? 'selected' : ''}>Poste: ${escapeHtml(e.extension)} — ${escapeHtml(e.name)}</option>`;
|
||
});
|
||
html += `</optgroup>`;
|
||
}
|
||
|
||
if (grps.length > 0) {
|
||
html += `<optgroup label="👥 Ring Groups (Groupes d'appel)">`;
|
||
grps.forEach(g => {
|
||
const val = `grp-${g.extension}`;
|
||
html += `<option value="${escapeHtml(val)}" ${normVal === val ? 'selected' : ''}>Ring Group: ${escapeHtml(g.extension)} — ${escapeHtml(g.name)}</option>`;
|
||
});
|
||
html += `</optgroup>`;
|
||
}
|
||
|
||
if (paging.length > 0) {
|
||
html += `<optgroup label="📢 Téléavertissement & Interphonie (Paging)">`;
|
||
paging.forEach(pg => {
|
||
const val = `page-${pg.extension}`;
|
||
html += `<option value="${escapeHtml(val)}" ${normVal === val ? 'selected' : ''}>Paging: ${escapeHtml(pg.extension)} — ${escapeHtml(pg.name)}</option>`;
|
||
});
|
||
html += `</optgroup>`;
|
||
}
|
||
|
||
if (ivrs.length > 0) {
|
||
html += `<optgroup label="🎙️ Serveurs Vocaux (SVI / IVR)">`;
|
||
ivrs.forEach(i => {
|
||
const val = i.name;
|
||
html += `<option value="${escapeHtml(val)}" ${normVal === val ? 'selected' : ''}>SVI / IVR: ${escapeHtml(i.name)} ${i.description ? '(' + escapeHtml(i.description) + ')' : ''}</option>`;
|
||
});
|
||
html += `</optgroup>`;
|
||
}
|
||
|
||
const vmExts = exts.filter(e => Number(e.voicemail_enabled) === 1);
|
||
const hasMissingVm = normVal.startsWith('vm-') && !vmExts.some(e => `vm-${e.extension}` === normVal);
|
||
if (vmExts.length > 0 || hasMissingVm) {
|
||
html += `<optgroup label="📬 Messagerie Vocale Directe (Répondeur)">`;
|
||
if (hasMissingVm) {
|
||
const missingExt = normVal.slice(3);
|
||
html += `<option value="${escapeHtml(normVal)}" selected>Répondeur: ${escapeHtml(missingExt)} (messagerie désactivée)</option>`;
|
||
}
|
||
vmExts.forEach(e => {
|
||
const val = `vm-${e.extension}`;
|
||
html += `<option value="${escapeHtml(val)}" ${normVal === val ? 'selected' : ''}>Répondeur: ${escapeHtml(e.extension)} — ${escapeHtml(e.name)}</option>`;
|
||
});
|
||
html += `</optgroup>`;
|
||
}
|
||
|
||
// Piège à Spammeurs / Musique d'attente en boucle continue (MoH)
|
||
const mohSounds = sounds.filter(s => s.usage_type === 'moh');
|
||
html += `<optgroup label="🎵 Piège à Spammeurs (Musique d'attente en boucle infinie)">`;
|
||
html += `<option value="moh-default" ${normVal === 'moh-default' ? 'selected' : ''}>🎵 Musique MoH générale par défaut (en boucle infinie)</option>`;
|
||
mohSounds.forEach(s => {
|
||
const val = `moh-${s.id}`;
|
||
html += `<option value="${escapeHtml(val)}" ${normVal === val ? 'selected' : ''}>🎵 Musique MoH dédiée : ${escapeHtml(s.name)} [${escapeHtml(s.site_category)}]</option>`;
|
||
});
|
||
html += `</optgroup>`;
|
||
|
||
const announcements = sounds.filter(s => s.usage_type !== 'moh');
|
||
if (announcements.length > 0) {
|
||
html += `<optgroup label="📢 Annonces Vocales (Diffuser 1 fois puis raccrocher)">`;
|
||
announcements.forEach(s => {
|
||
const val = `sound-${s.id}`;
|
||
html += `<option value="${escapeHtml(val)}" ${normVal === val ? 'selected' : ''}>Annonce : ${escapeHtml(s.name)} [${escapeHtml(s.site_category)}]</option>`;
|
||
});
|
||
html += `</optgroup>`;
|
||
|
||
html += `<optgroup label="👵 Piège Scammer / Fausse Voix en boucle infinie (Anti-Spam Tarpit)">`;
|
||
announcements.forEach(s => {
|
||
const val = `sound-loop-${s.id}`;
|
||
html += `<option value="${escapeHtml(val)}" ${normVal === val ? 'selected' : ''}>👵 Voix en boucle infinie (ex: vieille dame) : ${escapeHtml(s.name)}</option>`;
|
||
});
|
||
html += `</optgroup>`;
|
||
}
|
||
|
||
html += `
|
||
<optgroup label="🚫 Terminer l'appel (Terminate Call)">
|
||
<option value="terminate-hangup" ${normVal === 'terminate-hangup' || normVal === 'hangup' ? 'selected' : ''}>Raccrocher immédiatement (Hangup)</option>
|
||
<option value="terminate-busy" ${normVal === 'terminate-busy' || normVal === 'busy' ? 'selected' : ''}>Tonalité d'occupation (Busy)</option>
|
||
<option value="terminate-congestion" ${normVal === 'terminate-congestion' || normVal === 'congestion' ? 'selected' : ''}>Tonalité de congestion / réseau saturé (Congestion)</option>
|
||
<option value="terminate-zapateller" ${normVal === 'terminate-zapateller' || normVal === 'zapateller' ? 'selected' : ''}>Tonalité SIT Numéro non attribué (Anti-Spam / Robot)</option>
|
||
<option value="terminate-ringforever" ${normVal === 'terminate-ringforever' || normVal === 'ringforever' ? 'selected' : ''}>Faire sonner indéfiniment (Wait Ringing)</option>
|
||
</optgroup>
|
||
`;
|
||
|
||
return html;
|
||
}
|
||
|
||
function updateInboundRoutesBadge() {
|
||
const count = document.querySelectorAll('#trunk-did-container .trunk-did-row').length;
|
||
const badge = document.getElementById('trunk-inbound-rules-badge');
|
||
if (badge) badge.textContent = String(count);
|
||
}
|
||
|
||
function styleDestinationSelect(selectEl) {
|
||
if (!selectEl) return;
|
||
const val = selectEl.value || '';
|
||
if (val.startsWith('terminate-')) {
|
||
selectEl.className = 'trunk-did-dest w-full px-2.5 py-1.5 rounded-lg bg-amber-500/10 border border-amber-500/40 text-xs text-amber-300 font-semibold';
|
||
} else if (val.startsWith('moh-') || val.startsWith('sound-loop-')) {
|
||
selectEl.className = 'trunk-did-dest w-full px-2.5 py-1.5 rounded-lg bg-fuchsia-500/15 border border-fuchsia-500/40 text-xs text-fuchsia-300 font-semibold';
|
||
} else if (val.startsWith('grp-')) {
|
||
selectEl.className = 'trunk-did-dest w-full px-2.5 py-1.5 rounded-lg bg-cyan-500/10 border border-cyan-500/40 text-xs text-cyan-300 font-semibold';
|
||
} else if (val.startsWith('vm-')) {
|
||
selectEl.className = 'trunk-did-dest w-full px-2.5 py-1.5 rounded-lg bg-violet-500/15 border border-violet-500/40 text-xs text-violet-300 font-semibold';
|
||
} else {
|
||
selectEl.className = 'trunk-did-dest w-full px-2.5 py-1.5 rounded-lg bg-emerald-500/10 border border-emerald-500/40 text-xs text-emerald-300 font-semibold';
|
||
}
|
||
}
|
||
|
||
function addTrunkInboundRouteRow(cid = 'Any', did = 'Any', description = '', dest = 'ext-101', prepend = false, timeOpts = {}) {
|
||
const container = document.getElementById('trunk-did-container');
|
||
if (!container) return;
|
||
|
||
const tcEnabled = Boolean(timeOpts && timeOpts.time_condition_enabled);
|
||
const tcStart = (timeOpts && timeOpts.time_start) ? timeOpts.time_start : '08:30';
|
||
const tcFinish = (timeOpts && timeOpts.time_finish) ? timeOpts.time_finish : '18:00';
|
||
const tcWeekdays = (timeOpts && timeOpts.weekdays) ? timeOpts.weekdays : 'mon-fri';
|
||
const tcClosed = (timeOpts && timeOpts.time_dest_closed) ? timeOpts.time_dest_closed : 'terminate-hangup';
|
||
const tcCidPrefix = (timeOpts && timeOpts.cid_prefix) ? timeOpts.cid_prefix : '';
|
||
|
||
const row = document.createElement('div');
|
||
row.className = 'trunk-did-row border-b border-anthracite-700/60 transition hover:bg-anthracite-800/40';
|
||
|
||
row.innerHTML = `
|
||
<div class="grid grid-cols-12 gap-3 items-center px-4 py-2.5">
|
||
<div class="col-span-3">
|
||
<input type="text" placeholder="Any ou _0162XXXXXX" value="${escapeHtml(cid || 'Any')}"
|
||
class="trunk-did-cid w-full px-3 py-1.5 rounded-lg bg-anthracite-950 border border-anthracite-700 text-xs text-white font-mono">
|
||
<input type="hidden" value="${escapeHtml(did || 'Any')}" class="trunk-did-num">
|
||
</div>
|
||
<div class="col-span-3">
|
||
<input type="text" placeholder="ex: Ligne Standard" value="${escapeHtml(description)}"
|
||
class="trunk-did-desc w-full px-3 py-1.5 rounded-lg bg-anthracite-950 border border-anthracite-700 text-xs text-white">
|
||
<div class="time-badge-container mt-1"></div>
|
||
</div>
|
||
<div class="col-span-4">
|
||
<select class="trunk-did-dest w-full px-2.5 py-1.5 rounded-lg bg-anthracite-950 border border-anthracite-700 text-xs text-white"
|
||
onchange="styleDestinationSelect(this)">
|
||
${buildDestinationOptionsHtml(dest)}
|
||
</select>
|
||
</div>
|
||
<div class="col-span-2 flex items-center justify-end gap-1.5">
|
||
<button type="button" class="btn-toggle-time px-2 py-1.5 rounded-lg text-xs font-semibold border transition" title="Configurer les horaires d'ouverture (Mode Jour / Nuit)">
|
||
⏱️ <span class="hidden sm:inline">Horaires</span>
|
||
</button>
|
||
<button type="button" title="Dupliquer cette règle" class="btn-dup-route p-1.5 rounded-lg bg-anthracite-950 hover:bg-indigo-500/20 text-slate-300 hover:text-indigo-300 border border-anthracite-700 text-xs">⧉</button>
|
||
<button type="button" title="Supprimer cette règle" class="btn-del-route p-1.5 rounded-lg bg-anthracite-950 hover:bg-red-500/20 text-slate-300 hover:text-red-300 border border-anthracite-700 text-xs">🗑️</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Tiroir rétractable des conditions horaires (Mode Jour / Nuit) -->
|
||
<div class="time-drawer hidden px-4 pb-3.5 pt-2 border-t border-anthracite-700/50 bg-anthracite-950/80 space-y-3">
|
||
<div class="flex flex-wrap items-center justify-between gap-2">
|
||
<label class="flex items-center gap-2 cursor-pointer">
|
||
<input type="checkbox" class="trunk-tc-enabled rounded bg-anthracite-900 border-anthracite-600 text-amber-500 focus:ring-0" ${tcEnabled ? 'checked' : ''}>
|
||
<span class="text-xs font-bold text-amber-300">Activer la condition horaire (Mode Jour / Nuit)</span>
|
||
</label>
|
||
<span class="text-[11px] text-slate-400">Si actif, l'appel bascule automatiquement selon l'heure de réception</span>
|
||
</div>
|
||
|
||
<div class="time-fields-grid grid grid-cols-1 sm:grid-cols-12 gap-3 items-center ${tcEnabled ? '' : 'opacity-50 pointer-events-none'}">
|
||
<div class="sm:col-span-3">
|
||
<label class="block text-[10px] uppercase font-bold text-slate-400 mb-1">Heures d'ouverture</label>
|
||
<div class="flex items-center gap-1.5">
|
||
<input type="time" class="trunk-tc-start px-2 py-1 rounded bg-anthracite-900 border border-anthracite-700 text-xs text-white" value="${escapeHtml(tcStart)}">
|
||
<span class="text-slate-400 text-xs">à</span>
|
||
<input type="time" class="trunk-tc-finish px-2 py-1 rounded bg-anthracite-900 border border-anthracite-700 text-xs text-white" value="${escapeHtml(tcFinish)}">
|
||
</div>
|
||
</div>
|
||
|
||
<div class="sm:col-span-4">
|
||
<label class="block text-[10px] uppercase font-bold text-slate-400 mb-1">Jours actifs</label>
|
||
<select class="trunk-tc-weekdays w-full px-2 py-1 rounded bg-anthracite-900 border border-anthracite-700 text-xs text-white">
|
||
<option value="mon-fri" ${tcWeekdays === 'mon-fri' ? 'selected' : ''}>Du Lundi au Vendredi (Semaine)</option>
|
||
<option value="mon-sat" ${tcWeekdays === 'mon-sat' ? 'selected' : ''}>Du Lundi au Samedi</option>
|
||
<option value="*" ${tcWeekdays === '*' ? 'selected' : ''}>Tous les jours (7j/7)</option>
|
||
<option value="sat-sun" ${tcWeekdays === 'sat-sun' ? 'selected' : ''}>Week-end uniquement (Sam-Dim)</option>
|
||
</select>
|
||
</div>
|
||
|
||
<div class="sm:col-span-5">
|
||
<label class="block text-[10px] uppercase font-bold text-red-400 mb-1">Destination si FERMÉ (Hors horaires)</label>
|
||
<select class="trunk-tc-closed-dest w-full px-2.5 py-1 rounded bg-anthracite-900 border border-anthracite-700 text-xs text-white">
|
||
${buildDestinationOptionsHtml(tcClosed)}
|
||
</select>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="flex flex-wrap items-center gap-2 pt-2 border-t border-anthracite-800 text-xs">
|
||
<label class="text-[11px] font-semibold text-slate-300 flex items-center gap-1">
|
||
<span>🏷️ Préfixe CID spécifique à cette règle :</span>
|
||
</label>
|
||
<input type="text" placeholder="ex: [Ligne-1] (vide = hériter du Trunk)" value="${escapeHtml(tcCidPrefix)}"
|
||
class="trunk-did-prefix px-2.5 py-1 rounded bg-anthracite-900 border border-anthracite-700 text-xs text-white font-mono w-56 focus:outline-none focus:border-indigo-500">
|
||
<span class="text-[10px] text-slate-400">Ajouté devant le nom de l'appelant sur le téléphone (laisser vide pour utiliser le préfixe général du Trunk).</span>
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
const selectEl = row.querySelector('.trunk-did-dest');
|
||
styleDestinationSelect(selectEl);
|
||
|
||
const tcCheck = row.querySelector('.trunk-tc-enabled');
|
||
const timeDrawer = row.querySelector('.time-drawer');
|
||
const timeFields = row.querySelector('.time-fields-grid');
|
||
const toggleBtn = row.querySelector('.btn-toggle-time');
|
||
const badgeContainer = row.querySelector('.time-badge-container');
|
||
const tcStartInput = row.querySelector('.trunk-tc-start');
|
||
const tcFinishInput = row.querySelector('.trunk-tc-finish');
|
||
const tcWeekdaysSelect = row.querySelector('.trunk-tc-weekdays');
|
||
|
||
function updateTimeUi() {
|
||
const isEnabled = tcCheck.checked;
|
||
timeFields.classList.toggle('opacity-50', !isEnabled);
|
||
timeFields.classList.toggle('pointer-events-none', !isEnabled);
|
||
|
||
if (isEnabled) {
|
||
toggleBtn.className = 'btn-toggle-time px-2 py-1.5 rounded-lg text-xs font-semibold bg-amber-500/20 text-amber-300 border border-amber-500/40 transition shadow-sm';
|
||
const daysLabel = tcWeekdaysSelect.value === 'mon-fri' ? 'Lun-Ven' : (tcWeekdaysSelect.value === 'mon-sat' ? 'Lun-Sam' : (tcWeekdaysSelect.value === '*' ? '7j/7' : 'Sam-Dim'));
|
||
badgeContainer.innerHTML = `<span class="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[10px] font-semibold bg-amber-500/15 text-amber-300 border border-amber-500/30">⏱️ ${escapeHtml(tcStartInput.value)}-${escapeHtml(tcFinishInput.value)} (${escapeHtml(daysLabel)})</span>`;
|
||
} else {
|
||
toggleBtn.className = 'btn-toggle-time px-2 py-1.5 rounded-lg text-xs font-semibold bg-anthracite-950 hover:bg-anthracite-900 text-slate-400 hover:text-white border border-anthracite-700 transition';
|
||
badgeContainer.innerHTML = '';
|
||
}
|
||
}
|
||
|
||
tcCheck.addEventListener('change', updateTimeUi);
|
||
tcStartInput.addEventListener('change', updateTimeUi);
|
||
tcFinishInput.addEventListener('change', updateTimeUi);
|
||
tcWeekdaysSelect.addEventListener('change', updateTimeUi);
|
||
|
||
toggleBtn.addEventListener('click', () => {
|
||
timeDrawer.classList.toggle('hidden');
|
||
});
|
||
|
||
updateTimeUi();
|
||
|
||
row.querySelector('.btn-del-route').addEventListener('click', () => {
|
||
row.remove();
|
||
updateInboundRoutesBadge();
|
||
});
|
||
|
||
row.querySelector('.btn-dup-route').addEventListener('click', () => {
|
||
const curCid = row.querySelector('.trunk-did-cid').value;
|
||
const curDid = row.querySelector('.trunk-did-num').value;
|
||
const curDesc = row.querySelector('.trunk-did-desc').value + ' (Copie)';
|
||
const curDest = row.querySelector('.trunk-did-dest').value;
|
||
const curTcOpts = {
|
||
time_condition_enabled: tcCheck.checked,
|
||
time_start: tcStartInput.value,
|
||
time_finish: tcFinishInput.value,
|
||
weekdays: tcWeekdaysSelect.value,
|
||
time_dest_closed: row.querySelector('.trunk-tc-closed-dest').value
|
||
};
|
||
addTrunkInboundRouteRow(curCid, curDid, curDesc, curDest, false, curTcOpts);
|
||
});
|
||
|
||
if (prepend && container.firstChild) {
|
||
container.insertBefore(row, container.firstChild);
|
||
} else {
|
||
container.appendChild(row);
|
||
}
|
||
updateInboundRoutesBadge();
|
||
}
|
||
|
||
function addArcepSpamBlockPreset() {
|
||
const spamRanges = [
|
||
{ cid: '_0162XXXXXX', desc: 'Block 0162 Démarchage' },
|
||
{ cid: '_0163XXXXXX', desc: 'Block 0163 Démarchage' },
|
||
{ cid: '_0270XXXXXX', desc: 'Block 0270 Démarchage' },
|
||
{ cid: '_0271XXXXXX', desc: 'Block 0271 Démarchage' },
|
||
{ cid: '_0377XXXXXX', desc: 'Block 0377 Démarchage' },
|
||
{ cid: '_0378XXXXXX', desc: 'Block 0378 Démarchage' },
|
||
{ cid: '_0424XXXXXX', desc: 'Block 0424 Démarchage' },
|
||
{ cid: '_0425XXXXXX', desc: 'Block 0425 Démarchage' },
|
||
{ cid: '_0568XXXXXX', desc: 'Block 0568 Démarchage' },
|
||
{ cid: '_0569XXXXXX', desc: 'Block 0569 Démarchage' },
|
||
{ cid: '_0948XXXXXX', desc: 'Block 0948 Démarchage' },
|
||
];
|
||
|
||
const existingCids = new Set(
|
||
Array.from(document.querySelectorAll('#trunk-did-container .trunk-did-cid')).map(el => el.value.trim())
|
||
);
|
||
|
||
let added = 0;
|
||
spamRanges.forEach(item => {
|
||
if (!existingCids.has(item.cid)) {
|
||
addTrunkInboundRouteRow(item.cid, 'Any', item.desc, 'terminate-busy', false);
|
||
added++;
|
||
}
|
||
});
|
||
|
||
if (added > 0) {
|
||
showToast(`${added} plages de démarchage ajoutées en mode « Terminate Call: Occupation » !`, 'success');
|
||
} else {
|
||
showToast('Toutes les plages anti-démarchage sont déjà présentes dans la liste.', 'info');
|
||
}
|
||
}
|
||
|
||
function filterInboundRoutesRows(query) {
|
||
const q = String(query || '').toLowerCase().trim();
|
||
document.querySelectorAll('#trunk-did-container .trunk-did-row').forEach(row => {
|
||
const cid = row.querySelector('.trunk-did-cid')?.value.toLowerCase() || '';
|
||
const did = row.querySelector('.trunk-did-num')?.value.toLowerCase() || '';
|
||
const desc = row.querySelector('.trunk-did-desc')?.value.toLowerCase() || '';
|
||
const dest = row.querySelector('.trunk-did-dest')?.selectedOptions?.[0]?.textContent?.toLowerCase() || '';
|
||
const match = !q || cid.includes(q) || did.includes(q) || desc.includes(q) || dest.includes(q);
|
||
row.classList.toggle('hidden', !match);
|
||
});
|
||
}
|
||
|
||
function syncTrunkHostDomain(hostVal) {
|
||
const form = document.getElementById('form-trunk');
|
||
if (!form) return;
|
||
// Laisse from_domain vide pour qu'il prenne automatiquement la valeur exacte de host
|
||
form.elements['from_domain'].value = '';
|
||
}
|
||
|
||
function setTrunkDialOptionsPreset(val) {
|
||
const form = document.getElementById('form-trunk');
|
||
if (form && form.elements['dial_options']) {
|
||
form.elements['dial_options'].value = val;
|
||
}
|
||
}
|
||
|
||
function populateInboundDestDropdown(selectedValue = '101') {
|
||
const select = document.getElementById('trunk-inbound-select');
|
||
const form = document.getElementById('form-trunk');
|
||
if (!select || !form) return;
|
||
|
||
select.innerHTML = buildDestinationOptionsHtml(selectedValue);
|
||
form.elements['inbound_dest'].value = select.value;
|
||
}
|
||
|
||
function syncInboundDestSelect(val) {
|
||
const form = document.getElementById('form-trunk');
|
||
if (form) {
|
||
form.elements['inbound_dest'].value = val;
|
||
}
|
||
}
|
||
|
||
function toggleFreePbxImporter() {
|
||
const box = document.getElementById('freepbx-importer-box');
|
||
if (box) box.classList.toggle('hidden');
|
||
}
|
||
|
||
function parseFreePbxConfig() {
|
||
const raw = (document.getElementById('freepbx-paste-input')?.value || '').trim();
|
||
const form = document.getElementById('form-trunk');
|
||
if (!raw || !form) return;
|
||
|
||
// 1. Test format Register String : user:secret@host:port/contact
|
||
const regMatch = raw.match(/^(?:sip:)?([^:@\s]+):([^@\s]+)@([^:\/\s]+)(?::(\d+))?(?:\/(\S+))?$/i);
|
||
if (regMatch) {
|
||
const [, user, secret, host, port, contact] = regMatch;
|
||
if (!form.elements['name'].value) form.elements['name'].value = host.split('.')[0] + '-trunk';
|
||
form.elements['host'].value = host;
|
||
form.elements['port'].value = port || 5060;
|
||
form.elements['username'].value = user;
|
||
form.elements['secret'].value = secret;
|
||
form.elements['from_domain'].value = '';
|
||
if (contact) form.elements['contact_user'].value = contact;
|
||
form.elements['register'].checked = true;
|
||
toggleFreePbxImporter();
|
||
showToast('Register String FreePBX convertie et appliquée !', 'success');
|
||
return;
|
||
}
|
||
|
||
// 2. Format PEER Details (clé=valeur)
|
||
const lines = raw.split(/\r?\n/);
|
||
let found = 0;
|
||
lines.forEach(line => {
|
||
const m = line.match(/^\s*([a-zA-Z0-9_]+)\s*=\s*(.+?)\s*$/);
|
||
if (!m) return;
|
||
const key = m[1].toLowerCase();
|
||
const val = m[2].trim();
|
||
found++;
|
||
|
||
if (key === 'host') form.elements['host'].value = val;
|
||
else if (key === 'port') form.elements['port'].value = val;
|
||
else if (key === 'username' || key === 'defaultuser') form.elements['username'].value = val;
|
||
else if (key === 'authuser' || key === 'auth') form.elements['auth_username'].value = val;
|
||
else if (key === 'secret' || key === 'password') form.elements['secret'].value = val;
|
||
else if (key === 'fromuser') form.elements['from_user'].value = val;
|
||
else if (key === 'fromdomain') form.elements['from_domain'].value = val;
|
||
else if (key === 'context') {
|
||
if (val.includes('toheader')) form.elements['context'].value = 'from-pstn-toheader';
|
||
else form.elements['context'].value = 'from-external';
|
||
}
|
||
else if (key === 'dialopts' || key === 'dial_options' || key === 'asterisk_trunk_dial_options') form.elements['dial_options'].value = val;
|
||
else if (key === 'outboundproxy') form.elements['outbound_proxy'].value = val;
|
||
});
|
||
|
||
if (found > 0) {
|
||
if (!form.elements['name'].value && form.elements['host'].value) {
|
||
form.elements['name'].value = form.elements['host'].value.split('.')[0] + '-trunk';
|
||
}
|
||
toggleFreePbxImporter();
|
||
showToast(`${found} paramètres FreePBX convertis vers PJSIP !`, 'success');
|
||
} else {
|
||
showToast('Format non reconnu. Collez user:pass@host ou host=...', 'error');
|
||
}
|
||
}
|
||
|
||
function onTrunkRegistrationModeChange(mode) {
|
||
const form = document.getElementById('form-trunk');
|
||
if (!form) return;
|
||
|
||
const hostInput = form.elements['host'];
|
||
const hostLabel = document.getElementById('trunk-host-label');
|
||
const hostHint = document.getElementById('trunk-host-hint');
|
||
const receiveGuide = document.getElementById('trunk-receive-guide');
|
||
const regNotice = document.getElementById('trunk-reg-outbound-notice');
|
||
|
||
if (mode === 'receive') {
|
||
if (hostInput) {
|
||
hostInput.required = false;
|
||
hostInput.placeholder = 'Optionnel (vide = IP découverte dynamiquement au REGISTER de la passerelle)';
|
||
}
|
||
if (hostLabel) {
|
||
hostLabel.innerHTML = 'Serveur SIP / IP Passerelle <span class="text-emerald-400 font-semibold">(Optionnel en mode Réception)</span>';
|
||
}
|
||
hostHint?.classList.remove('hidden');
|
||
receiveGuide?.classList.remove('hidden');
|
||
regNotice?.classList.remove('hidden');
|
||
if (form.elements['register']) form.elements['register'].checked = false;
|
||
if (form.elements['line_support']) form.elements['line_support'].checked = false;
|
||
} else if (mode === 'none') {
|
||
if (hostInput) {
|
||
hostInput.required = true;
|
||
hostInput.placeholder = 'ex: 192.168.1.250 ou sbc.direct.net';
|
||
}
|
||
if (hostLabel) {
|
||
hostLabel.innerHTML = 'Serveur SIP / SBC / Passerelle * <span class="text-slate-400">(Host / IP)</span>';
|
||
}
|
||
hostHint?.classList.add('hidden');
|
||
receiveGuide?.classList.add('hidden');
|
||
regNotice?.classList.remove('hidden');
|
||
if (form.elements['register']) form.elements['register'].checked = false;
|
||
} else {
|
||
// mode === 'send'
|
||
if (hostInput) {
|
||
hostInput.required = true;
|
||
hostInput.placeholder = 'ex: sbc.operateur.net ou siptrunk.ovh.net';
|
||
}
|
||
if (hostLabel) {
|
||
hostLabel.innerHTML = 'Serveur SIP / SBC / Passerelle * <span class="text-slate-400">(Host / IP)</span>';
|
||
}
|
||
hostHint?.classList.add('hidden');
|
||
receiveGuide?.classList.add('hidden');
|
||
regNotice?.classList.add('hidden');
|
||
if (form.elements['register']) form.elements['register'].checked = true;
|
||
}
|
||
}
|
||
|
||
function applyTrunkArchitecture(arch) {
|
||
const form = document.getElementById('form-trunk');
|
||
if (!form) return;
|
||
|
||
const architectures = {
|
||
register_auth: {
|
||
label: 'Trunk avec Enregistrement (REGISTER + Auth)',
|
||
port: 5060,
|
||
transport: 'udp',
|
||
auth_mode: 'userpass',
|
||
registration_mode: 'send',
|
||
register: true,
|
||
line_support: true,
|
||
media_encryption: 'no',
|
||
nat_symmetric: true,
|
||
qualify_frequency: 60
|
||
},
|
||
gateway_receive: {
|
||
label: 'Passerelle FXO / Ligne Box (Grandstream HT813, Cisco SPA, AudioCodes, ATA)',
|
||
port: 5060,
|
||
transport: 'udp',
|
||
auth_mode: 'userpass',
|
||
registration_mode: 'receive',
|
||
register: false,
|
||
line_support: false,
|
||
media_encryption: 'no',
|
||
nat_symmetric: true,
|
||
qualify_frequency: 60
|
||
},
|
||
ip_peering: {
|
||
label: 'Peering IP / SBC / Passerelle (Sans REGISTER)',
|
||
port: 5060,
|
||
transport: 'udp',
|
||
auth_mode: 'ip',
|
||
registration_mode: 'none',
|
||
register: false,
|
||
line_support: false,
|
||
media_encryption: 'no',
|
||
nat_symmetric: true,
|
||
qualify_frequency: 30
|
||
},
|
||
tls_srtp: {
|
||
label: 'Trunk Sécurisé (TLS 5061 + SRTP SDES)',
|
||
port: 5061,
|
||
transport: 'tls',
|
||
auth_mode: 'userpass',
|
||
registration_mode: 'send',
|
||
register: true,
|
||
line_support: true,
|
||
media_encryption: 'sdes',
|
||
nat_symmetric: true,
|
||
qualify_frequency: 60
|
||
}
|
||
};
|
||
|
||
const a = architectures[arch];
|
||
if (!a) return;
|
||
|
||
// Ne jamais écraser le nom, l'hôte, les identifiants ou le from_domain de l'utilisateur !
|
||
form.elements['port'].value = a.port;
|
||
form.elements['transport'].value = a.transport;
|
||
form.querySelectorAll('input[name="auth_mode"]').forEach(r => { r.checked = (r.value === a.auth_mode); });
|
||
toggleTrunkAuthFields(a.auth_mode);
|
||
|
||
if (a.registration_mode) {
|
||
form.querySelectorAll('input[name="registration_mode"]').forEach(r => {
|
||
r.checked = (r.value === a.registration_mode);
|
||
});
|
||
onTrunkRegistrationModeChange(a.registration_mode);
|
||
}
|
||
|
||
form.elements['register'].checked = a.register;
|
||
form.elements['line_support'].checked = a.line_support;
|
||
form.elements['media_encryption'].value = a.media_encryption;
|
||
form.elements['nat_symmetric'].checked = a.nat_symmetric;
|
||
form.elements['qualify_frequency'].value = a.qualify_frequency;
|
||
|
||
showToast(`Architecture « ${a.label} » appliquée sans écraser votre serveur ni vos identifiants.`, 'info');
|
||
}
|
||
|
||
function openTrunkModal(trunk = null) {
|
||
const form = document.getElementById('form-trunk');
|
||
form.reset();
|
||
switchTrunkModalTab('general');
|
||
document.getElementById('freepbx-importer-box')?.classList.add('hidden');
|
||
document.getElementById('modal-trunk-title').textContent = trunk ? `Modifier le Trunk ${trunk.name}` : 'Nouveau Trunk SIP / PJSIP Universel';
|
||
|
||
form.elements['id'].value = trunk ? trunk.id : '';
|
||
form.elements['name'].value = trunk ? trunk.name : '';
|
||
form.elements['host'].value = trunk ? (trunk.host ?? '') : '';
|
||
form.elements['port'].value = trunk ? (trunk.port ?? 5060) : 5060;
|
||
form.elements['outbound_proxy'].value = trunk ? (trunk.outbound_proxy ?? '') : '';
|
||
form.elements['transport'].value = trunk ? (trunk.transport ?? 'udp') : 'udp';
|
||
|
||
const regMode = trunk ? (trunk.registration_mode || (Number(trunk.register) === 1 ? 'send' : 'none')) : 'send';
|
||
form.querySelectorAll('input[name="registration_mode"]').forEach(r => {
|
||
r.checked = (r.value === regMode);
|
||
});
|
||
onTrunkRegistrationModeChange(regMode);
|
||
form.elements['transport'].value = trunk ? (trunk.transport ?? 'udp') : 'udp';
|
||
|
||
const authMode = trunk ? (trunk.auth_mode || 'userpass') : 'userpass';
|
||
form.querySelectorAll('input[name="auth_mode"]').forEach(r => {
|
||
r.checked = (r.value === authMode);
|
||
});
|
||
toggleTrunkAuthFields(authMode);
|
||
|
||
form.elements['username'].value = trunk ? (trunk.username ?? '') : '';
|
||
form.elements['auth_username'].value = trunk ? (trunk.auth_username ?? '') : '';
|
||
form.elements['secret'].value = trunk ? (trunk.secret ?? '') : '';
|
||
form.elements['enabled'].checked = trunk ? Number(trunk.enabled ?? 1) === 1 : true;
|
||
|
||
const uInput = form.elements['username'];
|
||
const updateGuide = () => {
|
||
const val = uInput.value.trim() || 'Même valeur que Username';
|
||
const gUser = document.getElementById('ht813-guide-user');
|
||
const gAuth = document.getElementById('ht813-guide-auth');
|
||
if (gUser) gUser.textContent = val;
|
||
if (gAuth) gAuth.textContent = val;
|
||
};
|
||
uInput.oninput = updateGuide;
|
||
updateGuide();
|
||
|
||
// Onglet 2 : Registration & Identité SIP
|
||
form.elements['register'].checked = trunk ? Number(trunk.register ?? 1) === 1 : true;
|
||
form.elements['line_support'].checked = trunk ? Number(trunk.line_support ?? 1) === 1 : true;
|
||
form.elements['contact_user'].value = trunk ? (trunk.contact_user ?? '') : '';
|
||
form.elements['expiration'].value = trunk ? (trunk.expiration ?? 3600) : 3600;
|
||
form.elements['retry_interval'].value = trunk ? (trunk.retry_interval ?? 60) : 60;
|
||
form.elements['max_retries'].value = trunk ? (trunk.max_retries ?? 10000) : 10000;
|
||
form.elements['forbidden_retry_interval'].value = trunk ? (trunk.forbidden_retry_interval ?? 600) : 600;
|
||
form.elements['fatal_retry_interval'].value = trunk ? (trunk.fatal_retry_interval ?? 0) : 0;
|
||
form.elements['server_uri'].value = trunk ? (trunk.server_uri ?? '') : '';
|
||
form.elements['client_uri'].value = trunk ? (trunk.client_uri ?? '') : '';
|
||
form.elements['from_user'].value = trunk ? (trunk.from_user ?? '') : '';
|
||
form.elements['from_domain'].value = trunk ? (trunk.from_domain ?? '') : '';
|
||
form.elements['outbound_callerid'].value = trunk ? (trunk.outbound_callerid ?? '') : '';
|
||
form.elements['send_pai'].checked = trunk ? Number(trunk.send_pai ?? 1) === 1 : true;
|
||
form.elements['send_rpid'].checked = trunk ? Number(trunk.send_rpid ?? 0) === 1 : false;
|
||
form.elements['send_diversion'].checked = trunk ? Number(trunk.send_diversion ?? 1) === 1 : true;
|
||
form.elements['trust_id_inbound'].checked = trunk ? Number(trunk.trust_id_inbound ?? 1) === 1 : true;
|
||
form.elements['trust_id_outbound'].checked = trunk ? Number(trunk.trust_id_outbound ?? 1) === 1 : true;
|
||
|
||
// Onglet 3 : Codecs, NAT, Timers, T.38 & Identify
|
||
form.elements['codecs'].value = trunk ? (trunk.codecs || 'alaw,ulaw,g722') : 'alaw,ulaw,g722';
|
||
form.elements['dtmf_mode'].value = trunk ? (trunk.dtmf_mode || 'rfc4733') : 'rfc4733';
|
||
form.elements['media_encryption'].value = trunk ? (trunk.media_encryption || 'no') : 'no';
|
||
form.elements['qualify_frequency'].value = trunk ? (trunk.qualify_frequency ?? 60) : 60;
|
||
form.elements['rtp_keepalive'].value = trunk ? (trunk.rtp_keepalive ?? 15) : 15;
|
||
form.elements['max_channels'].value = trunk ? (trunk.max_channels ?? 0) : 0;
|
||
form.elements['timers'].value = trunk ? (trunk.timers || 'yes') : 'yes';
|
||
form.elements['t38_udptl'].value = trunk ? (trunk.t38_udptl || 'no') : 'no';
|
||
form.elements['language'].value = trunk ? (trunk.language || 'fr') : 'fr';
|
||
form.elements['nat_symmetric'].checked = trunk ? Number(trunk.nat_symmetric ?? 1) === 1 : true;
|
||
form.elements['direct_media'].checked = trunk ? Number(trunk.direct_media ?? 0) === 1 : false;
|
||
form.elements['inband_progress'].checked = trunk ? Number(trunk.inband_progress ?? 0) === 1 : false;
|
||
form.elements['fax_detect'].checked = trunk ? Number(trunk.fax_detect ?? 0) === 1 : false;
|
||
form.elements['match_ips'].value = trunk ? (trunk.match_ips ?? '') : '';
|
||
|
||
// Contexte & Destination entrante (Totalement libres + assistés)
|
||
form.elements['context'].value = trunk ? (trunk.context || 'from-pstn-toheader') : 'from-pstn-toheader';
|
||
if (form.elements['cid_name_prefix']) {
|
||
form.elements['cid_name_prefix'].value = trunk ? (trunk.cid_name_prefix || '') : '';
|
||
}
|
||
if (form.elements['dial_options']) {
|
||
form.elements['dial_options'].value = trunk ? (trunk.dial_options || 'tTR') : 'tTR';
|
||
}
|
||
populateInboundDestDropdown(trunk ? (trunk.inbound_dest || '101') : '101');
|
||
|
||
// Musique d'attente (MoH) spécifique à ce Trunk
|
||
const mohSelect = document.getElementById('trunk-moh-sound-id');
|
||
if (mohSelect) {
|
||
const mohSounds = (appState.sounds || []).filter(s => s.usage_type === 'moh');
|
||
const curMohId = trunk ? Number(trunk.moh_sound_id || 0) : 0;
|
||
let opts = `<option value="0" ${curMohId === 0 ? 'selected' : ''}>🌐 Hériter de la musique générale par défaut</option>`;
|
||
mohSounds.forEach(s => {
|
||
const isSel = (Number(s.id) === curMohId) ? 'selected' : '';
|
||
opts += `<option value="${s.id}" ${isSel}>🎵 ${escapeHtml(s.name)} (${escapeHtml(s.site_category || 'Global')})</option>`;
|
||
});
|
||
mohSelect.innerHTML = opts;
|
||
}
|
||
|
||
// Onglet 4 : Routage SDA & Directives PJSIP Natives ([endpoint], [registration], [aor])
|
||
form.elements['outbound_prefix'].value = trunk ? (trunk.outbound_prefix ?? '') : '';
|
||
form.elements['custom_endpoint_params'].value = trunk ? (trunk.custom_endpoint_params ?? '') : '';
|
||
form.elements['custom_reg_params'].value = trunk ? (trunk.custom_reg_params ?? '') : '';
|
||
form.elements['custom_aor_params'].value = trunk ? (trunk.custom_aor_params ?? '') : '';
|
||
|
||
const filterInput = document.getElementById('inbound-routes-filter');
|
||
if (filterInput) filterInput.value = '';
|
||
|
||
const didContainer = document.getElementById('trunk-did-container');
|
||
if (didContainer) {
|
||
didContainer.innerHTML = '';
|
||
const rawRoutes = trunk && trunk.did_routes ? trunk.did_routes : [];
|
||
let routesList = [];
|
||
|
||
if (Array.isArray(rawRoutes)) {
|
||
routesList = rawRoutes;
|
||
} else if (typeof rawRoutes === 'object' && rawRoutes !== null) {
|
||
routesList = Object.entries(rawRoutes).map(([did, destination]) => ({
|
||
cid: 'Any',
|
||
did,
|
||
description: `SDA ${did}`,
|
||
destination
|
||
}));
|
||
}
|
||
|
||
if (routesList.length === 0) {
|
||
const defDest = trunk ? (trunk.inbound_dest || 'ext-101') : 'ext-101';
|
||
const defDesc = trunk ? `Ligne ${trunk.name}` : 'Route Principale';
|
||
addTrunkInboundRouteRow('Any', 'Any', defDesc, defDest, false);
|
||
} else {
|
||
routesList.forEach(r => {
|
||
addTrunkInboundRouteRow(
|
||
r.cid || 'Any',
|
||
r.did || 'Any',
|
||
r.description || '',
|
||
r.destination || 'ext-101',
|
||
false,
|
||
{
|
||
time_condition_enabled: Boolean(Number(r.time_condition_enabled)),
|
||
time_start: r.time_start || '08:30',
|
||
time_finish: r.time_finish || '18:00',
|
||
weekdays: r.weekdays || 'mon-fri',
|
||
time_dest_closed: r.time_dest_closed || 'terminate-hangup',
|
||
cid_prefix: r.cid_prefix || ''
|
||
}
|
||
);
|
||
});
|
||
}
|
||
}
|
||
|
||
openModal('modal-trunk');
|
||
}
|
||
|
||
function editTrunk(id, initialTab = 'general') {
|
||
const trunk = (appState.trunks || []).find(t => Number(t.id) === Number(id));
|
||
if (trunk) {
|
||
openTrunkModal(trunk);
|
||
if (initialTab && initialTab !== 'general') {
|
||
switchTrunkModalTab(initialTab);
|
||
}
|
||
}
|
||
}
|
||
|
||
async function deleteTrunk(id, optName = null) {
|
||
const t = (appState.trunks || []).find(tr => Number(tr.id) === Number(id));
|
||
const name = optName || (t ? t.name : 'ce trunk');
|
||
if (!confirm(`Supprimer le Trunk SIP ${name} ?`)) return;
|
||
try {
|
||
const res = await apiCall('delete_trunk', { id: Number(id) });
|
||
showToast(res.message, 'success');
|
||
await loadState();
|
||
} catch (err) {
|
||
showToast(err.message, 'error');
|
||
}
|
||
}
|
||
|
||
// --- 3B. Rendu & CRUD Routes Sortantes (Outbound Routes & Failover Trunks) ---
|
||
function renderOutboundRoutesTable() {
|
||
const tbody = document.getElementById('outbound-routes-tbody');
|
||
if (!tbody) return;
|
||
const routes = appState.outbound_routes || [];
|
||
|
||
if (routes.length === 0) {
|
||
tbody.innerHTML = `<tr><td colspan="7" class="py-8 text-center text-slate-400 text-xs">Aucune route sortante configurée. Cliquez sur « + Ajout route sortante ».</td></tr>`;
|
||
return;
|
||
}
|
||
|
||
tbody.innerHTML = routes.map((r, idx) => {
|
||
const trunkSeq = Array.isArray(r.trunk_sequence) ? r.trunk_sequence : [];
|
||
const trunkBadges = trunkSeq.length > 0
|
||
? trunkSeq.map((t, tIdx) => `
|
||
<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded-lg ${tIdx === 0 ? 'bg-indigo-500/20 text-indigo-200 border border-indigo-500/30 font-bold' : 'bg-anthracite-950 text-slate-300 border border-anthracite-700'} text-xs font-mono">
|
||
<span class="text-[10px] text-slate-400">${tIdx + 1}.</span>${escapeHtml(t)}
|
||
</span>
|
||
`).join(' <span class="text-slate-500 text-xs">→</span> ')
|
||
: `<span class="text-amber-400 text-xs font-semibold">⚠️ Aucun Trunk associé</span>`;
|
||
|
||
const isRestricted = r.permission_mode === 'restricted';
|
||
const allowedGrps = Array.isArray(r.allowed_groups) ? r.allowed_groups : [];
|
||
const allowedExts = Array.isArray(r.allowed_extensions) ? r.allowed_extensions : [];
|
||
|
||
let permBadge = '';
|
||
if (!isRestricted) {
|
||
permBadge = `<span class="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-xs font-semibold bg-emerald-500/15 text-emerald-300 border border-emerald-500/30">✓ Tous Postes & Groupes</span>`;
|
||
} else {
|
||
const grpTags = allowedGrps.map(g => `<span class="px-1.5 py-0.2 rounded bg-cyan-500/20 text-cyan-300 text-[11px] font-bold">Grp ${escapeHtml(g)}</span>`).join(' ');
|
||
const extTags = allowedExts.length > 0 ? `<span class="text-[11px] text-indigo-300 font-mono">${allowedExts.length} poste(s)</span>` : '';
|
||
permBadge = `
|
||
<div class="space-y-1">
|
||
<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[11px] font-bold bg-cyan-500/15 text-cyan-300 border border-cyan-500/30">Restreint</span>
|
||
<div class="flex flex-wrap gap-1">${grpTags} ${extTags}</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
const patterns = Array.isArray(r.dial_patterns) ? r.dial_patterns : [];
|
||
const patCount = patterns.length;
|
||
const patPreview = patterns.slice(0, 2).map(p => escapeHtml(p.match_pattern || '')).filter(Boolean).join(', ');
|
||
|
||
const rTypeBadge = r.route_type === 'emergency'
|
||
? `<span class="px-2 py-0.5 rounded-full text-[10px] font-bold bg-amber-500/20 text-amber-300 border border-amber-500/40">🚑 Urgence</span>`
|
||
: (r.route_type === 'intracompany'
|
||
? `<span class="px-2 py-0.5 rounded-full text-[10px] font-bold bg-cyan-500/20 text-cyan-300 border border-cyan-500/40">Intra</span>`
|
||
: `<span class="px-2 py-0.5 rounded-full text-[10px] font-medium bg-anthracite-950 text-slate-300 border border-anthracite-700">Standard</span>`);
|
||
|
||
const isFirst = idx === 0;
|
||
const isLast = idx === routes.length - 1;
|
||
|
||
return `
|
||
<tr class="hover:bg-anthracite-750/60 transition">
|
||
<td class="py-3.5 px-4">
|
||
<div class="flex items-center gap-1">
|
||
<span class="w-6 h-6 rounded-lg bg-indigo-600/30 border border-indigo-500/40 text-indigo-300 font-mono font-bold text-xs flex items-center justify-center">#${Number(r.priority)}</span>
|
||
<div class="flex flex-col gap-0.5">
|
||
<button type="button" onclick="reorderOutboundRoute(${Number(r.id)}, 'up')" ${isFirst ? 'disabled class="opacity-20 cursor-not-allowed"' : 'class="hover:text-white text-slate-400"'} title="Monter">▲</button>
|
||
<button type="button" onclick="reorderOutboundRoute(${Number(r.id)}, 'down')" ${isLast ? 'disabled class="opacity-20 cursor-not-allowed"' : 'class="hover:text-white text-slate-400"'} title="Descendre">▼</button>
|
||
</div>
|
||
</div>
|
||
</td>
|
||
<td class="py-3.5 px-4">
|
||
<div class="font-bold text-white text-sm flex items-center gap-2">
|
||
<span>${escapeHtml(r.name)}</span>
|
||
${rTypeBadge}
|
||
</div>
|
||
</td>
|
||
<td class="py-3.5 px-4 font-mono text-xs text-indigo-300">
|
||
${r.outbound_callerid ? escapeHtml(r.outbound_callerid) : '<span class="text-slate-500 italic">CID du poste ou du trunk</span>'}
|
||
${Number(r.override_ext_cid) === 1 ? '<span class="ml-1 text-[10px] text-amber-300 font-bold">(Force)</span>' : ''}
|
||
</td>
|
||
<td class="py-3.5 px-4">
|
||
<div class="flex flex-wrap items-center gap-1">${trunkBadges}</div>
|
||
</td>
|
||
<td class="py-3.5 px-4">${permBadge}</td>
|
||
<td class="py-3.5 px-4">
|
||
<div class="text-xs font-semibold text-slate-200">${patCount} masque(s)</div>
|
||
<div class="text-[11px] font-mono text-slate-400 truncate max-w-[160px]">${patPreview}${patCount > 2 ? '...' : ''}</div>
|
||
</td>
|
||
<td class="py-3.5 px-4 text-right space-x-2">
|
||
<button type="button" onclick="editOutboundRoute(${Number(r.id)})" class="text-xs font-semibold text-emerald-300 hover:text-emerald-200">Éditer</button>
|
||
<button type="button" onclick="deleteOutboundRoute(${Number(r.id)})" class="text-xs font-semibold text-red-400 hover:text-red-300">Supprimer</button>
|
||
</td>
|
||
</tr>
|
||
`;
|
||
}).join('');
|
||
}
|
||
|
||
let activeObAllowedExts = new Set();
|
||
let activeObBlockedExts = new Set();
|
||
|
||
function switchOutboundModalTab(tabKey) {
|
||
document.querySelectorAll('.ob-subpanel').forEach(p => p.classList.add('hidden'));
|
||
const target = document.getElementById(`ob-panel-${tabKey}`);
|
||
if (target) target.classList.remove('hidden');
|
||
|
||
document.querySelectorAll('.ob-tab-btn').forEach(btn => {
|
||
if (btn.dataset.obTab === tabKey) {
|
||
btn.className = 'ob-tab-btn px-4 py-2 text-xs font-bold rounded-t-xl bg-indigo-600 text-white transition flex items-center gap-1.5';
|
||
} else {
|
||
btn.className = 'ob-tab-btn px-4 py-2 text-xs font-semibold rounded-t-xl bg-anthracite-950 text-slate-300 hover:text-white transition flex items-center gap-1.5';
|
||
}
|
||
});
|
||
}
|
||
|
||
function openOutboundRouteModal(route = null) {
|
||
const form = document.getElementById('form-outbound');
|
||
form.reset();
|
||
document.getElementById('modal-outbound-title').textContent = route ? `Éditer la Route Sortante : ${route.name}` : 'Ajout Route Sortante';
|
||
|
||
form.elements['id'].value = route ? route.id : '';
|
||
form.elements['name'].value = route ? route.name : '';
|
||
form.elements['outbound_callerid'].value = route ? route.outbound_callerid : '';
|
||
form.elements['priority'].value = route ? route.priority : ((appState.outbound_routes || []).length + 1);
|
||
form.elements['route_password'].value = route ? route.route_password : '';
|
||
form.elements['enabled'].checked = route ? Number(route.enabled) === 1 : true;
|
||
form.elements['continue_next_route'].checked = route ? Number(route.continue_next_route) === 1 : true;
|
||
|
||
// Radios
|
||
const overrideCid = route ? String(route.override_ext_cid) : '0';
|
||
form.querySelectorAll('input[name="override_ext_cid"]').forEach(r => r.checked = (r.value === overrideCid));
|
||
|
||
const routeType = route ? route.route_type : 'normal';
|
||
form.querySelectorAll('input[name="route_type"]').forEach(r => r.checked = (r.value === routeType));
|
||
|
||
const permMode = route ? route.permission_mode : 'all';
|
||
form.querySelectorAll('input[name="permission_mode"]').forEach(r => r.checked = (r.value === permMode));
|
||
toggleOutboundPermissionMode(permMode);
|
||
|
||
const recMode = route ? route.record_mode : 'dontcare';
|
||
form.querySelectorAll('input[name="record_mode"]').forEach(r => r.checked = (r.value === recMode));
|
||
|
||
// Destination sur congestion / Trunks indisponibles
|
||
const congSel = document.getElementById('ob-congestion-dest-select');
|
||
if (congSel) {
|
||
const curCong = route ? route.congestion_dest : 'all-circuits-busy';
|
||
let congOpts = `
|
||
<optgroup label="📢 Messages Vocaux (Recommandé)">
|
||
<option value="all-circuits-busy" ${(curCong === 'all-circuits-busy' || (!route && curCong !== 'terminate-congestion')) ? 'selected' : ''}>📢 Message vocal standard (« Toutes les lignes sont occupées »)</option>
|
||
`;
|
||
const annSounds = (appState.sounds || []).filter(s => s.usage_type !== 'moh');
|
||
if (annSounds.length > 0) {
|
||
annSounds.forEach(s => {
|
||
const val = `sound-${s.id}`;
|
||
congOpts += `<option value="${val}" ${curCong === val ? 'selected' : ''}>🎙️ Annonce : ${escapeHtml(s.name)}</option>`;
|
||
});
|
||
}
|
||
congOpts += `
|
||
</optgroup>
|
||
<optgroup label="📴 Tonalités & Raccrochage">
|
||
<option value="terminate-congestion" ${curCong === 'terminate-congestion' ? 'selected' : ''}>Tonalité de Congestion (Bip rapide réseau saturé)</option>
|
||
<option value="terminate-busy" ${curCong === 'terminate-busy' ? 'selected' : ''}>Tonalité d'Occupation (Bip occupé)</option>
|
||
<option value="terminate-hangup" ${curCong === 'terminate-hangup' ? 'selected' : ''}>Raccrocher immédiatement</option>
|
||
</optgroup>
|
||
`;
|
||
congSel.innerHTML = congOpts;
|
||
}
|
||
|
||
// Musique d'attente (MOH)
|
||
const mohSel = document.getElementById('ob-musicclass-select');
|
||
if (mohSel) {
|
||
const curMoh = route ? (route.moh_class || 'default') : 'default';
|
||
const mohSounds = (appState.sounds || []).filter(s => s.usage_type === 'moh');
|
||
let mohOpts = `<option value="default" ${curMoh === 'default' ? 'selected' : ''}>🌐 Hériter du Trunk / Global (Recommandé)</option>`;
|
||
mohSounds.forEach(s => {
|
||
const sSlug = slugifyTrunkName(s.site_category || 'global');
|
||
const cName = (sSlug === 'global' || sSlug === 'default') ? 'default' : `moh-${sSlug}`;
|
||
if (cName !== 'default') {
|
||
mohOpts += `<option value="${cName}" ${curMoh === cName ? 'selected' : ''}>${escapeHtml(s.name)} [${escapeHtml(s.site_category)}]</option>`;
|
||
}
|
||
});
|
||
mohSel.innerHTML = mohOpts;
|
||
}
|
||
|
||
// Séquence Trunks
|
||
const tSeqContainer = document.getElementById('ob-trunk-seq-container');
|
||
tSeqContainer.innerHTML = '';
|
||
const initialTrunks = route && Array.isArray(route.trunk_sequence) && route.trunk_sequence.length > 0
|
||
? route.trunk_sequence
|
||
: [(appState.trunks?.[0]?.name || '')];
|
||
initialTrunks.forEach(tName => addOutboundTrunkSeqRow(tName));
|
||
|
||
// Dial Patterns
|
||
const pContainer = document.getElementById('ob-patterns-container');
|
||
pContainer.innerHTML = '';
|
||
const initialPatterns = route && Array.isArray(route.dial_patterns) && route.dial_patterns.length > 0
|
||
? route.dial_patterns
|
||
: [
|
||
{ prepend: '', prefix: '', match_pattern: '0[1-59]XXXXXXXX', callerid: '' },
|
||
{ prepend: '', prefix: '', match_pattern: '0[67]XXXXXXXX', callerid: '' }
|
||
];
|
||
initialPatterns.forEach(p => addOutboundPatternRow(p.prepend, p.prefix, p.match_pattern, p.callerid));
|
||
updateOutboundPatternsBadge();
|
||
|
||
// Groupes d'extensions autorisés (Sites & Droits Sortants)
|
||
const grpsContainer = document.getElementById('ob-allowed-groups-container');
|
||
const allowedGrpsSet = new Set(route && Array.isArray(route.allowed_groups) ? route.allowed_groups.map(String) : []);
|
||
const groups = appState.extension_groups || [];
|
||
if (groups.length === 0) {
|
||
grpsContainer.innerHTML = `<span class="text-slate-500 text-xs col-span-3">Aucun groupe d'extensions configuré. Allez dans « Groupes » > « Groupes d'extensions » pour créer le Groupe Nice, Groupe Paris, etc.</span>`;
|
||
} else {
|
||
grpsContainer.innerHTML = groups.map(g => {
|
||
const isChecked = allowedGrpsSet.has(String(g.id)) || allowedGrpsSet.has(String(g.name));
|
||
const mCount = Array.isArray(g.members) ? g.members.length : 0;
|
||
return `
|
||
<label class="flex items-center gap-2.5 p-2.5 rounded-xl bg-anthracite-900 border border-anthracite-700 hover:border-cyan-400 cursor-pointer">
|
||
<input type="checkbox" name="ob_allowed_groups[]" value="${escapeHtml(g.name)}" ${isChecked ? 'checked' : ''} class="rounded bg-anthracite-950 border-anthracite-600">
|
||
<div class="leading-tight">
|
||
<div class="text-xs font-bold text-white">${escapeHtml(g.name)}</div>
|
||
<div class="text-[10px] text-cyan-300 font-mono">${mCount} poste(s)${g.description ? ` • ${escapeHtml(g.description)}` : ''}</div>
|
||
</div>
|
||
</label>
|
||
`;
|
||
}).join('');
|
||
}
|
||
|
||
// Extension Routing (Allowed Extensions <-> Blocked Extensions)
|
||
const allExts = appState.extensions || [];
|
||
const routeAllowedExts = route && Array.isArray(route.allowed_extensions) ? route.allowed_extensions.map(String) : null;
|
||
activeObAllowedExts = new Set();
|
||
activeObBlockedExts = new Set();
|
||
|
||
allExts.forEach(e => {
|
||
const eStr = String(e.extension);
|
||
if (routeAllowedExts === null) {
|
||
// Par défaut, tous autorisés
|
||
activeObAllowedExts.add(eStr);
|
||
} else if (routeAllowedExts.includes(eStr)) {
|
||
activeObAllowedExts.add(eStr);
|
||
} else {
|
||
activeObBlockedExts.add(eStr);
|
||
}
|
||
});
|
||
|
||
renderOutboundExtensionBoxes();
|
||
switchOutboundModalTab('settings');
|
||
openModal('modal-outbound');
|
||
}
|
||
|
||
function editOutboundRoute(id) {
|
||
const route = (appState.outbound_routes || []).find(r => Number(r.id) === Number(id));
|
||
if (route) openOutboundRouteModal(route);
|
||
}
|
||
|
||
async function deleteOutboundRoute(id, optName = null) {
|
||
const r = (appState.outbound_routes || []).find(rt => Number(rt.id) === Number(id));
|
||
const name = optName || (r ? r.name : 'cette route');
|
||
if (!confirm(`Supprimer définitivement la Route Sortante « ${name} » ?`)) return;
|
||
try {
|
||
const res = await apiCall('delete_outbound_route', { id: Number(id) });
|
||
showToast(res.message, 'success');
|
||
await loadState();
|
||
} catch (err) {
|
||
showToast(err.message, 'error');
|
||
}
|
||
}
|
||
|
||
async function reorderOutboundRoute(id, direction) {
|
||
try {
|
||
const res = await apiCall('reorder_outbound_route', { id, direction });
|
||
showToast(res.message, 'success');
|
||
await loadState();
|
||
} catch (err) {
|
||
showToast(err.message, 'error');
|
||
}
|
||
}
|
||
|
||
function addOutboundTrunkSeqRow(selectedTrunk = '') {
|
||
const container = document.getElementById('ob-trunk-seq-container');
|
||
if (!container) return;
|
||
const trunks = appState.trunks || [];
|
||
|
||
const row = document.createElement('div');
|
||
row.className = 'flex items-center gap-2 ob-trunk-seq-row';
|
||
|
||
const optsHtml = trunks.map(t => {
|
||
const isSel = (t.name === selectedTrunk) || (!selectedTrunk && trunks.indexOf(t) === 0);
|
||
return `<option value="${escapeHtml(t.name)}" ${isSel ? 'selected' : ''}>${escapeHtml(t.name)} (${escapeHtml(t.host)}:${Number(t.port)})</option>`;
|
||
}).join('');
|
||
|
||
const currentCount = container.querySelectorAll('.ob-trunk-seq-row').length + 1;
|
||
row.innerHTML = `
|
||
<span class="w-7 h-7 rounded-lg bg-indigo-500/20 text-indigo-300 border border-indigo-500/30 font-bold text-xs flex items-center justify-center shrink-0">
|
||
${currentCount}
|
||
</span>
|
||
<select class="ob-trunk-select flex-1 px-3 py-2 rounded-lg bg-anthracite-900 border border-anthracite-700 text-xs text-white font-medium">
|
||
${optsHtml || '<option value="">Aucun Trunk disponible (Créez un Trunk)</option>'}
|
||
</select>
|
||
<button type="button" class="btn-del-trunk-row p-2 rounded-lg bg-anthracite-900 hover:bg-red-500/20 text-slate-400 hover:text-red-300 border border-anthracite-700 text-xs">🗑️</button>
|
||
`;
|
||
|
||
row.querySelector('.btn-del-trunk-row').addEventListener('click', () => {
|
||
if (container.querySelectorAll('.ob-trunk-seq-row').length <= 1) {
|
||
showToast('Une route sortante doit avoir au moins un Trunk.', 'error');
|
||
return;
|
||
}
|
||
row.remove();
|
||
container.querySelectorAll('.ob-trunk-seq-row').forEach((r, idx) => {
|
||
r.querySelector('span').textContent = String(idx + 1);
|
||
});
|
||
});
|
||
|
||
container.appendChild(row);
|
||
}
|
||
|
||
function updateOutboundPatternsBadge() {
|
||
const count = document.querySelectorAll('#ob-patterns-container .ob-pat-row').length;
|
||
const badge = document.getElementById('ob-patterns-count-badge');
|
||
if (badge) badge.textContent = String(count);
|
||
}
|
||
|
||
function addOutboundPatternRow(prepend = '', prefix = '', match = '', cid = '') {
|
||
const container = document.getElementById('ob-patterns-container');
|
||
if (!container) return;
|
||
|
||
const row = document.createElement('div');
|
||
row.className = 'grid grid-cols-12 gap-2 px-4 py-2 items-center hover:bg-anthracite-800/60 transition ob-pat-row';
|
||
row.innerHTML = `
|
||
<div class="col-span-2">
|
||
<input type="text" placeholder="prepend" value="${escapeHtml(prepend)}"
|
||
class="ob-pat-prepend w-full px-2.5 py-1.5 rounded-lg bg-anthracite-950 border border-anthracite-700 text-xs text-white font-mono">
|
||
</div>
|
||
<div class="col-span-2">
|
||
<input type="text" placeholder="préfixe |" value="${escapeHtml(prefix)}"
|
||
class="ob-pat-prefix w-full px-2.5 py-1.5 rounded-lg bg-anthracite-950 border border-anthracite-700 text-xs text-white font-mono">
|
||
</div>
|
||
<div class="col-span-5">
|
||
<input type="text" placeholder="0[1-59]XXXXXXXX ou X." value="${escapeHtml(match)}" required
|
||
class="ob-pat-match w-full px-2.5 py-1.5 rounded-lg bg-anthracite-950 border border-anthracite-700 text-xs text-emerald-300 font-mono font-semibold">
|
||
</div>
|
||
<div class="col-span-2">
|
||
<input type="text" placeholder="/ CallerID" value="${escapeHtml(cid)}"
|
||
class="ob-pat-cid w-full px-2.5 py-1.5 rounded-lg bg-anthracite-950 border border-anthracite-700 text-xs text-indigo-300 font-mono">
|
||
</div>
|
||
<div class="col-span-1 text-right">
|
||
<button type="button" class="btn-del-pat p-1.5 rounded-lg bg-anthracite-950 hover:bg-red-500/20 text-slate-400 hover:text-red-300 border border-anthracite-700 text-xs">🗑️</button>
|
||
</div>
|
||
`;
|
||
|
||
row.querySelector('.btn-del-pat').addEventListener('click', () => {
|
||
row.remove();
|
||
updateOutboundPatternsBadge();
|
||
});
|
||
|
||
container.appendChild(row);
|
||
updateOutboundPatternsBadge();
|
||
}
|
||
|
||
function applyOutboundPatternPreset(presetKey) {
|
||
if (presetKey === 'fr_national') {
|
||
addOutboundPatternRow('', '', '0[1-59]XXXXXXXX', '');
|
||
addOutboundPatternRow('', '', '0[67]XXXXXXXX', '');
|
||
} else if (presetKey === 'fr_free') {
|
||
addOutboundPatternRow('', '', '080[0-9]XXXXXX', '');
|
||
} else if (presetKey === 'fr_emergency') {
|
||
['15', '17', '18', '112', '114'].forEach(num => addOutboundPatternRow('', '', num, ''));
|
||
} else if (presetKey === 'international') {
|
||
addOutboundPatternRow('', '', '00.', '');
|
||
addOutboundPatternRow('', '', '+X.', '');
|
||
} else if (presetKey === 'all') {
|
||
addOutboundPatternRow('', '', 'X.', '');
|
||
}
|
||
}
|
||
|
||
function toggleOutboundPermissionMode(mode) {
|
||
const details = document.getElementById('ob-permissions-details');
|
||
if (!details) return;
|
||
if (mode === 'restricted') {
|
||
details.classList.remove('opacity-40', 'pointer-events-none');
|
||
} else {
|
||
details.classList.add('opacity-40', 'pointer-events-none');
|
||
}
|
||
}
|
||
|
||
function renderOutboundExtensionBoxes() {
|
||
const allowedBox = document.getElementById('ob-allowed-exts-box');
|
||
const blockedBox = document.getElementById('ob-blocked-exts-box');
|
||
if (!allowedBox || !blockedBox) return;
|
||
|
||
const allExts = appState.extensions || [];
|
||
let allowedHtml = '';
|
||
let blockedHtml = '';
|
||
|
||
allExts.forEach(e => {
|
||
const eStr = String(e.extension);
|
||
if (activeObAllowedExts.has(eStr)) {
|
||
allowedHtml += `
|
||
<button type="button" onclick="obToggleSingleExt('${escapeHtml(eStr)}')"
|
||
class="group px-3 py-1.5 rounded-xl bg-emerald-500/15 hover:bg-amber-500/20 border border-emerald-500/30 hover:border-amber-500/40 text-xs font-semibold text-emerald-300 hover:text-amber-300 transition cursor-pointer flex items-center gap-1.5">
|
||
<span class="font-mono">${escapeHtml(eStr)}</span>
|
||
<span class="text-slate-200">(${escapeHtml(e.name)})</span>
|
||
<span class="text-slate-400 group-hover:text-amber-300 ml-1">→</span>
|
||
</button>
|
||
`;
|
||
} else {
|
||
blockedHtml += `
|
||
<button type="button" onclick="obToggleSingleExt('${escapeHtml(eStr)}')"
|
||
class="group px-3 py-1.5 rounded-xl bg-anthracite-950 hover:bg-emerald-500/20 border border-anthracite-700 hover:border-emerald-500/40 text-xs font-medium text-slate-300 hover:text-emerald-300 transition cursor-pointer flex items-center gap-1.5">
|
||
<span class="text-slate-400 group-hover:text-emerald-300 mr-1">←</span>
|
||
<span class="font-mono text-indigo-300 font-bold">${escapeHtml(eStr)}</span>
|
||
<span class="text-slate-300">(${escapeHtml(e.name)})</span>
|
||
</button>
|
||
`;
|
||
}
|
||
});
|
||
|
||
allowedBox.innerHTML = allowedHtml || `<div class="text-slate-500 text-xs italic py-2">Aucun poste individuel autorisé.</div>`;
|
||
blockedBox.innerHTML = blockedHtml || `<div class="text-slate-500 text-xs italic py-2">Tous les postes sont autorisés sur cette route !</div>`;
|
||
|
||
document.getElementById('ob-allowed-exts-count').textContent = String(activeObAllowedExts.size);
|
||
document.getElementById('ob-blocked-exts-count').textContent = String(activeObBlockedExts.size);
|
||
}
|
||
|
||
function obToggleSingleExt(eStr) {
|
||
if (activeObAllowedExts.has(eStr)) {
|
||
activeObAllowedExts.delete(eStr);
|
||
activeObBlockedExts.add(eStr);
|
||
} else {
|
||
activeObBlockedExts.delete(eStr);
|
||
activeObAllowedExts.add(eStr);
|
||
}
|
||
renderOutboundExtensionBoxes();
|
||
}
|
||
|
||
function obTransferExtensions(action) {
|
||
const allExts = appState.extensions || [];
|
||
if (action === 'all_allow') {
|
||
allExts.forEach(e => {
|
||
activeObAllowedExts.add(String(e.extension));
|
||
activeObBlockedExts.delete(String(e.extension));
|
||
});
|
||
} else if (action === 'all_block') {
|
||
allExts.forEach(e => {
|
||
activeObBlockedExts.add(String(e.extension));
|
||
activeObAllowedExts.delete(String(e.extension));
|
||
});
|
||
} else if (action === 'invert') {
|
||
allExts.forEach(e => {
|
||
const eStr = String(e.extension);
|
||
if (activeObAllowedExts.has(eStr)) {
|
||
activeObAllowedExts.delete(eStr);
|
||
activeObBlockedExts.add(eStr);
|
||
} else {
|
||
activeObBlockedExts.delete(eStr);
|
||
activeObAllowedExts.add(eStr);
|
||
}
|
||
});
|
||
}
|
||
renderOutboundExtensionBoxes();
|
||
}
|
||
|
||
// --- 4. Rendu & CRUD SVI / IVR ---
|
||
function renderIvrsTable() {
|
||
const tbody = document.getElementById('ivrs-tbody');
|
||
const rows = appState.ivrs || [];
|
||
|
||
if (rows.length === 0) {
|
||
tbody.innerHTML = `<tr><td colspan="7" class="py-8 text-center text-slate-400 text-xs">Aucun serveur vocal interactif configuré. Cliquez sur « + Nouveau SVI / IVR » pour en créer un.</td></tr>`;
|
||
return;
|
||
}
|
||
|
||
tbody.innerHTML = rows.map(ivr => {
|
||
const opts = ivr.options || {};
|
||
const optEntries = Object.entries(opts);
|
||
const badges = optEntries.map(([k, v]) => {
|
||
let label = v;
|
||
if (v.startsWith('ext-')) label = `Poste ${v.replace('ext-', '')}`;
|
||
else if (v.startsWith('grp-')) label = `Groupe ${v.replace('grp-', '')}`;
|
||
else if (v.startsWith('sound-')) label = `Annonce`;
|
||
else if (v.startsWith('terminate-')) label = v.replace('terminate-', '');
|
||
|
||
return `<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded-lg bg-anthracite-950 border border-anthracite-700 text-xs font-mono shadow-sm">
|
||
<span class="w-4 h-4 rounded bg-indigo-500/20 text-indigo-300 font-bold flex items-center justify-center text-[10px]">${escapeHtml(k)}</span>
|
||
<span class="text-slate-400">→</span>
|
||
<span class="text-slate-200 font-semibold">${escapeHtml(label)}</span>
|
||
</span>`;
|
||
}).join(' ');
|
||
|
||
const directDialBadge = Number(ivr.direct_dial) !== 0
|
||
? `<span class="inline-flex items-center gap-1 px-2.5 py-1 rounded-full bg-emerald-500/10 text-emerald-400 border border-emerald-500/30 text-[11px] font-semibold">
|
||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/></svg>
|
||
Activée
|
||
</span>`
|
||
: `<span class="inline-flex items-center gap-1 px-2.5 py-1 rounded-full bg-slate-500/10 text-slate-400 border border-slate-500/30 text-[11px] font-semibold">Désactivée</span>`;
|
||
|
||
const testCode = '*8' + String(ivr.id).padStart(2, '0');
|
||
|
||
// Résolution du libellé de l'annonce audio
|
||
let audioName = escapeHtml(ivr.audio_file || 'demo-instruct');
|
||
const foundSound = (appState.sounds || []).find(s => `custom/${s.slug}` === ivr.audio_file || s.slug === ivr.audio_file);
|
||
if (foundSound) {
|
||
audioName = `${escapeHtml(foundSound.name)} <span class="text-[10px] text-slate-400">(${escapeHtml(foundSound.site_category)})</span>`;
|
||
}
|
||
|
||
return `
|
||
<tr class="hover:bg-anthracite-750/60 transition">
|
||
<td class="py-3.5 px-4">
|
||
<div class="font-bold text-white flex items-center gap-2">
|
||
<span class="w-2 h-2 rounded-full bg-indigo-400"></span>
|
||
<span>${escapeHtml(ivr.name)}</span>
|
||
</div>
|
||
${ivr.description ? `<div class="text-[11px] text-slate-400 mt-0.5">${escapeHtml(ivr.description)}</div>` : ''}
|
||
</td>
|
||
<td class="py-3.5 px-4 text-xs font-mono text-slate-200">
|
||
<div class="flex items-center gap-1.5">
|
||
<svg class="w-3.5 h-3.5 text-amber-400 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.536 8.464a5 5 0 010 7.072m2.828-9.9a9 9 0 010 12.728M5.586 15H4a1 1 0 01-1-1v-4a1 1 0 011-1h1.586l4.707-4.707C10.923 3.663 12 4.109 12 5v14c0 .891-1.077 1.337-1.707.707L5.586 15z"/>
|
||
</svg>
|
||
<span>${audioName}</span>
|
||
</div>
|
||
</td>
|
||
<td class="py-3.5 px-4">${directDialBadge}</td>
|
||
<td class="py-3.5 px-4">
|
||
<div class="flex flex-wrap gap-1.5 max-w-xs">
|
||
${badges || '<span class="text-xs text-slate-400 italic">Aucune touche</span>'}
|
||
</div>
|
||
</td>
|
||
<td class="py-3.5 px-4 text-xs text-slate-300">
|
||
<div>⏱️ Attente : <strong class="text-white">${Number(ivr.timeout)}s</strong> <span class="text-slate-400">(${Number(ivr.timeout_retries || 2)} rép)</span></div>
|
||
<div class="text-[11px] text-slate-400 mt-0.5">⚠️ Erreur : max <strong class="text-slate-200">${Number(ivr.invalid_retries || 3)}</strong> essais</div>
|
||
</td>
|
||
<td class="py-3.5 px-4">
|
||
<span class="inline-flex items-center gap-1 px-2.5 py-1 rounded-lg bg-indigo-500/10 text-indigo-300 border border-indigo-500/30 text-xs font-mono font-bold" title="Composer ce code depuis un poste interne pour tester le SVI">
|
||
<svg class="w-3 h-3 text-indigo-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z"/></svg>
|
||
${testCode}
|
||
</span>
|
||
</td>
|
||
<td class="py-3.5 px-4 text-right space-x-2">
|
||
<button type="button" onclick="editIvr(${Number(ivr.id)})" class="text-xs font-semibold text-indigo-300 hover:text-indigo-200 transition">Modifier</button>
|
||
<button type="button" onclick="deleteIvr(${Number(ivr.id)})" class="text-xs font-semibold text-red-400 hover:text-red-300 transition">Supprimer</button>
|
||
</td>
|
||
</tr>
|
||
`;
|
||
}).join('');
|
||
}
|
||
|
||
function addIvrOptionRow(digit = '', dest = '') {
|
||
const container = document.getElementById('ivr-options-container');
|
||
const row = document.createElement('div');
|
||
row.className = 'flex items-center gap-2.5 p-2 rounded-xl bg-anthracite-950/80 border border-anthracite-700/80 ivr-opt-row transition hover:border-indigo-500/50';
|
||
|
||
const digits = ['1', '2', '3', '4', '5', '6', '7', '8', '9', '0', '*', '#'];
|
||
let digitOptions = digits.map(d => `<option value="${d}" ${String(digit) === d ? 'selected' : ''}>Touche ${d}</option>`).join('');
|
||
|
||
row.innerHTML = `
|
||
<div class="flex items-center gap-1.5 flex-shrink-0">
|
||
<span class="text-xs text-slate-400 font-mono">Touche :</span>
|
||
<select class="ivr-digit px-2.5 py-1.5 rounded-lg bg-anthracite-900 border border-anthracite-700 text-xs font-bold text-indigo-300 font-mono focus:outline-none focus:border-indigo-500">
|
||
${digitOptions}
|
||
</select>
|
||
</div>
|
||
<span class="text-slate-400 font-bold">→</span>
|
||
<div class="flex-1 min-w-0">
|
||
<select class="ivr-dest w-full px-2.5 py-1.5 rounded-lg bg-anthracite-900 border border-anthracite-700 text-xs text-white focus:outline-none focus:border-indigo-500">
|
||
${buildDestinationOptionsHtml(dest || 'ext-101')}
|
||
</select>
|
||
</div>
|
||
<button type="button" class="p-1.5 text-slate-400 hover:text-red-400 hover:bg-red-500/10 rounded-lg transition" title="Supprimer cette touche">
|
||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||
</svg>
|
||
</button>
|
||
`;
|
||
row.querySelector('button').addEventListener('click', () => row.remove());
|
||
container.appendChild(row);
|
||
}
|
||
|
||
function openIvrModal(ivr = null) {
|
||
const form = document.getElementById('form-ivr');
|
||
form.reset();
|
||
document.getElementById('modal-ivr-title').textContent = ivr ? `Modifier le SVI « ${ivr.name} »` : 'Nouveau Serveur Vocal Interactif (SVI / IVR)';
|
||
|
||
form.elements['id'].value = ivr ? ivr.id : '';
|
||
form.elements['name'].value = ivr ? ivr.name : '';
|
||
form.elements['description'].value = ivr && ivr.description ? ivr.description : '';
|
||
form.elements['timeout'].value = ivr ? Number(ivr.timeout) : 5;
|
||
form.elements['timeout_retries'].value = ivr ? Number(ivr.timeout_retries || 2) : 2;
|
||
form.elements['invalid_retries'].value = ivr ? Number(ivr.invalid_retries || 3) : 3;
|
||
|
||
// Numérotation directe (défaut activé si nouveau ou si direct_dial == 1)
|
||
const directDialCheck = document.getElementById('ivr-direct-dial-checkbox');
|
||
if (directDialCheck) {
|
||
directDialCheck.checked = ivr ? Boolean(Number(ivr.direct_dial)) : true;
|
||
}
|
||
|
||
// Sélecteur audio d'accueil (audio_file)
|
||
const audioSel = document.getElementById('ivr-audio-select');
|
||
if (audioSel) {
|
||
const curAudio = ivr ? String(ivr.audio_file || '') : '';
|
||
const sounds = appState.sounds || [];
|
||
let audioHtml = '';
|
||
|
||
if (sounds.length > 0) {
|
||
audioHtml += `<optgroup label="📂 Fichiers sons de la médiathèque">`;
|
||
sounds.forEach(s => {
|
||
const val = `custom/${s.slug}`;
|
||
const isSel = curAudio === val || curAudio === s.slug || curAudio === String(s.id);
|
||
audioHtml += `<option value="${escapeHtml(val)}" ${isSel ? 'selected' : ''}>${escapeHtml(s.name)} [${escapeHtml(s.site_category)} • ${escapeHtml(s.usage_type)}]</option>`;
|
||
});
|
||
audioHtml += `</optgroup>`;
|
||
}
|
||
|
||
audioHtml += `
|
||
<optgroup label="🔔 Messages système Asterisk par défaut">
|
||
<option value="demo-instruct" ${curAudio === 'demo-instruct' || !curAudio ? 'selected' : ''}>demo-instruct (Instructions d'accueil)</option>
|
||
<option value="hello-world" ${curAudio === 'hello-world' ? 'selected' : ''}>hello-world (Message d'accueil basique)</option>
|
||
<option value="beep" ${curAudio === 'beep' ? 'selected' : ''}>beep (Simple bip sonore)</option>
|
||
</optgroup>
|
||
`;
|
||
audioSel.innerHTML = audioHtml;
|
||
}
|
||
|
||
// Sélecteur message d'erreur invalide (invalid_sound_id)
|
||
const invalidSoundSel = document.getElementById('ivr-invalid-sound-select');
|
||
if (invalidSoundSel) {
|
||
const curInvId = ivr ? Number(ivr.invalid_sound_id || 0) : 0;
|
||
let invSoundHtml = `<option value="0" ${curInvId === 0 ? 'selected' : ''}>Message par défaut (pbx-invalid : « Choix invalide »)</option>`;
|
||
(appState.sounds || []).forEach(s => {
|
||
invSoundHtml += `<option value="${s.id}" ${curInvId === Number(s.id) ? 'selected' : ''}>${escapeHtml(s.name)} [${escapeHtml(s.site_category)}]</option>`;
|
||
});
|
||
invalidSoundSel.innerHTML = invSoundHtml;
|
||
}
|
||
|
||
// Sélecteur destination d'échec invalide (invalid_dest)
|
||
const invalidDestSel = document.getElementById('ivr-invalid-dest-select');
|
||
if (invalidDestSel) {
|
||
invalidDestSel.innerHTML = buildDestinationOptionsHtml(ivr ? (ivr.invalid_dest || 'terminate-hangup') : 'terminate-hangup');
|
||
}
|
||
|
||
// Sélecteur message timeout (timeout_sound_id)
|
||
const timeoutSoundSel = document.getElementById('ivr-timeout-sound-select');
|
||
if (timeoutSoundSel) {
|
||
const curToutId = ivr ? Number(ivr.timeout_sound_id || 0) : 0;
|
||
let toutSoundHtml = `<option value="0" ${curToutId === 0 ? 'selected' : ''}>Aucun message supplémentaire (rejouer annonce)</option>`;
|
||
(appState.sounds || []).forEach(s => {
|
||
toutSoundHtml += `<option value="${s.id}" ${curToutId === Number(s.id) ? 'selected' : ''}>${escapeHtml(s.name)} [${escapeHtml(s.site_category)}]</option>`;
|
||
});
|
||
timeoutSoundSel.innerHTML = toutSoundHtml;
|
||
}
|
||
|
||
// Sélecteur destination d'échec timeout (timeout_dest)
|
||
const timeoutDestSel = document.getElementById('ivr-timeout-dest-select');
|
||
if (timeoutDestSel) {
|
||
timeoutDestSel.innerHTML = buildDestinationOptionsHtml(ivr ? (ivr.timeout_dest || 'terminate-hangup') : 'terminate-hangup');
|
||
}
|
||
|
||
// Conteneur des touches DTMF
|
||
const container = document.getElementById('ivr-options-container');
|
||
container.innerHTML = '';
|
||
const opts = ivr && ivr.options && Object.keys(ivr.options).length > 0
|
||
? ivr.options
|
||
: { '1': 'ext-101', '2': 'grp-600' };
|
||
Object.entries(opts).forEach(([d, dest]) => addIvrOptionRow(d, dest));
|
||
|
||
openModal('modal-ivr');
|
||
}
|
||
|
||
function editIvr(id) {
|
||
const ivr = (appState.ivrs || []).find(i => Number(i.id) === Number(id));
|
||
if (ivr) openIvrModal(ivr);
|
||
}
|
||
|
||
async function deleteIvr(id, optName = null) {
|
||
const ivr = (appState.ivrs || []).find(i => Number(i.id) === Number(id));
|
||
const name = optName || (ivr ? ivr.name : 'ce SVI');
|
||
if (!confirm(`Supprimer le SVI ${name} ?`)) return;
|
||
try {
|
||
const res = await apiCall('delete_ivr', { id: Number(id) });
|
||
showToast(res.message, 'success');
|
||
await loadState();
|
||
} catch (err) {
|
||
showToast(err.message, 'error');
|
||
}
|
||
}
|
||
|
||
// --- 4B. Rendu & CRUD Médiathèque & Fichiers Sons (MoH, Annonces, IVR, Congestion) ---
|
||
const USAGE_LABELS = {
|
||
moh: { label: "Musique d'attente (MoH)", color: "amber" },
|
||
announcement: { label: "Annonce d'accueil / Info", color: "indigo" },
|
||
ivr: { label: "Menu SVI / Guide interactif", color: "cyan" },
|
||
congestion: { label: "Ligne occupée / Congestion", color: "rose" },
|
||
custom: { label: "Autre / Personnalisé", color: "slate" }
|
||
};
|
||
|
||
function formatDurationSec(sec) {
|
||
const s = Math.max(0, parseInt(sec, 10) || 0);
|
||
const m = Math.floor(s / 60);
|
||
const rem = s % 60;
|
||
return `${m}:${rem.toString().padStart(2, '0')}`;
|
||
}
|
||
|
||
function formatBytes(bytes) {
|
||
const b = parseInt(bytes, 10) || 0;
|
||
if (b < 1024) return `${b} o`;
|
||
if (b < 1024 * 1024) return `${(b / 1024).toFixed(1)} Ko`;
|
||
return `${(b / (1024 * 1024)).toFixed(1)} Mo`;
|
||
}
|
||
|
||
function renderSoundsTable() {
|
||
const tbody = document.getElementById('sounds-tbody');
|
||
if (!tbody) return;
|
||
|
||
const allSounds = appState.sounds || [];
|
||
const soundSites = appState.sound_sites || [];
|
||
|
||
// Met à jour la liste des sites dans le filtre (sans icône)
|
||
const siteFilterEl = document.getElementById('filter-sound-site');
|
||
if (siteFilterEl) {
|
||
const curVal = siteFilterEl.value || 'all';
|
||
let siteOpts = `<option value="all">Tous les sites</option>`;
|
||
soundSites.forEach(st => {
|
||
const isSel = (st.name === curVal) ? 'selected' : '';
|
||
siteOpts += `<option value="${escapeHtml(st.name)}" ${isSel}>${escapeHtml(st.name)}</option>`;
|
||
});
|
||
siteFilterEl.innerHTML = siteOpts;
|
||
}
|
||
|
||
// Met à jour le sélecteur de la Musique d'attente générale par défaut (MoH)
|
||
const defMohSelect = document.getElementById('global-default-moh-select');
|
||
if (defMohSelect) {
|
||
const mohSounds = allSounds.filter(s => s.usage_type === 'moh');
|
||
const curDefaultId = Number(appState.settings?.default_moh_sound_id || 0);
|
||
if (mohSounds.length === 0) {
|
||
defMohSelect.innerHTML = `<option value="">Aucune musique d'attente disponible</option>`;
|
||
} else {
|
||
defMohSelect.innerHTML = mohSounds.map(s => {
|
||
const isSel = (Number(s.id) === curDefaultId) ? 'selected' : '';
|
||
return `<option value="${s.id}" ${isSel}>${escapeHtml(s.name)} [${escapeHtml(s.site_category || 'Global')}]</option>`;
|
||
}).join('');
|
||
}
|
||
}
|
||
|
||
// Récupération des filtres actifs
|
||
const selSite = siteFilterEl ? siteFilterEl.value : 'all';
|
||
const selUsage = document.getElementById('filter-sound-usage')?.value || 'all';
|
||
const searchTxt = (document.getElementById('filter-sound-search')?.value || '').toLowerCase().trim();
|
||
|
||
const filtered = allSounds.filter(s => {
|
||
if (selSite !== 'all' && s.site_category !== selSite) return false;
|
||
if (selUsage !== 'all' && s.usage_type !== selUsage) return false;
|
||
if (searchTxt) {
|
||
const hay = `${s.name} ${s.description} ${s.slug} ${s.site_category} ${s.usage_type}`.toLowerCase();
|
||
if (!hay.includes(searchTxt)) return false;
|
||
}
|
||
return true;
|
||
});
|
||
|
||
if (filtered.length === 0) {
|
||
tbody.innerHTML = `<tr><td colspan="7" class="py-8 text-center text-slate-400 text-xs">Aucun fichier son trouvé pour les filtres sélectionnés. Cliquez sur « + Téléverser un Fichier Son ».</td></tr>`;
|
||
return;
|
||
}
|
||
|
||
const curDefaultMohId = Number(appState.settings?.default_moh_sound_id || 0);
|
||
|
||
tbody.innerHTML = filtered.map(snd => {
|
||
const usage = USAGE_LABELS[snd.usage_type] || USAGE_LABELS.custom;
|
||
const siteBadge = `<span class="px-2.5 py-1 rounded-lg text-xs font-semibold bg-amber-500/15 text-amber-300 border border-amber-500/30">${escapeHtml(snd.site_category || 'Global')}</span>`;
|
||
const usageBadge = `<span class="inline-flex items-center px-2.5 py-1 rounded-lg text-xs font-medium bg-anthracite-950 border border-anthracite-700 text-slate-200">${escapeHtml(usage.label || snd.usage_type)}</span>`;
|
||
const defaultBadge = (Number(snd.id) === curDefaultMohId)
|
||
? `<span class="ml-2 px-2 py-0.5 rounded text-[10px] font-bold bg-amber-500/20 text-amber-300 border border-amber-500/40">Par défaut</span>`
|
||
: '';
|
||
|
||
let astRef = `custom/${escapeHtml(snd.slug)}`;
|
||
if (snd.usage_type === 'moh') {
|
||
const sSlug = slugifyTrunkName(snd.site_category || 'global');
|
||
astRef = (sSlug === 'global' || sSlug === 'default') ? 'default' : `moh-${sSlug}`;
|
||
}
|
||
|
||
const audioUrl = `/api.php?action=stream_sound&id=${Number(snd.id)}`;
|
||
|
||
return `
|
||
<tr class="hover:bg-anthracite-750/60 transition">
|
||
<td class="py-3.5 px-4">
|
||
<div class="font-bold text-white text-sm flex items-center">
|
||
<span>${escapeHtml(snd.name)}</span>
|
||
${defaultBadge}
|
||
</div>
|
||
<div class="text-xs text-slate-400 mt-0.5">${escapeHtml(snd.description || '—')}</div>
|
||
</td>
|
||
<td class="py-3.5 px-4">${siteBadge}</td>
|
||
<td class="py-3.5 px-4">${usageBadge}</td>
|
||
<td class="py-3.5 px-4">
|
||
<audio controls preload="none" class="h-8 w-48 sm:w-56 rounded bg-anthracite-900 border border-anthracite-700" src="${audioUrl}"></audio>
|
||
</td>
|
||
<td class="py-3.5 px-4 font-mono text-xs text-slate-300 whitespace-nowrap">
|
||
<div>${formatDurationSec(snd.duration_sec)}</div>
|
||
<div class="text-[10px] text-slate-500">${formatBytes(snd.file_size)} • ${escapeHtml((snd.format || 'wav').toUpperCase())}</div>
|
||
</td>
|
||
<td class="py-3.5 px-4">
|
||
<div class="inline-flex items-center gap-1.5 px-2 py-1 rounded bg-anthracite-950 border border-anthracite-700 font-mono text-xs text-amber-300">
|
||
<span>${astRef}</span>
|
||
<button type="button" onclick="navigator.clipboard.writeText('${astRef}'); showToast('Code copié : ${astRef}', 'info');" title="Copier la référence Asterisk" class="text-slate-400 hover:text-white text-[11px] font-sans underline">Copier</button>
|
||
</div>
|
||
</td>
|
||
<td class="py-3.5 px-4 text-right space-x-2 whitespace-nowrap">
|
||
<button type="button" onclick="editSound(${Number(snd.id)})" class="text-xs font-semibold text-amber-400 hover:text-amber-300">Modifier</button>
|
||
<button type="button" onclick="deleteSound(${Number(snd.id)})" class="text-xs font-semibold text-red-400 hover:text-red-300">Supprimer</button>
|
||
</td>
|
||
</tr>
|
||
`;
|
||
}).join('');
|
||
}
|
||
|
||
async function applyDefaultMohFromSelect() {
|
||
const sel = document.getElementById('global-default-moh-select');
|
||
if (!sel || !sel.value) {
|
||
showToast("Veuillez sélectionner un fichier son pour la musique d'attente par défaut.", 'error');
|
||
return;
|
||
}
|
||
try {
|
||
const res = await apiCall('set_default_moh', { sound_id: Number(sel.value) });
|
||
showToast(res.message, 'success');
|
||
await loadState();
|
||
} catch (err) {
|
||
showToast(err.message, 'error');
|
||
}
|
||
}
|
||
|
||
// --- Gestion des catégories de sites (Création, Modification, Suppression) ---
|
||
function openSoundSitesModal() {
|
||
const form = document.getElementById('form-sound-site');
|
||
if (form) form.reset();
|
||
const idInput = document.getElementById('sound-site-id');
|
||
if (idInput) idInput.value = '';
|
||
const btn = document.getElementById('btn-save-sound-site');
|
||
if (btn) btn.textContent = 'Enregistrer';
|
||
renderSoundSitesTable();
|
||
openModal('modal-sound-sites');
|
||
}
|
||
|
||
function renderSoundSitesTable() {
|
||
const tbody = document.getElementById('sound-sites-tbody');
|
||
if (!tbody) return;
|
||
|
||
const sites = appState.sound_sites || [];
|
||
const sounds = appState.sounds || [];
|
||
|
||
if (sites.length === 0) {
|
||
tbody.innerHTML = `<tr><td colspan="4" class="py-6 text-center text-slate-400">Aucune catégorie de site définie.</td></tr>`;
|
||
return;
|
||
}
|
||
|
||
tbody.innerHTML = sites.map(st => {
|
||
const count = sounds.filter(s => (s.site_category || '').toLowerCase() === (st.name || '').toLowerCase()).length;
|
||
const isGlobal = (st.name || '').toLowerCase() === 'global';
|
||
|
||
const deleteBtn = isGlobal
|
||
? `<span class="text-slate-600 text-xs italic" title="Socle standard du système, non supprimable">Protégé</span>`
|
||
: `<button type="button" onclick="deleteSoundSite(${Number(st.id)})" class="text-xs font-semibold text-red-400 hover:text-red-300">Supprimer</button>`;
|
||
|
||
return `
|
||
<tr class="hover:bg-anthracite-900/50 transition">
|
||
<td class="py-2.5 px-4 font-semibold text-white">
|
||
${escapeHtml(st.name)}
|
||
${isGlobal ? '<span class="ml-1.5 px-1.5 py-0.5 rounded text-[10px] bg-slate-800 text-slate-300 border border-slate-700">Système</span>' : ''}
|
||
</td>
|
||
<td class="py-2.5 px-4 text-slate-300">${escapeHtml(st.description || '—')}</td>
|
||
<td class="py-2.5 px-4 text-slate-300">
|
||
<span class="px-2 py-0.5 rounded-full text-xs font-mono bg-anthracite-900 border border-anthracite-700">${count} fichier${count > 1 ? 's' : ''}</span>
|
||
</td>
|
||
<td class="py-2.5 px-4 text-right space-x-2 whitespace-nowrap">
|
||
<button type="button" onclick="editSoundSite(${Number(st.id)})" class="text-xs font-semibold text-amber-400 hover:text-amber-300">Modifier</button>
|
||
${deleteBtn}
|
||
</td>
|
||
</tr>
|
||
`;
|
||
}).join('');
|
||
}
|
||
|
||
function editSoundSite(id) {
|
||
const site = (appState.sound_sites || []).find(s => Number(s.id) === Number(id));
|
||
if (!site) return;
|
||
const idInput = document.getElementById('sound-site-id');
|
||
const nameInput = document.getElementById('sound-site-name');
|
||
const descInput = document.getElementById('sound-site-desc');
|
||
const btn = document.getElementById('btn-save-sound-site');
|
||
|
||
if (idInput) idInput.value = site.id;
|
||
if (nameInput) {
|
||
nameInput.value = site.name;
|
||
nameInput.focus();
|
||
}
|
||
if (descInput) descInput.value = site.description || '';
|
||
if (btn) btn.textContent = 'Mettre à jour';
|
||
}
|
||
|
||
async function deleteSoundSite(id, optName = null) {
|
||
const st = (appState.sound_sites || []).find(s => Number(s.id) === Number(id));
|
||
const name = optName || (st ? st.name : 'ce site');
|
||
if (!confirm(`Supprimer la catégorie de site « ${name} » ?\nTous les sons associés seront automatiquement réassignés à « Global ».`)) return;
|
||
try {
|
||
const res = await apiCall('delete_sound_site', { id: Number(id) });
|
||
showToast(res.message, 'success');
|
||
await loadState();
|
||
renderSoundSitesTable();
|
||
} catch (err) {
|
||
showToast(err.message, 'error');
|
||
}
|
||
}
|
||
|
||
function handleSoundFileSelected(input) {
|
||
const file = input.files?.[0];
|
||
const placeEl = document.getElementById('sound-upload-placeholder');
|
||
const prevEl = document.getElementById('sound-upload-preview');
|
||
const nameEl = document.getElementById('sound-upload-filename');
|
||
const sizeEl = document.getElementById('sound-upload-filesize');
|
||
const nameInput = document.getElementById('sound-input-name');
|
||
|
||
if (file) {
|
||
placeEl?.classList.add('hidden');
|
||
prevEl?.classList.remove('hidden');
|
||
if (nameEl) nameEl.textContent = file.name;
|
||
if (sizeEl) sizeEl.textContent = formatBytes(file.size);
|
||
|
||
if (nameInput && !nameInput.value.trim()) {
|
||
const cleanName = file.name.replace(/\.[^/.]+$/, '').replace(/[-_]+/g, ' ');
|
||
nameInput.value = cleanName.charAt(0).toUpperCase() + cleanName.slice(1);
|
||
}
|
||
} else {
|
||
placeEl?.classList.remove('hidden');
|
||
prevEl?.classList.add('hidden');
|
||
}
|
||
}
|
||
|
||
function openSoundModal(snd = null) {
|
||
const form = document.getElementById('form-sound');
|
||
form.reset();
|
||
document.getElementById('modal-sound-title').textContent = snd ? `Modifier le fichier « ${snd.name} »` : "Téléverser un Fichier Son";
|
||
document.getElementById('btn-submit-sound').textContent = snd ? "Enregistrer les Modifications" : "Téléverser & Enregistrer";
|
||
|
||
form.elements['id'].value = snd ? snd.id : '';
|
||
form.elements['name'].value = snd ? snd.name : '';
|
||
form.elements['usage_type'].value = snd ? snd.usage_type : 'announcement';
|
||
form.elements['description'].value = snd ? snd.description : '';
|
||
|
||
// Remplit la liste déroulante des catégories de sites
|
||
const siteSelect = document.getElementById('sound-input-site');
|
||
if (siteSelect) {
|
||
const sites = appState.sound_sites || [];
|
||
const curSite = snd ? snd.site_category : 'Global';
|
||
siteSelect.innerHTML = sites.map(st => {
|
||
const isSel = (st.name === curSite) ? 'selected' : '';
|
||
return `<option value="${escapeHtml(st.name)}" ${isSel}>${escapeHtml(st.name)}</option>`;
|
||
}).join('');
|
||
if (curSite && !sites.some(st => st.name === curSite)) {
|
||
siteSelect.insertAdjacentHTML('afterbegin', `<option value="${escapeHtml(curSite)}" selected>${escapeHtml(curSite)}</option>`);
|
||
}
|
||
}
|
||
|
||
const uploadBlock = document.getElementById('sound-file-upload-block');
|
||
const fileInput = document.getElementById('sound-audio-file-input');
|
||
const placeEl = document.getElementById('sound-upload-placeholder');
|
||
const prevEl = document.getElementById('sound-upload-preview');
|
||
|
||
if (snd) {
|
||
if (uploadBlock) uploadBlock.classList.add('hidden');
|
||
if (fileInput) fileInput.removeAttribute('required');
|
||
} else {
|
||
if (uploadBlock) uploadBlock.classList.remove('hidden');
|
||
if (fileInput) fileInput.setAttribute('required', 'required');
|
||
placeEl?.classList.remove('hidden');
|
||
prevEl?.classList.add('hidden');
|
||
}
|
||
|
||
openModal('modal-sound');
|
||
}
|
||
|
||
function editSound(id) {
|
||
const snd = (appState.sounds || []).find(s => Number(s.id) === Number(id));
|
||
if (snd) openSoundModal(snd);
|
||
}
|
||
|
||
async function deleteSound(id, optName = null) {
|
||
const snd = (appState.sounds || []).find(s => Number(s.id) === Number(id));
|
||
const name = optName || (snd ? snd.name : 'ce fichier son');
|
||
if (!confirm(`Supprimer définitivement le fichier son « ${name} » et ses encodages Asterisk ?`)) return;
|
||
try {
|
||
const res = await apiCall('delete_sound', { id: Number(id) });
|
||
showToast(res.message, 'success');
|
||
await loadState();
|
||
} catch (err) {
|
||
showToast(err.message, 'error');
|
||
}
|
||
}
|
||
|
||
// --- 5. Rendu Paramètres, Diagnostic Debian & Prévisualisations .conf ---
|
||
function renderSettingsAndDiagnostics() {
|
||
const s = appState.settings || {};
|
||
const form = document.getElementById('settings-form');
|
||
if (form) {
|
||
if (form.elements['company_name']) {
|
||
form.elements['company_name'].value = s.company_name || '';
|
||
}
|
||
if (form.elements['system_language']) {
|
||
form.elements['system_language'].value = s.system_language || 'fr';
|
||
}
|
||
form.elements['domain'].value = s.domain || 'pbx.local';
|
||
form.elements['letsencrypt_domain'].value = s.letsencrypt_domain || '';
|
||
form.elements['local_net'].value = s.local_net || '10.10.0.0/16';
|
||
form.elements['external_ip'].value = s.external_ip || '';
|
||
form.elements['udp_port'].value = s.udp_port || 5060;
|
||
form.elements['tls_port'].value = s.tls_port || 5061;
|
||
form.elements['ami_host'].value = s.ami_host || '127.0.0.1';
|
||
form.elements['ami_port'].value = s.ami_port || 5038;
|
||
form.elements['ami_user'].value = s.ami_user || 'pbxgui';
|
||
form.elements['ami_secret'].value = s.ami_secret || '';
|
||
form.elements['new_admin_password'].value = '';
|
||
}
|
||
|
||
const diagList = document.getElementById('diagnostics-list');
|
||
const checks = appState.diagnostics?.checks || [];
|
||
diagList.innerHTML = checks.map(c => `
|
||
<div class="flex items-start gap-3 p-3 rounded-xl bg-anthracite-950 border ${c.ok ? 'border-anthracite-700' : 'border-amber-500/40'}">
|
||
<span class="mt-0.5 w-5 h-5 rounded-full flex items-center justify-center text-xs font-bold ${c.ok ? 'bg-emerald-500/20 text-emerald-300' : 'bg-amber-500/20 text-amber-300'}">
|
||
${c.ok ? '✓' : '!'}
|
||
</span>
|
||
<div class="flex-1 min-w-0">
|
||
<div class="text-xs font-semibold text-white">${escapeHtml(c.label)}</div>
|
||
<div class="text-[11px] text-slate-300 break-words mt-0.5">${escapeHtml(c.detail)}</div>
|
||
</div>
|
||
</div>
|
||
`).join('');
|
||
|
||
document.getElementById('preview-pjsip').textContent = appState.previews?.pjsip || '';
|
||
document.getElementById('preview-extensions').textContent = appState.previews?.extensions || '';
|
||
const prevVm = document.getElementById('preview-voicemail');
|
||
if (prevVm) {
|
||
prevVm.textContent = appState.previews?.voicemail || '';
|
||
}
|
||
}
|
||
|
||
// --- Navigation Sidebar & Modales ---
|
||
const PAGE_TITLES = {
|
||
dashboard: 'Tableau de Bord & Supervision Temps Réel',
|
||
cdr: 'Journal d\'Appels & Historique CDR (Call Detail Records)',
|
||
extensions: 'Postes Téléphoniques SIP (PJSIP)',
|
||
groups: "Groupes de Sonnerie (Ring Groups)",
|
||
extgroups: "Groupes d'Extensions (Sites & Droits Sortants)",
|
||
paging: 'Groupes de Téléavertissement & Interphonie (Paging)',
|
||
trunks: 'Trunks Opérateurs SIP',
|
||
outbound: 'Routes Sortantes (Outbound Routes & Séquence Trunks)',
|
||
ivrs: 'Serveurs Vocaux Interactifs (SVI / IVR)',
|
||
sounds: 'Fichiers sons',
|
||
featurecodes: 'Codes de Fonctions & Services Téléphoniques Asterisk',
|
||
sipsettings: 'Paramètres SIP Globaux & Configuration NAT (PJSIP & RTP)',
|
||
certificates: 'Gestionnaire de Certificats SSL / TLS (Let\'s Encrypt & Imports)',
|
||
system: 'Paramètres Globaux, Diagnostic Debian & Fichiers .conf',
|
||
backups: 'Sauvegardes & Restauration Système (Disaster Recovery)',
|
||
firewall: 'Pare-feu Réseau & Sécurité Téléphonie (Fail2ban & NFTables)',
|
||
ucp: 'Portail Utilisateur (UCP) — Comptes & Droits',
|
||
cluster: 'Haute Disponibilité & Redondance (Cluster HA Keepalived)'
|
||
};
|
||
|
||
function switchTab(tabId) {
|
||
document.querySelectorAll('.tab-panel').forEach(p => p.classList.add('hidden'));
|
||
const target = document.getElementById(`tab-${tabId}`);
|
||
if (target) target.classList.remove('hidden');
|
||
|
||
const titleEl = document.getElementById('page-header-title');
|
||
if (titleEl && PAGE_TITLES[tabId]) {
|
||
titleEl.textContent = PAGE_TITLES[tabId];
|
||
}
|
||
|
||
document.querySelectorAll('.tab-btn').forEach(btn => {
|
||
const isSubBtn = btn.parentElement?.id === 'submenu-operators' || btn.parentElement?.id === 'submenu-groups' || btn.parentElement?.id === 'submenu-system';
|
||
if (btn.dataset.tab === tabId) {
|
||
if (isSubBtn) {
|
||
btn.className = 'tab-btn w-full flex items-center justify-between px-3 py-1.5 rounded-lg text-xs font-bold bg-indigo-600 text-white shadow-sm transition text-left';
|
||
} else {
|
||
btn.className = 'tab-btn w-full flex items-center justify-between px-3.5 py-2.5 rounded-xl text-sm font-semibold bg-indigo-600 text-white shadow-md shadow-indigo-600/20 transition';
|
||
}
|
||
} else {
|
||
if (isSubBtn) {
|
||
btn.className = 'tab-btn w-full flex items-center justify-between px-3 py-1.5 rounded-lg text-xs font-medium text-slate-300 hover:bg-anthracite-800 hover:text-white transition text-left';
|
||
} else {
|
||
btn.className = 'tab-btn w-full flex items-center justify-between px-3.5 py-2.5 rounded-xl text-sm font-medium text-slate-300 hover:bg-anthracite-800 hover:text-white transition';
|
||
}
|
||
}
|
||
});
|
||
|
||
// Si on clique sur un sous-menu Opérateurs, s'assurer que le menu parent est bien déplié
|
||
if (tabId === 'trunks' || tabId === 'outbound') {
|
||
const submenu = document.getElementById('submenu-operators');
|
||
const arrow = document.getElementById('arrow-menu-operators');
|
||
if (submenu && submenu.classList.contains('hidden')) {
|
||
submenu.classList.remove('hidden');
|
||
arrow?.classList.remove('-rotate-90');
|
||
}
|
||
}
|
||
|
||
// Si on clique sur un sous-menu Groupes, s'assurer que le menu parent est bien déplié
|
||
if (tabId === 'groups' || tabId === 'extgroups') {
|
||
const submenu = document.getElementById('submenu-groups');
|
||
const arrow = document.getElementById('arrow-menu-groups');
|
||
if (submenu && submenu.classList.contains('hidden')) {
|
||
submenu.classList.remove('hidden');
|
||
arrow?.classList.remove('-rotate-90');
|
||
}
|
||
}
|
||
|
||
// Si on clique sur un sous-menu Système, s'assurer que le menu parent est bien déplié
|
||
if (tabId === 'system' || tabId === 'backups' || tabId === 'firewall' || tabId === 'cluster') {
|
||
const submenu = document.getElementById('submenu-system');
|
||
const arrow = document.getElementById('arrow-menu-system');
|
||
if (submenu && submenu.classList.contains('hidden')) {
|
||
submenu.classList.remove('hidden');
|
||
arrow?.classList.remove('-rotate-90');
|
||
}
|
||
}
|
||
|
||
// Si onglet Sauvegardes, charger la liste des sauvegardes et la configuration
|
||
if (tabId === 'backups') {
|
||
loadBackups();
|
||
}
|
||
|
||
// Si onglet Journal d'appels, charger l'historique CDR
|
||
if (tabId === 'cdr') {
|
||
loadCdr();
|
||
}
|
||
|
||
// Si onglet Pare-feu, charger le statut en direct
|
||
if (tabId === 'firewall') {
|
||
loadFirewallStatus();
|
||
}
|
||
|
||
// Si onglet Cluster HA, charger les interfaces et le statut en direct
|
||
if (tabId === 'cluster') {
|
||
loadCluster();
|
||
}
|
||
|
||
// Si onglet Portail Utilisateur (UCP), actualiser l'affichage
|
||
if (tabId === 'ucp') {
|
||
renderUcpTab();
|
||
}
|
||
|
||
toggleMobileSidebar(false);
|
||
}
|
||
|
||
function toggleSystemMenu() {
|
||
const submenu = document.getElementById('submenu-system');
|
||
const arrow = document.getElementById('arrow-menu-system');
|
||
if (!submenu) return;
|
||
const isHidden = submenu.classList.toggle('hidden');
|
||
if (arrow) {
|
||
if (isHidden) {
|
||
arrow.classList.add('-rotate-90');
|
||
} else {
|
||
arrow.classList.remove('-rotate-90');
|
||
}
|
||
}
|
||
}
|
||
|
||
function toggleGroupsMenu() {
|
||
const submenu = document.getElementById('submenu-groups');
|
||
const arrow = document.getElementById('arrow-menu-groups');
|
||
if (!submenu) return;
|
||
const isHidden = submenu.classList.toggle('hidden');
|
||
if (arrow) {
|
||
if (isHidden) {
|
||
arrow.classList.add('-rotate-90');
|
||
} else {
|
||
arrow.classList.remove('-rotate-90');
|
||
}
|
||
}
|
||
}
|
||
|
||
function toggleOperatorsMenu() {
|
||
const submenu = document.getElementById('submenu-operators');
|
||
const arrow = document.getElementById('arrow-menu-operators');
|
||
if (!submenu) return;
|
||
const isHidden = submenu.classList.toggle('hidden');
|
||
if (arrow) {
|
||
if (isHidden) {
|
||
arrow.classList.add('-rotate-90');
|
||
} else {
|
||
arrow.classList.remove('-rotate-90');
|
||
}
|
||
}
|
||
}
|
||
|
||
function toggleMobileSidebar(open) {
|
||
const sidebar = document.getElementById('sidebar');
|
||
const backdrop = document.getElementById('sidebar-backdrop');
|
||
if (!sidebar || !backdrop) return;
|
||
|
||
if (open) {
|
||
sidebar.classList.remove('-translate-x-full');
|
||
backdrop.classList.remove('hidden');
|
||
} else {
|
||
sidebar.classList.add('-translate-x-full');
|
||
backdrop.classList.add('hidden');
|
||
}
|
||
}
|
||
|
||
function openModal(id) {
|
||
const el = document.getElementById(id);
|
||
if (el) {
|
||
el.classList.remove('hidden');
|
||
el.classList.add('flex');
|
||
}
|
||
}
|
||
|
||
function closeModal(id) {
|
||
const el = document.getElementById(id);
|
||
if (el) {
|
||
el.classList.add('hidden');
|
||
el.classList.remove('flex');
|
||
}
|
||
}
|
||
|
||
// --- Écouteurs d'événements DOM ---
|
||
document.addEventListener('DOMContentLoaded', () => {
|
||
// Login AJAX
|
||
document.getElementById('login-form')?.addEventListener('submit', async (e) => {
|
||
e.preventDefault();
|
||
const formData = Object.fromEntries(new FormData(e.target).entries());
|
||
try {
|
||
const res = await apiCall('login', formData);
|
||
showToast(res.message, 'success');
|
||
showAppScreen();
|
||
await loadState();
|
||
} catch (err) {
|
||
showToast(err.message, 'error');
|
||
}
|
||
});
|
||
|
||
// Logout AJAX
|
||
document.getElementById('btn-logout')?.addEventListener('click', async () => {
|
||
await apiCall('logout', {});
|
||
showLoginScreen();
|
||
showToast('Vous êtes déconnecté.', 'info');
|
||
});
|
||
|
||
// Form Extension
|
||
document.getElementById('form-extension')?.addEventListener('submit', async (e) => {
|
||
e.preventDefault();
|
||
const fd = new FormData(e.target);
|
||
const payload = Object.fromEntries(fd.entries());
|
||
payload.encryption = Number(document.getElementById('ext-encryption')?.value ?? 1);
|
||
payload.member_extension_groups = Array.from(e.target.querySelectorAll('input[name="member_extension_groups[]"]:checked')).map(c => c.value);
|
||
payload.voicemail_enabled = document.getElementById('ext-voicemail-enabled')?.checked ? 1 : 0;
|
||
payload.force_pin_change = (payload.pin_mode === 'first_call') ? 1 : 0;
|
||
payload.voicemail_skip_pin = document.getElementById('ext-skip-pin')?.checked ? 1 : 0;
|
||
payload.voicemail_attach = document.getElementById('ext-vm-attach')?.checked ? 1 : 0;
|
||
payload.voicemail_delete = document.getElementById('ext-vm-delete')?.checked ? 1 : 0;
|
||
payload.ring_time = Number(payload.ring_time || 20);
|
||
payload.groundwire_enabled = document.getElementById('ext-groundwire-enabled')?.checked ? 1 : 0;
|
||
|
||
try {
|
||
const res = await apiCall('save_extension', payload);
|
||
showToast(res.message, 'success');
|
||
closeModal('modal-extension');
|
||
await loadState();
|
||
} catch (err) {
|
||
showToast(err.message, 'error');
|
||
}
|
||
});
|
||
|
||
// Form Groupe de Sonnerie (Ring Groups)
|
||
document.getElementById('form-group')?.addEventListener('submit', async (e) => {
|
||
e.preventDefault();
|
||
const fd = new FormData(e.target);
|
||
const payload = Object.fromEntries(fd.entries());
|
||
payload.members = Array.from(e.target.querySelectorAll('input[name="member_ext"]:checked')).map(c => c.value);
|
||
|
||
try {
|
||
const res = await apiCall('save_group', payload);
|
||
showToast(res.message, 'success');
|
||
closeModal('modal-group');
|
||
await loadState();
|
||
} catch (err) {
|
||
showToast(err.message, 'error');
|
||
}
|
||
});
|
||
|
||
// Form Groupe d'Extensions (Sites & Droits Sortants)
|
||
document.getElementById('form-extgroup')?.addEventListener('submit', async (e) => {
|
||
e.preventDefault();
|
||
const fd = new FormData(e.target);
|
||
const payload = Object.fromEntries(fd.entries());
|
||
payload.members = Array.from(activeExtGroupSelectedExts);
|
||
|
||
try {
|
||
const res = await apiCall('save_extension_group', payload);
|
||
showToast(res.message, 'success');
|
||
closeModal('modal-extgroup');
|
||
await loadState();
|
||
} catch (err) {
|
||
showToast(err.message, 'error');
|
||
}
|
||
});
|
||
|
||
// Form Groupe de Téléavertissement (Paging)
|
||
document.getElementById('form-paging')?.addEventListener('submit', async (e) => {
|
||
e.preventDefault();
|
||
const fd = new FormData(e.target);
|
||
const payload = Object.fromEntries(fd.entries());
|
||
payload.members = Array.from(activePagingSelectedExts);
|
||
payload.duplex = Number(payload.duplex || 0);
|
||
payload.timeout = Number(payload.timeout || 120);
|
||
payload.play_beep = e.target.querySelector('input[name="play_beep"]')?.checked ? 1 : 0;
|
||
payload.enabled = e.target.querySelector('input[name="enabled"]')?.checked ? 1 : 0;
|
||
|
||
try {
|
||
const res = await apiCall('save_paging_group', payload);
|
||
showToast(res.message, 'success');
|
||
closeModal('modal-paging');
|
||
await loadState();
|
||
} catch (err) {
|
||
showToast(err.message, 'error');
|
||
}
|
||
});
|
||
|
||
// Form Trunk (Configuration PJSIP complète)
|
||
document.getElementById('form-trunk')?.addEventListener('submit', async (e) => {
|
||
e.preventDefault();
|
||
const form = e.target;
|
||
const fd = new FormData(form);
|
||
const payload = Object.fromEntries(fd.entries());
|
||
|
||
payload.registration_mode = form.querySelector('input[name="registration_mode"]:checked')?.value || 'send';
|
||
payload.enabled = form.elements['enabled'].checked ? 1 : 0;
|
||
payload.register = form.elements['register'].checked ? 1 : 0;
|
||
payload.line_support = form.elements['line_support'].checked ? 1 : 0;
|
||
payload.send_pai = form.elements['send_pai'].checked ? 1 : 0;
|
||
payload.send_rpid = form.elements['send_rpid'].checked ? 1 : 0;
|
||
payload.send_diversion = form.elements['send_diversion'].checked ? 1 : 0;
|
||
payload.trust_id_inbound = form.elements['trust_id_inbound'].checked ? 1 : 0;
|
||
payload.trust_id_outbound = form.elements['trust_id_outbound'].checked ? 1 : 0;
|
||
payload.nat_symmetric = form.elements['nat_symmetric'].checked ? 1 : 0;
|
||
payload.direct_media = form.elements['direct_media'].checked ? 1 : 0;
|
||
payload.inband_progress = form.elements['inband_progress'].checked ? 1 : 0;
|
||
payload.fax_detect = form.elements['fax_detect'].checked ? 1 : 0;
|
||
|
||
const didRoutes = [];
|
||
document.querySelectorAll('#trunk-did-container .trunk-did-row').forEach(row => {
|
||
const cid = row.querySelector('.trunk-did-cid')?.value.trim() || 'Any';
|
||
const did = row.querySelector('.trunk-did-num')?.value.trim() || 'Any';
|
||
const desc = row.querySelector('.trunk-did-desc')?.value.trim() || '';
|
||
const dest = row.querySelector('.trunk-did-dest')?.value.trim() || '';
|
||
const tcEnabled = row.querySelector('.trunk-tc-enabled')?.checked ? 1 : 0;
|
||
const tcStart = row.querySelector('.trunk-tc-start')?.value || '08:30';
|
||
const tcFinish = row.querySelector('.trunk-tc-finish')?.value || '18:00';
|
||
const weekdays = row.querySelector('.trunk-tc-weekdays')?.value || 'mon-fri';
|
||
const tcClosed = row.querySelector('.trunk-tc-closed-dest')?.value || 'terminate-hangup';
|
||
const cidPrefix = row.querySelector('.trunk-did-prefix')?.value.trim() || '';
|
||
|
||
if (dest) {
|
||
didRoutes.push({
|
||
cid,
|
||
did,
|
||
description: desc,
|
||
destination: dest,
|
||
cid_prefix: cidPrefix,
|
||
time_condition_enabled: tcEnabled,
|
||
time_start: tcStart,
|
||
time_finish: tcFinish,
|
||
weekdays: weekdays,
|
||
time_dest_closed: tcClosed
|
||
});
|
||
}
|
||
});
|
||
payload.did_routes = didRoutes;
|
||
|
||
try {
|
||
const res = await apiCall('save_trunk', payload);
|
||
showToast(res.message, 'success');
|
||
closeModal('modal-trunk');
|
||
await loadState();
|
||
} catch (err) {
|
||
showToast(err.message, 'error');
|
||
}
|
||
});
|
||
|
||
// Form Outbound Route
|
||
document.getElementById('form-outbound')?.addEventListener('submit', async (e) => {
|
||
e.preventDefault();
|
||
const form = e.target;
|
||
const fd = new FormData(form);
|
||
const payload = Object.fromEntries(fd.entries());
|
||
|
||
payload.enabled = form.elements['enabled'].checked ? 1 : 0;
|
||
payload.continue_next_route = form.elements['continue_next_route'].checked ? 1 : 0;
|
||
payload.override_ext_cid = form.querySelector('input[name="override_ext_cid"]:checked')?.value === '1' ? 1 : 0;
|
||
payload.route_type = form.querySelector('input[name="route_type"]:checked')?.value || 'normal';
|
||
payload.permission_mode = form.querySelector('input[name="permission_mode"]:checked')?.value || 'all';
|
||
payload.record_mode = form.querySelector('input[name="record_mode"]:checked')?.value || 'dontcare';
|
||
|
||
// Séquence de Trunks
|
||
const trunks = [];
|
||
document.querySelectorAll('#ob-trunk-seq-container .ob-trunk-select').forEach(sel => {
|
||
const val = sel.value.trim();
|
||
if (val && !trunks.includes(val)) trunks.push(val);
|
||
});
|
||
payload.trunk_sequence = trunks;
|
||
|
||
// Dial Patterns
|
||
const patterns = [];
|
||
document.querySelectorAll('#ob-patterns-container .ob-pat-row').forEach(row => {
|
||
const prep = row.querySelector('.ob-pat-prepend')?.value.trim() || '';
|
||
const pfx = row.querySelector('.ob-pat-prefix')?.value.trim() || '';
|
||
const mtch = row.querySelector('.ob-pat-match')?.value.trim() || '';
|
||
const cid = row.querySelector('.ob-pat-cid')?.value.trim() || '';
|
||
if (mtch) {
|
||
patterns.push({
|
||
prepend: prep,
|
||
prefix: pfx,
|
||
match_pattern: mtch,
|
||
callerid: cid
|
||
});
|
||
}
|
||
});
|
||
payload.dial_patterns = patterns;
|
||
|
||
// Groupes autorisés
|
||
payload.allowed_groups = Array.from(form.querySelectorAll('input[name="ob_allowed_groups[]"]:checked')).map(c => c.value);
|
||
|
||
// Extensions autorisées (depuis le drag/transfert interactif)
|
||
payload.allowed_extensions = Array.from(activeObAllowedExts);
|
||
|
||
try {
|
||
const res = await apiCall('save_outbound_route', payload);
|
||
showToast(res.message, 'success');
|
||
closeModal('modal-outbound');
|
||
await loadState();
|
||
} catch (err) {
|
||
showToast(err.message, 'error');
|
||
}
|
||
});
|
||
|
||
// Form IVR
|
||
document.getElementById('form-ivr')?.addEventListener('submit', async (e) => {
|
||
e.preventDefault();
|
||
const fd = new FormData(e.target);
|
||
const payload = Object.fromEntries(fd.entries());
|
||
payload.direct_dial = document.getElementById('ivr-direct-dial-checkbox')?.checked ? 1 : 0;
|
||
|
||
const options = {};
|
||
document.querySelectorAll('#ivr-options-container .ivr-opt-row').forEach(row => {
|
||
const d = (row.querySelector('.ivr-digit')?.value || '').trim();
|
||
const dest = (row.querySelector('.ivr-dest')?.value || '').trim();
|
||
if (d && dest) options[d] = dest;
|
||
});
|
||
payload.options = options;
|
||
|
||
try {
|
||
const res = await apiCall('save_ivr', payload);
|
||
showToast(res.message, 'success');
|
||
closeModal('modal-ivr');
|
||
await loadState();
|
||
} catch (err) {
|
||
showToast(err.message, 'error');
|
||
}
|
||
});
|
||
|
||
// Form Fichier Son (Upload multipart / Update)
|
||
document.getElementById('form-sound')?.addEventListener('submit', async (e) => {
|
||
e.preventDefault();
|
||
const form = e.target;
|
||
const fd = new FormData(form);
|
||
const id = form.elements['id'].value;
|
||
const submitBtn = document.getElementById('btn-submit-sound');
|
||
|
||
if (submitBtn) {
|
||
submitBtn.disabled = true;
|
||
submitBtn.textContent = "Traitement audio en cours...";
|
||
}
|
||
|
||
try {
|
||
let res;
|
||
if (id) {
|
||
const payload = Object.fromEntries(fd.entries());
|
||
res = await apiCall('update_sound', payload);
|
||
} else {
|
||
const response = await fetch('/api.php?action=upload_sound', {
|
||
method: 'POST',
|
||
body: fd
|
||
});
|
||
const cType = response.headers.get('content-type') || '';
|
||
if (cType.includes('application/json')) {
|
||
res = await response.json();
|
||
} else {
|
||
const rawText = await response.text();
|
||
if (response.status === 413) {
|
||
throw new Error("Le fichier audio est trop volumineux pour le serveur (Limite 64 Mo).");
|
||
}
|
||
throw new Error(`Erreur serveur (${response.status}) : ${rawText.replace(/<[^>]*>/g, '').trim().slice(0, 150) || 'Réponse inattendue'}`);
|
||
}
|
||
if (!response.ok || !res.success) {
|
||
throw new Error(res.message || 'Erreur lors du téléversement du fichier audio.');
|
||
}
|
||
}
|
||
showToast(res.message, 'success');
|
||
closeModal('modal-sound');
|
||
await loadState();
|
||
} catch (err) {
|
||
showToast(err.message, 'error');
|
||
} finally {
|
||
if (submitBtn) {
|
||
submitBtn.disabled = false;
|
||
submitBtn.textContent = id ? "Enregistrer les Modifications" : "Téléverser & Enregistrer";
|
||
}
|
||
}
|
||
});
|
||
|
||
// Form Catégories de sites
|
||
document.getElementById('form-sound-site')?.addEventListener('submit', async (e) => {
|
||
e.preventDefault();
|
||
const form = e.target;
|
||
const payload = Object.fromEntries(new FormData(form).entries());
|
||
try {
|
||
const res = await apiCall('save_sound_site', payload);
|
||
showToast(res.message, 'success');
|
||
form.reset();
|
||
const idInput = document.getElementById('sound-site-id');
|
||
if (idInput) idInput.value = '';
|
||
const btn = document.getElementById('btn-save-sound-site');
|
||
if (btn) btn.textContent = 'Enregistrer';
|
||
await loadState();
|
||
renderSoundSitesTable();
|
||
} catch (err) {
|
||
showToast(err.message, 'error');
|
||
}
|
||
});
|
||
|
||
// Form Settings
|
||
document.getElementById('settings-form')?.addEventListener('submit', async (e) => {
|
||
e.preventDefault();
|
||
const payload = Object.fromEntries(new FormData(e.target).entries());
|
||
payload.wizard_completed = 1;
|
||
try {
|
||
const res = await apiCall('save_settings', payload);
|
||
showToast(res.message, 'success');
|
||
await loadState();
|
||
} catch (err) {
|
||
showToast(err.message, 'error');
|
||
}
|
||
});
|
||
|
||
// Bouton Tester AMI
|
||
document.getElementById('btn-test-ami')?.addEventListener('click', async () => {
|
||
try {
|
||
const res = await apiCall('test_ami');
|
||
showToast(res.message, 'success');
|
||
} catch (err) {
|
||
showToast(err.message, 'error');
|
||
}
|
||
});
|
||
|
||
// Bouton Appliquer les configurations (Écriture /etc/asterisk + Reload AMI)
|
||
document.getElementById('btn-apply-config')?.addEventListener('click', async () => {
|
||
const icon = document.getElementById('apply-icon');
|
||
icon?.classList.add('animate-spin');
|
||
try {
|
||
const res = await apiCall('apply_config', {});
|
||
showToast(res.message, 'success');
|
||
await loadState();
|
||
} catch (err) {
|
||
showToast(err.message, 'error');
|
||
} finally {
|
||
icon?.classList.remove('animate-spin');
|
||
}
|
||
});
|
||
|
||
// Formulaire Let's Encrypt
|
||
document.getElementById('form-cert-letsencrypt')?.addEventListener('submit', async (e) => {
|
||
e.preventDefault();
|
||
const f = e.target;
|
||
const btn = document.getElementById('btn-submit-letsencrypt');
|
||
const spinner = document.getElementById('spinner-letsencrypt');
|
||
const btnText = document.getElementById('btn-text-letsencrypt');
|
||
const msgBox = document.getElementById('letsencrypt-msg-box');
|
||
|
||
const formData = new FormData(f);
|
||
const payload = {
|
||
name: (formData.get('name') || '').trim(),
|
||
domain: (formData.get('domain') || '').trim(),
|
||
email: (formData.get('email') || '').trim(),
|
||
is_default: formData.get('is_default') === '1' ? 1 : 0,
|
||
staging: formData.get('staging') === '1' ? 1 : 0
|
||
};
|
||
|
||
if (btn) btn.disabled = true;
|
||
if (spinner) spinner.classList.remove('hidden');
|
||
if (btnText) btnText.textContent = "Communication avec Let's Encrypt...";
|
||
if (msgBox) { msgBox.className = 'hidden'; msgBox.textContent = ''; }
|
||
|
||
try {
|
||
const res = await apiCall('request_letsencrypt_cert', payload);
|
||
showToast(res.message, 'success');
|
||
closeModal('modal-cert-letsencrypt');
|
||
await loadState();
|
||
} catch (err) {
|
||
if (msgBox) {
|
||
msgBox.className = 'p-3 rounded-xl text-xs font-mono bg-rose-950/60 border border-rose-500/40 text-rose-300';
|
||
msgBox.textContent = "Erreur Let's Encrypt :\n" + err.message;
|
||
}
|
||
showToast("Échec de l'émission Let's Encrypt", 'error');
|
||
} finally {
|
||
if (btn) btn.disabled = false;
|
||
if (spinner) spinner.classList.add('hidden');
|
||
if (btnText) btnText.textContent = "Lancer l'émission ACME";
|
||
}
|
||
});
|
||
|
||
// Formulaire Import Certificat Custom (supporte FormData multipart pour fichiers .crt, .key)
|
||
document.getElementById('form-cert-custom')?.addEventListener('submit', async (e) => {
|
||
e.preventDefault();
|
||
const f = e.target;
|
||
const btn = document.getElementById('btn-submit-custom-cert');
|
||
const spinner = document.getElementById('spinner-custom-cert');
|
||
const msgBox = document.getElementById('custom-cert-msg-box');
|
||
|
||
const formData = new FormData(f);
|
||
|
||
if (btn) btn.disabled = true;
|
||
if (spinner) spinner.classList.remove('hidden');
|
||
if (msgBox) { msgBox.className = 'hidden'; msgBox.textContent = ''; }
|
||
|
||
try {
|
||
const resp = await fetch('/api.php?action=save_custom_cert', {
|
||
method: 'POST',
|
||
body: formData
|
||
});
|
||
const data = await resp.json();
|
||
if (!data.success) {
|
||
throw new Error(data.message || 'Erreur lors de l\'enregistrement du certificat');
|
||
}
|
||
showToast(data.message, 'success');
|
||
closeModal('modal-cert-custom');
|
||
await loadState();
|
||
} catch (err) {
|
||
if (msgBox) {
|
||
msgBox.className = 'p-3 rounded-xl text-xs font-mono bg-rose-950/60 border border-rose-500/40 text-rose-300';
|
||
msgBox.textContent = "Erreur de validation du certificat :\n" + err.message;
|
||
}
|
||
showToast(err.message, 'error');
|
||
} finally {
|
||
if (btn) btn.disabled = false;
|
||
if (spinner) spinner.classList.add('hidden');
|
||
}
|
||
});
|
||
|
||
// Lecture automatique des fichiers .crt, .key dans les textareas lors de la sélection
|
||
const bindFileReader = (fileInputId, textareaId) => {
|
||
const input = document.getElementById(fileInputId);
|
||
const textarea = document.getElementById(textareaId);
|
||
if (input && textarea) {
|
||
input.addEventListener('change', (e) => {
|
||
const file = e.target.files[0];
|
||
if (file) {
|
||
const reader = new FileReader();
|
||
reader.onload = (re) => { textarea.value = re.target.result; };
|
||
reader.readAsText(file);
|
||
}
|
||
});
|
||
}
|
||
};
|
||
bindFileReader('file-cert-custom', 'textarea-cert-pem');
|
||
bindFileReader('file-key-custom', 'textarea-key-pem');
|
||
bindFileReader('file-chain-custom', 'textarea-chain-pem');
|
||
|
||
// Formulaire Paramètres SIP & NAT
|
||
document.getElementById('form-sip-settings')?.addEventListener('submit', async (e) => {
|
||
e.preventDefault();
|
||
const f = e.target;
|
||
const btn = document.getElementById('btn-save-sip-settings');
|
||
const spinner = document.getElementById('spinner-save-sip-settings');
|
||
|
||
const netInputs = f.querySelectorAll('input[name="local_networks[]"]');
|
||
const localNets = Array.from(netInputs).map(i => i.value.trim()).filter(v => v !== '');
|
||
|
||
const payload = {
|
||
external_ip: (document.getElementById('sip-external-ip')?.value || '').trim(),
|
||
external_udp_port: Number(document.getElementById('sip-external-udp-port')?.value || 0),
|
||
external_tls_port: Number(document.getElementById('sip-external-tls-port')?.value || 0),
|
||
udp_port: Number(document.getElementById('sip-udp-port')?.value || 5060),
|
||
tls_port: Number(document.getElementById('sip-tls-port')?.value || 5061),
|
||
rtp_start: Number(document.getElementById('sip-rtp-start')?.value || 10000),
|
||
rtp_end: Number(document.getElementById('sip-rtp-end')?.value || 20000),
|
||
rtp_strict: Number(document.getElementById('sip-rtp-strict')?.value || 1),
|
||
rtp_checksums: Number(document.getElementById('sip-rtp-checksums')?.value || 1),
|
||
rtp_keepalive: Number(document.getElementById('sip-rtp-keepalive')?.value || 15),
|
||
rtp_timeout: Number(document.getElementById('sip-rtp-timeout')?.value || 30),
|
||
rtp_timeout_hold: Number(document.getElementById('sip-rtp-timeout-hold')?.value || 0),
|
||
allow_anonymous_sip: Number(document.getElementById('sip-allow-anonymous')?.value || 0),
|
||
local_networks: localNets
|
||
};
|
||
|
||
if (btn) btn.disabled = true;
|
||
if (spinner) spinner.classList.remove('hidden');
|
||
|
||
try {
|
||
const res = await apiCall('save_sip_settings', payload);
|
||
showToast(res.message, 'success');
|
||
const container = document.getElementById('local-networks-container');
|
||
if (container) delete container.dataset.initialized;
|
||
await loadState();
|
||
} catch (err) {
|
||
showToast(err.message, 'error');
|
||
} finally {
|
||
if (btn) btn.disabled = false;
|
||
if (spinner) spinner.classList.add('hidden');
|
||
}
|
||
});
|
||
|
||
// Formulaire Édition d'un Code de Fonction
|
||
document.getElementById('form-edit-feature-code')?.addEventListener('submit', async (e) => {
|
||
e.preventDefault();
|
||
const btn = document.getElementById('btn-submit-edit-fc');
|
||
const spinner = document.getElementById('spinner-edit-fc');
|
||
const id = Number(document.getElementById('edit-fc-id')?.value || 0);
|
||
const code = (document.getElementById('edit-fc-code')?.value || '').trim();
|
||
|
||
if (btn) btn.disabled = true;
|
||
if (spinner) spinner.classList.remove('hidden');
|
||
|
||
try {
|
||
const res = await apiCall('update_feature_code', { id, code });
|
||
showToast(res.message, 'success');
|
||
closeModal('modal-edit-feature-code');
|
||
await loadState();
|
||
} catch (err) {
|
||
showToast(err.message, 'error');
|
||
} finally {
|
||
if (btn) btn.disabled = false;
|
||
if (spinner) spinner.classList.add('hidden');
|
||
}
|
||
});
|
||
|
||
// Formulaire Paramètres de Sauvegarde & SSH
|
||
document.getElementById('backup-settings-form')?.addEventListener('submit', async (e) => {
|
||
e.preventDefault();
|
||
const f = e.target;
|
||
const autoEnabled = f.elements['backup_auto_enabled']?.checked ? 1 : 0;
|
||
const frequency = f.elements['backup_frequency']?.value || 'daily';
|
||
const retention = Number(f.elements['backup_retention']?.value || 7);
|
||
const storageType = f.querySelector('input[name="backup_storage_type"]:checked')?.value || 'local';
|
||
const sshHost = (f.elements['backup_ssh_host']?.value || '').trim();
|
||
const sshPort = Number(f.elements['backup_ssh_port']?.value || 22);
|
||
const sshUser = (f.elements['backup_ssh_user']?.value || '').trim();
|
||
const sshPassword = f.elements['backup_ssh_password']?.value || '';
|
||
const sshRemotePath = (f.elements['backup_ssh_remote_path']?.value || '/backups/nexus').trim();
|
||
|
||
const payload = {
|
||
backup_auto_enabled: autoEnabled,
|
||
backup_frequency: frequency,
|
||
backup_retention: retention,
|
||
backup_storage_type: storageType,
|
||
backup_ssh_host: sshHost,
|
||
backup_ssh_port: sshPort,
|
||
backup_ssh_user: sshUser,
|
||
backup_ssh_auth_type: 'password',
|
||
backup_ssh_password: sshPassword,
|
||
backup_ssh_remote_path: sshRemotePath
|
||
};
|
||
|
||
try {
|
||
const res = await apiCall('save_backup_settings', payload);
|
||
showToast(res.message, 'success');
|
||
await loadBackups();
|
||
} catch (err) {
|
||
showToast("Erreur enregistrement : " + err.message, 'error');
|
||
}
|
||
});
|
||
|
||
// Formulaire Paramètres Pare-feu & Fail2ban
|
||
document.getElementById('firewall-settings-form')?.addEventListener('submit', async (e) => {
|
||
e.preventDefault();
|
||
const f = e.target;
|
||
const btn = document.getElementById('btn-save-firewall-settings');
|
||
if (btn) btn.disabled = true;
|
||
|
||
const rawUcpHttps = f.elements['ucp_https_port']?.value;
|
||
const rawUcpHttp = f.elements['ucp_http_port']?.value;
|
||
const payload = {
|
||
firewall_enabled: f.elements['firewall_enabled']?.checked ? 1 : 0,
|
||
admin_https_port: Number(f.elements['admin_https_port']?.value || 8443),
|
||
certbot_strict_port80: f.elements['certbot_strict_port80']?.checked ? 1 : 0,
|
||
fail2ban_sip_maxretry: Number(f.elements['fail2ban_sip_maxretry']?.value || 3),
|
||
fail2ban_sip_bantime: Number(f.elements['fail2ban_sip_bantime']?.value || 3600),
|
||
fail2ban_whitelist_ips: (f.elements['fail2ban_whitelist_ips']?.value || '').trim(),
|
||
ucp_enabled: f.elements['ucp_enabled']?.checked ? 1 : 0,
|
||
ucp_https_port: rawUcpHttps !== '' && rawUcpHttps !== undefined ? Number(rawUcpHttps) : 8444,
|
||
ucp_http_port: rawUcpHttp !== '' && rawUcpHttp !== undefined ? Number(rawUcpHttp) : 8084,
|
||
fail2ban_ucp_maxretry: Number(f.elements['fail2ban_ucp_maxretry']?.value || 3),
|
||
fail2ban_ucp_bantime: Number(f.elements['fail2ban_ucp_bantime']?.value || 3600)
|
||
};
|
||
|
||
try {
|
||
const res = await apiCall('save_firewall_settings', payload);
|
||
showToast(res.message, 'success');
|
||
await loadFirewallStatus();
|
||
if (typeof loadState === 'function') {
|
||
await loadState();
|
||
}
|
||
} catch (err) {
|
||
showToast("Erreur application sécurité : " + err.message, 'error');
|
||
} finally {
|
||
if (btn) btn.disabled = false;
|
||
}
|
||
});
|
||
|
||
// Formulaire Bannissement Manuel Rapide
|
||
document.getElementById('form-manual-ban')?.addEventListener('submit', async (e) => {
|
||
e.preventDefault();
|
||
const ip = (document.getElementById('manual-ban-ip')?.value || '').trim();
|
||
const jail = document.getElementById('manual-ban-jail')?.value || 'asterisk';
|
||
if (!ip) return;
|
||
|
||
try {
|
||
const res = await apiCall('ban_ip', { ip, jail });
|
||
showToast(res.message, res.success ? 'success' : 'error');
|
||
document.getElementById('manual-ban-ip').value = '';
|
||
await loadFirewallStatus();
|
||
} catch (err) {
|
||
showToast("Erreur lors du bannissement : " + err.message, 'error');
|
||
}
|
||
});
|
||
|
||
// Si déjà authentifié au chargement initial
|
||
if (!document.getElementById('app-screen').classList.contains('hidden')) {
|
||
loadState();
|
||
}
|
||
});
|
||
|
||
// =========================================================================
|
||
// GESTIONNAIRE DE CERTIFICATS SSL / TLS (FONCTIONS GLOBALES)
|
||
// =========================================================================
|
||
function renderCertificates() {
|
||
const certs = appState.certificates || [];
|
||
const activeCert = appState.active_certificate || certs.find(c => Number(c.is_default) === 1) || certs[0];
|
||
|
||
// Badge sidebar
|
||
const badgeSidebar = document.getElementById('nav-badge-certificates');
|
||
if (badgeSidebar) {
|
||
badgeSidebar.textContent = certs.length;
|
||
}
|
||
|
||
const badgeTable = document.getElementById('certs-count-badge');
|
||
if (badgeTable) {
|
||
badgeTable.textContent = `${certs.length} certificat${certs.length > 1 ? 's' : ''}`;
|
||
}
|
||
|
||
// Carte Résumé Actif
|
||
if (activeCert) {
|
||
const nameEl = document.getElementById('active-cert-name');
|
||
const domEl = document.getElementById('active-cert-domain');
|
||
const issEl = document.getElementById('active-cert-issuer');
|
||
const typeEl = document.getElementById('active-cert-type-badge');
|
||
const daysEl = document.getElementById('active-cert-days');
|
||
const renewWrap = document.getElementById('active-cert-renew-wrapper');
|
||
|
||
if (nameEl) nameEl.textContent = activeCert.name || 'Certificat Actif';
|
||
if (domEl) domEl.textContent = activeCert.primary_domain || 'pbx.local';
|
||
if (issEl) issEl.textContent = activeCert.issuer || 'Inconnu';
|
||
|
||
if (typeEl) {
|
||
if (activeCert.type === 'letsencrypt') {
|
||
typeEl.className = 'px-2 py-0.5 rounded-full text-[10px] font-bold bg-emerald-500/20 text-emerald-300 border border-emerald-500/40';
|
||
typeEl.textContent = "Let's Encrypt (ACME Auto)";
|
||
} else if (activeCert.type === 'custom') {
|
||
typeEl.className = 'px-2 py-0.5 rounded-full text-[10px] font-bold bg-cyan-500/20 text-cyan-300 border border-cyan-500/40';
|
||
typeEl.textContent = "Certificat Entreprise (Import)";
|
||
} else {
|
||
typeEl.className = 'px-2 py-0.5 rounded-full text-[10px] font-bold bg-anthracite-700 text-slate-300 border border-anthracite-600';
|
||
typeEl.textContent = "Auto-signé Système";
|
||
}
|
||
}
|
||
|
||
if (daysEl) {
|
||
const days = Number(activeCert.days_remaining ?? 0);
|
||
if (days < 0) {
|
||
daysEl.className = 'text-xs font-bold text-rose-400 mt-0.5';
|
||
daysEl.textContent = `Expiré (${Math.abs(days)}j)`;
|
||
} else if (days <= 15) {
|
||
daysEl.className = 'text-xs font-bold text-amber-400 mt-0.5';
|
||
daysEl.textContent = `${days} j restants (À renouveler)`;
|
||
} else {
|
||
daysEl.className = 'text-xs font-bold text-emerald-400 mt-0.5';
|
||
daysEl.textContent = `${days} j restants`;
|
||
}
|
||
}
|
||
|
||
if (renewWrap) {
|
||
if (activeCert.type === 'letsencrypt') {
|
||
renewWrap.classList.remove('hidden');
|
||
} else {
|
||
renewWrap.classList.add('hidden');
|
||
}
|
||
}
|
||
}
|
||
|
||
// Statut Mutual TLS (mTLS) verify_client
|
||
const settings = appState.settings || {};
|
||
const mtlsVal = Number(settings.tls_verify_client ?? 0);
|
||
const selMtls = document.getElementById('select-tls-verify-client');
|
||
const badgeMtls = document.getElementById('badge-mtls-status');
|
||
if (selMtls) {
|
||
selMtls.value = String(mtlsVal);
|
||
}
|
||
if (badgeMtls) {
|
||
if (mtlsVal === 1) {
|
||
badgeMtls.className = 'px-2 py-0.5 rounded-full text-[10px] font-bold bg-amber-500/20 text-amber-300 border border-amber-500/40';
|
||
badgeMtls.textContent = 'Activé (Strict / mTLS)';
|
||
} else {
|
||
badgeMtls.className = 'px-2 py-0.5 rounded-full text-[10px] font-bold bg-anthracite-700 text-slate-300 border border-anthracite-600';
|
||
badgeMtls.textContent = 'Désactivé (Standard)';
|
||
}
|
||
}
|
||
|
||
// Tableau des certificats
|
||
const tbody = document.getElementById('certificates-tbody');
|
||
if (!tbody) return;
|
||
|
||
if (certs.length === 0) {
|
||
tbody.innerHTML = `
|
||
<tr>
|
||
<td colspan="7" class="py-8 text-center text-slate-400">
|
||
Aucun certificat enregistré. Cliquez sur « Générer via Let's Encrypt » ou « Importer un Certificat ».
|
||
</td>
|
||
</tr>
|
||
`;
|
||
return;
|
||
}
|
||
|
||
tbody.innerHTML = certs.map(c => {
|
||
const isDefault = Number(c.is_default) === 1;
|
||
const days = Number(c.days_remaining ?? 0);
|
||
|
||
let typeBadge = '';
|
||
if (c.type === 'letsencrypt') {
|
||
typeBadge = `<span class="px-2 py-0.5 rounded-md text-[10px] font-bold bg-emerald-500/20 text-emerald-300 border border-emerald-500/40">Let's Encrypt</span>`;
|
||
} else if (c.type === 'custom') {
|
||
typeBadge = `<span class="px-2 py-0.5 rounded-md text-[10px] font-bold bg-cyan-500/20 text-cyan-300 border border-cyan-500/40">Import Custom</span>`;
|
||
} else {
|
||
typeBadge = `<span class="px-2 py-0.5 rounded-md text-[10px] font-bold bg-anthracite-700 text-slate-300 border border-anthracite-600">Auto-signé</span>`;
|
||
}
|
||
|
||
let validityBadge = '';
|
||
if (days < 0) {
|
||
validityBadge = `<span class="px-2 py-0.5 rounded-md text-[10px] font-bold bg-rose-500/20 text-rose-300 border border-rose-500/40">Expiré (${c.valid_to ? c.valid_to.substring(0, 10) : ''})</span>`;
|
||
} else if (days <= 15) {
|
||
validityBadge = `<span class="px-2 py-0.5 rounded-md text-[10px] font-bold bg-amber-500/20 text-amber-300 border border-amber-500/40">${days}j restants (${c.valid_to ? c.valid_to.substring(0, 10) : ''})</span>`;
|
||
} else {
|
||
validityBadge = `<span class="px-2 py-0.5 rounded-md text-[10px] font-bold bg-emerald-500/20 text-emerald-300 border border-emerald-500/40">${days}j restants (${c.valid_to ? c.valid_to.substring(0, 10) : ''})</span>`;
|
||
}
|
||
|
||
let defaultBadge = '';
|
||
if (isDefault) {
|
||
defaultBadge = `
|
||
<div class="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg bg-emerald-500/20 text-emerald-300 border border-emerald-500/40 font-bold text-[11px]">
|
||
<span class="w-1.5 h-1.5 rounded-full bg-emerald-400 animate-pulse"></span>
|
||
<span>ACTIF PAR DÉFAUT</span>
|
||
</div>
|
||
`;
|
||
} else {
|
||
defaultBadge = `
|
||
<button type="button" onclick="setDefaultCertificate(${c.id})"
|
||
class="px-2.5 py-1 rounded-lg bg-anthracite-700 hover:bg-emerald-600 hover:text-white text-slate-300 border border-anthracite-600 font-semibold text-xs transition">
|
||
Activer
|
||
</button>
|
||
`;
|
||
}
|
||
|
||
let actions = '<div class="flex items-center justify-end gap-1.5">';
|
||
if (c.type === 'letsencrypt') {
|
||
actions += `
|
||
<button type="button" onclick="renewCertificate(${c.id})" title="Renouveler ce certificat Let's Encrypt"
|
||
class="p-1.5 rounded-lg text-emerald-400 hover:bg-emerald-500/20 border border-emerald-500/30 transition">
|
||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/></svg>
|
||
</button>
|
||
`;
|
||
}
|
||
|
||
if (!isDefault) {
|
||
actions += `
|
||
<button type="button" onclick="deleteCertificate(${c.id}, '${escapeHtml(c.name)}')" title="Supprimer ce certificat"
|
||
class="p-1.5 rounded-lg text-rose-400 hover:bg-rose-500/20 border border-rose-500/30 transition">
|
||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/></svg>
|
||
</button>
|
||
`;
|
||
}
|
||
actions += '</div>';
|
||
|
||
const sanDisplay = c.alt_names ? `<div class="text-[10px] text-slate-400 font-mono mt-0.5 truncate max-w-xs" title="${escapeHtml(c.alt_names)}">+ ${escapeHtml(c.alt_names)}</div>` : '';
|
||
|
||
return `
|
||
<tr class="hover:bg-anthracite-900/50 transition">
|
||
<td class="py-3 px-4 font-semibold text-white">
|
||
<div class="flex items-center gap-2">
|
||
<span>${escapeHtml(c.name)}</span>
|
||
${isDefault ? '<span class="text-emerald-400" title="Actif en production">★</span>' : ''}
|
||
</div>
|
||
</td>
|
||
<td class="py-3 px-4">${typeBadge}</td>
|
||
<td class="py-3 px-4 font-mono text-slate-200">
|
||
<div class="font-bold text-white">${escapeHtml(c.primary_domain)}</div>
|
||
${sanDisplay}
|
||
</td>
|
||
<td class="py-3 px-4 text-slate-300 font-medium">${escapeHtml(c.issuer || '-')}</td>
|
||
<td class="py-3 px-4">${validityBadge}</td>
|
||
<td class="py-3 px-4 text-center">${defaultBadge}</td>
|
||
<td class="py-3 px-4 text-right">${actions}</td>
|
||
</tr>
|
||
`;
|
||
}).join('');
|
||
}
|
||
|
||
function openLetsEncryptModal() {
|
||
const f = document.getElementById('form-cert-letsencrypt');
|
||
if (f) f.reset();
|
||
const box = document.getElementById('letsencrypt-msg-box');
|
||
if (box) { box.className = 'hidden'; box.textContent = ''; }
|
||
if (appState.settings && appState.settings.domain && appState.settings.domain !== 'pbx.local') {
|
||
const domInput = f?.querySelector('input[name="domain"]');
|
||
if (domInput && !domInput.value) domInput.value = appState.settings.domain;
|
||
}
|
||
openModal('modal-cert-letsencrypt');
|
||
}
|
||
|
||
function openCustomCertModal() {
|
||
const f = document.getElementById('form-cert-custom');
|
||
if (f) f.reset();
|
||
const box = document.getElementById('custom-cert-msg-box');
|
||
if (box) { box.className = 'hidden'; box.textContent = ''; }
|
||
openModal('modal-cert-custom');
|
||
}
|
||
|
||
async function setDefaultCertificate(id) {
|
||
if (!confirm("Voulez-vous activer ce certificat comme certificat par défaut ?\n\nIl sera immédiatement utilisé pour le transport PJSIP TLS (port 5061) et le chiffrement des communications audio (SRTP).")) {
|
||
return;
|
||
}
|
||
try {
|
||
const res = await apiCall('set_default_cert', { id: Number(id) });
|
||
showToast(res.message, 'success');
|
||
await loadState();
|
||
} catch (err) {
|
||
showToast(err.message, 'error');
|
||
}
|
||
}
|
||
|
||
async function renewCertificate(id) {
|
||
if (!confirm("Lancer le renouvellement ACME Let's Encrypt pour ce certificat ?")) {
|
||
return;
|
||
}
|
||
try {
|
||
showToast("Renouvellement Let's Encrypt en cours...", 'info');
|
||
const res = await apiCall('renew_cert', { id: Number(id) });
|
||
showToast(res.message, 'success');
|
||
await loadState();
|
||
} catch (err) {
|
||
showToast("Erreur de renouvellement : " + err.message, 'error');
|
||
}
|
||
}
|
||
|
||
async function renewCertificateFromBanner() {
|
||
const certs = appState.certificates || [];
|
||
const activeCert = appState.active_certificate || certs.find(c => Number(c.is_default) === 1);
|
||
if (activeCert && activeCert.id) {
|
||
await renewCertificate(activeCert.id);
|
||
}
|
||
}
|
||
|
||
async function deleteCertificate(id, name) {
|
||
if (!confirm(`Confirmez-vous la suppression du certificat « ${name} » ?\nCette action est irréversible.`)) {
|
||
return;
|
||
}
|
||
try {
|
||
const res = await apiCall('delete_cert', { id: Number(id) });
|
||
showToast(res.message, 'success');
|
||
await loadState();
|
||
} catch (err) {
|
||
showToast(err.message, 'error');
|
||
}
|
||
}
|
||
|
||
async function saveTlsSecurityPolicy() {
|
||
const sel = document.getElementById('select-tls-verify-client');
|
||
const val = Number(sel?.value ?? 0);
|
||
const label = val === 1
|
||
? 'Activer le mode Mutual TLS strict (certificat client X.509 obligatoirement exigé sur chaque téléphone)'
|
||
: 'Désactiver le mode Mutual TLS (standard : authentification par mot de passe SIP dans le tunnel TLS)';
|
||
if (!confirm(`Confirmez-vous cette modification de la politique TLS ?\n\n${label}\n\nAsterisk rechargera immédiatement le transport TLS 5061.`)) {
|
||
return;
|
||
}
|
||
try {
|
||
const res = await apiCall('set_tls_security', { tls_verify_client: val });
|
||
showToast(res.message, 'success');
|
||
await loadState();
|
||
} catch (err) {
|
||
showToast(err.message, 'error');
|
||
}
|
||
}
|
||
|
||
// =========================================================================
|
||
// PARAMÈTRES SIP GLOBAUX & NAT (FONCTIONS GLOBALES)
|
||
// =========================================================================
|
||
function renderSipSettings() {
|
||
const s = appState.settings || {};
|
||
|
||
const extIpEl = document.getElementById('sip-external-ip');
|
||
if (extIpEl && document.activeElement !== extIpEl) {
|
||
extIpEl.value = s.external_ip || '';
|
||
}
|
||
|
||
const extUdpPortEl = document.getElementById('sip-external-udp-port');
|
||
if (extUdpPortEl && document.activeElement !== extUdpPortEl) {
|
||
extUdpPortEl.value = (s.external_udp_port && Number(s.external_udp_port) > 0) ? s.external_udp_port : '';
|
||
}
|
||
|
||
const extTlsPortEl = document.getElementById('sip-external-tls-port');
|
||
if (extTlsPortEl && document.activeElement !== extTlsPortEl) {
|
||
extTlsPortEl.value = (s.external_tls_port && Number(s.external_tls_port) > 0) ? s.external_tls_port : '';
|
||
}
|
||
|
||
const udpPortEl = document.getElementById('sip-udp-port');
|
||
if (udpPortEl && document.activeElement !== udpPortEl) {
|
||
udpPortEl.value = s.udp_port || 5060;
|
||
}
|
||
|
||
const tlsPortEl = document.getElementById('sip-tls-port');
|
||
if (tlsPortEl && document.activeElement !== tlsPortEl) {
|
||
tlsPortEl.value = s.tls_port || 5061;
|
||
}
|
||
|
||
const rtpStartEl = document.getElementById('sip-rtp-start');
|
||
if (rtpStartEl && document.activeElement !== rtpStartEl) {
|
||
rtpStartEl.value = s.rtp_start || 10000;
|
||
}
|
||
|
||
const rtpEndEl = document.getElementById('sip-rtp-end');
|
||
if (rtpEndEl && document.activeElement !== rtpEndEl) {
|
||
rtpEndEl.value = s.rtp_end || 20000;
|
||
}
|
||
|
||
const rtpStrictEl = document.getElementById('sip-rtp-strict');
|
||
if (rtpStrictEl) {
|
||
rtpStrictEl.value = (s.rtp_strict !== undefined && s.rtp_strict !== null) ? String(s.rtp_strict) : '1';
|
||
}
|
||
|
||
const rtpChecksumsEl = document.getElementById('sip-rtp-checksums');
|
||
if (rtpChecksumsEl) {
|
||
rtpChecksumsEl.value = (s.rtp_checksums !== undefined && s.rtp_checksums !== null) ? String(s.rtp_checksums) : '1';
|
||
}
|
||
|
||
const rtpKeepaliveEl = document.getElementById('sip-rtp-keepalive');
|
||
if (rtpKeepaliveEl && document.activeElement !== rtpKeepaliveEl) {
|
||
rtpKeepaliveEl.value = (s.rtp_keepalive !== undefined && s.rtp_keepalive !== null) ? s.rtp_keepalive : 15;
|
||
}
|
||
|
||
const rtpTimeoutEl = document.getElementById('sip-rtp-timeout');
|
||
if (rtpTimeoutEl && document.activeElement !== rtpTimeoutEl) {
|
||
rtpTimeoutEl.value = (s.rtp_timeout !== undefined && s.rtp_timeout !== null) ? s.rtp_timeout : 30;
|
||
}
|
||
|
||
const rtpHoldTimeoutEl = document.getElementById('sip-rtp-timeout-hold');
|
||
if (rtpHoldTimeoutEl && document.activeElement !== rtpHoldTimeoutEl) {
|
||
rtpHoldTimeoutEl.value = (s.rtp_timeout_hold !== undefined && s.rtp_timeout_hold !== null) ? s.rtp_timeout_hold : 0;
|
||
}
|
||
|
||
const allowAnonEl = document.getElementById('sip-allow-anonymous');
|
||
if (allowAnonEl) {
|
||
allowAnonEl.value = (s.allow_anonymous_sip !== undefined && s.allow_anonymous_sip !== null) ? String(s.allow_anonymous_sip) : '0';
|
||
}
|
||
|
||
// Réseaux locaux (Local Networks list)
|
||
const container = document.getElementById('local-networks-container');
|
||
if (container && !container.dataset.initialized) {
|
||
container.innerHTML = '';
|
||
const nets = Array.isArray(s.local_networks) && s.local_networks.length > 0
|
||
? s.local_networks
|
||
: (s.local_net ? [s.local_net] : ['10.10.0.0/16']);
|
||
|
||
nets.forEach(net => addLocalNetworkField(net));
|
||
container.dataset.initialized = 'true';
|
||
}
|
||
}
|
||
|
||
function addLocalNetworkField(value = '') {
|
||
const container = document.getElementById('local-networks-container');
|
||
if (!container) return;
|
||
|
||
const row = document.createElement('div');
|
||
row.className = 'flex items-center gap-2 local-network-row';
|
||
row.innerHTML = `
|
||
<div class="relative flex-1">
|
||
<input type="text" name="local_networks[]" value="${escapeHtml(value)}" placeholder="ex: 10.10.0.0/16 ou 192.168.1.0/24"
|
||
class="w-full px-3 py-2 rounded-xl bg-anthracite-950 border border-anthracite-700 text-xs text-white font-mono focus:ring-2 focus:ring-emerald-500 focus:outline-none">
|
||
</div>
|
||
<button type="button" onclick="removeLocalNetworkField(this)" title="Supprimer ce réseau"
|
||
class="p-2 rounded-xl bg-anthracite-900 border border-anthracite-700 text-slate-400 hover:text-rose-400 hover:border-rose-500/50 transition">
|
||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||
</svg>
|
||
</button>
|
||
`;
|
||
container.appendChild(row);
|
||
}
|
||
|
||
function removeLocalNetworkField(btn) {
|
||
const container = document.getElementById('local-networks-container');
|
||
if (!container) return;
|
||
const rows = container.querySelectorAll('.local-network-row');
|
||
if (rows.length <= 1) {
|
||
showToast("Au moins un réseau local doit être défini.", "error");
|
||
return;
|
||
}
|
||
btn.closest('.local-network-row')?.remove();
|
||
}
|
||
|
||
async function detectPublicIp() {
|
||
const btn = document.getElementById('btn-detect-ip');
|
||
const spinner = document.getElementById('spinner-detect-ip');
|
||
const input = document.getElementById('sip-external-ip');
|
||
|
||
if (btn) btn.disabled = true;
|
||
if (spinner) spinner.classList.remove('hidden');
|
||
|
||
try {
|
||
const res = await apiCall('detect_external_ip');
|
||
if (res.ip && input) {
|
||
input.value = res.ip;
|
||
showToast(`IP publique détectée avec succès : ${res.ip}`, 'success');
|
||
} else {
|
||
showToast("Impossible de déterminer l'adresse IP publique automatiquement.", "error");
|
||
}
|
||
} catch (err) {
|
||
showToast("Erreur de détection : " + err.message, "error");
|
||
} finally {
|
||
if (btn) btn.disabled = false;
|
||
if (spinner) spinner.classList.add('hidden');
|
||
}
|
||
}
|
||
|
||
// =========================================================================
|
||
// CODES DE FONCTIONS TÉLÉPHONIQUES & SERVICES (FONCTIONS GLOBALES)
|
||
// =========================================================================
|
||
let currentFeatureCategory = 'all';
|
||
|
||
function filterFeatureCodes(category) {
|
||
currentFeatureCategory = category;
|
||
document.querySelectorAll('.fc-filter-btn').forEach(btn => {
|
||
if (btn.dataset.category === category) {
|
||
btn.className = 'fc-filter-btn px-3 py-1.5 rounded-xl text-xs font-semibold bg-indigo-600 text-white transition';
|
||
} else {
|
||
btn.className = 'fc-filter-btn px-3 py-1.5 rounded-xl text-xs font-semibold bg-anthracite-800 text-slate-300 hover:text-white transition';
|
||
}
|
||
});
|
||
renderFeatureCodes();
|
||
}
|
||
|
||
function renderFeatureCodes() {
|
||
const list = appState.feature_codes || [];
|
||
const tbody = document.getElementById('feature-codes-table-body');
|
||
const badge = document.getElementById('nav-badge-featurecodes');
|
||
const countAll = document.getElementById('fc-count-all');
|
||
|
||
const activeCount = list.filter(f => Number(f.enabled) === 1).length;
|
||
if (badge) badge.textContent = String(activeCount);
|
||
if (countAll) countAll.textContent = String(list.length);
|
||
|
||
if (!tbody) return;
|
||
|
||
const filtered = (currentFeatureCategory === 'all')
|
||
? list
|
||
: list.filter(item => item.category === currentFeatureCategory);
|
||
|
||
if (filtered.length === 0) {
|
||
tbody.innerHTML = `
|
||
<tr>
|
||
<td colspan="6" class="text-center py-8 text-slate-400 text-xs">
|
||
Aucun code de fonction trouvé dans cette catégorie.
|
||
</td>
|
||
</tr>
|
||
`;
|
||
return;
|
||
}
|
||
|
||
const categoryBadges = {
|
||
forward: '<span class="px-2 py-0.5 rounded-md text-[10px] font-bold bg-amber-500/20 text-amber-300 border border-amber-500/30">Renvois</span>',
|
||
pickup: '<span class="px-2 py-0.5 rounded-md text-[10px] font-bold bg-indigo-500/20 text-indigo-300 border border-indigo-500/30">BLF / Capture</span>',
|
||
dnd: '<span class="px-2 py-0.5 rounded-md text-[10px] font-bold bg-rose-500/20 text-rose-300 border border-rose-500/30">Ne Pas Déranger</span>',
|
||
voicemail: '<span class="px-2 py-0.5 rounded-md text-[10px] font-bold bg-purple-500/20 text-purple-300 border border-purple-500/30">Messagerie</span>',
|
||
security: '<span class="px-2 py-0.5 rounded-md text-[10px] font-bold bg-rose-500/20 text-rose-300 border border-rose-500/30">Sécurité</span>',
|
||
tools: '<span class="px-2 py-0.5 rounded-md text-[10px] font-bold bg-cyan-500/20 text-cyan-300 border border-cyan-500/30">Outils</span>'
|
||
};
|
||
|
||
tbody.innerHTML = filtered.map(fc => {
|
||
const isEnabled = Number(fc.enabled) === 1;
|
||
const catBadge = categoryBadges[fc.category] || `<span class="px-2 py-0.5 rounded-md text-[10px] font-bold bg-anthracite-700 text-slate-300">${fc.category}</span>`;
|
||
const codeDisplay = escapeHtml(fc.code);
|
||
|
||
return `
|
||
<tr class="hover:bg-anthracite-750 transition group">
|
||
<td class="py-3 px-4 font-mono font-bold text-sm text-indigo-300">
|
||
<span class="px-2.5 py-1 rounded-lg bg-indigo-500/15 border border-indigo-500/30 inline-block">${codeDisplay}</span>
|
||
</td>
|
||
<td class="py-3 px-4">
|
||
<div class="font-semibold text-white text-xs">${escapeHtml(fc.name)}</div>
|
||
<div class="text-[10px] text-slate-400 font-mono">Standard : ${escapeHtml(fc.default_code)}</div>
|
||
</td>
|
||
<td class="py-3 px-4 text-xs text-slate-300 max-w-xs hidden sm:table-cell leading-relaxed">
|
||
${escapeHtml(fc.description)}
|
||
</td>
|
||
<td class="py-3 px-4">
|
||
${catBadge}
|
||
</td>
|
||
<td class="py-3 px-4 text-center">
|
||
<label class="relative inline-flex items-center cursor-pointer">
|
||
<input type="checkbox" ${isEnabled ? 'checked' : ''} onchange="toggleFeatureCode(${fc.id}, this.checked)" class="sr-only peer">
|
||
<div class="w-9 h-5 bg-anthracite-700 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-emerald-600"></div>
|
||
</label>
|
||
</td>
|
||
<td class="py-3 px-4 text-right">
|
||
<button type="button" onclick="openEditFeatureCodeModal(${fc.id})"
|
||
class="p-1.5 rounded-lg bg-anthracite-700 hover:bg-indigo-600 text-slate-300 hover:text-white transition" title="Personnaliser ce code">
|
||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/>
|
||
</svg>
|
||
</button>
|
||
</td>
|
||
</tr>
|
||
`;
|
||
}).join('');
|
||
}
|
||
|
||
async function toggleFeatureCode(id, enabled) {
|
||
try {
|
||
const res = await apiCall('toggle_feature_code', { id: Number(id), enabled: enabled ? 1 : 0 });
|
||
showToast(res.message, 'success');
|
||
await loadState();
|
||
} catch (err) {
|
||
showToast(err.message, 'error');
|
||
await loadState();
|
||
}
|
||
}
|
||
|
||
function openEditFeatureCodeModal(id) {
|
||
const list = appState.feature_codes || [];
|
||
const item = list.find(f => Number(f.id) === Number(id));
|
||
if (!item) return;
|
||
|
||
document.getElementById('edit-fc-id').value = item.id;
|
||
document.getElementById('edit-fc-code').value = item.code;
|
||
document.getElementById('modal-fc-service-name').textContent = item.name;
|
||
document.getElementById('modal-fc-default-code').textContent = item.default_code;
|
||
|
||
openModal('modal-edit-feature-code');
|
||
}
|
||
|
||
async function resetFeatureCodes() {
|
||
if (!confirm("Voulez-vous réinitialiser tous les codes de fonctions aux valeurs standard FreePBX / Asterisk ?\n\n(ex: ** pour BLF, *8 pour groupe, *72/*73 pour renvois, *78/*79 pour DND, *97 pour boîte vocale)")) {
|
||
return;
|
||
}
|
||
try {
|
||
const res = await apiCall('reset_feature_codes');
|
||
showToast(res.message, 'success');
|
||
await loadState();
|
||
} catch (err) {
|
||
showToast(err.message, 'error');
|
||
}
|
||
}
|
||
|
||
// =========================================================================
|
||
// GESTIONNAIRE DE SAUVEGARDES & RESTAURATION (DISASTER RECOVERY)
|
||
// =========================================================================
|
||
|
||
let appBackupsState = {
|
||
settings: {},
|
||
backups: []
|
||
};
|
||
|
||
async function loadBackups() {
|
||
try {
|
||
const res = await apiCall('get_backup_data', {});
|
||
if (res.success) {
|
||
appBackupsState.settings = res.settings || {};
|
||
appBackupsState.backups = res.backups || [];
|
||
renderBackups();
|
||
}
|
||
} catch (err) {
|
||
showToast("Erreur chargement des sauvegardes : " + err.message, 'error');
|
||
}
|
||
}
|
||
|
||
function renderBackups() {
|
||
const s = appBackupsState.settings || {};
|
||
const backups = appBackupsState.backups || [];
|
||
|
||
// 1. Stats d'en-tête
|
||
const lastRunEl = document.getElementById('backup-stat-last-run');
|
||
if (lastRunEl) {
|
||
lastRunEl.textContent = s.backup_last_run ? s.backup_last_run : 'Jamais exécutée';
|
||
}
|
||
|
||
const storageEl = document.getElementById('backup-stat-storage');
|
||
if (storageEl) {
|
||
if (s.backup_storage_type === 'ssh') {
|
||
storageEl.textContent = `Distant SSH (${s.backup_ssh_host || 'non configuré'})`;
|
||
storageEl.className = 'text-sm font-bold text-purple-400 truncate';
|
||
} else {
|
||
storageEl.textContent = 'Stockage Local';
|
||
storageEl.className = 'text-sm font-bold text-emerald-400 truncate';
|
||
}
|
||
}
|
||
|
||
const countEl = document.getElementById('backup-stat-count');
|
||
if (countEl) {
|
||
countEl.textContent = `${backups.length} archive(s)`;
|
||
}
|
||
|
||
const badgeSidebar = document.getElementById('nav-badge-backups');
|
||
if (badgeSidebar) {
|
||
badgeSidebar.textContent = backups.length;
|
||
}
|
||
|
||
// 2. Remplissage du formulaire de configuration
|
||
const form = document.getElementById('backup-settings-form');
|
||
if (form) {
|
||
if (form.elements['backup_auto_enabled']) {
|
||
form.elements['backup_auto_enabled'].checked = Number(s.backup_auto_enabled) === 1;
|
||
}
|
||
if (form.elements['backup_frequency']) {
|
||
form.elements['backup_frequency'].value = s.backup_frequency || 'daily';
|
||
}
|
||
if (form.elements['backup_retention']) {
|
||
form.elements['backup_retention'].value = s.backup_retention || 7;
|
||
}
|
||
const storageRadios = form.querySelectorAll('input[name="backup_storage_type"]');
|
||
storageRadios.forEach(r => {
|
||
r.checked = (r.value === (s.backup_storage_type || 'local'));
|
||
});
|
||
if (form.elements['backup_ssh_host']) {
|
||
form.elements['backup_ssh_host'].value = s.backup_ssh_host || '';
|
||
}
|
||
if (form.elements['backup_ssh_port']) {
|
||
form.elements['backup_ssh_port'].value = s.backup_ssh_port || 22;
|
||
}
|
||
if (form.elements['backup_ssh_user']) {
|
||
form.elements['backup_ssh_user'].value = s.backup_ssh_user || '';
|
||
}
|
||
if (form.elements['backup_ssh_password']) {
|
||
form.elements['backup_ssh_password'].value = s.backup_ssh_password || '';
|
||
}
|
||
if (form.elements['backup_ssh_remote_path']) {
|
||
form.elements['backup_ssh_remote_path'].value = s.backup_ssh_remote_path || '/backups/nexus';
|
||
}
|
||
|
||
toggleSshBackupFields((s.backup_storage_type || 'local') === 'ssh');
|
||
}
|
||
|
||
// 3. Remplissage du tableau des archives
|
||
const tbody = document.getElementById('backups-table-body');
|
||
if (!tbody) return;
|
||
|
||
if (backups.length === 0) {
|
||
tbody.innerHTML = `
|
||
<tr>
|
||
<td colspan="5" class="px-4 py-8 text-center text-slate-400">
|
||
<div class="flex flex-col items-center justify-center gap-2">
|
||
<svg class="w-8 h-8 text-slate-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M8 7H5a2 2 0 00-2 2v9a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-3m-1 4l-3 3m0 0l-3-3m3 3V4"/>
|
||
</svg>
|
||
<span>Aucune archive de sauvegarde disponible. Cliquez sur "Créer une Sauvegarde Immédiate" pour générer la première.</span>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
`;
|
||
return;
|
||
}
|
||
|
||
tbody.innerHTML = backups.map(b => {
|
||
const isLocal = b.is_local;
|
||
const locBadge = isLocal
|
||
? `<span class="px-2 py-0.5 rounded-full text-[10px] font-bold bg-emerald-500/20 text-emerald-300 border border-emerald-500/30">Local</span>`
|
||
: `<span class="px-2 py-0.5 rounded-full text-[10px] font-bold bg-purple-500/20 text-purple-300 border border-purple-500/30">Distant (SSH)</span>`;
|
||
|
||
const versionBadge = `<span class="px-1.5 py-0.2 rounded text-[10px] font-mono font-bold bg-indigo-500/20 text-indigo-300 border border-indigo-500/30">v${escapeHtml(b.version || '1.6.2')}</span>`;
|
||
let srcBadge = '';
|
||
if (b.source === 'pre-update') {
|
||
srcBadge = `<span class="px-1.5 py-0.2 rounded text-[10px] font-bold bg-amber-500/20 text-amber-300 border border-amber-500/30" title="Sauvegarde automatique pré-mise à jour">Pré-MàJ</span>`;
|
||
} else if (b.source === 'scheduled') {
|
||
srcBadge = `<span class="px-1.5 py-0.2 rounded text-[10px] font-bold bg-cyan-500/20 text-cyan-300 border border-cyan-500/30" title="Sauvegarde planifiée automatique">Auto</span>`;
|
||
}
|
||
|
||
return `
|
||
<tr class="hover:bg-anthracite-750 transition">
|
||
<td class="px-4 py-3 font-mono font-medium text-white">
|
||
<div class="flex items-center gap-2">
|
||
<svg class="w-4 h-4 text-purple-400 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
|
||
</svg>
|
||
<span class="truncate max-w-xs sm:max-w-md">${escapeHtml(b.filename)}</span>
|
||
${versionBadge}
|
||
${srcBadge}
|
||
</div>
|
||
</td>
|
||
<td class="px-4 py-3 text-slate-300 whitespace-nowrap">${escapeHtml(b.date_formatted)}</td>
|
||
<td class="px-4 py-3 font-mono text-emerald-300 whitespace-nowrap">${escapeHtml(b.size_formatted)}</td>
|
||
<td class="px-4 py-3 whitespace-nowrap">${locBadge}</td>
|
||
<td class="px-4 py-3 text-right whitespace-nowrap">
|
||
<div class="flex items-center justify-end gap-1.5">
|
||
<button type="button" onclick="downloadBackup('${escapeHtml(b.filename)}')"
|
||
class="px-2.5 py-1 rounded-lg bg-anthracite-700 hover:bg-indigo-600 text-slate-200 hover:text-white transition flex items-center gap-1 font-semibold text-xs" title="Télécharger l'archive">
|
||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"/>
|
||
</svg>
|
||
<span>Télécharger</span>
|
||
</button>
|
||
<button type="button" onclick="confirmRestoreBackup('${escapeHtml(b.filename)}')"
|
||
class="px-2.5 py-1 rounded-lg bg-anthracite-700 hover:bg-emerald-600 text-slate-200 hover:text-white transition flex items-center gap-1 font-semibold text-xs" title="Restaurer cette sauvegarde">
|
||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/>
|
||
</svg>
|
||
<span>Restaurer</span>
|
||
</button>
|
||
<button type="button" onclick="deleteBackup('${escapeHtml(b.filename)}')"
|
||
class="p-1.5 rounded-lg bg-anthracite-700 hover:bg-rose-600 text-slate-400 hover:text-white transition" title="Supprimer">
|
||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/>
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
`;
|
||
}).join('');
|
||
}
|
||
|
||
function toggleSshBackupFields(show) {
|
||
const fields = document.getElementById('backup-ssh-fields');
|
||
if (!fields) return;
|
||
if (show) {
|
||
fields.classList.remove('hidden');
|
||
} else {
|
||
fields.classList.add('hidden');
|
||
}
|
||
}
|
||
|
||
async function createBackupNow() {
|
||
const btn = document.getElementById('btn-create-backup-now');
|
||
const label = document.getElementById('btn-create-backup-label');
|
||
if (btn) btn.disabled = true;
|
||
if (label) label.textContent = 'Génération de l\'archive...';
|
||
|
||
try {
|
||
const res = await apiCall('create_backup', {});
|
||
showToast(res.message, 'success');
|
||
await loadBackups();
|
||
} catch (err) {
|
||
showToast("Échec de création : " + err.message, 'error');
|
||
} finally {
|
||
if (btn) btn.disabled = false;
|
||
if (label) label.textContent = 'Créer une Sauvegarde Immédiate';
|
||
}
|
||
}
|
||
|
||
let pendingRestoreAction = null;
|
||
let pendingRestoreInput = null;
|
||
|
||
function showRestoreModal({ title, filename, badgeText, onConfirm, inputEl = null }) {
|
||
const titleEl = document.getElementById('modal-restore-title');
|
||
if (titleEl) titleEl.textContent = title || "Restauration Complète du Système";
|
||
|
||
const nameEl = document.getElementById('restore-target-name');
|
||
if (nameEl) nameEl.textContent = filename || "Archive de sauvegarde";
|
||
|
||
const badgeEl = document.getElementById('restore-target-badge');
|
||
if (badgeEl) badgeEl.textContent = badgeText || "Archive";
|
||
|
||
const confirmBtn = document.getElementById('btn-confirm-restore');
|
||
const cancelBtn = document.getElementById('btn-cancel-restore');
|
||
if (confirmBtn) {
|
||
confirmBtn.disabled = false;
|
||
confirmBtn.innerHTML = `
|
||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/></svg>
|
||
<span>Confirmer la Restauration</span>
|
||
`;
|
||
}
|
||
if (cancelBtn) cancelBtn.disabled = false;
|
||
|
||
pendingRestoreAction = onConfirm;
|
||
pendingRestoreInput = inputEl;
|
||
|
||
openModal('modal-restore-backup');
|
||
}
|
||
|
||
function closeRestoreModal() {
|
||
closeModal('modal-restore-backup');
|
||
if (pendingRestoreInput) {
|
||
pendingRestoreInput.value = '';
|
||
pendingRestoreInput = null;
|
||
}
|
||
pendingRestoreAction = null;
|
||
}
|
||
|
||
async function executePendingRestore() {
|
||
if (!pendingRestoreAction) return;
|
||
const confirmBtn = document.getElementById('btn-confirm-restore');
|
||
const cancelBtn = document.getElementById('btn-cancel-restore');
|
||
if (confirmBtn) {
|
||
confirmBtn.disabled = true;
|
||
confirmBtn.innerHTML = `
|
||
<svg class="animate-spin w-4 h-4" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z"></path></svg>
|
||
<span>Restauration en cours...</span>
|
||
`;
|
||
}
|
||
if (cancelBtn) cancelBtn.disabled = true;
|
||
|
||
try {
|
||
await pendingRestoreAction();
|
||
} catch (err) {
|
||
showToast("Erreur inattendue : " + err.message, "error");
|
||
closeRestoreModal();
|
||
}
|
||
}
|
||
|
||
function confirmRestoreBackup(filename) {
|
||
showRestoreModal({
|
||
title: "Restauration du Système NexusPBX",
|
||
filename: filename,
|
||
badgeText: "Archive Locale",
|
||
onConfirm: async () => {
|
||
showToast("Restauration du système en cours... Veuillez patienter.", "info");
|
||
try {
|
||
const res = await apiCall('restore_backup', { filename });
|
||
closeRestoreModal();
|
||
showToast(res.message || "Restauration terminée avec succès !", 'success');
|
||
setTimeout(() => {
|
||
window.location.reload();
|
||
}, 2000);
|
||
} catch (err) {
|
||
closeRestoreModal();
|
||
showToast("Échec de la restauration : " + err.message, 'error');
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
function triggerBackupUpload() {
|
||
document.getElementById('backup-upload-input')?.click();
|
||
}
|
||
|
||
function handleBackupUpload(input) {
|
||
const file = input?.files?.[0];
|
||
if (!file) return;
|
||
|
||
const lowerName = file.name.toLowerCase();
|
||
if (!lowerName.endsWith('.tar.gz') && !lowerName.endsWith('.tgz')) {
|
||
showToast("Format de fichier non supporté. Veuillez sélectionner une archive de sauvegarde .tar.gz ou .tgz", 'error');
|
||
input.value = '';
|
||
return;
|
||
}
|
||
|
||
const fileSizeMb = (file.size / (1024 * 1024)).toFixed(2);
|
||
|
||
showRestoreModal({
|
||
title: "Disaster Recovery — Archive Téléversée",
|
||
filename: `${file.name} (${fileSizeMb} Mo)`,
|
||
badgeText: "Fichier Téléversé",
|
||
inputEl: input,
|
||
onConfirm: async () => {
|
||
showToast("Téléversement et restauration de l'archive en cours...", "info");
|
||
const fd = new FormData();
|
||
fd.append('backup_file', file);
|
||
|
||
try {
|
||
const response = await fetch('/api.php?action=upload_restore_backup', {
|
||
method: 'POST',
|
||
body: fd
|
||
});
|
||
const res = await response.json();
|
||
closeRestoreModal();
|
||
if (!res.success) {
|
||
throw new Error(res.message || "Erreur lors de la restauration");
|
||
}
|
||
showToast(res.message, 'success');
|
||
setTimeout(() => {
|
||
window.location.reload();
|
||
}, 2000);
|
||
} catch (err) {
|
||
closeRestoreModal();
|
||
showToast("Échec de la restauration : " + err.message, 'error');
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
function downloadBackup(filename) {
|
||
window.location.href = `/api.php?action=download_backup&filename=${encodeURIComponent(filename)}`;
|
||
}
|
||
|
||
async function deleteBackup(filename) {
|
||
if (!confirm(`Êtes-vous sûr de vouloir supprimer définitivement l'archive de sauvegarde suivante ?\n\n${filename}`)) {
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const res = await apiCall('delete_backup', { filename });
|
||
showToast(res.message, res.success ? 'success' : 'error');
|
||
await loadBackups();
|
||
} catch (err) {
|
||
showToast("Erreur lors de la suppression : " + err.message, 'error');
|
||
}
|
||
}
|
||
|
||
async function testSshBackupConnection() {
|
||
const form = document.getElementById('backup-settings-form');
|
||
if (!form) return;
|
||
|
||
const payload = {
|
||
backup_ssh_host: (form.elements['backup_ssh_host']?.value || '').trim(),
|
||
backup_ssh_port: Number(form.elements['backup_ssh_port']?.value || 22),
|
||
backup_ssh_user: (form.elements['backup_ssh_user']?.value || '').trim(),
|
||
backup_ssh_password: form.elements['backup_ssh_password']?.value || '',
|
||
backup_ssh_remote_path: (form.elements['backup_ssh_remote_path']?.value || '/backups/nexus').trim(),
|
||
backup_ssh_auth_type: 'password'
|
||
};
|
||
|
||
showToast("Test de la connexion SSH vers " + payload.backup_ssh_host + "...", "info");
|
||
|
||
try {
|
||
const res = await apiCall('test_ssh_backup', payload);
|
||
if (res.success) {
|
||
showToast(res.message, 'success');
|
||
} else {
|
||
showToast(res.message, 'error');
|
||
}
|
||
} catch (err) {
|
||
showToast("Échec du test SSH : " + err.message, 'error');
|
||
}
|
||
}
|
||
|
||
// =========================================================================
|
||
// GESTIONNAIRE DE PARE-FEU & SÉCURITÉ (FAIL2BAN & NFTABLES)
|
||
// =========================================================================
|
||
|
||
let appFirewallState = {
|
||
is_running: false,
|
||
settings: {},
|
||
jails: {},
|
||
banned_ips: [],
|
||
total_banned: 0,
|
||
client_ip: ''
|
||
};
|
||
|
||
async function loadFirewallStatus() {
|
||
try {
|
||
const res = await apiCall('get_firewall_status', {});
|
||
if (res.success && res.data) {
|
||
appFirewallState = res.data;
|
||
renderFirewall();
|
||
}
|
||
} catch (err) {
|
||
showToast("Erreur chargement sécurité : " + err.message, 'error');
|
||
}
|
||
}
|
||
|
||
function renderFirewall() {
|
||
const d = appFirewallState;
|
||
const s = d.settings || {};
|
||
const banned = d.banned_ips || [];
|
||
|
||
// 1. Stat cards
|
||
const engineEl = document.getElementById('fw-stat-engine');
|
||
if (engineEl) {
|
||
engineEl.textContent = d.is_running ? 'En ligne (Actif)' : 'Arrêté (Inactif)';
|
||
engineEl.className = d.is_running ? 'text-sm font-bold text-emerald-400 truncate' : 'text-sm font-bold text-rose-400 truncate';
|
||
}
|
||
|
||
const sipJail = d.jails?.asterisk;
|
||
const sipJailEl = document.getElementById('fw-stat-sip-jail');
|
||
if (sipJailEl) {
|
||
sipJailEl.textContent = sipJail ? `Actif (${sipJail.banned_ips?.length || 0} banni)` : 'Inactif';
|
||
}
|
||
|
||
const webJail = d.jails?.['nexus-web'];
|
||
const webJailEl = document.getElementById('fw-stat-web-jail');
|
||
if (webJailEl) {
|
||
webJailEl.textContent = webJail ? `Protégé (${webJail.banned_ips?.length || 0} banni)` : 'Inactif';
|
||
}
|
||
|
||
const countEl = document.getElementById('fw-stat-banned-count');
|
||
if (countEl) {
|
||
countEl.textContent = `${d.total_banned} bannie(s)`;
|
||
countEl.className = d.total_banned > 0 ? 'text-sm font-bold text-rose-400 truncate' : 'text-sm font-bold text-emerald-400 truncate';
|
||
}
|
||
|
||
const badgeSidebar = document.getElementById('nav-badge-firewall');
|
||
if (badgeSidebar) {
|
||
badgeSidebar.textContent = d.total_banned;
|
||
badgeSidebar.className = d.total_banned > 0
|
||
? 'ml-2 shrink-0 px-1.5 py-0.2 text-[10px] rounded-full bg-rose-500/20 text-rose-300 border border-rose-500/30 font-bold'
|
||
: 'ml-2 shrink-0 px-1.5 py-0.2 text-[10px] rounded-full bg-anthracite-800 text-emerald-300 border border-anthracite-700 font-bold';
|
||
}
|
||
|
||
// 2. Table of banned IPs
|
||
const tbody = document.getElementById('firewall-banned-table-body');
|
||
if (tbody) {
|
||
if (banned.length === 0) {
|
||
tbody.innerHTML = `
|
||
<tr>
|
||
<td colspan="4" class="px-4 py-8 text-center text-slate-400">
|
||
<div class="flex flex-col items-center justify-center gap-2">
|
||
<svg class="w-8 h-8 text-emerald-500/60" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"/>
|
||
</svg>
|
||
<span class="text-slate-300 font-medium">Aucune adresse IP n'est actuellement bloquée.</span>
|
||
<span class="text-[11px] text-slate-500">Le pare-feu NFTables et Fail2ban surveillent les flux SIP et Web 24h/24.</span>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
`;
|
||
} else {
|
||
tbody.innerHTML = banned.map(b => {
|
||
let jailClass = 'bg-purple-500/20 text-purple-300 border border-purple-500/30';
|
||
if (b.jail === 'asterisk') {
|
||
jailClass = 'bg-cyan-500/20 text-cyan-300 border border-cyan-500/30';
|
||
} else if (b.jail === 'nexus-ucp') {
|
||
jailClass = 'bg-sky-500/20 text-sky-300 border border-sky-500/30';
|
||
}
|
||
return `
|
||
<tr class="hover:bg-anthracite-750 transition">
|
||
<td class="px-4 py-3 font-mono font-bold text-white text-sm">
|
||
<span class="text-rose-400">⛔ ${escapeHtml(b.ip)}</span>
|
||
</td>
|
||
<td class="px-4 py-3">
|
||
<span class="px-2 py-0.5 rounded-full text-[10px] font-bold ${jailClass}">${escapeHtml(b.jail_label || b.jail)}</span>
|
||
</td>
|
||
<td class="px-4 py-3">
|
||
<span class="px-2 py-0.5 rounded text-[10px] font-bold bg-rose-950/80 text-rose-300 border border-rose-600/40">DROP (Bloqué)</span>
|
||
</td>
|
||
<td class="px-4 py-3 text-right">
|
||
<button type="button" onclick="unbanIp('${escapeHtml(b.ip)}', '${escapeHtml(b.jail)}')"
|
||
class="px-2.5 py-1 rounded-lg bg-emerald-600/20 hover:bg-emerald-600 text-emerald-300 hover:text-white border border-emerald-500/40 text-xs font-semibold transition">
|
||
Débannir
|
||
</button>
|
||
</td>
|
||
</tr>
|
||
`;
|
||
}).join('');
|
||
}
|
||
}
|
||
|
||
// 3. Form fields
|
||
const form = document.getElementById('firewall-settings-form');
|
||
if (form) {
|
||
if (form.elements['admin_https_port']) {
|
||
form.elements['admin_https_port'].value = s.admin_https_port || 8443;
|
||
}
|
||
if (form.elements['certbot_strict_port80']) {
|
||
form.elements['certbot_strict_port80'].checked = Number(s.certbot_strict_port80) === 1;
|
||
}
|
||
if (form.elements['firewall_enabled']) {
|
||
form.elements['firewall_enabled'].checked = Number(s.firewall_enabled) === 1;
|
||
}
|
||
if (form.elements['fail2ban_sip_maxretry']) {
|
||
form.elements['fail2ban_sip_maxretry'].value = s.fail2ban_sip_maxretry || 3;
|
||
}
|
||
if (form.elements['fail2ban_sip_bantime']) {
|
||
form.elements['fail2ban_sip_bantime'].value = s.fail2ban_sip_bantime || 3600;
|
||
}
|
||
if (form.elements['fail2ban_whitelist_ips'] && document.activeElement !== form.elements['fail2ban_whitelist_ips']) {
|
||
form.elements['fail2ban_whitelist_ips'].value = s.fail2ban_whitelist_ips || '';
|
||
}
|
||
if (form.elements['ucp_enabled']) {
|
||
form.elements['ucp_enabled'].checked = Number(s.ucp_enabled ?? 1) === 1;
|
||
}
|
||
if (form.elements['ucp_https_port'] && document.activeElement !== form.elements['ucp_https_port']) {
|
||
form.elements['ucp_https_port'].value = s.ucp_https_port !== undefined ? s.ucp_https_port : 8444;
|
||
}
|
||
if (form.elements['ucp_http_port'] && document.activeElement !== form.elements['ucp_http_port']) {
|
||
form.elements['ucp_http_port'].value = s.ucp_http_port !== undefined ? s.ucp_http_port : 8084;
|
||
}
|
||
if (form.elements['fail2ban_ucp_maxretry']) {
|
||
form.elements['fail2ban_ucp_maxretry'].value = s.fail2ban_ucp_maxretry || 3;
|
||
}
|
||
if (form.elements['fail2ban_ucp_bantime']) {
|
||
form.elements['fail2ban_ucp_bantime'].value = s.fail2ban_ucp_bantime || 3600;
|
||
}
|
||
}
|
||
}
|
||
|
||
async function unbanIp(ip, jail) {
|
||
if (!confirm(`Débannir l'adresse IP ${ip} et rétablir son accès immédiat ?`)) {
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const res = await apiCall('unban_ip', { ip, jail });
|
||
showToast(res.message, res.success ? 'success' : 'error');
|
||
await loadFirewallStatus();
|
||
} catch (err) {
|
||
showToast("Erreur lors du débannissement : " + err.message, 'error');
|
||
}
|
||
}
|
||
|
||
function whitelistMyIp() {
|
||
const clientIp = appFirewallState.client_ip;
|
||
if (!clientIp) {
|
||
showToast("Impossible de déterminer votre adresse IP actuelle.", 'error');
|
||
return;
|
||
}
|
||
|
||
const textarea = document.getElementById('fail2ban_whitelist_ips');
|
||
if (!textarea) return;
|
||
|
||
const currentVal = textarea.value.trim();
|
||
const tokens = currentVal ? currentVal.split(/\s+/) : [];
|
||
|
||
if (tokens.includes(clientIp)) {
|
||
showToast(`Votre IP actuelle (${clientIp}) est déjà dans la liste blanche.`, 'info');
|
||
return;
|
||
}
|
||
|
||
tokens.push(clientIp);
|
||
textarea.value = tokens.join(' ');
|
||
showToast(`Votre IP actuelle (${clientIp}) a été ajoutée. Pensez à cliquer sur "Enregistrer" pour appliquer.`, 'success');
|
||
}
|
||
|
||
// Modal Journal des Événements de Sécurité
|
||
async function openSecurityEventsModal() {
|
||
const modal = document.getElementById('modal-security-events');
|
||
const tbody = document.getElementById('security-events-tbody');
|
||
const emptyState = document.getElementById('security-events-empty');
|
||
if (!modal) return;
|
||
|
||
modal.classList.remove('hidden');
|
||
if (tbody) tbody.innerHTML = '<tr><td colspan="5" class="py-8 text-center text-slate-400">Chargement des événements de sécurité en direct...</td></tr>';
|
||
if (emptyState) emptyState.classList.add('hidden');
|
||
|
||
try {
|
||
const resp = await fetch('/api.php?action=get_security_events');
|
||
const data = await resp.json();
|
||
const events = data.events || [];
|
||
|
||
if (events.length === 0) {
|
||
if (tbody) tbody.innerHTML = '';
|
||
if (emptyState) emptyState.classList.remove('hidden');
|
||
return;
|
||
}
|
||
|
||
if (tbody) {
|
||
tbody.innerHTML = events.map(e => `
|
||
<tr class="hover:bg-anthracite-900/50 transition border-b border-anthracite-800">
|
||
<td class="py-2.5 px-3 font-mono text-[11px] text-slate-300 whitespace-nowrap">${escapeHtml(e.time)}</td>
|
||
<td class="py-2.5 px-3">
|
||
<span class="px-2 py-0.5 rounded text-[10px] font-bold bg-indigo-500/15 text-indigo-300 border border-indigo-500/30 font-mono">
|
||
${escapeHtml(e.service)}
|
||
</span>
|
||
</td>
|
||
<td class="py-2.5 px-3">
|
||
<span class="text-rose-400 font-semibold font-mono text-xs">${escapeHtml(e.event)}</span>
|
||
<div class="text-[10px] text-slate-400">Échec mot de passe / Défi SIP</div>
|
||
</td>
|
||
<td class="py-2.5 px-3 font-mono text-xs text-amber-300 font-bold">${escapeHtml(e.account)}</td>
|
||
<td class="py-2.5 px-3 font-mono text-xs text-cyan-300 font-bold">${escapeHtml(e.ip)}</td>
|
||
</tr>
|
||
`).join('');
|
||
}
|
||
} catch (err) {
|
||
if (tbody) {
|
||
tbody.innerHTML = `<tr><td colspan="5" class="py-4 text-center text-rose-400">Erreur : ${escapeHtml(err.message)}</td></tr>`;
|
||
}
|
||
}
|
||
}
|
||
|
||
async function clearSecurityLog() {
|
||
if (!confirm("Voulez-vous vraiment vider le journal des événements de sécurité et réinitialiser les compteurs d'attaques à zéro ?")) {
|
||
return;
|
||
}
|
||
try {
|
||
const res = await apiCall('clear_security_log', {});
|
||
showToast(res.message, 'success');
|
||
await openSecurityEventsModal();
|
||
await pollMetrics();
|
||
} catch (err) {
|
||
showToast("Erreur lors de la purge du journal : " + err.message, 'error');
|
||
}
|
||
}
|
||
|
||
// =========================================================================
|
||
// 2C. ANNUAIRE D'ENTREPRISE & YEALINK REMOTE PHONEBOOK XML
|
||
// =========================================================================
|
||
let selectedPhonebookGroup = 'all';
|
||
|
||
function renderPhonebookTable() {
|
||
const tbody = document.getElementById('phonebook-tbody');
|
||
if (!tbody) return;
|
||
|
||
const contacts = appState.phonebook_contacts || [];
|
||
const groups = appState.phonebook_groups || [];
|
||
const searchVal = (document.getElementById('phonebook-search')?.value || '').trim().toLowerCase();
|
||
|
||
// 1. Rendu des Group Pills
|
||
const pillsContainer = document.getElementById('phonebook-group-pills');
|
||
if (pillsContainer) {
|
||
let pillsHtml = `
|
||
<button type="button" onclick="selectPhonebookGroupFilter('all')"
|
||
class="phonebook-grp-pill ${selectedPhonebookGroup === 'all' ? 'active bg-indigo-600 text-white' : 'bg-anthracite-950 text-slate-400 hover:text-white'} px-3 py-1 rounded-lg text-xs font-semibold border border-anthracite-700 transition">
|
||
Tous les groupes (${contacts.length})
|
||
</button>
|
||
`;
|
||
|
||
groups.forEach(g => {
|
||
const count = contacts.filter(c => {
|
||
const cg = (c.group_name || '').toLowerCase();
|
||
const gl = g.toLowerCase();
|
||
return cg === gl || cg.includes(gl) || cg === 'tous' || cg === 'all';
|
||
}).length;
|
||
const isActive = selectedPhonebookGroup.toLowerCase() === g.toLowerCase();
|
||
pillsHtml += `
|
||
<button type="button" onclick="selectPhonebookGroupFilter('${escapeHtml(g)}')"
|
||
class="phonebook-grp-pill ${isActive ? 'active bg-teal-600 text-white border-teal-500' : 'bg-anthracite-950 text-slate-400 hover:text-white border-anthracite-700'} px-3 py-1 rounded-lg text-xs font-semibold border transition">
|
||
${escapeHtml(g)} (${count})
|
||
</button>
|
||
`;
|
||
});
|
||
pillsContainer.innerHTML = pillsHtml;
|
||
}
|
||
|
||
// 2. Filtrage des contacts
|
||
const filtered = contacts.filter(c => {
|
||
// Filtre de groupe
|
||
if (selectedPhonebookGroup !== 'all') {
|
||
const cg = (c.group_name || '').toLowerCase();
|
||
const sg = selectedPhonebookGroup.toLowerCase();
|
||
if (cg !== sg && !cg.includes(sg) && cg !== 'tous' && cg !== 'all') {
|
||
return false;
|
||
}
|
||
}
|
||
// Filtre de recherche
|
||
if (searchVal !== '') {
|
||
const matchName = (c.display_name || '').toLowerCase().includes(searchVal);
|
||
const matchInt = (c.internal_number || '').toLowerCase().includes(searchVal);
|
||
const matchMob = (c.mobile_number || '').toLowerCase().includes(searchVal);
|
||
const matchOth = (c.other_number || '').toLowerCase().includes(searchVal);
|
||
const matchMail = (c.email || '').toLowerCase().includes(searchVal);
|
||
const matchGrp = (c.group_name || '').toLowerCase().includes(searchVal);
|
||
if (!matchName && !matchInt && !matchMob && !matchOth && !matchMail && !matchGrp) {
|
||
return false;
|
||
}
|
||
}
|
||
return true;
|
||
});
|
||
|
||
// 3. Statistiques affichées
|
||
const statsEl = document.getElementById('phonebook-stats');
|
||
if (statsEl) {
|
||
const intCount = contacts.filter(c => c.is_internal).length;
|
||
const extCount = contacts.length - intCount;
|
||
statsEl.innerHTML = `<span><strong>${filtered.length}</strong> affiché(s) • ${intCount} interne(s) • ${extCount} externe(s)</span>`;
|
||
}
|
||
|
||
if (filtered.length === 0) {
|
||
tbody.innerHTML = `<tr><td colspan="9" class="py-10 text-center text-slate-400 text-xs">Aucun contact trouvé dans l'annuaire pour ces critères.</td></tr>`;
|
||
return;
|
||
}
|
||
|
||
tbody.innerHTML = filtered.map(c => {
|
||
const initials = getInitials(c.display_name || '?');
|
||
const colorClass = getAvatarBgColor(c.display_name || '');
|
||
const avatarHtml = c.photo_url
|
||
? `<img src="${escapeHtml(c.photo_url)}" alt="${escapeHtml(c.display_name)}" class="w-8 h-8 rounded-full object-cover border border-anthracite-700 shadow" onerror="this.style.display='none'; if(this.nextElementSibling) this.nextElementSibling.style.display='flex';"><div class="w-8 h-8 rounded-full ${colorClass} text-white items-center justify-center font-bold text-xs shadow-inner" style="display:none;">${escapeHtml(initials)}</div>`
|
||
: `<div class="w-8 h-8 rounded-full ${colorClass} text-white flex items-center justify-center font-bold text-xs shadow-inner">${escapeHtml(initials)}</div>`;
|
||
|
||
const typeBadge = c.is_internal
|
||
? `<span class="px-2 py-0.5 rounded-full text-[10px] font-bold bg-indigo-500/15 text-indigo-300 border border-indigo-500/30">Poste ${escapeHtml(c.internal_number)}</span>`
|
||
: `<span class="px-2 py-0.5 rounded-full text-[10px] font-bold bg-slate-700/60 text-slate-300 border border-slate-600">Contact Externe</span>`;
|
||
|
||
return `
|
||
<tr class="hover:bg-anthracite-750/60 transition">
|
||
<td class="py-3 px-4">${avatarHtml}</td>
|
||
<td class="py-3 px-4 font-semibold text-white">
|
||
<div>${escapeHtml(c.display_name)}</div>
|
||
${c.photo_url ? '<span class="text-[10px] text-teal-400 font-mono">Photo active</span>' : ''}
|
||
</td>
|
||
<td class="py-3 px-4 font-mono font-bold text-indigo-300">${c.internal_number ? escapeHtml(c.internal_number) : '<span class="text-slate-600">—</span>'}</td>
|
||
<td class="py-3 px-4 font-mono text-xs text-slate-200">${c.mobile_number ? escapeHtml(c.mobile_number) : '<span class="text-slate-600">—</span>'}</td>
|
||
<td class="py-3 px-4 font-mono text-xs text-slate-400">${c.other_number ? escapeHtml(c.other_number) : '<span class="text-slate-600">—</span>'}</td>
|
||
<td class="py-3 px-4 text-xs text-slate-300">${c.email ? `<a href="mailto:${escapeHtml(c.email)}" class="hover:text-teal-300 underline">${escapeHtml(c.email)}</a>` : '<span class="text-slate-600">—</span>'}</td>
|
||
<td class="py-3 px-4">
|
||
<span class="px-2 py-0.5 rounded text-[11px] font-semibold bg-teal-500/15 text-teal-300 border border-teal-500/30">${escapeHtml(c.group_name || 'Général')}</span>
|
||
</td>
|
||
<td class="py-3 px-4">${typeBadge}</td>
|
||
<td class="py-3 px-4 text-right space-x-2">
|
||
<button type="button" onclick="editPhonebookContact(${c.id})" class="text-xs font-semibold text-indigo-300 hover:text-indigo-200">Modifier</button>
|
||
${!c.is_internal ? `<button type="button" onclick="deletePhonebookContact(${c.id})" class="text-xs font-semibold text-rose-400 hover:text-rose-300">Supprimer</button>` : ''}
|
||
</td>
|
||
</tr>
|
||
`;
|
||
}).join('');
|
||
}
|
||
|
||
function getInitials(name) {
|
||
const parts = name.trim().split(/\s+/);
|
||
if (parts.length >= 2) {
|
||
return (parts[0][0] + parts[1][0]).toUpperCase();
|
||
}
|
||
return (name.substring(0, 2) || '?').toUpperCase();
|
||
}
|
||
|
||
function getAvatarBgColor(str) {
|
||
const colors = [
|
||
'bg-teal-600', 'bg-indigo-600', 'bg-emerald-600', 'bg-cyan-600',
|
||
'bg-purple-600', 'bg-amber-600', 'bg-rose-600', 'bg-blue-600'
|
||
];
|
||
let hash = 0;
|
||
for (let i = 0; i < str.length; i++) {
|
||
hash = str.charCodeAt(i) + ((hash << 5) - hash);
|
||
}
|
||
return colors[Math.abs(hash) % colors.length];
|
||
}
|
||
|
||
function filterPhonebook() {
|
||
renderPhonebookTable();
|
||
}
|
||
|
||
function selectPhonebookGroupFilter(grp) {
|
||
selectedPhonebookGroup = grp;
|
||
renderPhonebookTable();
|
||
}
|
||
|
||
function openPhonebookContactModal(contact = null) {
|
||
const form = document.getElementById('form-phonebook-contact');
|
||
if (!form) return;
|
||
form.reset();
|
||
|
||
const titleEl = document.getElementById('modal-phonebook-title');
|
||
if (titleEl) {
|
||
titleEl.textContent = contact ? `Modifier le Contact ${contact.display_name}` : 'Nouveau Contact Annuaire';
|
||
}
|
||
|
||
form.elements['id'].value = contact ? contact.id : '';
|
||
form.elements['extension_id'].value = contact ? (contact.extension_id || '') : '';
|
||
form.elements['is_internal'].value = contact ? (contact.is_internal ? 1 : 0) : 0;
|
||
form.elements['photo_url'].value = contact ? (contact.photo_url || '') : '';
|
||
form.elements['display_name'].value = contact ? contact.display_name : '';
|
||
form.elements['internal_number'].value = contact ? (contact.internal_number || '') : '';
|
||
form.elements['mobile_number'].value = contact ? (contact.mobile_number || '') : '';
|
||
form.elements['other_number'].value = contact ? (contact.other_number || '') : '';
|
||
form.elements['email'].value = contact ? (contact.email || '') : '';
|
||
form.elements['group_name'].value = contact ? (contact.group_name || 'Général') : (selectedPhonebookGroup !== 'all' ? selectedPhonebookGroup : 'Général');
|
||
|
||
// Datalist des groupes existants
|
||
const dl = document.getElementById('phonebook-groups-datalist');
|
||
if (dl) {
|
||
dl.innerHTML = (appState.phonebook_groups || []).map(g => `<option value="${escapeHtml(g)}">`).join('');
|
||
}
|
||
|
||
updateAvatarPreview(contact?.photo_url || '', contact?.display_name || '?');
|
||
openModal('modal-phonebook-contact');
|
||
}
|
||
|
||
function editPhonebookContact(id) {
|
||
const contact = (appState.phonebook_contacts || []).find(c => Number(c.id) === Number(id));
|
||
if (contact) {
|
||
openPhonebookContactModal(contact);
|
||
}
|
||
}
|
||
|
||
async function savePhonebookContact(e) {
|
||
e.preventDefault();
|
||
const form = e.target;
|
||
const payload = {
|
||
id: form.elements['id'].value ? Number(form.elements['id'].value) : null,
|
||
extension_id: form.elements['extension_id'].value ? Number(form.elements['extension_id'].value) : null,
|
||
is_internal: Number(form.elements['is_internal'].value) || 0,
|
||
display_name: form.elements['display_name'].value.trim(),
|
||
internal_number: form.elements['internal_number'].value.trim(),
|
||
mobile_number: form.elements['mobile_number'].value.trim(),
|
||
other_number: form.elements['other_number'].value.trim(),
|
||
email: form.elements['email'].value.trim(),
|
||
group_name: form.elements['group_name'].value.trim() || 'Général',
|
||
photo_url: form.elements['photo_url'].value.trim(),
|
||
};
|
||
|
||
try {
|
||
const res = await apiCall('save_phonebook_contact', payload);
|
||
showToast(res.message, 'success');
|
||
closeModal('modal-phonebook-contact');
|
||
if (res.contacts) appState.phonebook_contacts = res.contacts;
|
||
if (res.groups) appState.phonebook_groups = res.groups;
|
||
renderPhonebookTable();
|
||
const pbBadge = document.getElementById('nav-badge-phonebook');
|
||
if (pbBadge) pbBadge.textContent = appState.phonebook_contacts.length;
|
||
} catch (err) {
|
||
showToast(err.message, 'error');
|
||
}
|
||
}
|
||
|
||
async function deletePhonebookContact(id) {
|
||
const contact = (appState.phonebook_contacts || []).find(c => Number(c.id) === Number(id));
|
||
if (!contact) return;
|
||
if (!confirm(`Supprimer le contact « ${contact.display_name} » de l'annuaire ?`)) return;
|
||
|
||
try {
|
||
const res = await apiCall('delete_phonebook_contact', { id });
|
||
showToast(res.message, 'info');
|
||
if (res.contacts) appState.phonebook_contacts = res.contacts;
|
||
if (res.groups) appState.phonebook_groups = res.groups;
|
||
renderPhonebookTable();
|
||
const pbBadge = document.getElementById('nav-badge-phonebook');
|
||
if (pbBadge) pbBadge.textContent = appState.phonebook_contacts.length;
|
||
} catch (err) {
|
||
showToast(err.message, 'error');
|
||
}
|
||
}
|
||
|
||
async function syncPhonebookNow() {
|
||
try {
|
||
const res = await apiCall('sync_phonebook', {});
|
||
showToast(res.message, 'success');
|
||
if (res.contacts) appState.phonebook_contacts = res.contacts;
|
||
if (res.groups) appState.phonebook_groups = res.groups;
|
||
renderPhonebookTable();
|
||
const pbBadge = document.getElementById('nav-badge-phonebook');
|
||
if (pbBadge) pbBadge.textContent = appState.phonebook_contacts.length;
|
||
} catch (err) {
|
||
showToast(err.message, 'error');
|
||
}
|
||
}
|
||
|
||
async function uploadContactPhoto(input) {
|
||
if (!input.files || !input.files[0]) return;
|
||
const file = input.files[0];
|
||
const form = document.getElementById('form-phonebook-contact');
|
||
const contactId = form ? form.elements['id'].value : '';
|
||
|
||
const formData = new FormData();
|
||
formData.append('avatar', file);
|
||
formData.append('contact_id', contactId);
|
||
|
||
try {
|
||
const res = await fetch('/api.php?action=upload_phonebook_avatar', {
|
||
method: 'POST',
|
||
body: formData,
|
||
});
|
||
const data = await res.json();
|
||
if (!data.success) throw new Error(data.message || "Erreur d'envoi");
|
||
|
||
document.getElementById('phonebook-input-photo-url').value = data.photo_url;
|
||
updateAvatarPreview(data.photo_url, form.elements['display_name'].value || '?');
|
||
showToast(data.message, 'success');
|
||
} catch (err) {
|
||
showToast(err.message, 'error');
|
||
}
|
||
}
|
||
|
||
function clearContactPhoto() {
|
||
document.getElementById('phonebook-input-photo-url').value = '';
|
||
const form = document.getElementById('form-phonebook-contact');
|
||
updateAvatarPreview('', form?.elements['display_name']?.value || '?');
|
||
}
|
||
|
||
function updateAvatarPreview(photoUrl, name) {
|
||
const preview = document.getElementById('phonebook-avatar-preview');
|
||
const clearBtn = document.getElementById('phonebook-btn-clear-photo');
|
||
if (!preview) return;
|
||
|
||
const initials = getInitials(name || '?');
|
||
const colorClass = getAvatarBgColor(name || '');
|
||
|
||
if (photoUrl) {
|
||
preview.className = 'w-14 h-14 rounded-full overflow-hidden border border-teal-500/40 shrink-0 shadow relative flex items-center justify-center';
|
||
preview.innerHTML = `
|
||
<img src="${escapeHtml(photoUrl)}" class="w-full h-full object-cover" onerror="this.style.display='none'; if(this.nextElementSibling) this.nextElementSibling.style.display='flex';">
|
||
<div class="w-full h-full ${colorClass} text-white items-center justify-center font-bold text-lg shadow-inner" style="display:none;">${escapeHtml(initials)}</div>
|
||
`;
|
||
clearBtn?.classList.remove('hidden');
|
||
} else {
|
||
preview.className = `w-14 h-14 rounded-full ${colorClass} text-white flex items-center justify-center font-bold text-lg overflow-hidden shrink-0 shadow-inner`;
|
||
preview.textContent = initials;
|
||
clearBtn?.classList.add('hidden');
|
||
}
|
||
}
|
||
|
||
// Modal Yealink Remote XML Phonebook
|
||
function openYealinkXmlModal() {
|
||
const sel = document.getElementById('yealink-modal-group-select');
|
||
if (sel) {
|
||
const groups = appState.phonebook_groups || [];
|
||
sel.innerHTML = `<option value="all">Tous les groupes (Annuaire complet de l'entreprise)</option>` +
|
||
groups.map(g => `<option value="${escapeHtml(g)}">${escapeHtml(g)}</option>`).join('');
|
||
if (selectedPhonebookGroup !== 'all' && groups.includes(selectedPhonebookGroup)) {
|
||
sel.value = selectedPhonebookGroup;
|
||
}
|
||
}
|
||
updateYealinkXmlUrls();
|
||
openModal('modal-yealink-xml');
|
||
}
|
||
|
||
function updateYealinkXmlUrls() {
|
||
const sel = document.getElementById('yealink-modal-group-select');
|
||
const grp = sel ? sel.value : 'all';
|
||
const host = window.location.hostname || '192.168.1.100';
|
||
const httpsPort = window.location.port || '8443';
|
||
const groupParam = grp !== 'all' ? `?group=${encodeURIComponent(grp)}` : `?group=all`;
|
||
|
||
const url8080 = `http://${host}:8080/phonebook.php${groupParam}`;
|
||
const urlHttps = `https://${host}:${httpsPort}/phonebook.php${groupParam}`;
|
||
|
||
const el8080 = document.getElementById('yealink-url-8080');
|
||
if (el8080) el8080.value = url8080;
|
||
const elHttps = document.getElementById('yealink-url-https');
|
||
if (elHttps) elHttps.value = urlHttps;
|
||
|
||
const openBtn = document.getElementById('yealink-open-browser-btn');
|
||
if (openBtn) openBtn.href = url8080;
|
||
|
||
const previewEl = document.getElementById('yealink-xml-preview');
|
||
if (previewEl) {
|
||
previewEl.textContent = 'Chargement du flux XML...';
|
||
fetch(`/phonebook.php${groupParam}`)
|
||
.then(r => r.text())
|
||
.then(xml => {
|
||
previewEl.textContent = xml;
|
||
})
|
||
.catch(() => {
|
||
previewEl.textContent = `<?xml version="1.0" encoding="UTF-8"?>\n<YealinkIPPhoneDirectory>\n <Title>Annuaire ${grp}</Title>\n</YealinkIPPhoneDirectory>`;
|
||
});
|
||
}
|
||
}
|
||
|
||
function copyYealinkUrl(inputId, btn) {
|
||
const el = document.getElementById(inputId);
|
||
if (!el) return;
|
||
el.select();
|
||
navigator.clipboard.writeText(el.value).then(() => {
|
||
showToast("URL Yealink copiée dans le presse-papiers !", 'success');
|
||
const origText = btn.innerHTML;
|
||
btn.innerHTML = '<span>Copié !</span>';
|
||
setTimeout(() => { btn.innerHTML = origText; }, 2000);
|
||
}).catch(() => {
|
||
showToast("Sélectionnez l'URL et appuyez sur Ctrl+C.", 'info');
|
||
});
|
||
}
|
||
|
||
// =========================================================================
|
||
// GESTIONNAIRE DE MISES À JOUR DU SYSTÈME NEXUSPBX (OTA)
|
||
// =========================================================================
|
||
function openUpdatePanel() {
|
||
switchTab('system');
|
||
setTimeout(() => {
|
||
const el = document.getElementById('update-status-box');
|
||
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||
}, 150);
|
||
}
|
||
|
||
async function checkSystemUpdate(notify = false) {
|
||
const btnCheck = document.getElementById('btn-check-update');
|
||
const btnRun = document.getElementById('btn-run-update');
|
||
const statusText = document.getElementById('update-status-text');
|
||
const lastChecked = document.getElementById('update-last-checked');
|
||
const changelogContainer = document.getElementById('update-changelog-container');
|
||
const changelogList = document.getElementById('update-changelog-list');
|
||
const pingDot = document.getElementById('update-ping');
|
||
const verTag = document.getElementById('system-version-tag');
|
||
const appBadge = document.getElementById('app-version-badge');
|
||
const headerUpdateBadge = document.getElementById('header-update-badge');
|
||
const headerUpdateText = document.getElementById('header-update-text');
|
||
|
||
const STORAGE_KEY_TIME = 'nexus_update_last_check_ts';
|
||
const STORAGE_KEY_DATA = 'nexus_update_cached_data';
|
||
const TWENTY_FOUR_HOURS = 24 * 60 * 60 * 1000;
|
||
|
||
// Si pas de clic explicite (notify === false), vérifier si un check a déjà été fait il y a moins de 24h
|
||
const installedVer = (appState && appState.version) ? appState.version : (document.querySelector('meta[name="nexus-app-version"]')?.content || '');
|
||
if (!notify) {
|
||
const lastTs = parseInt(localStorage.getItem(STORAGE_KEY_TIME) || '0', 10);
|
||
const cachedRaw = localStorage.getItem(STORAGE_KEY_DATA);
|
||
if (lastTs && (Date.now() - lastTs < TWENTY_FOUR_HOURS) && cachedRaw) {
|
||
try {
|
||
const cached = JSON.parse(cachedRaw);
|
||
// Si la version locale correspond et qu'on n'a pas un vieux flag "mise à jour dispo" vers la version déjà installée
|
||
if (installedVer && (cached.current_version !== installedVer || (cached.has_update && cached.latest_version === installedVer))) {
|
||
// Cache périmé suite à une mise à jour : on purge pour rafraîchir en direct
|
||
localStorage.removeItem(STORAGE_KEY_TIME);
|
||
localStorage.removeItem(STORAGE_KEY_DATA);
|
||
} else {
|
||
applyUpdateUI(cached);
|
||
return;
|
||
}
|
||
} catch (_) {
|
||
localStorage.removeItem(STORAGE_KEY_TIME);
|
||
localStorage.removeItem(STORAGE_KEY_DATA);
|
||
}
|
||
}
|
||
} else {
|
||
localStorage.removeItem(STORAGE_KEY_TIME);
|
||
localStorage.removeItem(STORAGE_KEY_DATA);
|
||
}
|
||
|
||
let origBtnHtml = null;
|
||
if (btnCheck) {
|
||
btnCheck.disabled = true;
|
||
btnCheck.classList.add('opacity-50');
|
||
origBtnHtml = btnCheck.innerHTML;
|
||
btnCheck.innerHTML = `<svg class="animate-spin w-4 h-4 text-cyan-400" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z"></path></svg><span>Vérification en cours...</span>`;
|
||
}
|
||
|
||
try {
|
||
const res = await fetch('/api.php?action=check_update&_t=' + Date.now());
|
||
const data = await res.json();
|
||
|
||
// Enregistrement du timestamp de vérification et des données en cache local (24h)
|
||
localStorage.setItem(STORAGE_KEY_TIME, Date.now().toString());
|
||
localStorage.setItem(STORAGE_KEY_DATA, JSON.stringify(data));
|
||
|
||
applyUpdateUI(data);
|
||
|
||
if (notify) {
|
||
if (data.has_update) {
|
||
showToast(`Nouvelle version NexusPBX v${data.latest_version} disponible !`, 'info');
|
||
} else {
|
||
showToast("Votre système NexusPBX est parfaitement à jour !", 'success');
|
||
}
|
||
}
|
||
} catch (e) {
|
||
if (notify) {
|
||
showToast("Erreur lors de la vérification des mises à jour : " + e.message, 'error');
|
||
}
|
||
} finally {
|
||
if (btnCheck) {
|
||
btnCheck.disabled = false;
|
||
btnCheck.classList.remove('opacity-50');
|
||
if (origBtnHtml) btnCheck.innerHTML = origBtnHtml;
|
||
}
|
||
}
|
||
|
||
function applyUpdateUI(data) {
|
||
if (lastChecked) {
|
||
const lastTs = parseInt(localStorage.getItem(STORAGE_KEY_TIME) || Date.now().toString(), 10);
|
||
const d = new Date(lastTs);
|
||
lastChecked.textContent = `Dernière vérification : ${d.toLocaleDateString()} à ${d.toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'})}`;
|
||
}
|
||
|
||
if (verTag && data.current_version) {
|
||
verTag.textContent = `v${data.current_version}`;
|
||
}
|
||
if (appBadge && data.current_version) {
|
||
appBadge.textContent = `v${data.current_version}`;
|
||
}
|
||
|
||
if (data.has_update) {
|
||
// Affichage du badge dans l'en-tête à côté de Tester AMI
|
||
if (headerUpdateBadge) {
|
||
headerUpdateBadge.style.display = 'inline-flex';
|
||
if (headerUpdateText) {
|
||
headerUpdateText.textContent = `v${escapeHtml(data.latest_version)} dispo`;
|
||
}
|
||
}
|
||
|
||
if (statusText) {
|
||
statusText.className = 'text-amber-400 font-bold';
|
||
statusText.innerHTML = `🚀 Nouvelle version disponible : <span class="underline">v${escapeHtml(data.latest_version)}</span> !`;
|
||
}
|
||
if (btnRun) {
|
||
btnRun.style.display = 'inline-flex';
|
||
btnRun.innerHTML = `<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"/></svg><span>Mettre à jour vers v${escapeHtml(data.latest_version)} (1-Clic)</span>`;
|
||
}
|
||
if (pingDot) pingDot.classList.remove('hidden');
|
||
|
||
if (changelogList && Array.isArray(data.changelog) && data.changelog.length > 0) {
|
||
changelogList.innerHTML = data.changelog.map(item => `<li>${escapeHtml(item)}</li>`).join('');
|
||
if (changelogContainer) changelogContainer.classList.remove('hidden');
|
||
}
|
||
} else {
|
||
// Pas de mise à jour disponible : masquer le badge dans l'en-tête
|
||
if (headerUpdateBadge) {
|
||
headerUpdateBadge.style.display = 'none';
|
||
}
|
||
|
||
if (statusText) {
|
||
statusText.className = 'text-emerald-400 font-bold';
|
||
statusText.textContent = `Votre PBX est à jour avec la dernière version officielle (v${data.current_version}).`;
|
||
}
|
||
if (btnRun) btnRun.style.display = 'none';
|
||
if (pingDot) pingDot.classList.add('hidden');
|
||
if (changelogContainer) changelogContainer.classList.add('hidden');
|
||
}
|
||
}
|
||
}
|
||
|
||
function openUpdateConfirmModal() {
|
||
const modal = document.getElementById('modal-confirm-update');
|
||
if (!modal) return;
|
||
|
||
let targetVer = '';
|
||
let currVer = (appState && appState.version) ? appState.version : (document.querySelector('meta[name="nexus-app-version"]')?.content || '');
|
||
|
||
try {
|
||
const cachedRaw = localStorage.getItem('nexus_update_cached_data');
|
||
if (cachedRaw) {
|
||
const cached = JSON.parse(cachedRaw);
|
||
if (cached.latest_version) targetVer = cached.latest_version;
|
||
if (cached.current_version) currVer = cached.current_version;
|
||
}
|
||
} catch (_) {}
|
||
|
||
const elTarget = document.getElementById('update-modal-target-version');
|
||
const elCurrent = document.getElementById('update-modal-current-version');
|
||
const elBadge = document.getElementById('update-modal-target-badge');
|
||
|
||
if (elTarget) elTarget.textContent = targetVer ? `v${targetVer}` : 'Dernière version';
|
||
if (elCurrent) elCurrent.textContent = currVer ? `v${currVer}` : 'vActuelle';
|
||
if (elBadge && targetVer) elBadge.textContent = `v${targetVer} disponible`;
|
||
|
||
modal.classList.remove('hidden');
|
||
modal.classList.add('flex');
|
||
}
|
||
|
||
function closeUpdateConfirmModal() {
|
||
const modal = document.getElementById('modal-confirm-update');
|
||
if (modal) {
|
||
modal.classList.add('hidden');
|
||
modal.classList.remove('flex');
|
||
}
|
||
}
|
||
|
||
function runSystemUpdate() {
|
||
openUpdateConfirmModal();
|
||
}
|
||
|
||
async function executeSystemUpdate() {
|
||
const btnModalConfirm = document.getElementById('btn-confirm-update-action');
|
||
const btnRun = document.getElementById('btn-run-update');
|
||
const statusText = document.getElementById('update-status-text');
|
||
|
||
if (btnModalConfirm) {
|
||
btnModalConfirm.disabled = true;
|
||
btnModalConfirm.classList.add('opacity-75', 'cursor-not-allowed');
|
||
btnModalConfirm.innerHTML = '<span class="animate-spin mr-2">↻</span> Déploiement en cours...';
|
||
}
|
||
|
||
if (btnRun) {
|
||
btnRun.disabled = true;
|
||
btnRun.innerHTML = '<span class="animate-spin mr-1.5">↻</span> Mise à jour en cours...';
|
||
}
|
||
if (statusText) {
|
||
statusText.className = 'text-cyan-300 font-bold';
|
||
statusText.textContent = 'Mise à jour en cours : sauvegarde, git pull, migrations SQLite et rechargement des services...';
|
||
}
|
||
|
||
showToast("Mise à jour en cours d'exécution en arrière-plan...", 'info');
|
||
|
||
// Purge préventive du cache local
|
||
localStorage.removeItem('nexus_update_last_check_ts');
|
||
localStorage.removeItem('nexus_update_cached_data');
|
||
|
||
function finalizeUpdateSuccess(newVer) {
|
||
newVer = String(newVer || '').replace(/^v/, '');
|
||
localStorage.setItem('nexus_update_last_check_ts', Date.now().toString());
|
||
localStorage.setItem('nexus_update_cached_data', JSON.stringify({
|
||
success: true,
|
||
current_version: newVer,
|
||
latest_version: newVer,
|
||
has_update: false,
|
||
changelog: [],
|
||
release_date: ''
|
||
}));
|
||
|
||
const verTag = document.getElementById('system-version-tag');
|
||
const appBadge = document.getElementById('app-version-badge');
|
||
const headerUpdateBadge = document.getElementById('header-update-badge');
|
||
const pingDot = document.getElementById('update-ping-dot');
|
||
|
||
if (verTag && newVer) verTag.textContent = `v${newVer}`;
|
||
if (appBadge && newVer) appBadge.textContent = `v${newVer}`;
|
||
if (headerUpdateBadge) headerUpdateBadge.style.display = 'none';
|
||
if (btnRun) btnRun.style.display = 'none';
|
||
if (pingDot) pingDot.classList.add('hidden');
|
||
|
||
showToast(`NexusPBX a été mis à jour avec succès en v${newVer} ! Rechargement...`, 'success');
|
||
if (statusText) {
|
||
statusText.className = 'text-emerald-400 font-bold';
|
||
statusText.textContent = `Mise à jour terminée avec succès (v${newVer}) ! Rechargement...`;
|
||
}
|
||
if (btnModalConfirm) {
|
||
btnModalConfirm.className = 'px-5 py-2.5 rounded-xl text-xs font-bold bg-emerald-600 text-white flex items-center gap-2';
|
||
btnModalConfirm.innerHTML = '✓ Mise à jour terminée ! Rechargement...';
|
||
}
|
||
|
||
setTimeout(() => {
|
||
closeUpdateConfirmModal();
|
||
window.location.href = window.location.origin + window.location.pathname + '?_t=' + Date.now();
|
||
}, 1800);
|
||
}
|
||
|
||
try {
|
||
const res = await fetch('/api.php?action=run_update&_t=' + Date.now(), {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({})
|
||
});
|
||
|
||
const rawText = await res.text();
|
||
let data = null;
|
||
try {
|
||
data = JSON.parse(rawText);
|
||
} catch (_) {
|
||
data = null;
|
||
}
|
||
|
||
if (data && data.success) {
|
||
finalizeUpdateSuccess(data.new_version);
|
||
return;
|
||
}
|
||
|
||
// Si la réponse n'était pas un JSON valide ou a été interrompue par le rechargement de php-fpm/nginx,
|
||
// on sonde le serveur pour vérifier si la mise à jour a en réalité bien été appliquée sur le disque
|
||
await new Promise(r => setTimeout(r, 2000));
|
||
try {
|
||
const probeRes = await fetch('/api.php?action=check_update&_t=' + Date.now());
|
||
const probeData = await probeRes.json();
|
||
if (probeData && probeData.success && probeData.current_version) {
|
||
finalizeUpdateSuccess(probeData.current_version);
|
||
return;
|
||
}
|
||
} catch (_) {}
|
||
|
||
// En cas d'échec réel avéré
|
||
closeUpdateConfirmModal();
|
||
const errMsg = (data && (data.output || data.message)) ? (data.output || data.message) : (rawText.slice(0, 150) || "Erreur inconnue");
|
||
alert("Erreur lors de la mise à jour :\n\n" + errMsg);
|
||
if (btnRun) {
|
||
btnRun.disabled = false;
|
||
btnRun.innerHTML = 'Réessayer la mise à jour';
|
||
}
|
||
if (btnModalConfirm) {
|
||
btnModalConfirm.disabled = false;
|
||
btnModalConfirm.classList.remove('opacity-75', 'cursor-not-allowed');
|
||
btnModalConfirm.innerHTML = '<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"/></svg><span>Lancer la mise à jour (1-Clic)</span>';
|
||
}
|
||
|
||
} catch (e) {
|
||
// En cas d'interruption réseau du socket (Safari WebKit DOMException ou timeout),
|
||
// on vérifie avant d'alerter si le serveur a quand même réussi à déployer les fichiers
|
||
await new Promise(r => setTimeout(r, 2000));
|
||
try {
|
||
const probeRes = await fetch('/api.php?action=check_update&_t=' + Date.now());
|
||
const probeData = await probeRes.json();
|
||
if (probeData && probeData.success && probeData.current_version) {
|
||
finalizeUpdateSuccess(probeData.current_version);
|
||
return;
|
||
}
|
||
} catch (_) {}
|
||
|
||
closeUpdateConfirmModal();
|
||
alert("Erreur réseau lors de la mise à jour : " + (e.message || "Connexion interrompue"));
|
||
if (btnRun) {
|
||
btnRun.disabled = false;
|
||
btnRun.innerHTML = 'Réessayer la mise à jour';
|
||
}
|
||
if (btnModalConfirm) {
|
||
btnModalConfirm.disabled = false;
|
||
btnModalConfirm.classList.remove('opacity-75', 'cursor-not-allowed');
|
||
btnModalConfirm.innerHTML = '<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"/></svg><span>Lancer la mise à jour (1-Clic)</span>';
|
||
}
|
||
}
|
||
}
|
||
|
||
// =========================================================================
|
||
// JOURNAL D'APPELS (CDR) & HISTORIQUE DES COMMUNICATIONS
|
||
// =========================================================================
|
||
let cdrDebounceTimer = null;
|
||
|
||
function debounceCdrLoad() {
|
||
clearTimeout(cdrDebounceTimer);
|
||
cdrDebounceTimer = setTimeout(() => {
|
||
loadCdr();
|
||
}, 300);
|
||
}
|
||
|
||
async function loadCdr() {
|
||
const tbody = document.getElementById('cdr-table-body');
|
||
const info = document.getElementById('cdr-status-info');
|
||
const spinner = document.getElementById('spinner-refresh-cdr');
|
||
if (spinner) spinner.classList.add('animate-spin');
|
||
|
||
const search = document.getElementById('cdr-filter-search')?.value || '';
|
||
const period = document.getElementById('cdr-filter-period')?.value || 'today';
|
||
const direction = document.getElementById('cdr-filter-direction')?.value || 'all';
|
||
const disposition = document.getElementById('cdr-filter-disposition')?.value || 'all';
|
||
const limit = document.getElementById('cdr-filter-limit')?.value || 100;
|
||
|
||
try {
|
||
const res = await apiCall('get_cdr', {
|
||
search,
|
||
period,
|
||
direction,
|
||
disposition,
|
||
limit
|
||
});
|
||
if (!res || !res.success) throw new Error(res?.message || 'Erreur lors de la récupération des CDR');
|
||
|
||
renderCdrStats(res.stats || {});
|
||
renderCdrTable(res.calls || []);
|
||
|
||
if (info) {
|
||
const count = (res.calls || []).length;
|
||
const total = res.total_records || 0;
|
||
info.textContent = `Affichage de ${count} appel(s) sur ${total} analysé(s) pour la période sélectionnée.`;
|
||
}
|
||
} catch (err) {
|
||
if (tbody) {
|
||
tbody.innerHTML = `
|
||
<tr>
|
||
<td colspan="7" class="py-8 text-center text-rose-400">
|
||
Erreur de chargement du journal d'appels : ${escapeHtml(err.message)}
|
||
</td>
|
||
</tr>
|
||
`;
|
||
}
|
||
} finally {
|
||
if (spinner) spinner.classList.remove('animate-spin');
|
||
}
|
||
}
|
||
|
||
function renderCdrStats(stats) {
|
||
const elTotal = document.getElementById('cdr-stat-total');
|
||
const elAns = document.getElementById('cdr-stat-answered');
|
||
const elRate = document.getElementById('cdr-stat-rate');
|
||
const elMiss = document.getElementById('cdr-stat-missed');
|
||
const elOut = document.getElementById('cdr-stat-outbound');
|
||
const elIn = document.getElementById('cdr-stat-inbound');
|
||
|
||
const total = stats.total || 0;
|
||
const answered = stats.answered || 0;
|
||
const missed = stats.missed || 0;
|
||
const rate = total > 0 ? Math.round((answered / total) * 100) : 0;
|
||
|
||
if (elTotal) elTotal.textContent = total;
|
||
if (elAns) elAns.textContent = answered;
|
||
if (elRate) elRate.textContent = `${rate}% de réponse`;
|
||
if (elMiss) elMiss.textContent = missed;
|
||
if (elOut) elOut.textContent = stats.outbound || 0;
|
||
if (elIn) elIn.textContent = stats.inbound || 0;
|
||
}
|
||
|
||
function renderCdrTable(calls) {
|
||
const tbody = document.getElementById('cdr-table-body');
|
||
if (!tbody) return;
|
||
|
||
if (!calls || calls.length === 0) {
|
||
tbody.innerHTML = `
|
||
<tr>
|
||
<td colspan="7" class="py-8 text-center text-slate-400">
|
||
Aucun appel enregistré pour les filtres sélectionnés.
|
||
</td>
|
||
</tr>
|
||
`;
|
||
return;
|
||
}
|
||
|
||
tbody.innerHTML = calls.map(c => {
|
||
// Direction badge & icon
|
||
let dirBadge = '';
|
||
if (c.direction === 'inbound') {
|
||
dirBadge = `<span class="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-[11px] font-bold bg-teal-500/15 text-teal-300 border border-teal-500/30">
|
||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 14l-7 7m0 0l-7-7m7 7V3"/></svg>
|
||
<span>Entrant</span>
|
||
</span>`;
|
||
} else if (c.direction === 'outbound') {
|
||
dirBadge = `<span class="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-[11px] font-bold bg-cyan-500/15 text-cyan-300 border border-cyan-500/30">
|
||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 10l7-7m0 0l7 7m-7-7v18"/></svg>
|
||
<span>Sortant</span>
|
||
</span>`;
|
||
} else if (c.direction === 'service') {
|
||
dirBadge = `<span class="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-[11px] font-bold bg-amber-500/15 text-amber-300 border border-amber-500/30">
|
||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/></svg>
|
||
<span>Service</span>
|
||
</span>`;
|
||
} else if (c.direction === 'group') {
|
||
dirBadge = `<span class="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-[11px] font-bold bg-purple-500/15 text-purple-300 border border-purple-500/30">
|
||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z"/></svg>
|
||
<span>Groupe</span>
|
||
</span>`;
|
||
} else if (c.direction === 'ivr') {
|
||
dirBadge = `<span class="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-[11px] font-bold bg-pink-500/15 text-pink-300 border border-pink-500/30">
|
||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11a7 7 0 01-7 7m0 0a7 7 0 01-7-7m7 7v4m0 0H8m4 0h4m-4-8a3 3 0 01-3-3V5a3 3 0 116 0v6a3 3 0 01-3 3z"/></svg>
|
||
<span>SVI</span>
|
||
</span>`;
|
||
} else {
|
||
dirBadge = `<span class="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-[11px] font-bold bg-indigo-500/15 text-indigo-300 border border-indigo-500/30">
|
||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4"/></svg>
|
||
<span>Interne</span>
|
||
</span>`;
|
||
}
|
||
|
||
// Status badge
|
||
let statusBadge = '';
|
||
if (c.disposition === 'ANSWERED') {
|
||
statusBadge = `<span class="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-[11px] font-bold bg-emerald-500/15 text-emerald-300 border border-emerald-500/30">
|
||
<span class="w-1.5 h-1.5 rounded-full bg-emerald-400"></span>
|
||
<span>Décroché</span>
|
||
</span>`;
|
||
} else if (c.disposition === 'NO ANSWER') {
|
||
statusBadge = `<span class="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-[11px] font-bold bg-amber-500/15 text-amber-300 border border-amber-500/30">
|
||
<span class="w-1.5 h-1.5 rounded-full bg-amber-400"></span>
|
||
<span>Non répondu</span>
|
||
</span>`;
|
||
} else if (c.disposition === 'BUSY') {
|
||
statusBadge = `<span class="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-[11px] font-bold bg-purple-500/15 text-purple-300 border border-purple-500/30">
|
||
<span class="w-1.5 h-1.5 rounded-full bg-purple-400"></span>
|
||
<span>Occupé</span>
|
||
</span>`;
|
||
} else {
|
||
statusBadge = `<span class="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-[11px] font-bold bg-rose-500/15 text-rose-300 border border-rose-500/30">
|
||
<span class="w-1.5 h-1.5 rounded-full bg-rose-400"></span>
|
||
<span>Échec</span>
|
||
</span>`;
|
||
}
|
||
|
||
// Trunk badge
|
||
let trunkBadge = '';
|
||
if (c.trunk_name) {
|
||
trunkBadge = `<span class="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-xl text-xs font-semibold bg-emerald-950/60 text-emerald-300 border border-emerald-500/40 shadow-sm">
|
||
<svg class="w-3.5 h-3.5 text-emerald-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9"/></svg>
|
||
<span>${escapeHtml(c.trunk_name)}</span>
|
||
</span>`;
|
||
} else {
|
||
trunkBadge = `<span class="text-slate-500 font-mono text-xs">—</span>`;
|
||
}
|
||
|
||
// Caller display
|
||
const callerNameHtml = c.caller_name ? `<div class="font-bold text-white text-xs">${escapeHtml(c.caller_name)}</div>` : '';
|
||
const callerNumHtml = `<div class="font-mono text-slate-300 text-xs">${escapeHtml(c.caller_number || 'Inconnu')}</div>`;
|
||
|
||
// Callee display
|
||
const calleeNameHtml = c.callee_name ? `<div class="font-bold text-white text-xs">${escapeHtml(c.callee_name)}</div>` : '';
|
||
const calleeNumHtml = `<div class="font-mono text-slate-300 text-xs">${escapeHtml(c.callee_number || '—')}</div>`;
|
||
|
||
// Talked vs Total duration
|
||
const durationHtml = c.billsec > 0
|
||
? `<div><span class="font-bold text-emerald-300 font-mono text-xs">${c.billsec_formatted}</span> <span class="text-[10px] text-slate-500 font-sans">(${c.duration_formatted} tot.)</span></div>`
|
||
: `<span class="font-mono text-slate-400 text-xs">${c.duration_formatted}</span>`;
|
||
|
||
return `
|
||
<tr class="hover:bg-anthracite-750/50 transition">
|
||
<td class="py-3 px-4 whitespace-nowrap text-slate-300 font-mono text-xs">
|
||
<div class="font-semibold text-slate-200">${escapeHtml(c.datetime_formatted)}</div>
|
||
<div class="text-[10px] text-slate-500 font-sans">${escapeHtml(c.datetime)}</div>
|
||
</td>
|
||
<td class="py-3 px-3 whitespace-nowrap">
|
||
${dirBadge}
|
||
</td>
|
||
<td class="py-3 px-4">
|
||
${callerNameHtml}
|
||
${callerNumHtml}
|
||
</td>
|
||
<td class="py-3 px-4">
|
||
${calleeNameHtml}
|
||
${calleeNumHtml}
|
||
</td>
|
||
<td class="py-3 px-4 whitespace-nowrap">
|
||
${trunkBadge}
|
||
</td>
|
||
<td class="py-3 px-3 whitespace-nowrap">
|
||
${durationHtml}
|
||
</td>
|
||
<td class="py-3 px-3 whitespace-nowrap">
|
||
${statusBadge}
|
||
</td>
|
||
</tr>
|
||
`;
|
||
}).join('');
|
||
}
|
||
|
||
function exportCdrCsv() {
|
||
const search = encodeURIComponent(document.getElementById('cdr-filter-search')?.value || '');
|
||
const period = encodeURIComponent(document.getElementById('cdr-filter-period')?.value || 'all');
|
||
const direction = encodeURIComponent(document.getElementById('cdr-filter-direction')?.value || 'all');
|
||
const disposition = encodeURIComponent(document.getElementById('cdr-filter-disposition')?.value || 'all');
|
||
|
||
window.location.href = `/api.php?action=export_cdr_csv&search=${search}&period=${period}&direction=${direction}&disposition=${disposition}`;
|
||
}
|
||
|
||
async function confirmClearCdr() {
|
||
if (!confirm("Êtes-vous sûr de vouloir purger l'historique complet du journal d'appels Asterisk ? Cette action est irréversible.")) {
|
||
return;
|
||
}
|
||
try {
|
||
const res = await apiCall('clear_cdr', {});
|
||
showToast(res.message || "Journal d'appels réinitialisé.", 'success');
|
||
loadCdr();
|
||
} catch (e) {
|
||
showToast(e.message || "Erreur lors de la purge du journal.", 'error');
|
||
}
|
||
}
|
||
|
||
/* ==============================================================================
|
||
MODULE HAUTE DISPONIBILITÉ (CLUSTER HA ACTIF / PASSIF KEEPALIVED)
|
||
============================================================================== */
|
||
|
||
let currentClusterData = null;
|
||
|
||
async function loadCluster() {
|
||
await loadClusterInterfaces();
|
||
await refreshClusterStatus();
|
||
}
|
||
|
||
async function loadClusterInterfaces() {
|
||
try {
|
||
const res = await apiCall('cluster_interfaces');
|
||
const select = document.getElementById('cluster-interface');
|
||
if (!select || !res.interfaces) return;
|
||
|
||
const currentVal = select.value;
|
||
select.innerHTML = res.interfaces.map(iface => {
|
||
const ipDisplay = iface.primary ? `(${iface.primary}/${iface.netmask})` : '(pas d\'IP)';
|
||
return `<option value="${iface.name}">${iface.name} ${ipDisplay}</option>`;
|
||
}).join('');
|
||
|
||
if (currentVal && res.interfaces.some(i => i.name === currentVal)) {
|
||
select.value = currentVal;
|
||
}
|
||
} catch (e) {
|
||
console.error("Erreur lors du chargement des interfaces réseau :", e);
|
||
}
|
||
}
|
||
|
||
async function refreshClusterStatus() {
|
||
try {
|
||
const res = await apiCall('cluster_status');
|
||
if (!res.success || !res.status) return;
|
||
|
||
const st = res.status;
|
||
currentClusterData = st;
|
||
|
||
// Formulaire - Mise à jour des valeurs
|
||
const chkEnabled = document.getElementById('cluster-enabled');
|
||
if (chkEnabled) chkEnabled.checked = Boolean(st.enabled);
|
||
|
||
const radioRole = document.querySelector(`input[name="role"][value="${st.configured_role || 'master'}"]`);
|
||
if (radioRole) radioRole.checked = true;
|
||
|
||
const ifaceSelect = document.getElementById('cluster-interface');
|
||
if (ifaceSelect && st.interface) ifaceSelect.value = st.interface;
|
||
|
||
const vipInput = document.getElementById('cluster-virtual-ip');
|
||
if (vipInput) vipInput.value = st.virtual_ip || '';
|
||
|
||
const vmaskInput = document.getElementById('cluster-virtual-netmask');
|
||
if (vmaskInput && st.virtual_netmask) vmaskInput.value = st.virtual_netmask;
|
||
|
||
const vridInput = document.getElementById('cluster-vrid');
|
||
if (vridInput && st.vrid) vridInput.value = st.vrid;
|
||
|
||
const peerIpInput = document.getElementById('cluster-peer-ip');
|
||
if (peerIpInput) peerIpInput.value = st.peer_ip || '';
|
||
|
||
const peerPortInput = document.getElementById('cluster-peer-port');
|
||
if (peerPortInput && st.peer_port) peerPortInput.value = st.peer_port;
|
||
|
||
const autoSyncChk = document.getElementById('cluster-auto-sync');
|
||
if (autoSyncChk && st.auto_sync !== undefined) autoSyncChk.checked = Boolean(st.auto_sync);
|
||
|
||
const secretInput = document.getElementById('cluster-secret');
|
||
if (secretInput && st.cluster_secret) secretInput.value = st.cluster_secret;
|
||
|
||
const blfExpiryContainer = document.getElementById('cluster-blf-expiry-container');
|
||
const blfExpiryInput = document.getElementById('cluster-blf-expiry');
|
||
if (blfExpiryContainer && blfExpiryInput) {
|
||
if (st.enabled) {
|
||
blfExpiryContainer.classList.remove('hidden');
|
||
blfExpiryInput.value = st.blf_expiry || 120;
|
||
} else {
|
||
blfExpiryContainer.classList.add('hidden');
|
||
blfExpiryInput.value = st.blf_expiry || 1800;
|
||
}
|
||
}
|
||
|
||
if (chkEnabled && !chkEnabled.dataset.blfBound) {
|
||
chkEnabled.dataset.blfBound = '1';
|
||
chkEnabled.addEventListener('change', () => {
|
||
const c = document.getElementById('cluster-blf-expiry-container');
|
||
const inp = document.getElementById('cluster-blf-expiry');
|
||
if (chkEnabled.checked) {
|
||
if (c) c.classList.remove('hidden');
|
||
if (inp && (!inp.value || inp.value === '1800')) inp.value = 120;
|
||
} else {
|
||
if (c) c.classList.add('hidden');
|
||
if (inp) inp.value = 1800;
|
||
}
|
||
});
|
||
}
|
||
|
||
// Badges & Cartes
|
||
const headerBadge = document.getElementById('cluster-header-state-badge');
|
||
const navBadge = document.getElementById('nav-badge-cluster');
|
||
const cardRoleBadge = document.getElementById('cluster-card-role-badge');
|
||
const cardState = document.getElementById('cluster-card-state');
|
||
const cardVipInd = document.getElementById('cluster-card-vip-indicator');
|
||
const cardIface = document.getElementById('cluster-card-iface');
|
||
const cardVip = document.getElementById('cluster-card-vip');
|
||
const cardVrid = document.getElementById('cluster-card-vrid');
|
||
const cardPeerStatus = document.getElementById('cluster-card-peer-status');
|
||
const cardPeerIp = document.getElementById('cluster-card-peer-ip');
|
||
const cardPeerPing = document.getElementById('cluster-card-peer-ping');
|
||
const cardSyncStatus = document.getElementById('cluster-card-sync-status');
|
||
const cardSyncTime = document.getElementById('cluster-card-sync-time');
|
||
|
||
if (cardVrid) cardVrid.textContent = st.vrid || '51';
|
||
if (cardIface) cardIface.textContent = `Interface : ${st.interface || 'eth0'}`;
|
||
if (cardVip) cardVip.textContent = st.virtual_ip || 'Non configurée';
|
||
|
||
if (cardRoleBadge) {
|
||
cardRoleBadge.textContent = (st.configured_role || 'master').toUpperCase();
|
||
}
|
||
|
||
if (st.enabled) {
|
||
if (st.effective_state === 'MASTER') {
|
||
if (headerBadge) {
|
||
headerBadge.className = 'px-2.5 py-0.5 rounded-full text-xs font-bold bg-emerald-500/20 text-emerald-300 border border-emerald-500/30';
|
||
headerBadge.textContent = 'ACTIF (MASTER)';
|
||
}
|
||
if (navBadge) {
|
||
navBadge.className = 'ml-2 shrink-0 px-1.5 py-0.2 text-[10px] rounded-full bg-emerald-500/20 text-emerald-300 border border-emerald-500/30 font-bold';
|
||
navBadge.textContent = 'MASTER';
|
||
}
|
||
if (cardState) {
|
||
cardState.className = 'text-lg font-bold text-emerald-400 tracking-wide';
|
||
cardState.textContent = 'MASTER (ACTIF)';
|
||
}
|
||
if (cardVipInd) {
|
||
cardVipInd.className = 'text-[10px] font-mono px-1.5 py-0.5 rounded bg-emerald-950 text-emerald-300 border border-emerald-800';
|
||
cardVipInd.textContent = 'VIP: Portée';
|
||
}
|
||
} else {
|
||
if (headerBadge) {
|
||
headerBadge.className = 'px-2.5 py-0.5 rounded-full text-xs font-bold bg-blue-500/20 text-blue-300 border border-blue-500/30';
|
||
headerBadge.textContent = 'STANDBY (EN VEILLE)';
|
||
}
|
||
if (navBadge) {
|
||
navBadge.className = 'ml-2 shrink-0 px-1.5 py-0.2 text-[10px] rounded-full bg-blue-500/20 text-blue-300 border border-blue-500/30 font-bold';
|
||
navBadge.textContent = 'STANDBY';
|
||
}
|
||
if (cardState) {
|
||
cardState.className = 'text-lg font-bold text-blue-400 tracking-wide';
|
||
cardState.textContent = 'STANDBY (VEILLE)';
|
||
}
|
||
if (cardVipInd) {
|
||
cardVipInd.className = 'text-[10px] font-mono px-1.5 py-0.5 rounded bg-anthracite-900 text-slate-400';
|
||
cardVipInd.textContent = 'VIP: Écoute';
|
||
}
|
||
}
|
||
} else {
|
||
if (headerBadge) {
|
||
headerBadge.className = 'px-2.5 py-0.5 rounded-full text-xs font-bold bg-slate-700 text-slate-300';
|
||
headerBadge.textContent = 'DÉSACTIVÉ';
|
||
}
|
||
if (navBadge) {
|
||
navBadge.className = 'ml-2 shrink-0 px-1.5 py-0.2 text-[10px] rounded-full bg-anthracite-800 text-slate-400 border border-anthracite-700 font-bold';
|
||
navBadge.textContent = 'OFF';
|
||
}
|
||
if (cardState) {
|
||
cardState.className = 'text-lg font-bold text-slate-400 tracking-wide';
|
||
cardState.textContent = 'STANDALONE';
|
||
}
|
||
if (cardVipInd) {
|
||
cardVipInd.className = 'text-[10px] font-mono px-1.5 py-0.5 rounded bg-anthracite-900 text-slate-400';
|
||
cardVipInd.textContent = 'VIP: Inactive';
|
||
}
|
||
}
|
||
|
||
// Nœud Distant (Peer)
|
||
if (cardPeerIp) cardPeerIp.textContent = st.peer_ip || 'Non configuré';
|
||
if (cardPeerStatus) {
|
||
if (!st.peer_ip) {
|
||
cardPeerStatus.className = 'px-2 py-0.5 rounded text-[10px] font-bold bg-slate-700 text-slate-300';
|
||
cardPeerStatus.textContent = 'NON DÉFINI';
|
||
} else if (st.peer_reachable) {
|
||
cardPeerStatus.className = 'px-2 py-0.5 rounded text-[10px] font-bold bg-emerald-500/20 text-emerald-300 border border-emerald-500/30';
|
||
cardPeerStatus.textContent = 'JOIGNABLE';
|
||
} else {
|
||
cardPeerStatus.className = 'px-2 py-0.5 rounded text-[10px] font-bold bg-red-500/20 text-red-300 border border-red-500/30';
|
||
cardPeerStatus.textContent = 'INJOIGNABLE';
|
||
}
|
||
}
|
||
if (cardPeerPing) {
|
||
cardPeerPing.textContent = (st.peer_ping_ms !== null) ? `Latence ping : ${st.peer_ping_ms.toFixed(1)} ms` : 'Latence ping : Inaccessible';
|
||
}
|
||
|
||
// Synchronisation
|
||
if (cardSyncStatus) {
|
||
if (st.last_sync_status === 'success' || st.last_sync_status === 'synced') {
|
||
cardSyncStatus.className = 'text-sm font-bold text-emerald-400 truncate';
|
||
cardSyncStatus.textContent = 'Synchronisation OK';
|
||
} else if (st.last_sync_status === 'error') {
|
||
cardSyncStatus.className = 'text-sm font-bold text-red-400 truncate';
|
||
cardSyncStatus.textContent = 'Erreur réplication';
|
||
} else {
|
||
cardSyncStatus.className = 'text-sm font-bold text-slate-400 truncate';
|
||
cardSyncStatus.textContent = 'En attente';
|
||
}
|
||
}
|
||
if (cardSyncTime) {
|
||
cardSyncTime.textContent = st.last_sync_at ? `Dernière synchro : ${st.last_sync_at}` : 'Dernière synchro : Jamais';
|
||
}
|
||
|
||
// Services Système (Diagnostic)
|
||
const svcKeepalived = document.getElementById('cluster-svc-keepalived');
|
||
if (svcKeepalived) {
|
||
if (!st.keepalived_installed) {
|
||
svcKeepalived.className = 'font-mono text-amber-400 font-bold';
|
||
svcKeepalived.textContent = '⚠️ Non installé';
|
||
} else if (st.keepalived_running) {
|
||
svcKeepalived.className = 'font-mono text-emerald-400 font-bold';
|
||
svcKeepalived.textContent = '🟢 Actif (En cours)';
|
||
} else {
|
||
svcKeepalived.className = 'font-mono text-slate-400';
|
||
svcKeepalived.textContent = '⚪ Arrêté';
|
||
}
|
||
}
|
||
|
||
const svcAsterisk = document.getElementById('cluster-svc-asterisk');
|
||
if (svcAsterisk) {
|
||
if (st.asterisk_healthy) {
|
||
svcAsterisk.className = 'font-mono text-emerald-400 font-bold';
|
||
svcAsterisk.textContent = '🟢 En ligne (Ping Pong OK)';
|
||
} else if (st.asterisk_running) {
|
||
svcAsterisk.className = 'font-mono text-amber-400 font-bold';
|
||
svcAsterisk.textContent = '🟡 En cours (Ping en attente)';
|
||
} else {
|
||
svcAsterisk.className = 'font-mono text-red-400 font-bold';
|
||
svcAsterisk.textContent = '🔴 Arrêté';
|
||
}
|
||
}
|
||
|
||
const svcPeer = document.getElementById('cluster-svc-peer');
|
||
if (svcPeer) {
|
||
if (!st.peer_ip) {
|
||
svcPeer.textContent = 'Non configuré';
|
||
svcPeer.className = 'font-mono text-slate-500';
|
||
} else if (st.peer_reachable) {
|
||
svcPeer.textContent = `🟢 Joignable (${st.peer_ping_ms !== null ? st.peer_ping_ms.toFixed(1) + 'ms' : 'OK'})`;
|
||
svcPeer.className = 'font-mono text-emerald-400 font-bold';
|
||
} else {
|
||
svcPeer.textContent = '🔴 Injoignable (Timeout)';
|
||
svcPeer.className = 'font-mono text-red-400 font-bold';
|
||
}
|
||
}
|
||
|
||
// Journal de logs
|
||
const logsContainer = document.getElementById('cluster-logs-container');
|
||
if (logsContainer && st.recent_logs) {
|
||
if (st.recent_logs.length > 0) {
|
||
logsContainer.innerHTML = st.recent_logs.map(line => {
|
||
let color = 'text-slate-300';
|
||
if (line.includes('MASTER')) color = 'text-emerald-300 font-bold';
|
||
if (line.includes('BACKUP') || line.includes('STANDBY')) color = 'text-blue-300';
|
||
if (line.includes('FAULT') || line.includes('ALERTE')) color = 'text-red-400 font-bold';
|
||
return `<div class="${color}">${escapeHtml(line)}</div>`;
|
||
}).join('');
|
||
} else {
|
||
logsContainer.innerHTML = '<p class="text-slate-500">Aucun événement enregistré.</p>';
|
||
}
|
||
}
|
||
|
||
} catch (e) {
|
||
console.error("Erreur lors du rafraîchissement du cluster :", e);
|
||
}
|
||
}
|
||
|
||
async function saveClusterSettings(e) {
|
||
e.preventDefault();
|
||
const form = e.target;
|
||
const payload = {
|
||
enabled: form.elements['enabled'].checked ? 1 : 0,
|
||
role: form.elements['role'].value,
|
||
interface: form.elements['interface'].value,
|
||
virtual_ip: form.elements['virtual_ip'].value.trim(),
|
||
virtual_netmask: form.elements['virtual_netmask'].value.trim() || '24',
|
||
peer_ip: form.elements['peer_ip'].value.trim(),
|
||
peer_port: parseInt(form.elements['peer_port'] ? form.elements['peer_port'].value : 8443, 10) || 8443,
|
||
cluster_secret: form.elements['cluster_secret'].value.trim(),
|
||
vrid: parseInt(form.elements['vrid'].value, 10) || 51,
|
||
blf_expiry: parseInt(form.elements['blf_expiry'] ? form.elements['blf_expiry'].value : 120, 10) || 120,
|
||
auto_sync: form.elements['auto_sync'].checked ? 1 : 0
|
||
};
|
||
|
||
if (payload.enabled && !payload.virtual_ip) {
|
||
showToast("L'adresse IP Virtuelle Flottante (VIP) est requise pour activer le cluster.", 'error');
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const res = await apiCall('cluster_save', payload);
|
||
showToast(res.message || "Paramètres du cluster enregistrés.", 'success');
|
||
refreshClusterStatus();
|
||
} catch (err) {
|
||
showToast(err.message || "Erreur lors de l'enregistrement du cluster.", 'error');
|
||
}
|
||
}
|
||
|
||
async function triggerClusterSyncNow() {
|
||
if (!currentClusterData || !currentClusterData.peer_ip) {
|
||
showToast("Veuillez d'abord renseigner et enregistrer l'adresse IP du Nœud Pair (Peer IP).", 'warning');
|
||
return;
|
||
}
|
||
showToast("Déclenchement de la réplication vers le pair...", 'info');
|
||
try {
|
||
const res = await apiCall('cluster_sync_now', {});
|
||
showToast(res.message || "Synchronisation réussie avec le nœud pair !", 'success');
|
||
refreshClusterStatus();
|
||
} catch (e) {
|
||
showToast(e.message || "Erreur lors de la synchronisation.", 'error');
|
||
}
|
||
}
|
||
|
||
async function triggerClusterSwitchover() {
|
||
if (!currentClusterData || !currentClusterData.enabled) {
|
||
showToast("Le cluster doit être activé pour effectuer une bascule.", 'warning');
|
||
return;
|
||
}
|
||
const currentRole = currentClusterData.configured_role || 'master';
|
||
const targetRole = (currentRole === 'master') ? 'STANDBY' : 'MASTER';
|
||
|
||
if (!confirm(`Confirmez-vous la bascule manuelle de rôle (Switchover) vers l'état ${targetRole} ?\nL'adresse IP flottante va être transférée.`)) {
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const res = await apiCall('cluster_switchover', {});
|
||
showToast(res.message || `Bascule effectuée. Nouveau rôle : ${targetRole}`, 'success');
|
||
refreshClusterStatus();
|
||
} catch (e) {
|
||
showToast(e.message || "Erreur lors de la bascule.", 'error');
|
||
}
|
||
}
|
||
|
||
function regenerateClusterSecret() {
|
||
const secretInput = document.getElementById('cluster-secret');
|
||
if (!secretInput) return;
|
||
const array = new Uint8Array(16);
|
||
window.crypto.getRandomValues(array);
|
||
const newSecret = Array.from(array, byte => byte.toString(16).padStart(2, '0')).join('');
|
||
secretInput.value = newSecret;
|
||
showToast("Nouveau jeton généré. N'oubliez pas d'enregistrer et de copier cette même clé sur le second serveur !", 'info');
|
||
}
|
||
|
||
function copyClusterSecret() {
|
||
const secretInput = document.getElementById('cluster-secret');
|
||
if (!secretInput || !secretInput.value) return;
|
||
navigator.clipboard.writeText(secretInput.value).then(() => {
|
||
showToast("Jeton secret copié dans le presse-papier !", 'success');
|
||
}).catch(() => {
|
||
secretInput.select();
|
||
document.execCommand('copy');
|
||
showToast("Jeton secret copié !", 'success');
|
||
});
|
||
}
|
||
|
||
// =========================================================================
|
||
// GESTION DES COMPTES PORTAIL UTILISATEUR (UCP)
|
||
// =========================================================================
|
||
function renderUcpTab() {
|
||
const s = appState.settings || {};
|
||
const ucpEnabled = Number(s.ucp_enabled ?? 1) === 1;
|
||
const httpsPort = Number(s.ucp_https_port ?? 8444);
|
||
const httpPort = Number(s.ucp_http_port ?? 8084);
|
||
const host = window.location.hostname || 'localhost';
|
||
|
||
const statusBadge = document.getElementById('ucp-service-status-badge');
|
||
if (statusBadge) {
|
||
if (!ucpEnabled || (httpsPort <= 0 && httpPort <= 0)) {
|
||
statusBadge.textContent = 'Portail désactivé';
|
||
statusBadge.className = 'px-2 py-0.5 rounded-full text-[11px] font-semibold bg-rose-500/15 text-rose-300 border border-rose-500/30';
|
||
} else {
|
||
statusBadge.textContent = 'En ligne & Isolé';
|
||
statusBadge.className = 'px-2 py-0.5 rounded-full text-[11px] font-semibold bg-emerald-500/15 text-emerald-300 border border-emerald-500/30';
|
||
}
|
||
}
|
||
|
||
const urlsBox = document.getElementById('ucp-access-urls');
|
||
if (urlsBox) {
|
||
if (!ucpEnabled) {
|
||
urlsBox.innerHTML = '<span class="text-slate-500 italic">Activez l’UCP dans Pare-feu & Sécurité pour exposer le portail.</span>';
|
||
} else {
|
||
const links = [];
|
||
if (httpsPort > 0) {
|
||
const url = `https://${host}:${httpsPort}/`;
|
||
links.push(`<a href="${escapeHtml(url)}" target="_blank" rel="noopener" class="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg bg-emerald-500/10 hover:bg-emerald-500/20 text-emerald-300 border border-emerald-500/30 font-mono transition">🔒 ${escapeHtml(url)}</a>`);
|
||
}
|
||
if (httpPort > 0) {
|
||
const url = `http://${host}:${httpPort}/`;
|
||
links.push(`<a href="${escapeHtml(url)}" target="_blank" rel="noopener" class="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg bg-sky-500/10 hover:bg-sky-500/20 text-sky-300 border border-sky-500/30 font-mono transition">🌐 ${escapeHtml(url)} (Reverse Proxy)</a>`);
|
||
}
|
||
urlsBox.innerHTML = links.length ? links.join('') : '<span class="text-amber-400">Aucun port d’écoute configuré.</span>';
|
||
}
|
||
}
|
||
|
||
renderUcpUsersTable();
|
||
}
|
||
|
||
function renderUcpUsersTable() {
|
||
const tbody = document.getElementById('ucp-users-table-body');
|
||
if (!tbody) return;
|
||
|
||
const users = appState.ucp_users || [];
|
||
const extMap = new Map((appState.extensions || []).map(e => [String(e.extension), e]));
|
||
|
||
if (users.length === 0) {
|
||
tbody.innerHTML = `
|
||
<tr>
|
||
<td colspan="6" class="px-5 py-10 text-center text-slate-400">
|
||
<div class="flex flex-col items-center gap-2">
|
||
<span class="text-sm font-medium text-slate-300">Aucun utilisateur UCP créé pour le moment.</span>
|
||
<span class="text-xs text-slate-500">Cliquez sur « Nouvel Utilisateur UCP » pour ouvrir un accès employé à sa messagerie vocale, ses renvois et l’annuaire.</span>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
`;
|
||
return;
|
||
}
|
||
|
||
tbody.innerHTML = users.map(u => {
|
||
const exts = Array.isArray(u.extensions) ? u.extensions : [];
|
||
const extChips = exts.length
|
||
? exts.map(num => {
|
||
const info = extMap.get(String(num));
|
||
const title = info ? `${num} — ${info.caller_id_name || ''}` : String(num);
|
||
return `<span title="${escapeHtml(title)}" class="px-2 py-0.5 rounded-md bg-sky-500/15 text-sky-300 border border-sky-500/30 font-mono text-[11px] font-semibold">${escapeHtml(String(num))}</span>`;
|
||
}).join(' ')
|
||
: '<span class="text-slate-500 italic">Aucune extension</span>';
|
||
|
||
const isEnabled = Number(u.enabled) === 1;
|
||
const statusPill = isEnabled
|
||
? '<span class="px-2 py-0.5 rounded-full text-[11px] font-semibold bg-emerald-500/15 text-emerald-300 border border-emerald-500/30">Actif</span>'
|
||
: '<span class="px-2 py-0.5 rounded-full text-[11px] font-semibold bg-rose-500/15 text-rose-300 border border-rose-500/30">Désactivé</span>';
|
||
|
||
const lastLogin = u.last_login_at ? escapeHtml(u.last_login_at) : '<span class="text-slate-500">Jamais connecté</span>';
|
||
|
||
return `
|
||
<tr class="hover:bg-anthracite-800/40 transition">
|
||
<td class="px-5 py-3.5 font-mono font-bold text-white">${escapeHtml(u.username || '')}</td>
|
||
<td class="px-5 py-3.5">
|
||
<div class="font-semibold text-slate-200">${escapeHtml(u.display_name || u.username || '')}</div>
|
||
<div class="text-[11px] text-slate-400">${u.email ? escapeHtml(u.email) : '<span class="text-slate-600">—</span>'}</div>
|
||
</td>
|
||
<td class="px-5 py-3.5">
|
||
<div class="flex flex-wrap gap-1.5">${extChips}</div>
|
||
</td>
|
||
<td class="px-5 py-3.5">${statusPill}</td>
|
||
<td class="px-5 py-3.5 text-slate-400">${lastLogin}</td>
|
||
<td class="px-5 py-3.5 text-right whitespace-nowrap">
|
||
<button type="button" onclick="openUcpUserModal(${Number(u.id)})"
|
||
class="px-2.5 py-1 rounded-lg bg-anthracite-800 hover:bg-sky-600/20 text-slate-300 hover:text-sky-300 border border-anthracite-700 hover:border-sky-500/40 text-xs font-semibold transition mr-1.5">
|
||
Modifier
|
||
</button>
|
||
<button type="button" onclick="deleteUcpUser(${Number(u.id)}, '${escapeHtml(u.username || '')}')"
|
||
class="px-2.5 py-1 rounded-lg bg-rose-500/10 hover:bg-rose-600 text-rose-300 hover:text-white border border-rose-500/30 text-xs font-semibold transition">
|
||
Supprimer
|
||
</button>
|
||
</td>
|
||
</tr>
|
||
`;
|
||
}).join('');
|
||
}
|
||
|
||
function openUcpUserModal(userId = 0) {
|
||
const modal = document.getElementById('modal-ucp-user');
|
||
if (!modal) return;
|
||
|
||
const users = appState.ucp_users || [];
|
||
const user = userId ? users.find(u => Number(u.id) === Number(userId)) : null;
|
||
|
||
document.getElementById('ucp-user-id').value = user ? Number(user.id) : 0;
|
||
document.getElementById('ucp-modal-title').textContent = user ? `Modifier l'utilisateur UCP (${user.username})` : 'Nouvel utilisateur UCP';
|
||
document.getElementById('ucp-user-username').value = user ? (user.username || '') : '';
|
||
document.getElementById('ucp-user-display-name').value = user ? (user.display_name || '') : '';
|
||
document.getElementById('ucp-user-email').value = user ? (user.email || '') : '';
|
||
document.getElementById('ucp-user-password').value = '';
|
||
document.getElementById('ucp-user-password').required = !user;
|
||
document.getElementById('ucp-password-hint').textContent = user ? '(laisser vide pour conserver)' : '(min. 6 car., requis)';
|
||
document.getElementById('ucp-user-enabled').checked = user ? Number(user.enabled) === 1 : true;
|
||
|
||
const selectedExts = new Set((user?.extensions || []).map(String));
|
||
const container = document.getElementById('ucp-user-extensions-container');
|
||
const exts = [...(appState.extensions || [])].sort((a, b) => Number(a.extension) - Number(b.extension));
|
||
|
||
if (container) {
|
||
if (exts.length === 0) {
|
||
container.innerHTML = '<div class="col-span-full text-xs text-slate-500 py-2">Aucune extension SIP configurée sur le PBX.</div>';
|
||
} else {
|
||
container.innerHTML = exts.map(ext => {
|
||
const num = String(ext.extension);
|
||
const checked = selectedExts.has(num) ? 'checked' : '';
|
||
return `
|
||
<label class="flex items-center gap-2.5 p-2 rounded-lg bg-anthracite-900 border border-anthracite-800 hover:border-sky-500/40 cursor-pointer select-none transition">
|
||
<input type="checkbox" name="ucp_ext_choice" value="${escapeHtml(num)}" ${checked}
|
||
class="w-4 h-4 rounded border-anthracite-600 bg-anthracite-950 text-sky-500 focus:ring-sky-500">
|
||
<div class="min-w-0">
|
||
<div class="text-xs font-bold text-white font-mono">${escapeHtml(num)}</div>
|
||
<div class="text-[11px] text-slate-400 truncate">${escapeHtml(ext.caller_id_name || 'Sans nom')}</div>
|
||
</div>
|
||
</label>
|
||
`;
|
||
}).join('');
|
||
}
|
||
}
|
||
|
||
modal.classList.remove('hidden');
|
||
modal.classList.add('flex');
|
||
}
|
||
|
||
function closeUcpUserModal() {
|
||
const modal = document.getElementById('modal-ucp-user');
|
||
if (!modal) return;
|
||
modal.classList.add('hidden');
|
||
modal.classList.remove('flex');
|
||
}
|
||
|
||
async function saveUcpUser(event) {
|
||
event.preventDefault();
|
||
const btn = document.getElementById('btn-save-ucp-user');
|
||
if (btn) btn.disabled = true;
|
||
|
||
const checkedExts = Array.from(document.querySelectorAll('#ucp-user-extensions-container input[name="ucp_ext_choice"]:checked'))
|
||
.map(el => el.value);
|
||
|
||
const payload = {
|
||
id: Number(document.getElementById('ucp-user-id').value || 0),
|
||
username: (document.getElementById('ucp-user-username').value || '').trim(),
|
||
display_name: (document.getElementById('ucp-user-display-name').value || '').trim(),
|
||
email: (document.getElementById('ucp-user-email').value || '').trim(),
|
||
password: document.getElementById('ucp-user-password').value || '',
|
||
enabled: document.getElementById('ucp-user-enabled').checked ? 1 : 0,
|
||
extensions: checkedExts
|
||
};
|
||
|
||
try {
|
||
const res = await apiCall('save_ucp_user', payload);
|
||
appState.ucp_users = res.ucp_users || res.users || [];
|
||
renderUcpTab();
|
||
const ucpBadge = document.getElementById('nav-badge-ucp');
|
||
if (ucpBadge) ucpBadge.textContent = appState.ucp_users.length;
|
||
closeUcpUserModal();
|
||
showToast(res.message || 'Utilisateur UCP enregistré.', 'success');
|
||
} catch (err) {
|
||
showToast(err.message || "Erreur lors de l'enregistrement de l'utilisateur UCP.", 'error');
|
||
} finally {
|
||
if (btn) btn.disabled = false;
|
||
}
|
||
}
|
||
|
||
async function deleteUcpUser(userId, username) {
|
||
if (!confirm(`Supprimer définitivement le compte UCP « ${username} » ?`)) {
|
||
return;
|
||
}
|
||
try {
|
||
const res = await apiCall('delete_ucp_user', { id: Number(userId) });
|
||
appState.ucp_users = res.ucp_users || res.users || [];
|
||
renderUcpTab();
|
||
const ucpBadge = document.getElementById('nav-badge-ucp');
|
||
if (ucpBadge) ucpBadge.textContent = appState.ucp_users.length;
|
||
showToast(res.message || 'Utilisateur UCP supprimé.', 'success');
|
||
} catch (err) {
|
||
showToast(err.message || "Erreur lors de la suppression de l'utilisateur UCP.", 'error');
|
||
}
|
||
}
|
||
|
||
|
||
|