From 7c28a237bafb83a5c3b0cf24c3ced41c8ba5f21d Mon Sep 17 00:00:00 2001 From: sorcier Date: Wed, 4 Nov 2026 14:42:29 +0100 Subject: [PATCH] feat(cdr): add Call Logs & CDR module with automatic trunk detection and CSV export (v1.6.0) --- public/api.php | 45 ++++ public/assets/app.js | 238 +++++++++++++++++++++ public/index.php | 164 +++++++++++++++ src/CdrManager.php | 477 +++++++++++++++++++++++++++++++++++++++++++ version.json | 4 +- 5 files changed, 927 insertions(+), 1 deletion(-) create mode 100644 src/CdrManager.php diff --git a/public/api.php b/public/api.php index 385e25e..a64f277 100644 --- a/public/api.php +++ b/public/api.php @@ -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'); @@ -2276,6 +2278,49 @@ try { echo json_encode($res); break; + case 'get_cdr': + Auth::requireAuth(); + $filters = [ + 'limit' => (int)($_GET['limit'] ?? $payload['limit'] ?? 100), + 'direction' => (string)($_GET['direction'] ?? $payload['direction'] ?? 'all'), + 'disposition' => (string)($_GET['disposition'] ?? $payload['disposition'] ?? 'all'), + 'period' => (string)($_GET['period'] ?? $payload['period'] ?? 'all'), + 'search' => (string)($_GET['search'] ?? $payload['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': + Auth::requireAuth(); + $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': + Auth::requireAuth(); + $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"); diff --git a/public/assets/app.js b/public/assets/app.js index 4c94be0..b5f9a09 100644 --- a/public/assets/app.js +++ b/public/assets/app.js @@ -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,235 @@ 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 query = new URLSearchParams({ + search, + period, + direction, + disposition, + limit + }); + + const res = await apiCall(`get_cdr&${query.toString()}`); + 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 = ` + + + Erreur de chargement du journal d'appels : ${escapeHtml(err.message)} + + + `; + } + } 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 = ` + + + Aucun appel enregistré pour les filtres sélectionnés. + + + `; + return; + } + + tbody.innerHTML = calls.map(c => { + // Direction badge & icon + let dirBadge = ''; + if (c.direction === 'inbound') { + dirBadge = ` + + Entrant + `; + } else if (c.direction === 'outbound') { + dirBadge = ` + + Sortant + `; + } else if (c.direction === 'service') { + dirBadge = ` + + Service + `; + } else if (c.direction === 'group') { + dirBadge = ` + + Groupe + `; + } else if (c.direction === 'ivr') { + dirBadge = ` + + SVI + `; + } else { + dirBadge = ` + + Interne + `; + } + + // Status badge + let statusBadge = ''; + if (c.disposition === 'ANSWERED') { + statusBadge = ` + + Décroché + `; + } else if (c.disposition === 'NO ANSWER') { + statusBadge = ` + + Non répondu + `; + } else if (c.disposition === 'BUSY') { + statusBadge = ` + + Occupé + `; + } else { + statusBadge = ` + + Échec + `; + } + + // Trunk badge + let trunkBadge = ''; + if (c.trunk_name) { + trunkBadge = ` + + ${escapeHtml(c.trunk_name)} + `; + } else { + trunkBadge = `—`; + } + + // Caller display + const callerNameHtml = c.caller_name ? `
${escapeHtml(c.caller_name)}
` : ''; + const callerNumHtml = `
${escapeHtml(c.caller_number || 'Inconnu')}
`; + + // Callee display + const calleeNameHtml = c.callee_name ? `
${escapeHtml(c.callee_name)}
` : ''; + const calleeNumHtml = `
${escapeHtml(c.callee_number || '—')}
`; + + // Talked vs Total duration + const durationHtml = c.billsec > 0 + ? `
${c.billsec_formatted} (${c.duration_formatted} tot.)
` + : `${c.duration_formatted}`; + + return ` + + +
${escapeHtml(c.datetime_formatted)}
+
${escapeHtml(c.datetime)}
+ + + ${dirBadge} + + + ${callerNameHtml} + ${callerNumHtml} + + + ${calleeNameHtml} + ${calleeNumHtml} + + + ${trunkBadge} + + + ${durationHtml} + + + ${statusBadge} + + + `; + }).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'); + } +} + diff --git a/public/index.php b/public/index.php index 820f517..1e733f7 100644 --- a/public/index.php +++ b/public/index.php @@ -258,6 +258,18 @@ $appVersion = $appVerData['version'] ?? '1.5.0'; + + +
Téléphonie IP
@@ -980,6 +992,158 @@ $appVersion = $appVerData['version'] ?? '1.5.0'; + + + + + diff --git a/src/CdrManager.php b/src/CdrManager.php new file mode 100644 index 0000000..7c2c981 --- /dev/null +++ b/src/CdrManager.php @@ -0,0 +1,477 @@ + 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); + } +} diff --git a/version.json b/version.json index 378fc05..89df209 100644 --- a/version.json +++ b/version.json @@ -1,9 +1,11 @@ { - "version": "1.5.3", + "version": "1.6.0", "codename": "Helios", "release_date": "2026-11-04", "min_php_version": "8.2", "changelog": [ + "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)",