Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
860594fded | ||
|
|
c0760ec52a | ||
|
|
2d4c80a7ec | ||
|
|
2eef15458c | ||
|
|
08837686e3 | ||
|
|
e6cd08ab4e | ||
|
|
21a88440c3 | ||
|
|
7c28a237ba | ||
|
|
a9d86dd6ac | ||
|
|
eadba4af3f | ||
|
|
e3f22e8e0b | ||
|
|
40e36e0423 | ||
|
|
65a62ca0e4 | ||
|
|
accec47d08 | ||
|
|
6c74e09203 |
No files matched your search
+17
-9
@@ -159,12 +159,12 @@ mkdir -p "$TARGET_DIR/public/avatars"
|
||||
mkdir -p /etc/asterisk/keys
|
||||
mkdir -p /var/lib/asterisk/sounds/custom
|
||||
mkdir -p /var/spool/asterisk/voicemail/default
|
||||
mkdir -p /var/log/asterisk
|
||||
mkdir -p /var/log/asterisk /var/log/asterisk/cdr-csv
|
||||
mkdir -p /var/log/nexus-pbx
|
||||
|
||||
touch /var/log/asterisk/messages /var/log/asterisk/security.log
|
||||
chown asterisk:asterisk /var/log/asterisk/messages /var/log/asterisk/security.log 2>/dev/null || true
|
||||
chmod 0664 /var/log/asterisk/messages /var/log/asterisk/security.log 2>/dev/null || true
|
||||
touch /var/log/asterisk/messages /var/log/asterisk/security.log /var/log/asterisk/cdr-csv/Master.csv
|
||||
chown -R asterisk:asterisk /var/log/asterisk 2>/dev/null || true
|
||||
chmod 0664 /var/log/asterisk/messages /var/log/asterisk/security.log /var/log/asterisk/cdr-csv/Master.csv 2>/dev/null || true
|
||||
|
||||
touch /var/log/nexus_security.log
|
||||
chown www-data:asterisk /var/log/nexus_security.log 2>/dev/null || true
|
||||
@@ -180,11 +180,11 @@ chmod 2775 "$TARGET_DIR/storage" \
|
||||
|
||||
mkdir -p /var/lib/asterisk/sounds/custom /var/lib/asterisk/moh /var/spool/asterisk/voicemail
|
||||
chown -R asterisk:asterisk /etc/asterisk /var/lib/asterisk /var/spool/asterisk /var/log/asterisk
|
||||
chmod -R 775 /etc/asterisk /var/lib/asterisk/sounds /var/lib/asterisk/moh /var/spool/asterisk
|
||||
chmod 2775 /etc/asterisk /var/lib/asterisk/sounds /var/lib/asterisk/sounds/custom /var/lib/asterisk/moh /var/spool/asterisk
|
||||
chmod -R 775 /etc/asterisk /var/lib/asterisk/sounds /var/lib/asterisk/moh /var/spool/asterisk /var/log/asterisk
|
||||
chmod 2775 /etc/asterisk /var/lib/asterisk/sounds /var/lib/asterisk/sounds/custom /var/lib/asterisk/moh /var/spool/asterisk /var/log/asterisk /var/log/asterisk/cdr-csv
|
||||
chmod 2750 /etc/asterisk/keys
|
||||
setfacl -R -m u:www-data:rwx,g:asterisk:rwx /etc/asterisk /var/lib/asterisk/sounds /var/lib/asterisk/moh /var/spool/asterisk 2>/dev/null || true
|
||||
setfacl -R -d -m u:www-data:rwx,g:asterisk:rwx /etc/asterisk /var/lib/asterisk/sounds /var/lib/asterisk/moh /var/spool/asterisk 2>/dev/null || true
|
||||
setfacl -R -m u:www-data:rwx,g:asterisk:rwx /etc/asterisk /var/lib/asterisk/sounds /var/lib/asterisk/moh /var/spool/asterisk /var/log/asterisk 2>/dev/null || true
|
||||
setfacl -R -d -m u:www-data:rwx,g:asterisk:rwx /etc/asterisk /var/lib/asterisk/sounds /var/lib/asterisk/moh /var/spool/asterisk /var/log/asterisk 2>/dev/null || true
|
||||
|
||||
# Droits sudoers pour l'interface web (www-data)
|
||||
cat << 'EOF' > /etc/sudoers.d/nexus-pbx
|
||||
@@ -201,12 +201,17 @@ for fpm_svc in /lib/systemd/system/php*-fpm.service /usr/lib/systemd/system/php*
|
||||
cat << 'EOF_OVERRIDE' > "/etc/systemd/system/${svc_name}.d/override.conf"
|
||||
[Service]
|
||||
ProtectSystem=false
|
||||
ReadWritePaths=/etc/asterisk /etc/fail2ban /etc/nginx /var/log /var/lib/asterisk /var/spool/asterisk /var/www /usr/local/bin /etc/sudoers.d
|
||||
ReadWritePaths=/etc/asterisk /etc/fail2ban /etc/nginx /var/log /var/lib/asterisk /var/spool/asterisk /var/www /usr/local/bin /etc/sudoers.d /etc/letsencrypt /var/lib/letsencrypt
|
||||
EOF_OVERRIDE
|
||||
fi
|
||||
done
|
||||
systemctl daemon-reload
|
||||
|
||||
# Répertoires Let's Encrypt et permissions pour Asterisk / Web
|
||||
mkdir -p /etc/letsencrypt /var/log/letsencrypt /var/lib/letsencrypt
|
||||
chgrp -R asterisk /etc/letsencrypt 2>/dev/null || true
|
||||
chmod -R g+rX /etc/letsencrypt 2>/dev/null || true
|
||||
|
||||
# Configuration des limites d'upload PHP (Restauration d'archives 512 Mo & Sons volumineux)
|
||||
for php_dir in /etc/php/*; do
|
||||
if [ -d "$php_dir/fpm/conf.d" ]; then
|
||||
@@ -329,6 +334,9 @@ for lang in fr en es; do
|
||||
if [ -d "/usr/share/asterisk/sounds/$lang" ] && [ ! -e "/var/lib/asterisk/sounds/$lang" ]; then
|
||||
ln -sf "/usr/share/asterisk/sounds/$lang" "/var/lib/asterisk/sounds/$lang"
|
||||
fi
|
||||
if [ -d "/var/lib/asterisk/sounds/$lang" ] && [ ! -e "/var/lib/asterisk/sounds/$lang/custom" ]; then
|
||||
ln -sf /var/lib/asterisk/sounds/custom "/var/lib/asterisk/sounds/$lang/custom" 2>/dev/null || true
|
||||
fi
|
||||
done
|
||||
if [ ! -e "/usr/share/asterisk/sounds/custom" ]; then
|
||||
ln -sf /var/lib/asterisk/sounds/custom /usr/share/asterisk/sounds/custom 2>/dev/null || true
|
||||
|
||||
+43
-1
@@ -11,6 +11,7 @@ require_once __DIR__ . '/../src/CertificateManager.php';
|
||||
require_once __DIR__ . '/../src/BackupManager.php';
|
||||
require_once __DIR__ . '/../src/FirewallManager.php';
|
||||
require_once __DIR__ . '/../src/PhonebookManager.php';
|
||||
require_once __DIR__ . '/../src/CdrManager.php';
|
||||
|
||||
use App\Database;
|
||||
use App\Auth;
|
||||
@@ -22,6 +23,7 @@ use App\CertificateManager;
|
||||
use App\BackupManager;
|
||||
use App\FirewallManager;
|
||||
use App\PhonebookManager;
|
||||
use App\CdrManager;
|
||||
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
header('Cache-Control: no-store, no-cache, must-revalidate');
|
||||
@@ -140,7 +142,7 @@ try {
|
||||
$installedVerData = file_exists("{$appDir}/version.json")
|
||||
? json_decode((string)file_get_contents("{$appDir}/version.json"), true)
|
||||
: [];
|
||||
$installedVer = $installedVerData['version'] ?? '1.4.8';
|
||||
$installedVer = $installedVerData['version'] ?? '1.5.0';
|
||||
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
@@ -2276,6 +2278,46 @@ try {
|
||||
echo json_encode($res);
|
||||
break;
|
||||
|
||||
case 'get_cdr':
|
||||
$filters = [
|
||||
'limit' => (int)($payload['limit'] ?? $_GET['limit'] ?? 100),
|
||||
'direction' => (string)($payload['direction'] ?? $_GET['direction'] ?? 'all'),
|
||||
'disposition' => (string)($payload['disposition'] ?? $_GET['disposition'] ?? 'all'),
|
||||
'period' => (string)($payload['period'] ?? $_GET['period'] ?? 'all'),
|
||||
'search' => (string)($payload['search'] ?? $_GET['search'] ?? ''),
|
||||
];
|
||||
$cdrData = CdrManager::getCalls($db, $filters);
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'calls' => $cdrData['calls'],
|
||||
'stats' => $cdrData['stats'],
|
||||
'total_records' => $cdrData['total_records'],
|
||||
]);
|
||||
break;
|
||||
|
||||
case 'export_cdr_csv':
|
||||
$filters = [
|
||||
'direction' => (string)($_GET['direction'] ?? 'all'),
|
||||
'disposition' => (string)($_GET['disposition'] ?? 'all'),
|
||||
'period' => (string)($_GET['period'] ?? 'all'),
|
||||
'search' => (string)($_GET['search'] ?? ''),
|
||||
];
|
||||
$csv = CdrManager::exportCsv($db, $filters);
|
||||
header('Content-Type: text/csv; charset=utf-8');
|
||||
header('Content-Disposition: attachment; filename="nexuspbx_journal_appels_' . date('Y-m-d_His') . '.csv"');
|
||||
header('Pragma: no-cache');
|
||||
header('Expires: 0');
|
||||
echo $csv;
|
||||
exit;
|
||||
|
||||
case 'clear_cdr':
|
||||
$cleared = CdrManager::clearCdr();
|
||||
echo json_encode([
|
||||
'success' => $cleared,
|
||||
'message' => $cleared ? "Journal d'appels réinitialisé avec succès." : "Impossible de vider le fichier CDR.",
|
||||
]);
|
||||
break;
|
||||
|
||||
case 'check_update':
|
||||
$appDir = dirname(__DIR__);
|
||||
clearstatcache(true, "{$appDir}/version.json");
|
||||
|
||||
@@ -3508,6 +3508,7 @@ function renderSettingsAndDiagnostics() {
|
||||
// --- 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)",
|
||||
@@ -3586,6 +3587,11 @@ function switchTab(tabId) {
|
||||
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();
|
||||
@@ -6073,3 +6079,233 @@ async function executeSystemUpdate() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 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');
|
||||
}
|
||||
}
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 8.9 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 795 KiB |
+174
-16
@@ -18,7 +18,7 @@ $companyLogo = trim($branding['company_logo'] ?? '');
|
||||
$hasBranding = !empty($companyName) || !empty($companyLogo);
|
||||
|
||||
$appVerData = file_exists(__DIR__ . '/../version.json') ? json_decode((string)file_get_contents(__DIR__ . '/../version.json'), true) : [];
|
||||
$appVersion = $appVerData['version'] ?? '1.4.8';
|
||||
$appVersion = $appVerData['version'] ?? '1.5.0';
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr" class="h-full">
|
||||
@@ -26,7 +26,9 @@ $appVersion = $appVerData['version'] ?? '1.4.8';
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="nexus-app-version" content="<?= htmlspecialchars($appVersion) ?>">
|
||||
<title>NexusPBX — Supervision & Gestion PJSIP sur Debian</title>
|
||||
<title>NexusPBX — Supervision & Téléphonie IP • Powered by Asterisk</title>
|
||||
<link rel="icon" type="image/png" href="/assets/favicon.png?v=<?= file_exists(__DIR__ . '/assets/favicon.png') ? filemtime(__DIR__ . '/assets/favicon.png') : 1 ?>">
|
||||
<link rel="apple-touch-icon" href="/assets/logo.png?v=<?= file_exists(__DIR__ . '/assets/logo.png') ? filemtime(__DIR__ . '/assets/logo.png') : 1 ?>">
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script>
|
||||
tailwind.config = {
|
||||
@@ -157,15 +159,11 @@ $appVersion = $appVerData['version'] ?? '1.4.8';
|
||||
|
||||
<!-- Carte de connexion flottante avec effet Glassmorphism transparent et aéré -->
|
||||
<div class="relative z-10 w-full max-w-md bg-anthracite-900/60 backdrop-blur-2xl border border-white/15 rounded-3xl shadow-2xl shadow-black/50 p-8 sm:p-9 transition-all">
|
||||
<div class="flex items-center gap-3.5 mb-6">
|
||||
<div class="w-12 h-12 rounded-xl bg-indigo-500/20 border border-indigo-400/40 flex items-center justify-center text-indigo-300 shadow-inner">
|
||||
<svg class="w-6 h-6" 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>
|
||||
</div>
|
||||
<div class="flex items-center gap-4 mb-6">
|
||||
<img src="/assets/logo.png?v=<?= file_exists(__DIR__ . '/assets/logo.png') ? filemtime(__DIR__ . '/assets/logo.png') : 1 ?>" alt="NexusPBX Logo" class="w-20 h-20 sm:w-24 sm:h-24 object-contain shrink-0 filter drop-shadow-xl">
|
||||
<div>
|
||||
<h1 class="text-xl font-bold text-white tracking-tight">NexusPBX</h1>
|
||||
<p class="text-xs text-slate-300">Supervision & Gestion PJSIP • Debian 13</p>
|
||||
<h1 class="text-2xl sm:text-3xl font-black text-white tracking-tight leading-none mb-1">NexusPBX</h1>
|
||||
<p class="text-xs text-slate-300">Supervision & Téléphonie IP • Powered by Asterisk</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -207,11 +205,7 @@ $appVersion = $appVerData['version'] ?? '1.4.8';
|
||||
<div class="px-4 py-3.5 border-b border-anthracite-700/80 space-y-2.5">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-10 h-10 rounded-xl bg-gradient-to-br from-indigo-500/30 to-purple-600/20 border border-indigo-400/40 flex items-center justify-center text-indigo-300 shadow-lg shadow-indigo-500/10 shrink-0">
|
||||
<svg class="w-5 h-5 text-indigo-300" 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>
|
||||
</div>
|
||||
<img src="/assets/logo.png?v=<?= file_exists(__DIR__ . '/assets/logo.png') ? filemtime(__DIR__ . '/assets/logo.png') : 1 ?>" alt="NexusPBX Logo" class="w-14 h-14 object-contain shrink-0 filter drop-shadow-md">
|
||||
<div>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="font-black text-white text-2xl tracking-tight leading-none">
|
||||
@@ -219,7 +213,7 @@ $appVersion = $appVerData['version'] ?? '1.4.8';
|
||||
</div>
|
||||
<span id="app-version-badge" class="px-1.5 py-0.5 text-[10px] font-mono font-bold tracking-wider rounded bg-indigo-500/20 text-indigo-300 border border-indigo-500/30">v<?= htmlspecialchars($appVersion) ?></span>
|
||||
</div>
|
||||
<div class="text-[10px] text-slate-400 font-medium tracking-wide mt-0.5">Moteur VoIP & Téléphonie IP</div>
|
||||
<div class="text-[10px] text-slate-400 font-medium tracking-wide mt-0.5">Powered by Asterisk</div>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" onclick="toggleMobileSidebar(false)" class="lg:hidden text-slate-400 hover:text-white p-1">✕</button>
|
||||
@@ -258,6 +252,18 @@ $appVersion = $appVerData['version'] ?? '1.4.8';
|
||||
<span class="w-2 h-2 rounded-full bg-emerald-300 animate-pulse"></span>
|
||||
</button>
|
||||
|
||||
<!-- Journal d'appels (CDR) -->
|
||||
<button type="button" data-tab="cdr" onclick="switchTab('cdr')"
|
||||
class="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">
|
||||
<span class="flex items-center gap-3">
|
||||
<svg class="w-5 h-5 text-sky-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||
</svg>
|
||||
<span>Journal d'appels (CDR)</span>
|
||||
</span>
|
||||
<span id="nav-badge-cdr" class="px-2 py-0.5 text-xs rounded-full bg-anthracite-800 text-sky-300 border border-anthracite-700 font-bold">CDR</span>
|
||||
</button>
|
||||
|
||||
<div class="pt-3 px-3 py-1.5 text-[11px] font-bold uppercase tracking-wider text-slate-400">Téléphonie IP</div>
|
||||
|
||||
<!-- Postes SIP -->
|
||||
@@ -980,6 +986,158 @@ $appVersion = $appVerData['version'] ?? '1.4.8';
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ========================================================= -->
|
||||
<!-- ONGLET : JOURNAL D'APPELS (CDR) -->
|
||||
<!-- ========================================================= -->
|
||||
<section id="tab-cdr" class="tab-panel hidden space-y-4">
|
||||
<!-- En-tête avec Actions -->
|
||||
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-3">
|
||||
<div>
|
||||
<div class="flex items-center gap-2">
|
||||
<h2 class="text-lg font-bold text-white">Journal d'Appels & Historique CDR</h2>
|
||||
<span class="px-2 py-0.5 rounded-full text-[10px] font-bold bg-sky-500/20 text-sky-300 border border-sky-500/30">Asterisk CDR</span>
|
||||
</div>
|
||||
<p class="text-xs text-slate-300">Traçabilité complète des flux téléphoniques entrants, sortants et internes avec identification automatique des correspondants et des Trunks SIP.</p>
|
||||
</div>
|
||||
<div class="flex items-center flex-wrap gap-2">
|
||||
<button type="button" onclick="loadCdr()" id="btn-refresh-cdr"
|
||||
class="inline-flex items-center gap-1.5 px-3 py-2 rounded-xl text-xs sm:text-sm font-medium bg-anthracite-800 hover:bg-anthracite-750 text-slate-200 border border-anthracite-700 transition" title="Recharger les derniers appels">
|
||||
<svg id="spinner-refresh-cdr" class="w-4 h-4 text-sky-400" 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>Actualiser</span>
|
||||
</button>
|
||||
<button type="button" onclick="exportCdrCsv()"
|
||||
class="inline-flex items-center gap-1.5 px-3 py-2 rounded-xl text-xs sm:text-sm font-medium bg-emerald-600/20 hover:bg-emerald-600/30 text-emerald-300 border border-emerald-500/40 shadow-sm transition" title="Exporter au format CSV compatible Excel">
|
||||
<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="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 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>Exporter CSV</span>
|
||||
</button>
|
||||
<button type="button" onclick="confirmClearCdr()"
|
||||
class="inline-flex items-center gap-1.5 px-3 py-2 rounded-xl text-xs sm:text-sm font-medium bg-rose-600/10 hover:bg-rose-600/20 text-rose-300 border border-rose-500/30 transition" title="Purger le journal d'appels">
|
||||
<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>
|
||||
<span>Purger</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Cartes KPIs Statistiques -->
|
||||
<div class="grid grid-cols-2 lg:grid-cols-5 gap-3">
|
||||
<div class="bg-anthracite-800 border border-anthracite-700 rounded-2xl p-4 shadow-lg">
|
||||
<div class="text-[11px] font-semibold uppercase text-slate-400">Total Appels</div>
|
||||
<div id="cdr-stat-total" class="text-2xl font-bold text-white mt-1">0</div>
|
||||
<div class="text-[11px] text-slate-400 mt-0.5">Sur la période</div>
|
||||
</div>
|
||||
<div class="bg-anthracite-800 border border-anthracite-700 rounded-2xl p-4 shadow-lg">
|
||||
<div class="text-[11px] font-semibold uppercase text-emerald-400">Décrochés</div>
|
||||
<div id="cdr-stat-answered" class="text-2xl font-bold text-emerald-400 mt-1">0</div>
|
||||
<div id="cdr-stat-rate" class="text-[11px] text-emerald-300 font-semibold mt-0.5">0% de réponse</div>
|
||||
</div>
|
||||
<div class="bg-anthracite-800 border border-anthracite-700 rounded-2xl p-4 shadow-lg">
|
||||
<div class="text-[11px] font-semibold uppercase text-amber-400">Non Répondus / Manqués</div>
|
||||
<div id="cdr-stat-missed" class="text-2xl font-bold text-amber-400 mt-1">0</div>
|
||||
<div class="text-[11px] text-slate-400 mt-0.5">Sans réponse / échecs</div>
|
||||
</div>
|
||||
<div class="bg-anthracite-800 border border-anthracite-700 rounded-2xl p-4 shadow-lg">
|
||||
<div class="text-[11px] font-semibold uppercase text-cyan-400">Appels Sortants</div>
|
||||
<div id="cdr-stat-outbound" class="text-2xl font-bold text-cyan-400 mt-1">0</div>
|
||||
<div class="text-[11px] text-slate-400 mt-0.5">Vers opérateurs / Trunks</div>
|
||||
</div>
|
||||
<div class="bg-anthracite-800 border border-anthracite-700 rounded-2xl p-4 shadow-lg">
|
||||
<div class="text-[11px] font-semibold uppercase text-teal-400">Appels Entrants</div>
|
||||
<div id="cdr-stat-inbound" class="text-2xl font-bold text-teal-400 mt-1">0</div>
|
||||
<div class="text-[11px] text-slate-400 mt-0.5">Reçus via SDA / Trunks</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Barre de Recherche & Filtres Multicritères -->
|
||||
<div class="bg-anthracite-800 border border-anthracite-700 rounded-2xl p-4 shadow-lg space-y-3">
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-5 gap-3">
|
||||
<!-- Recherche textuelle -->
|
||||
<div class="relative lg:col-span-2">
|
||||
<span class="absolute inset-y-0 left-0 pl-3.5 flex items-center pointer-events-none text-slate-400">
|
||||
<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="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/></svg>
|
||||
</span>
|
||||
<input type="text" id="cdr-filter-search" oninput="debounceCdrLoad()" placeholder="Rechercher par appelant, destinataire, trunk..."
|
||||
class="w-full pl-10 pr-4 py-2 rounded-xl bg-anthracite-950 border border-anthracite-700 text-xs sm:text-sm text-white placeholder-slate-500 focus:outline-none focus:border-sky-500 transition">
|
||||
</div>
|
||||
|
||||
<!-- Filtre Période -->
|
||||
<div>
|
||||
<select id="cdr-filter-period" onchange="loadCdr()"
|
||||
class="w-full px-3 py-2 rounded-xl bg-anthracite-950 border border-anthracite-700 text-xs text-white focus:outline-none focus:border-sky-500 transition">
|
||||
<option value="all">Toute la période</option>
|
||||
<option value="today" selected>Aujourd'hui</option>
|
||||
<option value="yesterday">Hier</option>
|
||||
<option value="week">7 derniers jours</option>
|
||||
<option value="month">30 derniers jours</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Filtre Direction -->
|
||||
<div>
|
||||
<select id="cdr-filter-direction" onchange="loadCdr()"
|
||||
class="w-full px-3 py-2 rounded-xl bg-anthracite-950 border border-anthracite-700 text-xs text-white focus:outline-none focus:border-sky-500 transition">
|
||||
<option value="all">Toutes les directions</option>
|
||||
<option value="inbound">Entrants (Trunk → Poste)</option>
|
||||
<option value="outbound">Sortants (Poste → Trunk)</option>
|
||||
<option value="internal">Internes (Poste à Poste)</option>
|
||||
<option value="service">Services (*98, SVI, etc.)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Filtre Statut / Disposition -->
|
||||
<div>
|
||||
<select id="cdr-filter-disposition" onchange="loadCdr()"
|
||||
class="w-full px-3 py-2 rounded-xl bg-anthracite-950 border border-anthracite-700 text-xs text-white focus:outline-none focus:border-sky-500 transition">
|
||||
<option value="all">Tous les statuts</option>
|
||||
<option value="answered">Décrochés (Répondus)</option>
|
||||
<option value="no_answer">Non répondus (Manqués)</option>
|
||||
<option value="busy">Occupés</option>
|
||||
<option value="failed">Échecs</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Ligne d'info & Limite d'affichage -->
|
||||
<div class="flex items-center justify-between pt-2 border-t border-anthracite-700/60 text-xs text-slate-400">
|
||||
<div id="cdr-status-info">Chargement de l'historique...</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span>Afficher :</span>
|
||||
<select id="cdr-filter-limit" onchange="loadCdr()"
|
||||
class="px-2 py-1 rounded-lg bg-anthracite-950 border border-anthracite-700 text-xs text-white focus:outline-none">
|
||||
<option value="25">25</option>
|
||||
<option value="50">50</option>
|
||||
<option value="100" selected>100</option>
|
||||
<option value="250">250</option>
|
||||
<option value="500">500</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tableau du Journal d'Appels -->
|
||||
<div class="bg-anthracite-800 border border-anthracite-700 rounded-2xl overflow-hidden shadow-lg">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-left border-collapse">
|
||||
<thead>
|
||||
<tr class="bg-anthracite-950/80 text-[11px] font-semibold uppercase text-slate-400 border-b border-anthracite-700">
|
||||
<th class="py-3 px-4">Date & Heure</th>
|
||||
<th class="py-3 px-3">Direction</th>
|
||||
<th class="py-3 px-4">Appelant (Qui a appelé)</th>
|
||||
<th class="py-3 px-4">Destinataire (Qui a été appelé)</th>
|
||||
<th class="py-3 px-4">Trunk Utilisé</th>
|
||||
<th class="py-3 px-3">Durée Parlée</th>
|
||||
<th class="py-3 px-3">Statut</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="cdr-table-body" class="divide-y divide-anthracite-700/60 text-xs">
|
||||
<tr>
|
||||
<td colspan="7" class="py-8 text-center text-slate-400">Chargement du journal d'appels...</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ========================================================= -->
|
||||
<!-- ONGLET 1 : EXTENSIONS SIP -->
|
||||
<!-- ========================================================= -->
|
||||
|
||||
+14
-9
@@ -20,11 +20,11 @@ echo "-> Installation des dépendances système indispensables (ACL, ffmpeg, rsy
|
||||
which ffmpeg >/dev/null 2>&1 && which rsync >/dev/null 2>&1 && which setfacl >/dev/null 2>&1 && which sshpass >/dev/null 2>&1 || (apt-get update -y && apt-get install -y --no-install-recommends acl ffmpeg rsync sox sshpass)
|
||||
|
||||
echo "-> Création des dossiers et des fichiers de journalisation..."
|
||||
mkdir -p /etc/fail2ban/filter.d /etc/asterisk /var/log/asterisk /var/log/nexus-pbx /var/www/nexus-pbx/storage/backups
|
||||
mkdir -p /etc/fail2ban/filter.d /etc/asterisk /var/log/asterisk /var/log/asterisk/cdr-csv /var/log/nexus-pbx /var/www/nexus-pbx/storage/backups
|
||||
|
||||
touch /var/log/asterisk/messages /var/log/asterisk/security.log /var/log/nexus_security.log
|
||||
chown asterisk:asterisk /var/log/asterisk/messages /var/log/asterisk/security.log 2>/dev/null || true
|
||||
chmod 0664 /var/log/asterisk/messages /var/log/asterisk/security.log 2>/dev/null || true
|
||||
touch /var/log/asterisk/messages /var/log/asterisk/security.log /var/log/asterisk/cdr-csv/Master.csv /var/log/nexus_security.log
|
||||
chown asterisk:asterisk /var/log/asterisk/messages /var/log/asterisk/security.log /var/log/asterisk/cdr-csv/Master.csv 2>/dev/null || true
|
||||
chmod 0664 /var/log/asterisk/messages /var/log/asterisk/security.log /var/log/asterisk/cdr-csv/Master.csv 2>/dev/null || true
|
||||
chown www-data:asterisk /var/log/nexus_security.log 2>/dev/null || true
|
||||
chmod 0664 /var/log/nexus_security.log 2>/dev/null || true
|
||||
|
||||
@@ -57,7 +57,9 @@ echo "-> Droits applicatifs et ACL POSIX (/etc/asterisk et /var/www/nexus-pbx)..
|
||||
usermod -aG asterisk www-data 2>/dev/null || true
|
||||
usermod -aG www-data asterisk 2>/dev/null || true
|
||||
|
||||
mkdir -p /var/www/nexus-pbx/public/avatars /var/www/nexus-pbx/storage/sounds /var/www/nexus-pbx/storage/backups /var/www/nexus-pbx/storage/system_backups
|
||||
mkdir -p /var/www/nexus-pbx/public/avatars /var/www/nexus-pbx/storage/sounds /var/www/nexus-pbx/storage/backups /var/www/nexus-pbx/storage/system_backups /etc/letsencrypt /var/log/letsencrypt /var/lib/letsencrypt
|
||||
chgrp -R asterisk /etc/letsencrypt 2>/dev/null || true
|
||||
chmod -R g+rX /etc/letsencrypt 2>/dev/null || true
|
||||
chown -R www-data:asterisk /var/www/nexus-pbx
|
||||
chmod 2775 /var/www/nexus-pbx/storage /var/www/nexus-pbx/storage/backups /var/www/nexus-pbx/storage/system_backups /var/www/nexus-pbx/storage/sounds /var/www/nexus-pbx/public/avatars 2>/dev/null || true
|
||||
|
||||
@@ -107,18 +109,21 @@ for lang in fr en es; do
|
||||
if [ -d "/usr/share/asterisk/sounds/$lang" ] && [ ! -e "/var/lib/asterisk/sounds/$lang" ]; then
|
||||
ln -sf "/usr/share/asterisk/sounds/$lang" "/var/lib/asterisk/sounds/$lang" 2>/dev/null || true
|
||||
fi
|
||||
if [ -d "/var/lib/asterisk/sounds/$lang" ] && [ ! -e "/var/lib/asterisk/sounds/$lang/custom" ]; then
|
||||
ln -sf /var/lib/asterisk/sounds/custom "/var/lib/asterisk/sounds/$lang/custom" 2>/dev/null || true
|
||||
fi
|
||||
done
|
||||
if [ ! -e "/usr/share/asterisk/sounds/custom" ]; then
|
||||
ln -sf /var/lib/asterisk/sounds/custom /usr/share/asterisk/sounds/custom 2>/dev/null || true
|
||||
fi
|
||||
chown -R asterisk:asterisk /etc/asterisk /var/lib/asterisk /var/spool/asterisk /var/log/asterisk 2>/dev/null || true
|
||||
chmod -R 775 /etc/asterisk /var/lib/asterisk/sounds /var/lib/asterisk/moh /var/spool/asterisk 2>/dev/null || true
|
||||
chmod 2775 /etc/asterisk /var/lib/asterisk/sounds /var/lib/asterisk/sounds/custom /var/lib/asterisk/moh /var/spool/asterisk /var/spool/asterisk/voicemail 2>/dev/null || true
|
||||
chmod -R 775 /etc/asterisk /var/lib/asterisk/sounds /var/lib/asterisk/moh /var/spool/asterisk /var/log/asterisk 2>/dev/null || true
|
||||
chmod 2775 /etc/asterisk /var/lib/asterisk/sounds /var/lib/asterisk/sounds/custom /var/lib/asterisk/moh /var/spool/asterisk /var/spool/asterisk/voicemail /var/log/asterisk /var/log/asterisk/cdr-csv 2>/dev/null || true
|
||||
chmod 2750 /etc/asterisk/keys 2>/dev/null || true
|
||||
chmod 0664 /etc/asterisk/*.conf /etc/asterisk/*.custom.conf 2>/dev/null || true
|
||||
|
||||
setfacl -R -m u:www-data:rwx,g:asterisk:rwx /etc/asterisk /var/lib/asterisk/sounds /var/lib/asterisk/moh /var/spool/asterisk 2>/dev/null || true
|
||||
setfacl -R -d -m u:www-data:rwx,g:asterisk:rwx /etc/asterisk /var/lib/asterisk/sounds /var/lib/asterisk/moh /var/spool/asterisk 2>/dev/null || true
|
||||
setfacl -R -m u:www-data:rwx,g:asterisk:rwx /etc/asterisk /var/lib/asterisk/sounds /var/lib/asterisk/moh /var/spool/asterisk /var/log/asterisk 2>/dev/null || true
|
||||
setfacl -R -d -m u:www-data:rwx,g:asterisk:rwx /etc/asterisk /var/lib/asterisk/sounds /var/lib/asterisk/moh /var/spool/asterisk /var/log/asterisk 2>/dev/null || true
|
||||
|
||||
chown root:asterisk /etc/fail2ban/jail.local /etc/nginx/sites-available/nexus-pbx 2>/dev/null || true
|
||||
chmod 0664 /etc/fail2ban/jail.local /etc/nginx/sites-available/nexus-pbx 2>/dev/null || true
|
||||
|
||||
+13
-4
@@ -96,6 +96,9 @@ echo -e "${C_GRAY}[2/6] Déploiement des nouveaux fichiers de code...${C_RESET}"
|
||||
git reset --hard FETCH_HEAD 2>/dev/null || git reset --hard "${GIT_REMOTE}/${GIT_BRANCH}"
|
||||
chown -R www-data:asterisk "$APP_DIR" 2>/dev/null || true
|
||||
chmod 2775 "$APP_DIR/storage" "$APP_DIR/storage/backups" "$APP_DIR/storage/sounds" 2>/dev/null || true
|
||||
mkdir -p /etc/letsencrypt /var/log/letsencrypt /var/lib/letsencrypt 2>/dev/null || true
|
||||
chgrp -R asterisk /etc/letsencrypt 2>/dev/null || true
|
||||
chmod -R g+rX /etc/letsencrypt 2>/dev/null || true
|
||||
chmod +x "$APP_DIR/scripts/update.sh" "$APP_DIR/scripts/nexus-firewall" "$APP_DIR/scripts/nexus-banner.sh" 2>/dev/null || true
|
||||
|
||||
# Liens symboliques CLI (non-bloquants si exécuté sous sandbox PHP ProtectSystem)
|
||||
@@ -140,14 +143,20 @@ for lang in fr en es; do
|
||||
if [ -d "/usr/share/asterisk/sounds/$lang" ] && [ ! -e "/var/lib/asterisk/sounds/$lang" ]; then
|
||||
ln -sf "/usr/share/asterisk/sounds/$lang" "/var/lib/asterisk/sounds/$lang" 2>/dev/null || true
|
||||
fi
|
||||
if [ -d "/var/lib/asterisk/sounds/$lang" ] && [ ! -e "/var/lib/asterisk/sounds/$lang/custom" ]; then
|
||||
ln -sf /var/lib/asterisk/sounds/custom "/var/lib/asterisk/sounds/$lang/custom" 2>/dev/null || true
|
||||
fi
|
||||
done
|
||||
if [ ! -e "/usr/share/asterisk/sounds/custom" ]; then
|
||||
ln -sf /var/lib/asterisk/sounds/custom /usr/share/asterisk/sounds/custom 2>/dev/null || true
|
||||
fi
|
||||
chown -R asterisk:asterisk /var/spool/asterisk /var/lib/asterisk/sounds /var/lib/asterisk/moh 2>/dev/null || true
|
||||
chmod 2775 /var/spool/asterisk /var/spool/asterisk/voicemail /var/lib/asterisk/sounds /var/lib/asterisk/moh 2>/dev/null || true
|
||||
setfacl -R -m u:www-data:rwx,g:asterisk:rwx /etc/asterisk /var/lib/asterisk/sounds /var/lib/asterisk/moh /var/spool/asterisk 2>/dev/null || true
|
||||
setfacl -R -d -m u:www-data:rwx,g:asterisk:rwx /etc/asterisk /var/lib/asterisk/sounds /var/lib/asterisk/moh /var/spool/asterisk 2>/dev/null || true
|
||||
chown -R asterisk:asterisk /var/spool/asterisk /var/lib/asterisk/sounds /var/lib/asterisk/moh /var/log/asterisk 2>/dev/null || true
|
||||
chmod 2775 /var/spool/asterisk /var/spool/asterisk/voicemail /var/lib/asterisk/sounds /var/lib/asterisk/moh /var/log/asterisk /var/log/asterisk/cdr-csv 2>/dev/null || true
|
||||
mkdir -p /var/log/asterisk/cdr-csv 2>/dev/null || true
|
||||
touch /var/log/asterisk/cdr-csv/Master.csv 2>/dev/null || true
|
||||
chmod 0664 /var/log/asterisk/cdr-csv/Master.csv 2>/dev/null || true
|
||||
setfacl -R -m u:www-data:rwx,g:asterisk:rwx /etc/asterisk /var/lib/asterisk/sounds /var/lib/asterisk/moh /var/spool/asterisk /var/log/asterisk 2>/dev/null || true
|
||||
setfacl -R -d -m u:www-data:rwx,g:asterisk:rwx /etc/asterisk /var/lib/asterisk/sounds /var/lib/asterisk/moh /var/spool/asterisk /var/log/asterisk 2>/dev/null || true
|
||||
chmod 0664 /etc/asterisk/*.conf /etc/asterisk/*.custom.conf 2>/dev/null || true
|
||||
|
||||
# 6. Rechargement des services
|
||||
|
||||
@@ -0,0 +1,478 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App;
|
||||
|
||||
use PDO;
|
||||
use Throwable;
|
||||
|
||||
class CdrManager
|
||||
{
|
||||
public const CDR_FILE = '/var/log/asterisk/cdr-csv/Master.csv';
|
||||
|
||||
/**
|
||||
* Récupère la liste analysée et enrichie des derniers appels Asterisk.
|
||||
*
|
||||
* @param PDO $db
|
||||
* @param array $filters [limit => 100, direction => 'all', disposition => 'all', period => 'all', search => '']
|
||||
* @return array [calls => array, stats => array, total_records => int]
|
||||
*/
|
||||
public static function getCalls(PDO $db, array $filters = []): array
|
||||
{
|
||||
$limit = max(10, min(1000, (int)($filters['limit'] ?? 100)));
|
||||
$direction = trim((string)($filters['direction'] ?? 'all'));
|
||||
$disposition = trim((string)($filters['disposition'] ?? 'all'));
|
||||
$period = trim((string)($filters['period'] ?? 'all'));
|
||||
$search = strtolower(trim((string)($filters['search'] ?? '')));
|
||||
|
||||
if (!file_exists(self::CDR_FILE) || !is_readable(self::CDR_FILE)) {
|
||||
return [
|
||||
'calls' => [],
|
||||
'stats' => self::emptyStats(),
|
||||
'total_records' => 0,
|
||||
];
|
||||
}
|
||||
|
||||
// 1. Dictionnaires en mémoire pour résolution rapide
|
||||
$trunks = $db->query('SELECT id, name, host, username FROM trunks')->fetchAll(PDO::FETCH_ASSOC) ?: [];
|
||||
$trunkMap = [];
|
||||
foreach ($trunks as $t) {
|
||||
$info = ['id' => (int)$t['id'], 'name' => (string)$t['name']];
|
||||
$name = strtolower(trim((string)$t['name']));
|
||||
$user = strtolower(trim((string)($t['username'] ?? '')));
|
||||
$slug = self::slugify($t['name']);
|
||||
|
||||
$trunkMap[$name] = $info;
|
||||
$trunkMap["trunk-{$name}"] = $info;
|
||||
$trunkMap[$slug] = $info;
|
||||
$trunkMap["trunk-{$slug}"] = $info;
|
||||
if ($user !== '') {
|
||||
$trunkMap[$user] = $info;
|
||||
$trunkMap["trunk-{$user}"] = $info;
|
||||
}
|
||||
}
|
||||
|
||||
$extensions = $db->query('SELECT extension, name FROM extensions')->fetchAll(PDO::FETCH_ASSOC) ?: [];
|
||||
$extMap = [];
|
||||
foreach ($extensions as $e) {
|
||||
$extMap[(string)$e['extension']] = (string)$e['name'];
|
||||
}
|
||||
|
||||
$groups = $db->query('SELECT extension, name FROM groups')->fetchAll(PDO::FETCH_ASSOC) ?: [];
|
||||
$groupMap = [];
|
||||
foreach ($groups as $g) {
|
||||
$groupMap[(string)$g['extension']] = (string)$g['name'];
|
||||
}
|
||||
|
||||
$ivrs = $db->query('SELECT id, name FROM ivrs')->fetchAll(PDO::FETCH_ASSOC) ?: [];
|
||||
$ivrMap = [];
|
||||
foreach ($ivrs as $i) {
|
||||
$ivrMap[(int)$i['id']] = (string)$i['name'];
|
||||
}
|
||||
|
||||
$contactMap = [];
|
||||
try {
|
||||
$contacts = $db->query('SELECT display_name, internal_number, mobile_number, other_number FROM phonebook_contacts')->fetchAll(PDO::FETCH_ASSOC) ?: [];
|
||||
foreach ($contacts as $c) {
|
||||
$cName = trim((string)$c['display_name']);
|
||||
if ($cName === '') continue;
|
||||
if (!empty($c['internal_number'])) $contactMap[trim((string)$c['internal_number'])] = $cName;
|
||||
if (!empty($c['mobile_number'])) $contactMap[trim((string)$c['mobile_number'])] = $cName;
|
||||
if (!empty($c['other_number'])) $contactMap[trim((string)$c['other_number'])] = $cName;
|
||||
}
|
||||
} catch (Throwable) {}
|
||||
|
||||
// 2. Lecture des dernières lignes du fichier CSV (inversées pour avoir les plus récents en premier)
|
||||
$rawLines = self::tailFile(self::CDR_FILE, 2000);
|
||||
$rawLines = array_reverse($rawLines);
|
||||
|
||||
$now = time();
|
||||
$calls = [];
|
||||
$stats = self::emptyStats();
|
||||
$totalParsed = 0;
|
||||
|
||||
foreach ($rawLines as $line) {
|
||||
$row = str_getcsv($line);
|
||||
if (count($row) < 15) continue;
|
||||
|
||||
$accountcode = trim((string)($row[0] ?? ''));
|
||||
$src = trim((string)($row[1] ?? ''));
|
||||
$dst = trim((string)($row[2] ?? ''));
|
||||
$dcontext = trim((string)($row[3] ?? ''));
|
||||
$clid = trim((string)($row[4] ?? ''));
|
||||
$channel = trim((string)($row[5] ?? ''));
|
||||
$dstchannel = trim((string)($row[6] ?? ''));
|
||||
$lastapp = trim((string)($row[7] ?? ''));
|
||||
$lastdata = trim((string)($row[8] ?? ''));
|
||||
$startTime = trim((string)($row[9] ?? ''));
|
||||
$answerTime = trim((string)($row[10] ?? ''));
|
||||
$endTime = trim((string)($row[11] ?? ''));
|
||||
$duration = max(0, (int)($row[12] ?? 0));
|
||||
$billsec = max(0, (int)($row[13] ?? 0));
|
||||
$dispRaw = strtoupper(trim((string)($row[14] ?? 'NO ANSWER')));
|
||||
$uniqueid = trim((string)($row[16] ?? ''));
|
||||
|
||||
$startTs = strtotime($startTime) ?: 0;
|
||||
|
||||
// Filtre par période
|
||||
if ($period !== 'all' && $startTs > 0) {
|
||||
if ($period === 'today') {
|
||||
if (date('Y-m-d', $startTs) !== date('Y-m-d', $now)) continue;
|
||||
} elseif ($period === 'yesterday') {
|
||||
if (date('Y-m-d', $startTs) !== date('Y-m-d', $now - 86400)) continue;
|
||||
} elseif ($period === 'week') {
|
||||
if ($startTs < ($now - 7 * 86400)) continue;
|
||||
} elseif ($period === 'month') {
|
||||
if ($startTs < ($now - 30 * 86400)) continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Identification endpoints & direction
|
||||
$srcEp = self::extractEndpoint($channel);
|
||||
$dstEp = self::extractEndpoint($dstchannel);
|
||||
$dialTrunk = self::extractTrunkFromDialData($lastdata);
|
||||
|
||||
$dir = 'internal';
|
||||
$dirLabel = 'Interne';
|
||||
$trunkName = null;
|
||||
$trunkId = null;
|
||||
|
||||
$candidateOut = strtolower($dialTrunk ?: $dstEp);
|
||||
if ($candidateOut !== '' && isset($trunkMap[$candidateOut])) {
|
||||
$dir = 'outbound';
|
||||
$dirLabel = 'Sortant';
|
||||
$trunkName = $trunkMap[$candidateOut]['name'];
|
||||
$trunkId = $trunkMap[$candidateOut]['id'];
|
||||
} elseif (isset($trunkMap[strtolower($srcEp)])) {
|
||||
$dir = 'inbound';
|
||||
$dirLabel = 'Entrant';
|
||||
$trunkName = $trunkMap[strtolower($srcEp)]['name'];
|
||||
$trunkId = $trunkMap[strtolower($srcEp)]['id'];
|
||||
} elseif (in_array($dcontext, ['from-external', 'from-pstn', 'from-trunk', 'from-pstn-toheader'], true)) {
|
||||
$dir = 'inbound';
|
||||
$dirLabel = 'Entrant';
|
||||
$trunkName = 'Trunk Externe';
|
||||
} elseif (str_starts_with($dst, '*') || in_array($lastapp, ['VoiceMailMain', 'Echo', 'WaitExten', 'Playback'], true)) {
|
||||
$dir = 'service';
|
||||
$dirLabel = 'Service';
|
||||
} elseif (isset($groupMap[$dst])) {
|
||||
$dir = 'group';
|
||||
$dirLabel = 'Groupe';
|
||||
} elseif (isset($ivrMap[(int)ltrim($dst, '*80')]) || str_starts_with($dst, '*8')) {
|
||||
$dir = 'ivr';
|
||||
$dirLabel = 'SVI / IVR';
|
||||
}
|
||||
|
||||
// Identification de l'Appelant et du Destinataire
|
||||
$callerNum = $src;
|
||||
$callerName = '';
|
||||
$calleeNum = $dst;
|
||||
$calleeName = '';
|
||||
|
||||
// Appel sortant : le poste physique réel appelant se trouve dans channel
|
||||
if ($dir === 'outbound' && isset($extMap[$srcEp])) {
|
||||
$callerNum = $srcEp;
|
||||
$callerName = $extMap[$srcEp];
|
||||
} elseif (isset($extMap[$src])) {
|
||||
$callerName = $extMap[$src];
|
||||
} elseif (isset($contactMap[$src])) {
|
||||
$callerName = $contactMap[$src];
|
||||
} else {
|
||||
$callerName = self::extractNameFromClid($clid);
|
||||
}
|
||||
|
||||
// Destinataire
|
||||
if (isset($extMap[$dst])) {
|
||||
$calleeName = $extMap[$dst];
|
||||
} elseif (isset($groupMap[$dst])) {
|
||||
$calleeName = "Groupe : {$groupMap[$dst]}";
|
||||
} elseif (isset($contactMap[$dst])) {
|
||||
$calleeName = $contactMap[$dst];
|
||||
} elseif ($dst === '*97' || $dst === '*98' || $lastapp === 'VoiceMailMain') {
|
||||
$calleeName = 'Messagerie Vocale';
|
||||
} elseif ($dst === '*43' || $lastapp === 'Echo') {
|
||||
$calleeName = 'Test Écho Audio';
|
||||
} elseif (str_starts_with($dst, '*80') || str_starts_with($dst, '*8')) {
|
||||
$ivrId = (int)ltrim(substr($dst, 2), '0');
|
||||
$calleeName = isset($ivrMap[$ivrId]) ? "SVI : {$ivrMap[$ivrId]}" : 'Serveur Vocal SVI';
|
||||
}
|
||||
|
||||
// Statistiques globales de la période filtrée
|
||||
$totalParsed++;
|
||||
$stats['total']++;
|
||||
if ($dispRaw === 'ANSWERED') {
|
||||
$stats['answered']++;
|
||||
} else {
|
||||
$stats['missed']++;
|
||||
}
|
||||
if ($dir === 'inbound') $stats['inbound']++;
|
||||
if ($dir === 'outbound') $stats['outbound']++;
|
||||
if ($dir === 'internal') $stats['internal']++;
|
||||
|
||||
// Filtre par direction
|
||||
if ($direction !== 'all' && $dir !== $direction) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Filtre par statut (disposition)
|
||||
if ($disposition !== 'all') {
|
||||
if ($disposition === 'answered' && $dispRaw !== 'ANSWERED') continue;
|
||||
if ($disposition === 'no_answer' && $dispRaw !== 'NO ANSWER') continue;
|
||||
if ($disposition === 'busy' && $dispRaw !== 'BUSY') continue;
|
||||
if ($disposition === 'failed' && $dispRaw !== 'FAILED') continue;
|
||||
}
|
||||
|
||||
// Filtre de recherche textuelle
|
||||
if ($search !== '') {
|
||||
$haystack = strtolower("{$callerNum} {$callerName} {$calleeNum} {$calleeName} {$trunkName} {$lastapp} {$dcontext}");
|
||||
if (!str_contains($haystack, $search)) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Formatage statut
|
||||
$statusLabel = match ($dispRaw) {
|
||||
'ANSWERED' => 'Décroché',
|
||||
'NO ANSWER' => 'Non répondu',
|
||||
'BUSY' => 'Occupé',
|
||||
'FAILED' => 'Échec',
|
||||
default => $dispRaw,
|
||||
};
|
||||
|
||||
$statusBadge = match ($dispRaw) {
|
||||
'ANSWERED' => 'emerald',
|
||||
'NO ANSWER' => 'amber',
|
||||
'BUSY' => 'purple',
|
||||
'FAILED' => 'rose',
|
||||
default => 'slate',
|
||||
};
|
||||
|
||||
$calls[] = [
|
||||
'id' => $uniqueid ?: (string)$totalParsed,
|
||||
'datetime' => $startTime,
|
||||
'datetime_formatted' => self::formatDate($startTime),
|
||||
'direction' => $dir,
|
||||
'direction_label' => $dirLabel,
|
||||
'caller_number' => $callerNum,
|
||||
'caller_name' => $callerName,
|
||||
'callee_number' => $calleeNum,
|
||||
'callee_name' => $calleeName,
|
||||
'trunk_name' => $trunkName,
|
||||
'trunk_id' => $trunkId,
|
||||
'disposition' => $dispRaw,
|
||||
'status_label' => $statusLabel,
|
||||
'status_badge' => $statusBadge,
|
||||
'duration' => $duration,
|
||||
'duration_formatted' => self::formatSeconds($duration),
|
||||
'billsec' => $billsec,
|
||||
'billsec_formatted' => self::formatSeconds($billsec),
|
||||
'app' => $lastapp,
|
||||
'channel' => $channel,
|
||||
'dstchannel' => $dstchannel,
|
||||
];
|
||||
|
||||
if (count($calls) >= $limit) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'calls' => $calls,
|
||||
'stats' => $stats,
|
||||
'total_records' => $totalParsed,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Génère un export CSV UTF-8 prêt pour Excel / LibreOffice.
|
||||
*/
|
||||
public static function exportCsv(PDO $db, array $filters = []): string
|
||||
{
|
||||
$filters['limit'] = 5000;
|
||||
$data = self::getCalls($db, $filters);
|
||||
$calls = $data['calls'] ?? [];
|
||||
|
||||
$output = fopen('php://temp', 'r+');
|
||||
// BOM UTF-8 pour Excel
|
||||
fwrite($output, "\xEF\xBB\xBF");
|
||||
|
||||
// En-têtes CSV
|
||||
fputcsv($output, [
|
||||
'Date & Heure',
|
||||
'Direction',
|
||||
'Numéro Appelant',
|
||||
'Nom Appelant',
|
||||
'Numéro Destinataire',
|
||||
'Nom Destinataire',
|
||||
'Trunk Utilisé',
|
||||
'Statut',
|
||||
'Durée Totale (s)',
|
||||
'Durée Parlée (s)',
|
||||
'Durée Formatée',
|
||||
'Application Asterisk',
|
||||
], ';');
|
||||
|
||||
foreach ($calls as $c) {
|
||||
fputcsv($output, [
|
||||
$c['datetime'] ?? '',
|
||||
$c['direction_label'] ?? '',
|
||||
$c['caller_number'] ?? '',
|
||||
$c['caller_name'] ?? '',
|
||||
$c['callee_number'] ?? '',
|
||||
$c['callee_name'] ?? '',
|
||||
$c['trunk_name'] ?: 'Aucun (Direct / Interne)',
|
||||
$c['status_label'] ?? '',
|
||||
$c['duration'] ?? 0,
|
||||
$c['billsec'] ?? 0,
|
||||
$c['billsec_formatted'] ?? '00:00',
|
||||
$c['app'] ?? '',
|
||||
], ';');
|
||||
}
|
||||
|
||||
rewind($output);
|
||||
$csv = stream_get_contents($output);
|
||||
fclose($output);
|
||||
|
||||
return (string)$csv;
|
||||
}
|
||||
|
||||
/**
|
||||
* Purge ou archive le fichier Master.csv
|
||||
*/
|
||||
public static function clearCdr(): bool
|
||||
{
|
||||
if (file_exists(self::CDR_FILE) && is_writable(self::CDR_FILE)) {
|
||||
return (file_put_contents(self::CDR_FILE, '') !== false);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// --- Fonctions auxiliaires ---
|
||||
|
||||
private static function emptyStats(): array
|
||||
{
|
||||
return [
|
||||
'total' => 0,
|
||||
'answered' => 0,
|
||||
'missed' => 0,
|
||||
'inbound' => 0,
|
||||
'outbound' => 0,
|
||||
'internal' => 0,
|
||||
];
|
||||
}
|
||||
|
||||
public static function extractEndpoint(string $channel): string
|
||||
{
|
||||
$channel = trim($channel);
|
||||
if (preg_match('#^(?:PJSIP|SIP)/([a-zA-Z0-9_\-]+)-[0-9a-fA-F]+#', $channel, $m)) {
|
||||
return $m[1];
|
||||
}
|
||||
if (preg_match('#^(?:PJSIP|SIP)/([a-zA-Z0-9_\-]+)#', $channel, $m)) {
|
||||
return $m[1];
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
public static function extractTrunkFromDialData(string $lastdata): string
|
||||
{
|
||||
if (preg_match('#@([a-zA-Z0-9_\-]+)#', $lastdata, $m)) {
|
||||
return $m[1];
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
public static function extractNameFromClid(string $clid): string
|
||||
{
|
||||
if (preg_match('/"([^"]+)"/', $clid, $m)) {
|
||||
$name = trim($m[1]);
|
||||
// Nettoyage de préfixes comme [Sytelis] ou guillemets résiduels
|
||||
return trim(str_replace(['"', "'"], '', $name));
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
public static function formatSeconds(int $seconds): string
|
||||
{
|
||||
$m = floor($seconds / 60);
|
||||
$s = $seconds % 60;
|
||||
if ($m >= 60) {
|
||||
$h = floor($m / 60);
|
||||
$m = $m % 60;
|
||||
return sprintf('%02d:%02d:%02d', $h, $m, $s);
|
||||
}
|
||||
return sprintf('%02d:%02d', $m, $s);
|
||||
}
|
||||
|
||||
public static function formatDate(string $datetime): string
|
||||
{
|
||||
$ts = strtotime($datetime);
|
||||
if (!$ts) return $datetime;
|
||||
|
||||
$today = date('Y-m-d');
|
||||
$yesterday = date('Y-m-d', time() - 86400);
|
||||
$day = date('Y-m-d', $ts);
|
||||
|
||||
if ($day === $today) {
|
||||
return 'Aujourd\'hui à ' . date('H:i:s', $ts);
|
||||
} elseif ($day === $yesterday) {
|
||||
return 'Hier à ' . date('H:i:s', $ts);
|
||||
}
|
||||
return date('d/m/Y H:i', $ts);
|
||||
}
|
||||
|
||||
private static function slugify(string $text): string
|
||||
{
|
||||
$slug = strtolower(trim($text));
|
||||
$slug = preg_replace('/^ivr-/', '', $slug);
|
||||
$slug = preg_replace('/[^a-z0-9]+/', '-', $slug);
|
||||
return trim($slug, '-') ?: 'default';
|
||||
}
|
||||
|
||||
/**
|
||||
* Lit efficacement les N dernières lignes d'un fichier sans saturer la RAM.
|
||||
*/
|
||||
public static function tailFile(string $filepath, int $maxLines = 1000): array
|
||||
{
|
||||
if (!file_exists($filepath) || !is_readable($filepath)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$filesize = filesize($filepath);
|
||||
if ($filesize <= 0) return [];
|
||||
|
||||
// Fichiers < 5 Mo : lecture directe optimisée
|
||||
if ($filesize < 5 * 1024 * 1024) {
|
||||
$lines = @file($filepath, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: [];
|
||||
return array_slice($lines, -$maxLines);
|
||||
}
|
||||
|
||||
// Fichiers volumineux : parcours inverse par curseur fseek
|
||||
$fp = fopen($filepath, 'rb');
|
||||
if (!$fp) return [];
|
||||
|
||||
$pos = -1;
|
||||
$lines = [];
|
||||
$currentLine = '';
|
||||
|
||||
while (fseek($fp, $pos, SEEK_END) !== -1 && count($lines) < $maxLines) {
|
||||
$char = fgetc($fp);
|
||||
if ($char === "\n") {
|
||||
if ($currentLine !== '') {
|
||||
$lines[] = strrev($currentLine);
|
||||
$currentLine = '';
|
||||
}
|
||||
} else {
|
||||
$currentLine .= $char;
|
||||
}
|
||||
$pos--;
|
||||
}
|
||||
|
||||
if ($currentLine !== '' && count($lines) < $maxLines) {
|
||||
$lines[] = strrev($currentLine);
|
||||
}
|
||||
fclose($fp);
|
||||
|
||||
return array_reverse($lines);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -265,8 +265,9 @@ class CertificateManager
|
||||
|
||||
$stagingFlag = $staging ? '--test-cert' : '';
|
||||
|
||||
// Exécution de Certbot webroot non-interactive (Clé RSA 2048 universellement compatible téléphones IP Yealink/Fanvil/Grandstream)
|
||||
$cmd = "certbot certonly --webroot -w {$escWebroot} -d {$escDomain} --non-interactive --agree-tos -m {$escEmail} --keep-until-expiring --key-type rsa --rsa-key-size 2048 {$stagingFlag} 2>&1";
|
||||
// Exécution de Certbot webroot non-interactive avec sudo (Clé RSA 2048 universellement compatible téléphones IP Yealink/Fanvil/Grandstream)
|
||||
$hookCmd = escapeshellarg("chgrp -R asterisk /etc/letsencrypt 2>/dev/null; chmod -R g+rX /etc/letsencrypt 2>/dev/null || true");
|
||||
$cmd = "sudo /usr/bin/certbot certonly --webroot -w {$escWebroot} -d {$escDomain} --non-interactive --agree-tos -m {$escEmail} --keep-until-expiring --key-type rsa --rsa-key-size 2048 --post-hook {$hookCmd} {$stagingFlag} 2>&1";
|
||||
|
||||
$output = [];
|
||||
$exitCode = 0;
|
||||
@@ -444,7 +445,8 @@ class CertificateManager
|
||||
$domain = $cert['primary_domain'];
|
||||
$escDomain = escapeshellarg($domain);
|
||||
|
||||
$cmd = "certbot renew --cert-name {$escDomain} --force-renewal 2>&1";
|
||||
$hookCmd = escapeshellarg("chgrp -R asterisk /etc/letsencrypt 2>/dev/null; chmod -R g+rX /etc/letsencrypt 2>/dev/null || true");
|
||||
$cmd = "sudo /usr/bin/certbot renew --cert-name {$escDomain} --force-renewal --post-hook {$hookCmd} 2>&1";
|
||||
$output = [];
|
||||
$exitCode = 0;
|
||||
exec($cmd, $output, $exitCode);
|
||||
|
||||
+18
-4
@@ -156,10 +156,15 @@ class ConfigGenerator
|
||||
$lines[] = "symmetric_transport = yes";
|
||||
$lines[] = "cert_file = {$certPath}";
|
||||
$lines[] = "priv_key_file = {$keyPath}";
|
||||
if ($caPath !== '') {
|
||||
// En Asterisk PJSIP, ca_list_file ne doit être renseigné QUE si la vérification
|
||||
// des certificats clients (mTLS) est requise. Si verify_client=no, renseigner ca_list_file
|
||||
// force OpenSSL à envoyer une requête CertificateRequest au téléphone, ce qui fait échouer
|
||||
// le handshake TLS sur la plupart des postes IP (Yealink, Fanvil, Grandstream...).
|
||||
if ($caPath !== '' && $verifyClient === 'yes') {
|
||||
$lines[] = "ca_list_file = {$caPath}";
|
||||
}
|
||||
$lines[] = "method = tlsv1_2";
|
||||
// method = sslv23 permet la négociation automatique TLSv1.2 et TLSv1.3 selon le client
|
||||
$lines[] = "method = sslv23";
|
||||
$lines[] = "verify_client = {$verifyClient}";
|
||||
$lines[] = "verify_server = no";
|
||||
foreach ($localNetworks as $net) {
|
||||
@@ -256,6 +261,9 @@ class ConfigGenerator
|
||||
$lines[] = "type = aor";
|
||||
$lines[] = "max_contacts = {$maxContacts}";
|
||||
$lines[] = "remove_existing = yes";
|
||||
$lines[] = "default_expiration = 120";
|
||||
$lines[] = "maximum_expiration = 300";
|
||||
$lines[] = "minimum_expiration = 60";
|
||||
$lines[] = "qualify_frequency = 30";
|
||||
if ($hasVm) {
|
||||
$lines[] = "mailboxes = {$num}@default";
|
||||
@@ -2219,18 +2227,24 @@ class ConfigGenerator
|
||||
$stmt = $this->db->prepare('SELECT slug FROM sounds WHERE id = :id');
|
||||
$stmt->execute([':id' => (int)$soundRef]);
|
||||
$slug = $stmt->fetchColumn();
|
||||
return $slug ? "custom/{$slug}" : $fallback;
|
||||
return $slug ? "/var/lib/asterisk/sounds/custom/{$slug}" : $fallback;
|
||||
}
|
||||
$ref = trim((string)$soundRef);
|
||||
if (str_starts_with($ref, 'sound-')) {
|
||||
return $this->resolveSoundFile(substr($ref, 6), $fallback);
|
||||
}
|
||||
if (str_starts_with($ref, 'custom/')) {
|
||||
return "/var/lib/asterisk/sounds/{$ref}";
|
||||
}
|
||||
if (str_starts_with($ref, '/var/lib/asterisk/sounds/custom/')) {
|
||||
return $ref;
|
||||
}
|
||||
if (!str_contains($ref, '/') && !in_array($ref, ['demo-instruct', 'demo-echotest', 'pbx-invalid', 'hello-world', 'beep', 'silence/1', 'vm-goodbye'])) {
|
||||
$stmt = $this->db->prepare('SELECT slug FROM sounds WHERE slug = :slug OR name = :name');
|
||||
$stmt->execute([':slug' => $ref, ':name' => $ref]);
|
||||
$slug = $stmt->fetchColumn();
|
||||
if ($slug) {
|
||||
return "custom/{$slug}";
|
||||
return "/var/lib/asterisk/sounds/custom/{$slug}";
|
||||
}
|
||||
}
|
||||
return $ref ?: $fallback;
|
||||
|
||||
@@ -235,6 +235,9 @@ class SystemChecker
|
||||
if (is_dir($src) && !file_exists($dst)) {
|
||||
@symlink($src, $dst);
|
||||
}
|
||||
if (is_dir($dst) && !file_exists("{$dst}/custom")) {
|
||||
@symlink('/var/lib/asterisk/sounds/custom', "{$dst}/custom");
|
||||
}
|
||||
}
|
||||
if (!file_exists('/usr/share/asterisk/sounds/custom')) {
|
||||
@symlink('/var/lib/asterisk/sounds/custom', '/usr/share/asterisk/sounds/custom');
|
||||
|
||||
+9
-5
@@ -1,12 +1,16 @@
|
||||
{
|
||||
"version": "1.4.8",
|
||||
"version": "1.6.0",
|
||||
"codename": "Helios",
|
||||
"release_date": "2026-11-04",
|
||||
"min_php_version": "8.2",
|
||||
"changelog": [
|
||||
"Correction du routage des codes de test direct SVI/IVR (*801, *802...) et suppression des conflits de préfixe avec l'interphonie (*80) et l'interception de groupe (*8)",
|
||||
"Restauration automatique des liens de médiathèque audio Debian (/usr/share/asterisk/sounds/fr -> /var/lib/asterisk/sounds) sur toute installation fraîche",
|
||||
"Définition systématique de la langue française dans les contextes SVI (CHANNEL(language)=fr) pour une lecture parfaite des annonces système",
|
||||
"Amélioration de la robustesse de l'interphonie 1-à-1 avec ciblage strict des postes"
|
||||
"Nouveau module Journal d'Appels & CDR : traçabilité complète des flux entrants, sortants, internes et services avec détection automatique des Trunks SIP et postes associés",
|
||||
"Filtres multicritères CDR en temps réel (période, direction, statut décroché/manqué, recherche textuelle) et export CSV Excel",
|
||||
"Optimisation TLS SIP PJSIP : négociation universelle TLSv1.2/TLSv1.3 (method=sslv23)",
|
||||
"Suppression de CertificateRequest en mode TLS standard pour compatibilité totale avec les téléphones IP Yealink, Fanvil et Grandstream",
|
||||
"Correction de l'émission et du renouvellement Let's Encrypt / Certbot ACME (privilèges sudo et permissions d'accès aux certificats)",
|
||||
"Plafond d'expiration d'enregistrement SIP standard (maximum_expiration = 300s, default = 120s) garantissant la reconnexion automatique rapide de tous les postes (1 à 3 min)",
|
||||
"Nettoyage et isolation stricte des sauvegardes (base NexusPBX, sons, spools voicemail, certificats)",
|
||||
"Résolution définitive et propre des conflits de préfixes Dialplan sur les SVI/IVR (*801-*899)"
|
||||
]
|
||||
}
|
||||
Reference in new issue
Block a user