Compare commits

..
25 Commits
Author SHA1 Message Date
sorcier 469112d6ee chore(release): bump version to 1.6.1 (official logo, Asterisk tagline, CDR script permissions) 2026-11-05 00:21:01 +01:00
sorcier 860594fded chore(branding): update tagline to Powered by Asterisk across login and sidebar 2026-11-05 00:12:27 +01:00
sorcier c0760ec52a style(ui): enlarge standalone logo on sidebar and login modal 2026-11-04 23:47:38 +01:00
sorcier 2d4c80a7ec style(ui): display standalone full-size logo without bounding box 2026-11-04 23:43:09 +01:00
sorcier 2eef15458c feat(ui): integrate official NexusPBX logo and favicon (replace placeholder icon) 2026-11-04 23:40:14 +01:00
sorcier 08837686e3 chore(install): ensure CDR directory, Master.csv and ACL permissions in install and update scripts 2026-11-04 18:20:24 +01:00
sorcier e6cd08ab4e fix(cdr): remove redundant Auth method calls in CDR actions 2026-11-04 14:57:03 +01:00
sorcier 21a88440c3 fix(cdr): pass filter parameters as payload object in apiCall 2026-11-04 14:47:35 +01:00
sorcier 7c28a237ba feat(cdr): add Call Logs & CDR module with automatic trunk detection and CSV export (v1.6.0) 2026-11-04 14:42:29 +01:00
sorcier a9d86dd6ac fix(tls): support TLS 1.2/1.3 and fix client certificate request for Yealink 2026-11-04 13:29:38 +01:00
sorcier eadba4af3f v1.5.2: Exécution Certbot ACME avec sudo et sécurisation des droits Let's Encrypt 2026-11-04 13:04:06 +01:00
sorcier e3f22e8e0b Nettoyage radical: Rollback complet de check-sync et AstDB. Seul maximum_expiration/default_expiration est conservé 2026-11-04 12:53:01 +01:00
sorcier 40e36e0423 Nettoyage: Suppression de la persistance AstDB et adoption exclusive du standard SIP maximum_expiration pour la reconnexion automatique 2026-11-04 12:48:50 +01:00
sorcier 65a62ca0e4 v1.5.1: Sauvegarde/restauration intégrale AstDB et synchronisation SIP immédiate des téléphones (check-sync) 2026-11-04 12:43:32 +01:00
sorcier accec47d08 feat(sounds): absolute audio paths for custom sounds making IVRs fully language-agnostic (v1.5.0) 2026-11-04 12:09:32 +01:00
sorcier 6c74e09203 fix(sounds): link custom sounds in language directories for Asterisk (v1.4.9) 2026-11-04 12:04:19 +01:00
sorcier 8f4ea50452 fix(ivr): resolve dialplan conflict on *801-*899 and link debian sound prompts (v1.4.8) 2026-11-04 11:51:21 +01:00
sorcier 1e288af617 fix(metrics): robust contact detection with AstDB fallback and reset f2b counters on clear log (v1.4.7) 2026-11-04 11:33:08 +01:00
sorcier bda4cb18a6 fix(voicemail): automate modules.conf and voicemail includes in install.sh and fix.sh (v1.4.6) 2026-11-04 11:06:04 +01:00
sorcier 9b2533695e feat(extensions): auto-regenerate and reload Asterisk configs instantly on save/delete (v1.4.5) 2026-11-04 10:39:28 +01:00
sorcier 07b53b8002 fix(ota): asynchronous web reload & fail-safe probe for Safari (v1.4.4) 2026-11-04 10:13:25 +01:00
sorcier bd8b519753 feat(ui): custom modal dialog for update confirmation (v1.4.3) 2026-11-04 10:08:15 +01:00
sorcier 7deee2be61 feat(login): native server-side operator branding & lighter glassmorphism modal (v1.4.2) 2026-11-04 10:03:15 +01:00
sorcier ea73d15eaf feat(ui): add immersive plexus background image and glassmorphism login modal (v1.4.1) 2026-11-04 09:56:53 +01:00
sorcier 0948a8da24 feat(charts): smooth CPU/RAM and Network curves using cubic Bézier splines (v1.4.0) 2026-11-04 09:40:33 +01:00
15 changed files with 1604 additions and 161 deletions

No files matched your search

+58 -15
View File
@@ -88,8 +88,8 @@ if ! apt-get install -y asterisk; then
apt-get install -y -t sid asterisk
fi
# Sons Asterisk en français (exacts de la machine de dev)
apt-get install -y asterisk-core-sounds-fr asterisk-core-sounds-fr-wav asterisk-core-sounds-fr-gsm 2>/dev/null || true
# Sons Asterisk en français et anglais
apt-get install -y asterisk-core-sounds-fr asterisk-core-sounds-fr-wav asterisk-core-sounds-fr-gsm asterisk-core-sounds-en asterisk-core-sounds-en-gsm 2>/dev/null || true
# Nginx, PHP-FPM et extensions requises
apt-get install -y --no-install-recommends \
@@ -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
@@ -287,23 +292,61 @@ touch /etc/asterisk/pjsip.conf \
/etc/asterisk/pjsip.custom.conf \
/etc/asterisk/extensions.conf \
/etc/asterisk/extensions.custom.conf \
/etc/asterisk/musiconhold.conf \
/etc/asterisk/musiconhold.custom.conf \
/etc/asterisk/voicemail.conf \
/etc/asterisk/voicemail.custom.conf \
/etc/asterisk/modules.conf \
/etc/asterisk/rtp.conf \
/etc/asterisk/features.conf
# 1. Modules Asterisk : Désactivation des modules ODBC/IMAP incompatibles sur Debian (évite conflit bloquant app_voicemail)
if ! grep -q 'noload => app_voicemail_odbc.so' /etc/asterisk/modules.conf 2>/dev/null; then
if grep -q '\[modules\]' /etc/asterisk/modules.conf 2>/dev/null; then
sed -i '/\[modules\]/a noload => app_voicemail_odbc.so\nnoload => app_voicemail_imap.so' /etc/asterisk/modules.conf 2>/dev/null || true
else
printf '[modules]\nautoload=yes\nnoload => app_voicemail_odbc.so\nnoload => app_voicemail_imap.so\n' >> /etc/asterisk/modules.conf
fi
fi
# 2. Inclusions des fichiers custom dans les configurations principales
if ! grep -q 'pjsip.custom.conf' /etc/asterisk/pjsip.conf 2>/dev/null; then
printf '\n#tryinclude "pjsip.custom.conf"\n' >> /etc/asterisk/pjsip.conf
fi
if ! grep -q 'extensions.custom.conf' /etc/asterisk/extensions.conf 2>/dev/null; then
printf '\n#tryinclude "extensions.custom.conf"\n' >> /etc/asterisk/extensions.conf
fi
chown -R asterisk:asterisk /etc/asterisk
if ! grep -q 'musiconhold.custom.conf' /etc/asterisk/musiconhold.conf 2>/dev/null; then
sed -i 's/^\[default\]/;[default]/' /etc/asterisk/musiconhold.conf 2>/dev/null || true
sed -i 's/^mode=files/;mode=files/' /etc/asterisk/musiconhold.conf 2>/dev/null || true
sed -i 's/^directory=moh/;directory=moh/' /etc/asterisk/musiconhold.conf 2>/dev/null || true
printf '\n#tryinclude "/etc/asterisk/musiconhold.custom.conf"\n' >> /etc/asterisk/musiconhold.conf
fi
if ! grep -q 'voicemail.custom.conf' /etc/asterisk/voicemail.conf 2>/dev/null; then
sed -i 's/^1234 => 4242/;1234 => 4242/' /etc/asterisk/voicemail.conf 2>/dev/null || true
sed -i 's/^;\s*passwordlocation\s*=\s*spooldir/passwordlocation=spooldir/' /etc/asterisk/voicemail.conf 2>/dev/null || true
sed -i 's|^;\s*externpassnotify\s*=.*|externpassnotify=/usr/bin/php /var/www/nexus-pbx/scripts/voicemail_pass_sync.php|' /etc/asterisk/voicemail.conf 2>/dev/null || true
printf '\n#tryinclude "voicemail.custom.conf"\n' >> /etc/asterisk/voicemail.conf
fi
mkdir -p /var/spool/asterisk/voicemail/default /var/lib/asterisk/sounds/custom /var/lib/asterisk/moh
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
fi
chown -R asterisk:asterisk /etc/asterisk /var/spool/asterisk /var/lib/asterisk
chmod 0664 /etc/asterisk/*.conf /etc/asterisk/*.custom.conf 2>/dev/null || true
chmod 2750 /etc/asterisk/keys
chmod 2775 /etc/asterisk
setfacl -R -m u:www-data:rwx,g:asterisk:rwx /etc/asterisk 2>/dev/null || true
setfacl -R -d -m u:www-data:rwx,g:asterisk:rwx /etc/asterisk 2>/dev/null || true
chmod 2775 /etc/asterisk /var/spool/asterisk /var/spool/asterisk/voicemail /var/lib/asterisk/sounds /var/lib/asterisk/moh
setfacl -R -m u:www-data:rwx,g:asterisk:rwx /etc/asterisk /var/spool/asterisk /var/lib/asterisk 2>/dev/null || true
setfacl -R -d -m u:www-data:rwx,g:asterisk:rwx /etc/asterisk /var/spool/asterisk /var/lib/asterisk 2>/dev/null || true
# Rechargement Asterisk immédiat pour appliquer AMI et Logger
asterisk -rx "core reload" 2>/dev/null || true
+95 -4
View File
@@ -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.3.9';
$installedVer = $installedVerData['version'] ?? '1.5.0';
echo json_encode([
'success' => true,
@@ -227,7 +229,7 @@ try {
@chmod($webLog, 0664);
}
@unlink('/tmp/nexus_fw_status_cache.json');
exec('sudo /usr/bin/fail2ban-client reload 2>&1');
exec('sudo /usr/bin/systemctl restart fail2ban 2>&1 || sudo /usr/bin/fail2ban-client reload 2>&1');
echo json_encode([
'success' => true,
@@ -369,6 +371,13 @@ try {
// Synchronisation automatique de l'annuaire d'entreprise
PhonebookManager::syncExtensions($db);
// Régénération automatique des fichiers Asterisk et rechargement PJSIP/Dialplan en direct
$generator->writeAllConfigs();
try {
$ami = AmiClient::fromDatabase($db);
$ami->reloadPjsipAndDialplan();
} catch (\Throwable $e) {}
$modeBadge = $transport === 'tls' ? 'Distant (TLS + SRTP)' : ($encryption ? 'Interne (UDP + SRTP)' : 'Interne (UDP)');
echo json_encode([
'success' => true,
@@ -381,6 +390,13 @@ try {
$stmt = $db->prepare('DELETE FROM extensions WHERE id = :id');
$stmt->execute([':id' => $id]);
PhonebookManager::syncExtensions($db);
$generator->writeAllConfigs();
try {
$ami = AmiClient::fromDatabase($db);
$ami->reloadPjsipAndDialplan();
} catch (\Throwable $e) {}
echo json_encode([
'success' => true,
'message' => 'Poste téléphonique supprimé de la base SQLite.',
@@ -440,6 +456,12 @@ try {
]);
}
$generator->writeAllConfigs();
try {
$ami = AmiClient::fromDatabase($db);
$ami->reloadPjsipAndDialplan();
} catch (\Throwable $e) {}
echo json_encode([
'success' => true,
'message' => "Groupe d'appel {$extension} ({$name}) sauvegardé.",
@@ -449,6 +471,12 @@ try {
case 'delete_group':
$id = (int)($payload['id'] ?? 0);
$db->prepare('DELETE FROM groups WHERE id = :id')->execute([':id' => $id]);
$generator->writeAllConfigs();
try {
$ami = AmiClient::fromDatabase($db);
$ami->reloadPjsipAndDialplan();
} catch (\Throwable $e) {}
echo json_encode([
'success' => true,
'message' => "Groupe de sonnerie supprimé.",
@@ -540,6 +568,12 @@ try {
]);
}
$generator->writeAllConfigs();
try {
$ami = AmiClient::fromDatabase($db);
$ami->reloadPjsipAndDialplan();
} catch (\Throwable $e) {}
echo json_encode([
'success' => true,
'message' => "Groupe de téléavertissement {$extension} ({$name}) sauvegardé.",
@@ -549,6 +583,12 @@ try {
case 'delete_paging_group':
$id = (int)($payload['id'] ?? 0);
$db->prepare('DELETE FROM paging_groups WHERE id = :id')->execute([':id' => $id]);
$generator->writeAllConfigs();
try {
$ami = AmiClient::fromDatabase($db);
$ami->reloadPjsipAndDialplan();
} catch (\Throwable $e) {}
echo json_encode([
'success' => true,
'message' => "Groupe de téléavertissement supprimé.",
@@ -2238,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");
@@ -2311,14 +2391,25 @@ try {
$newVerJson = file_exists("{$appDir}/version.json")
? json_decode((string)file_get_contents("{$appDir}/version.json"), true)
: ['version' => '1.3.8'];
: ['version' => '1.4.3'];
while (ob_get_level() > 0) {
@ob_end_clean();
}
header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: no-cache, no-store, must-revalidate');
echo json_encode([
'success' => ($code === 0),
'new_version' => $newVerJson['version'] ?? '1.3.8',
'new_version' => $newVerJson['version'] ?? '1.4.3',
'output' => $outputStr,
'message' => ($code === 0) ? "Mise à jour réussie !" : "Erreur lors de la mise à jour",
]);
if (function_exists('fastcgi_finish_request')) {
fastcgi_finish_request();
}
break;
default:
+443 -68
View File
@@ -266,20 +266,28 @@ function renderCompanyBranding() {
if (loginBox) {
loginBox.classList.remove('hidden');
loginBox.classList.add('flex');
if (logo && loginLogo) {
loginLogo.src = logo;
loginLogo.classList.remove('hidden');
} else if (loginLogo) {
loginLogo.src = '';
loginLogo.classList.add('hidden');
}
if (loginName) loginName.textContent = name;
if (loginName) {
loginName.textContent = name;
loginName.classList.toggle('hidden', !name);
}
}
} else {
emptyBox?.classList.remove('hidden');
filledBox?.classList.add('hidden');
if (logoImg) { logoImg.src = ''; logoImg.classList.add('hidden'); }
if (nameEl) { nameEl.textContent = ''; nameEl.classList.add('hidden'); }
loginBox?.classList.add('hidden');
if (loginBox) {
loginBox.classList.add('hidden');
loginBox.classList.remove('flex');
}
}
}
@@ -629,20 +637,56 @@ function drawDualAreaChart(svgId, seriesA, seriesB, maxVal, colorA, colorB, unit
const padTop = 14;
const padBottom = 18;
const plotH = height - padTop - padBottom;
const baselineY = padTop + plotH;
const safeMax = maxVal > 0 ? maxVal : 100;
const toPoints = (arr) => arr.map((val, idx) => {
const x = (idx / (arr.length - 1)) * width;
const ratio = Math.max(0, Math.min(1, val / safeMax));
const y = padTop + plotH - (ratio * plotH);
return `${x.toFixed(1)},${y.toFixed(1)}`;
});
const clampY = (y) => Math.max(padTop, Math.min(baselineY, y));
const ptsA = toPoints(seriesA);
const ptsB = toPoints(seriesB);
// Génération de courbes splines adoucies (Catmull-Rom / Bézier cubique) pour éliminer les angles pointus
const computeSmoothPaths = (arr, tension = 0.2) => {
if (!arr || arr.length === 0) return { line: '', area: '' };
const areaA = `0,${padTop + plotH} ` + ptsA.join(' ') + ` ${width},${padTop + plotH}`;
const areaB = `0,${padTop + plotH} ` + ptsB.join(' ') + ` ${width},${padTop + plotH}`;
const points = arr.map((val, idx) => {
const x = (idx / (arr.length - 1)) * width;
const ratio = Math.max(0, Math.min(1, val / safeMax));
const y = baselineY - (ratio * plotH);
return { x, y };
});
if (points.length === 1) {
return {
line: `M ${points[0].x.toFixed(1)},${points[0].y.toFixed(1)}`,
area: `M 0,${baselineY} L ${points[0].x.toFixed(1)},${points[0].y.toFixed(1)} L ${width},${baselineY} Z`
};
}
let line = `M ${points[0].x.toFixed(1)},${points[0].y.toFixed(1)}`;
for (let i = 0; i < points.length - 1; i++) {
const p0 = points[i === 0 ? i : i - 1];
const p1 = points[i];
const p2 = points[i + 1];
const p3 = points[i + 2 < points.length ? i + 2 : i + 1];
let cp1x = p1.x + (p2.x - p0.x) * tension;
let cp1y = p1.y + (p2.y - p0.y) * tension;
let cp2x = p2.x - (p3.x - p1.x) * tension;
let cp2y = p2.y - (p3.y - p1.y) * tension;
// Clamping vertical pour garantir que la courbe ne déborde pas sous l'axe ni au-dessus du max
cp1y = clampY(cp1y);
cp2y = clampY(cp2y);
line += ` C ${cp1x.toFixed(1)},${cp1y.toFixed(1)} ${cp2x.toFixed(1)},${cp2y.toFixed(1)} ${p2.x.toFixed(1)},${p2.y.toFixed(1)}`;
}
const area = `${line} L ${width},${baselineY} L 0,${baselineY} Z`;
return { line, area };
};
const pathA = computeSmoothPaths(seriesA);
const pathB = computeSmoothPaths(seriesB);
const gradIdA = `${svgId}-grad-a`;
const gradIdB = `${svgId}-grad-b`;
@@ -662,18 +706,18 @@ function drawDualAreaChart(svgId, seriesA, seriesB, maxVal, colorA, colorB, unit
<!-- Lignes de grille horizontales -->
<line x1="0" y1="${padTop}" x2="${width}" y2="${padTop}" stroke="#2c323f" stroke-dasharray="3,3" stroke-width="1"/>
<line x1="0" y1="${padTop + plotH / 2}" x2="${width}" y2="${padTop + plotH / 2}" stroke="#2c323f" stroke-dasharray="3,3" stroke-width="1"/>
<line x1="0" y1="${padTop + plotH}" x2="${width}" y2="${padTop + plotH}" stroke="#3d4657" stroke-width="1"/>
<line x1="0" y1="${baselineY}" x2="${width}" y2="${baselineY}" stroke="#3d4657" stroke-width="1"/>
<text x="6" y="${padTop + 10}" fill="#94a3b8" font-size="10" font-family="monospace">${Math.round(safeMax)} ${unitLabel}</text>
<text x="6" y="${padTop + plotH / 2 - 4}" fill="#64748b" font-size="10" font-family="monospace">${Math.round(safeMax / 2)} ${unitLabel}</text>
<!-- Courbe B -->
<polygon points="${areaB}" fill="url(#${gradIdB})"/>
<polyline fill="none" stroke="${colorB}" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" points="${ptsB.join(' ')}"/>
<!-- Courbe B lissée avec dégradé -->
<path d="${pathB.area}" fill="url(#${gradIdB})"/>
<path d="${pathB.line}" fill="none" stroke="${colorB}" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/>
<!-- Courbe A -->
<polygon points="${areaA}" fill="url(#${gradIdA})"/>
<polyline fill="none" stroke="${colorA}" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" points="${ptsA.join(' ')}"/>
<!-- Courbe A lissée avec dégradé -->
<path d="${pathA.area}" fill="url(#${gradIdA})"/>
<path d="${pathA.line}" fill="none" stroke="${colorA}" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/>
`;
}
@@ -3464,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)",
@@ -3542,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();
@@ -5852,14 +5902,57 @@ async function checkSystemUpdate(notify = false) {
}
}
async function runSystemUpdate() {
if (!confirm("Voulez-vous lancer la mise à jour de NexusPBX ?\n\nUne sauvegarde préventive automatique de vos configurations et de votre base de données sera créée.")) {
return;
}
function openUpdateConfirmModal() {
const modal = document.getElementById('modal-confirm-update');
if (!modal) return;
let targetVer = '';
let currVer = (appState && appState.version) ? appState.version : (document.querySelector('meta[name="nexus-app-version"]')?.content || '');
try {
const cachedRaw = localStorage.getItem('nexus_update_cached_data');
if (cachedRaw) {
const cached = JSON.parse(cachedRaw);
if (cached.latest_version) targetVer = cached.latest_version;
if (cached.current_version) currVer = cached.current_version;
}
} catch (_) {}
const elTarget = document.getElementById('update-modal-target-version');
const elCurrent = document.getElementById('update-modal-current-version');
const elBadge = document.getElementById('update-modal-target-badge');
if (elTarget) elTarget.textContent = targetVer ? `v${targetVer}` : 'Dernière version';
if (elCurrent) elCurrent.textContent = currVer ? `v${currVer}` : 'vActuelle';
if (elBadge && targetVer) elBadge.textContent = `v${targetVer} disponible`;
modal.classList.remove('hidden');
modal.classList.add('flex');
}
function closeUpdateConfirmModal() {
const modal = document.getElementById('modal-confirm-update');
if (modal) {
modal.classList.add('hidden');
modal.classList.remove('flex');
}
}
function runSystemUpdate() {
openUpdateConfirmModal();
}
async function executeSystemUpdate() {
const btnModalConfirm = document.getElementById('btn-confirm-update-action');
const btnRun = document.getElementById('btn-run-update');
const statusText = document.getElementById('update-status-text');
if (btnModalConfirm) {
btnModalConfirm.disabled = true;
btnModalConfirm.classList.add('opacity-75', 'cursor-not-allowed');
btnModalConfirm.innerHTML = '<span class="animate-spin mr-2">↻</span> Déploiement en cours...';
}
if (btnRun) {
btnRun.disabled = true;
btnRun.innerHTML = '<span class="animate-spin mr-1.5">↻</span> Mise à jour en cours...';
@@ -5875,62 +5968,344 @@ async function runSystemUpdate() {
localStorage.removeItem('nexus_update_last_check_ts');
localStorage.removeItem('nexus_update_cached_data');
function finalizeUpdateSuccess(newVer) {
newVer = String(newVer || '').replace(/^v/, '');
localStorage.setItem('nexus_update_last_check_ts', Date.now().toString());
localStorage.setItem('nexus_update_cached_data', JSON.stringify({
success: true,
current_version: newVer,
latest_version: newVer,
has_update: false,
changelog: [],
release_date: ''
}));
const verTag = document.getElementById('system-version-tag');
const appBadge = document.getElementById('app-version-badge');
const headerUpdateBadge = document.getElementById('header-update-badge');
const pingDot = document.getElementById('update-ping-dot');
if (verTag && newVer) verTag.textContent = `v${newVer}`;
if (appBadge && newVer) appBadge.textContent = `v${newVer}`;
if (headerUpdateBadge) headerUpdateBadge.style.display = 'none';
if (btnRun) btnRun.style.display = 'none';
if (pingDot) pingDot.classList.add('hidden');
showToast(`NexusPBX a été mis à jour avec succès en v${newVer} ! Rechargement...`, 'success');
if (statusText) {
statusText.className = 'text-emerald-400 font-bold';
statusText.textContent = `Mise à jour terminée avec succès (v${newVer}) ! Rechargement...`;
}
if (btnModalConfirm) {
btnModalConfirm.className = 'px-5 py-2.5 rounded-xl text-xs font-bold bg-emerald-600 text-white flex items-center gap-2';
btnModalConfirm.innerHTML = '✓ Mise à jour terminée ! Rechargement...';
}
setTimeout(() => {
closeUpdateConfirmModal();
window.location.href = window.location.origin + window.location.pathname + '?_t=' + Date.now();
}, 1800);
}
try {
const res = await fetch('/api.php?action=run_update&_t=' + Date.now(), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({})
});
const data = await res.json();
if (data.success) {
const newVer = data.new_version || '';
// 1. Mettre à jour immédiatement le cache avec l'état à jour
localStorage.setItem('nexus_update_last_check_ts', Date.now().toString());
localStorage.setItem('nexus_update_cached_data', JSON.stringify({
success: true,
current_version: newVer,
latest_version: newVer,
has_update: false,
changelog: [],
release_date: ''
}));
// 2. Mettre à jour visuellement les badges immédiatement
const verTag = document.getElementById('system-version-tag');
const appBadge = document.getElementById('app-version-badge');
const headerUpdateBadge = document.getElementById('header-update-badge');
const pingDot = document.getElementById('update-ping-dot');
if (verTag && newVer) verTag.textContent = `v${newVer}`;
if (appBadge && newVer) appBadge.textContent = `v${newVer}`;
if (headerUpdateBadge) headerUpdateBadge.style.display = 'none';
if (btnRun) btnRun.style.display = 'none';
if (pingDot) pingDot.classList.add('hidden');
showToast(`NexusPBX a été mis à jour avec succès en v${newVer} ! Rechargement...`, 'success');
if (statusText) {
statusText.className = 'text-emerald-400 font-bold';
statusText.textContent = `Mise à jour terminée avec succès (v${newVer}) ! Rechargement...`;
}
// 3. Rechargement avec paramètre anti-cache pour forcer le navigateur à récupérer le nouveau DOM
setTimeout(() => {
window.location.href = window.location.origin + window.location.pathname + '?_t=' + Date.now();
}, 2000);
} else {
alert("Erreur lors de la mise à jour :\n\n" + (data.output || data.message));
if (btnRun) {
btnRun.disabled = false;
btnRun.innerHTML = 'Réessayer la mise à jour';
}
const rawText = await res.text();
let data = null;
try {
data = JSON.parse(rawText);
} catch (_) {
data = null;
}
} catch (e) {
alert("Erreur réseau lors de la mise à jour : " + e.message);
if (data && data.success) {
finalizeUpdateSuccess(data.new_version);
return;
}
// Si la réponse n'était pas un JSON valide ou a été interrompue par le rechargement de php-fpm/nginx,
// on sonde le serveur pour vérifier si la mise à jour a en réalité bien été appliquée sur le disque
await new Promise(r => setTimeout(r, 2000));
try {
const probeRes = await fetch('/api.php?action=check_update&_t=' + Date.now());
const probeData = await probeRes.json();
if (probeData && probeData.success && probeData.current_version) {
finalizeUpdateSuccess(probeData.current_version);
return;
}
} catch (_) {}
// En cas d'échec réel avéré
closeUpdateConfirmModal();
const errMsg = (data && (data.output || data.message)) ? (data.output || data.message) : (rawText.slice(0, 150) || "Erreur inconnue");
alert("Erreur lors de la mise à jour :\n\n" + errMsg);
if (btnRun) {
btnRun.disabled = false;
btnRun.innerHTML = 'Réessayer la mise à jour';
}
if (btnModalConfirm) {
btnModalConfirm.disabled = false;
btnModalConfirm.classList.remove('opacity-75', 'cursor-not-allowed');
btnModalConfirm.innerHTML = '<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"/></svg><span>Lancer la mise à jour (1-Clic)</span>';
}
} catch (e) {
// En cas d'interruption réseau du socket (Safari WebKit DOMException ou timeout),
// on vérifie avant d'alerter si le serveur a quand même réussi à déployer les fichiers
await new Promise(r => setTimeout(r, 2000));
try {
const probeRes = await fetch('/api.php?action=check_update&_t=' + Date.now());
const probeData = await probeRes.json();
if (probeData && probeData.success && probeData.current_version) {
finalizeUpdateSuccess(probeData.current_version);
return;
}
} catch (_) {}
closeUpdateConfirmModal();
alert("Erreur réseau lors de la mise à jour : " + (e.message || "Connexion interrompue"));
if (btnRun) {
btnRun.disabled = false;
btnRun.innerHTML = 'Réessayer la mise à jour';
}
if (btnModalConfirm) {
btnModalConfirm.disabled = false;
btnModalConfirm.classList.remove('opacity-75', 'cursor-not-allowed');
btnModalConfirm.innerHTML = '<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"/></svg><span>Lancer la mise à jour (1-Clic)</span>';
}
}
}
// =========================================================================
// JOURNAL D'APPELS (CDR) & HISTORIQUE DES COMMUNICATIONS
// =========================================================================
let cdrDebounceTimer = null;
function debounceCdrLoad() {
clearTimeout(cdrDebounceTimer);
cdrDebounceTimer = setTimeout(() => {
loadCdr();
}, 300);
}
async function loadCdr() {
const tbody = document.getElementById('cdr-table-body');
const info = document.getElementById('cdr-status-info');
const spinner = document.getElementById('spinner-refresh-cdr');
if (spinner) spinner.classList.add('animate-spin');
const search = document.getElementById('cdr-filter-search')?.value || '';
const period = document.getElementById('cdr-filter-period')?.value || 'today';
const direction = document.getElementById('cdr-filter-direction')?.value || 'all';
const disposition = document.getElementById('cdr-filter-disposition')?.value || 'all';
const limit = document.getElementById('cdr-filter-limit')?.value || 100;
try {
const res = await apiCall('get_cdr', {
search,
period,
direction,
disposition,
limit
});
if (!res || !res.success) throw new Error(res?.message || 'Erreur lors de la récupération des CDR');
renderCdrStats(res.stats || {});
renderCdrTable(res.calls || []);
if (info) {
const count = (res.calls || []).length;
const total = res.total_records || 0;
info.textContent = `Affichage de ${count} appel(s) sur ${total} analysé(s) pour la période sélectionnée.`;
}
} catch (err) {
if (tbody) {
tbody.innerHTML = `
<tr>
<td colspan="7" class="py-8 text-center text-rose-400">
Erreur de chargement du journal d'appels : ${escapeHtml(err.message)}
</td>
</tr>
`;
}
} finally {
if (spinner) spinner.classList.remove('animate-spin');
}
}
function renderCdrStats(stats) {
const elTotal = document.getElementById('cdr-stat-total');
const elAns = document.getElementById('cdr-stat-answered');
const elRate = document.getElementById('cdr-stat-rate');
const elMiss = document.getElementById('cdr-stat-missed');
const elOut = document.getElementById('cdr-stat-outbound');
const elIn = document.getElementById('cdr-stat-inbound');
const total = stats.total || 0;
const answered = stats.answered || 0;
const missed = stats.missed || 0;
const rate = total > 0 ? Math.round((answered / total) * 100) : 0;
if (elTotal) elTotal.textContent = total;
if (elAns) elAns.textContent = answered;
if (elRate) elRate.textContent = `${rate}% de réponse`;
if (elMiss) elMiss.textContent = missed;
if (elOut) elOut.textContent = stats.outbound || 0;
if (elIn) elIn.textContent = stats.inbound || 0;
}
function renderCdrTable(calls) {
const tbody = document.getElementById('cdr-table-body');
if (!tbody) return;
if (!calls || calls.length === 0) {
tbody.innerHTML = `
<tr>
<td colspan="7" class="py-8 text-center text-slate-400">
Aucun appel enregistré pour les filtres sélectionnés.
</td>
</tr>
`;
return;
}
tbody.innerHTML = calls.map(c => {
// Direction badge & icon
let dirBadge = '';
if (c.direction === 'inbound') {
dirBadge = `<span class="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-[11px] font-bold bg-teal-500/15 text-teal-300 border border-teal-500/30">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 14l-7 7m0 0l-7-7m7 7V3"/></svg>
<span>Entrant</span>
</span>`;
} else if (c.direction === 'outbound') {
dirBadge = `<span class="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-[11px] font-bold bg-cyan-500/15 text-cyan-300 border border-cyan-500/30">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 10l7-7m0 0l7 7m-7-7v18"/></svg>
<span>Sortant</span>
</span>`;
} else if (c.direction === 'service') {
dirBadge = `<span class="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-[11px] font-bold bg-amber-500/15 text-amber-300 border border-amber-500/30">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/></svg>
<span>Service</span>
</span>`;
} else if (c.direction === 'group') {
dirBadge = `<span class="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-[11px] font-bold bg-purple-500/15 text-purple-300 border border-purple-500/30">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z"/></svg>
<span>Groupe</span>
</span>`;
} else if (c.direction === 'ivr') {
dirBadge = `<span class="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-[11px] font-bold bg-pink-500/15 text-pink-300 border border-pink-500/30">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11a7 7 0 01-7 7m0 0a7 7 0 01-7-7m7 7v4m0 0H8m4 0h4m-4-8a3 3 0 01-3-3V5a3 3 0 116 0v6a3 3 0 01-3 3z"/></svg>
<span>SVI</span>
</span>`;
} else {
dirBadge = `<span class="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-[11px] font-bold bg-indigo-500/15 text-indigo-300 border border-indigo-500/30">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4"/></svg>
<span>Interne</span>
</span>`;
}
// Status badge
let statusBadge = '';
if (c.disposition === 'ANSWERED') {
statusBadge = `<span class="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-[11px] font-bold bg-emerald-500/15 text-emerald-300 border border-emerald-500/30">
<span class="w-1.5 h-1.5 rounded-full bg-emerald-400"></span>
<span>Décroché</span>
</span>`;
} else if (c.disposition === 'NO ANSWER') {
statusBadge = `<span class="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-[11px] font-bold bg-amber-500/15 text-amber-300 border border-amber-500/30">
<span class="w-1.5 h-1.5 rounded-full bg-amber-400"></span>
<span>Non répondu</span>
</span>`;
} else if (c.disposition === 'BUSY') {
statusBadge = `<span class="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-[11px] font-bold bg-purple-500/15 text-purple-300 border border-purple-500/30">
<span class="w-1.5 h-1.5 rounded-full bg-purple-400"></span>
<span>Occupé</span>
</span>`;
} else {
statusBadge = `<span class="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-[11px] font-bold bg-rose-500/15 text-rose-300 border border-rose-500/30">
<span class="w-1.5 h-1.5 rounded-full bg-rose-400"></span>
<span>Échec</span>
</span>`;
}
// Trunk badge
let trunkBadge = '';
if (c.trunk_name) {
trunkBadge = `<span class="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-xl text-xs font-semibold bg-emerald-950/60 text-emerald-300 border border-emerald-500/40 shadow-sm">
<svg class="w-3.5 h-3.5 text-emerald-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9"/></svg>
<span>${escapeHtml(c.trunk_name)}</span>
</span>`;
} else {
trunkBadge = `<span class="text-slate-500 font-mono text-xs">—</span>`;
}
// Caller display
const callerNameHtml = c.caller_name ? `<div class="font-bold text-white text-xs">${escapeHtml(c.caller_name)}</div>` : '';
const callerNumHtml = `<div class="font-mono text-slate-300 text-xs">${escapeHtml(c.caller_number || 'Inconnu')}</div>`;
// Callee display
const calleeNameHtml = c.callee_name ? `<div class="font-bold text-white text-xs">${escapeHtml(c.callee_name)}</div>` : '';
const calleeNumHtml = `<div class="font-mono text-slate-300 text-xs">${escapeHtml(c.callee_number || '—')}</div>`;
// Talked vs Total duration
const durationHtml = c.billsec > 0
? `<div><span class="font-bold text-emerald-300 font-mono text-xs">${c.billsec_formatted}</span> <span class="text-[10px] text-slate-500 font-sans">(${c.duration_formatted} tot.)</span></div>`
: `<span class="font-mono text-slate-400 text-xs">${c.duration_formatted}</span>`;
return `
<tr class="hover:bg-anthracite-750/50 transition">
<td class="py-3 px-4 whitespace-nowrap text-slate-300 font-mono text-xs">
<div class="font-semibold text-slate-200">${escapeHtml(c.datetime_formatted)}</div>
<div class="text-[10px] text-slate-500 font-sans">${escapeHtml(c.datetime)}</div>
</td>
<td class="py-3 px-3 whitespace-nowrap">
${dirBadge}
</td>
<td class="py-3 px-4">
${callerNameHtml}
${callerNumHtml}
</td>
<td class="py-3 px-4">
${calleeNameHtml}
${calleeNumHtml}
</td>
<td class="py-3 px-4 whitespace-nowrap">
${trunkBadge}
</td>
<td class="py-3 px-3 whitespace-nowrap">
${durationHtml}
</td>
<td class="py-3 px-3 whitespace-nowrap">
${statusBadge}
</td>
</tr>
`;
}).join('');
}
function exportCdrCsv() {
const search = encodeURIComponent(document.getElementById('cdr-filter-search')?.value || '');
const period = encodeURIComponent(document.getElementById('cdr-filter-period')?.value || 'all');
const direction = encodeURIComponent(document.getElementById('cdr-filter-direction')?.value || 'all');
const disposition = encodeURIComponent(document.getElementById('cdr-filter-disposition')?.value || 'all');
window.location.href = `/api.php?action=export_cdr_csv&search=${search}&period=${period}&direction=${direction}&disposition=${disposition}`;
}
async function confirmClearCdr() {
if (!confirm("Êtes-vous sûr de vouloir purger l'historique complet du journal d'appels Asterisk ? Cette action est irréversible.")) {
return;
}
try {
const res = await apiCall('clear_cdr', {});
showToast(res.message || "Journal d'appels réinitialisé.", 'success');
loadCdr();
} catch (e) {
showToast(e.message || "Erreur lors de la purge du journal.", 'error');
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 795 KiB

+280 -25
View File
@@ -11,8 +11,14 @@ Database::getConnection();
$isAuth = Auth::isAuthenticated();
$currentUser = Auth::getUsername();
$stmtSettings = Database::getConnection()->query('SELECT company_name, company_logo FROM settings WHERE id = 1 LIMIT 1');
$branding = $stmtSettings ? $stmtSettings->fetch(\PDO::FETCH_ASSOC) : null;
$companyName = trim($branding['company_name'] ?? '');
$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.3.9';
$appVersion = $appVerData['version'] ?? '1.5.0';
?>
<!DOCTYPE html>
<html lang="fr" class="h-full">
@@ -20,7 +26,9 @@ $appVersion = $appVerData['version'] ?? '1.3.9';
<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 = {
@@ -144,39 +152,40 @@ $appVersion = $appVerData['version'] ?? '1.3.9';
<div id="toast-container" class="fixed bottom-5 right-5 z-50 flex flex-col gap-2.5 max-w-md w-full pointer-events-none px-4 sm:px-0"></div>
<!-- ÉCRAN DE CONNEXION -->
<div id="login-screen" class="<?= $isAuth ? 'hidden' : 'flex' ?> min-h-screen w-full items-center justify-center p-4 bg-gradient-to-br from-anthracite-950 via-anthracite-900 to-indigo-950/40">
<div class="w-full max-w-md bg-anthracite-800 border border-anthracite-700 rounded-2xl shadow-2xl p-8">
<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 id="login-screen" class="<?= $isAuth ? 'hidden' : 'flex' ?> min-h-screen w-full items-center justify-center p-4 relative overflow-hidden bg-anthracite-950">
<!-- Arrière-plan personnalisé / Image de fond avec overlay sombre et léger flou -->
<div id="login-bg-layer" class="absolute inset-0 bg-cover bg-center bg-no-repeat" style="background-image: url('/assets/login-bg.jpg?v=<?= file_exists(__DIR__ . '/assets/login-bg.jpg') ? filemtime(__DIR__ . '/assets/login-bg.jpg') : 1 ?>');"></div>
<div class="absolute inset-0 bg-gradient-to-tr from-anthracite-950/70 via-anthracite-950/50 to-indigo-950/30 backdrop-blur-[1px]"></div>
<!-- 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-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>
<!-- Aperçu Logo Entreprise sur l'écran de connexion (si configuré) -->
<div id="login-company-box" class="hidden mb-5 p-3 rounded-xl bg-anthracite-950/90 border border-anthracite-700 flex items-center justify-center gap-3">
<img id="login-company-logo" src="" alt="Logo Entreprise" class="max-h-10 max-w-[160px] object-contain hidden">
<span id="login-company-name" class="text-xs font-semibold text-slate-200"></span>
<!-- Aperçu Logo Entreprise sur l'écran de connexion (rendu serveur natif) -->
<div id="login-company-box" class="<?= $hasBranding ? 'flex' : 'hidden' ?> mb-5 p-3 rounded-xl bg-anthracite-950/45 border border-white/10 backdrop-blur-md items-center justify-center gap-3">
<img id="login-company-logo" src="<?= htmlspecialchars($companyLogo) ?>" alt="Logo Entreprise" class="max-h-10 max-w-[160px] object-contain <?= !empty($companyLogo) ? '' : 'hidden' ?>">
<span id="login-company-name" class="text-xs font-semibold text-slate-200 <?= !empty($companyName) ? '' : 'hidden' ?>"><?= htmlspecialchars($companyName) ?></span>
</div>
<form id="login-form" class="space-y-4">
<div>
<label class="block text-xs font-semibold uppercase tracking-wider text-slate-300 mb-1.5">Identifiant</label>
<input type="text" name="username" value="admin" required
class="w-full px-3.5 py-2.5 rounded-lg bg-anthracite-950 border border-anthracite-700 text-white focus:outline-none focus:border-indigo-400 text-sm">
class="w-full px-3.5 py-2.5 rounded-xl bg-anthracite-950/50 backdrop-blur-md border border-white/10 text-white placeholder-slate-400 focus:outline-none focus:border-indigo-400 focus:ring-2 focus:ring-indigo-500/20 text-sm transition">
</div>
<div>
<label class="block text-xs font-semibold uppercase tracking-wider text-slate-300 mb-1.5">Mot de passe</label>
<input type="password" name="password" placeholder="••••••••" required
class="w-full px-3.5 py-2.5 rounded-lg bg-anthracite-950 border border-anthracite-700 text-white focus:outline-none focus:border-indigo-400 text-sm">
class="w-full px-3.5 py-2.5 rounded-xl bg-anthracite-950/50 backdrop-blur-md border border-white/10 text-white placeholder-slate-400 focus:outline-none focus:border-indigo-400 focus:ring-2 focus:ring-indigo-500/20 text-sm transition">
</div>
<button type="submit"
class="w-full py-2.5 px-4 bg-indigo-600 hover:bg-indigo-500 active:bg-indigo-700 text-white font-semibold rounded-lg shadow-lg shadow-indigo-600/25 transition text-sm">
class="w-full py-2.5 px-4 bg-gradient-to-r from-indigo-600 to-indigo-500 hover:from-indigo-500 hover:to-indigo-400 active:from-indigo-700 active:to-indigo-600 text-white font-semibold rounded-xl shadow-lg shadow-indigo-500/25 transition text-sm">
Se connecter au PBX
</button>
</form>
@@ -196,11 +205,7 @@ $appVersion = $appVerData['version'] ?? '1.3.9';
<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">
@@ -208,7 +213,7 @@ $appVersion = $appVerData['version'] ?? '1.3.9';
</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>
@@ -247,6 +252,18 @@ $appVersion = $appVerData['version'] ?? '1.3.9';
<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 -->
@@ -969,6 +986,158 @@ $appVersion = $appVerData['version'] ?? '1.3.9';
</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 -->
<!-- ========================================================= -->
@@ -4715,6 +4884,92 @@ $appVersion = $appVerData['version'] ?? '1.3.9';
</div>
</div>
<!-- ===================================================================== -->
<!-- MODAL : CONFIRMATION MISE À JOUR SYSTEME (OTA 1-CLIC) -->
<!-- ===================================================================== -->
<div id="modal-confirm-update" class="fixed inset-0 z-50 hidden items-center justify-center bg-black/80 backdrop-blur-sm p-3 sm:p-4 overflow-y-auto">
<div class="bg-anthracite-900 border border-indigo-500/40 rounded-2xl w-full max-w-lg shadow-2xl overflow-hidden my-6 animate-fadeIn">
<!-- Entête stylisée -->
<div class="px-6 py-5 border-b border-anthracite-800 bg-gradient-to-r from-indigo-950/50 via-anthracite-900 to-anthracite-900 flex items-start justify-between">
<div class="flex items-center gap-3.5">
<div class="w-11 h-11 rounded-xl bg-indigo-500/15 border border-indigo-500/30 text-indigo-400 flex items-center justify-center shrink-0 shadow-lg shadow-indigo-950/50">
<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="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"/>
</svg>
</div>
<div>
<div class="flex items-center gap-2">
<h3 class="text-base font-bold text-white">Mise à Jour du Système NexusPBX</h3>
<span id="update-modal-target-badge" class="px-2 py-0.5 rounded text-[10px] font-bold tracking-wide uppercase bg-indigo-500/20 text-indigo-300 border border-indigo-500/30">OTA 1-Clic</span>
</div>
<p class="text-xs text-slate-400 mt-0.5">Déploiement officiel et rechargement sans coupure</p>
</div>
</div>
<button type="button" onclick="closeUpdateConfirmModal()" class="text-slate-400 hover:text-white p-1.5 rounded-lg hover:bg-anthracite-800 transition">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/></svg>
</button>
</div>
<!-- Corps de la modale -->
<div class="p-6 space-y-4">
<!-- Info cible -->
<div class="p-3.5 rounded-xl bg-anthracite-950/90 border border-anthracite-800 flex items-center justify-between">
<div>
<div class="text-[11px] font-semibold text-slate-400 uppercase tracking-wider">Version Cible</div>
<div id="update-modal-target-version" class="text-base font-bold text-emerald-400 font-mono mt-0.5">v...</div>
</div>
<div class="text-right">
<div class="text-[11px] font-semibold text-slate-400 uppercase tracking-wider">Version Actuelle</div>
<div id="update-modal-current-version" class="text-sm font-semibold text-slate-300 font-mono mt-0.5">v...</div>
</div>
</div>
<!-- Garanties & Sauvegarde automatique -->
<div class="p-3.5 rounded-xl bg-emerald-500/10 border border-emerald-500/20 text-xs text-emerald-300 flex items-start gap-3">
<svg class="w-5 h-5 text-emerald-400 shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"/>
</svg>
<div>
<strong class="font-bold text-emerald-200">Sauvegarde préventive automatique incluse :</strong>
<p class="mt-0.5 text-emerald-300/90 leading-relaxed">Une sauvegarde complète de votre base SQLite et de vos configurations Asterisk sera créée avant toute modification.</p>
</div>
</div>
<!-- Ce que fait la mise à jour -->
<div class="space-y-2">
<div class="text-xs font-semibold text-slate-300">Étapes automatiques du déploiement :</div>
<ul class="text-xs text-slate-400 space-y-1.5 pl-1">
<li class="flex items-center gap-2">
<span class="w-1.5 h-1.5 rounded-full bg-cyan-400 shrink-0"></span>
<span>Téléchargement des derniers fichiers et correctifs du dépôt officiel.</span>
</li>
<li class="flex items-center gap-2">
<span class="w-1.5 h-1.5 rounded-full bg-indigo-400 shrink-0"></span>
<span>Exécution des migrations SQLite et régénération des configurations Asterisk.</span>
</li>
<li class="flex items-center gap-2">
<span class="w-1.5 h-1.5 rounded-full bg-emerald-400 shrink-0"></span>
<span>Rechargement à chaud sans interruption des communications actives.</span>
</li>
</ul>
</div>
</div>
<!-- Boutons d'action -->
<div class="px-6 py-4 border-t border-anthracite-800 bg-anthracite-950/80 flex items-center justify-end gap-3">
<button type="button" onclick="closeUpdateConfirmModal()"
class="px-4 py-2.5 rounded-xl text-xs font-semibold bg-anthracite-800 hover:bg-anthracite-750 text-slate-300 border border-anthracite-700 transition">
Annuler
</button>
<button type="button" id="btn-confirm-update-action" onclick="executeSystemUpdate()"
class="px-5 py-2.5 rounded-xl text-xs font-bold bg-gradient-to-r from-indigo-600 to-indigo-500 hover:from-indigo-500 hover:to-indigo-400 text-white shadow-lg shadow-indigo-600/30 transition flex items-center gap-2">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"/></svg>
<span>Lancer la mise à jour (1-Clic)</span>
</button>
</div>
</div>
</div>
<!-- ===================================================================== -->
<!-- MODAL : URL XML YEALINK REMOTE PHONEBOOK (cm_to_yl_ab.php) -->
<!-- ===================================================================== -->
+55 -10
View File
@@ -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
@@ -65,20 +67,63 @@ touch /etc/asterisk/pjsip.conf \
/etc/asterisk/pjsip.custom.conf \
/etc/asterisk/extensions.conf \
/etc/asterisk/extensions.custom.conf \
/etc/asterisk/musiconhold.conf \
/etc/asterisk/musiconhold.custom.conf \
/etc/asterisk/voicemail.conf \
/etc/asterisk/voicemail.custom.conf \
/etc/asterisk/modules.conf \
/etc/asterisk/rtp.conf \
/etc/asterisk/features.conf 2>/dev/null || true
mkdir -p /var/lib/asterisk/sounds/custom /var/lib/asterisk/moh /var/spool/asterisk/voicemail 2>/dev/null || true
# Désactivation des modules ODBC/IMAP incompatibles sur Debian (évite conflit bloquant app_voicemail)
if ! grep -q 'noload => app_voicemail_odbc.so' /etc/asterisk/modules.conf 2>/dev/null; then
if grep -q '\[modules\]' /etc/asterisk/modules.conf 2>/dev/null; then
sed -i '/\[modules\]/a noload => app_voicemail_odbc.so\nnoload => app_voicemail_imap.so' /etc/asterisk/modules.conf 2>/dev/null || true
else
printf '[modules]\nautoload=yes\nnoload => app_voicemail_odbc.so\nnoload => app_voicemail_imap.so\n' >> /etc/asterisk/modules.conf
fi
fi
# Inclusions Asterisk et configuration messagerie vocale
if ! grep -q 'pjsip.custom.conf' /etc/asterisk/pjsip.conf 2>/dev/null; then
printf '\n#tryinclude "pjsip.custom.conf"\n' >> /etc/asterisk/pjsip.conf
fi
if ! grep -q 'extensions.custom.conf' /etc/asterisk/extensions.conf 2>/dev/null; then
printf '\n#tryinclude "extensions.custom.conf"\n' >> /etc/asterisk/extensions.conf
fi
if ! grep -q 'musiconhold.custom.conf' /etc/asterisk/musiconhold.conf 2>/dev/null; then
sed -i 's/^\[default\]/;[default]/' /etc/asterisk/musiconhold.conf 2>/dev/null || true
sed -i 's/^mode=files/;mode=files/' /etc/asterisk/musiconhold.conf 2>/dev/null || true
sed -i 's/^directory=moh/;directory=moh/' /etc/asterisk/musiconhold.conf 2>/dev/null || true
printf '\n#tryinclude "/etc/asterisk/musiconhold.custom.conf"\n' >> /etc/asterisk/musiconhold.conf
fi
if ! grep -q 'voicemail.custom.conf' /etc/asterisk/voicemail.conf 2>/dev/null; then
sed -i 's/^1234 => 4242/;1234 => 4242/' /etc/asterisk/voicemail.conf 2>/dev/null || true
sed -i 's/^;\s*passwordlocation\s*=\s*spooldir/passwordlocation=spooldir/' /etc/asterisk/voicemail.conf 2>/dev/null || true
sed -i 's|^;\s*externpassnotify\s*=.*|externpassnotify=/usr/bin/php /var/www/nexus-pbx/scripts/voicemail_pass_sync.php|' /etc/asterisk/voicemail.conf 2>/dev/null || true
printf '\n#tryinclude "voicemail.custom.conf"\n' >> /etc/asterisk/voicemail.conf
fi
mkdir -p /var/lib/asterisk/sounds/custom /var/lib/asterisk/moh /var/spool/asterisk/voicemail/default 2>/dev/null || true
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 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
+35 -4
View File
@@ -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)
@@ -130,8 +133,30 @@ $gen->writeAllConfigs();
# 5. Fix permissions & ACLs
echo -e "${C_GRAY}[5/6] Application des permissions et ACLs système...${C_RESET}"
setfacl -R -m u:www-data:rwx,g:asterisk:rwx /etc/asterisk /var/lib/asterisk/sounds /var/lib/asterisk/moh 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 2>/dev/null || true
php -r '
require_once "/var/www/nexus-pbx/src/Database.php";
require_once "/var/www/nexus-pbx/src/SystemChecker.php";
App\SystemChecker::ensureAsteriskIncludes();
' 2>/dev/null || true
mkdir -p /var/spool/asterisk/voicemail/default /var/lib/asterisk/sounds/custom /var/lib/asterisk/moh 2>/dev/null || true
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 /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
@@ -139,8 +164,14 @@ echo -e "${C_GRAY}[6/6] Rechargement à chaud d'Asterisk et des services web...$
asterisk -rx "core reload" >/dev/null 2>&1 || true
asterisk -rx "pjsip reload" >/dev/null 2>&1 || true
asterisk -rx "dialplan reload" >/dev/null 2>&1 || true
systemctl reload php8.4-fpm 2>/dev/null || systemctl reload php8.2-fpm 2>/dev/null || systemctl reload php*-fpm 2>/dev/null || true
systemctl reload nginx 2>/dev/null || true
# Rechargement des services web : différé si exécuté depuis l'interface web pour éviter de couper la requête HTTP
if [ -t 1 ]; then
systemctl reload php8.4-fpm 2>/dev/null || systemctl reload php8.2-fpm 2>/dev/null || systemctl reload php*-fpm 2>/dev/null || true
systemctl reload nginx 2>/dev/null || true
else
(nohup bash -c "sleep 2 && (systemctl reload php8.4-fpm 2>/dev/null || systemctl reload php8.2-fpm 2>/dev/null || systemctl reload php*-fpm 2>/dev/null || true); systemctl reload nginx 2>/dev/null || true" >/dev/null 2>&1 &)
fi
NEW_VER="Inconnue"
if [ -f "$APP_DIR/version.json" ]; then
+478
View File
@@ -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);
}
}
+5 -3
View File
@@ -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);
+25 -15
View File
@@ -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";
@@ -571,10 +579,6 @@ class ConfigGenerator
$lines[] = " same => n,Hangup()";
}
if ($groupCode !== '') {
$lines[] = "exten => _{$groupCode}.,1,NoOp(Interception d'appel dirigee vers \${EXTEN:" . strlen($groupCode) . "})";
$lines[] = " same => n,Pickup(\${EXTEN:" . strlen($groupCode) . "}@from-internal)";
$lines[] = " same => n,Hangup()";
$lines[] = "exten => {$groupCode},1,NoOp(Interception d'appel de groupe)";
$lines[] = " same => n,Pickup()";
$lines[] = " same => n,Hangup()";
@@ -986,9 +990,10 @@ class ConfigGenerator
$lines[] = "";
}
// Interphonie directe 1-à-1 (*80 + numéro de poste)
// Interphonie directe 1-à-1 (*80 + numéro de poste, ex: *80101 pour le poste 101)
// Pattern _*80XX. pour ne cibler que les numéros de postes (au moins 2 chiffres) et ne pas chevaucher *801
$lines[] = "; --- Interphonie directe 1-à-1 (*80 + numéro de poste) ---";
$lines[] = "exten => _*80.,1,NoOp(Interphonie directe vers poste \${EXTEN:3})";
$lines[] = "exten => _*80XX.,1,NoOp(Interphonie directe vers poste \${EXTEN:3})";
$lines[] = " same => n,Set(CHANNEL(language)={$sysLang})";
$lines[] = " same => n,Answer()";
$lines[] = " same => n,Playback(beep)";
@@ -997,13 +1002,14 @@ class ConfigGenerator
$lines[] = "";
}
// Accès direct aux IVR depuis l'interne via *8 + id
// Accès direct aux IVR depuis l'interne via *8 + id (*801, *802...)
if (!empty($ivrs)) {
$lines[] = "; --- Test direct des SVI/IVR depuis l'interne ---";
$lines[] = "; --- Test direct des SVI/IVR depuis l'interne (*801, *802...) ---";
foreach ($ivrs as $ivr) {
$ivrSlug = $this->slugify($ivr['name']);
$testCode = '*8' . str_pad((string)$ivr['id'], 2, '0', STR_PAD_LEFT);
$lines[] = "exten => {$testCode},1,NoOp(Test SVI {$ivr['name']})";
$lines[] = " same => n,Set(CHANNEL(language)={$sysLang})";
$lines[] = " same => n,Goto(ivr-{$ivrSlug},s,1)";
}
$lines[] = "";
@@ -1500,6 +1506,7 @@ class ConfigGenerator
$lines[] = "; --- Serveur Vocal Interactif : {$ivr['name']} ---";
$lines[] = "[ivr-{$ivrSlug}]";
$lines[] = "exten => s,1,NoOp(SVI {$ivr['name']})";
$lines[] = " same => n,Set(CHANNEL(language)={$sysLang})";
$lines[] = " same => n,Answer()";
$lines[] = " same => n,Wait(1)";
$lines[] = " same => n,Set(IVR_INVALID_COUNT=0)";
@@ -1600,9 +1607,6 @@ class ConfigGenerator
return implode("\n", $lines) . "\n";
}
/**
* Sauvegarde les anciens fichiers et écrit atomiquement pjsip.custom.conf et extensions.custom.conf
* avec les droits Debian asterisk:asterisk (0660).
/**
* Génère les classes Musique d'attente (MOH) personnalisées par site/catégorie
*/
@@ -2223,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;
+84 -7
View File
@@ -70,17 +70,22 @@ class SystemChecker
: 'Accès refusé en écriture dans /etc/asterisk pour PHP-FPM',
];
// 5. Directives #tryinclude dans pjsip.conf et extensions.conf
// 5. Directives #tryinclude dans pjsip.conf, extensions.conf, voicemail.conf et modules.conf
$pjsipMain = @file_get_contents('/etc/asterisk/pjsip.conf') ?: '';
$extMain = @file_get_contents('/etc/asterisk/extensions.conf') ?: '';
$incOk = str_contains($pjsipMain, 'pjsip.custom.conf') && str_contains($extMain, 'extensions.custom.conf');
$vmMain = @file_get_contents('/etc/asterisk/voicemail.conf') ?: '';
$modMain = @file_get_contents('/etc/asterisk/modules.conf') ?: '';
$incOk = str_contains($pjsipMain, 'pjsip.custom.conf')
&& str_contains($extMain, 'extensions.custom.conf')
&& str_contains($vmMain, 'voicemail.custom.conf')
&& str_contains($modMain, 'app_voicemail_odbc.so');
$checks[] = [
'id' => 'asterisk_includes',
'label' => 'Inclusions natives (#tryinclude *.custom.conf)',
'label' => 'Inclusions Asterisk & Voicemail (#tryinclude & modules.conf)',
'ok' => $incOk,
'detail' => $incOk
? 'pjsip.conf et extensions.conf incluent bien les fichiers .custom.conf'
: 'Directive #tryinclude manquante dans pjsip.conf ou extensions.conf',
? 'pjsip.conf, extensions.conf, voicemail.conf et modules.conf configurés'
: 'Directive #tryinclude manquante ou conflit modules.conf détecté',
];
// 6. Certificats TLS (Let's Encrypt ou Certificat local Debian)
@@ -142,6 +147,25 @@ class SystemChecker
public static function ensureAsteriskIncludes(): void
{
// 1. modules.conf: Désactiver les modules voicemail ODBC/IMAP incompatibles sur Debian (évite collision bloquante avec app_voicemail.so)
$modConf = '/etc/asterisk/modules.conf';
if (is_file($modConf) && is_writable($modConf)) {
$content = (string)file_get_contents($modConf);
$changed = false;
if (!str_contains($content, 'app_voicemail_odbc.so')) {
if (preg_match('/\[modules\]/i', $content)) {
$content = preg_replace('/\[modules\]/i', "[modules]\nnoload => app_voicemail_odbc.so\nnoload => app_voicemail_imap.so", $content, 1);
$changed = true;
} else {
$content = "[modules]\nautoload=yes\nnoload => app_voicemail_odbc.so\nnoload => app_voicemail_imap.so\n\n" . $content;
$changed = true;
}
}
if ($changed) {
file_put_contents($modConf, $content);
}
}
$pjsipConf = '/etc/asterisk/pjsip.conf';
if (is_file($pjsipConf) && is_writable($pjsipConf)) {
$content = (string)file_get_contents($pjsipConf);
@@ -177,8 +201,20 @@ class SystemChecker
if (is_file($vmConf) && is_writable($vmConf)) {
$content = (string)file_get_contents($vmConf);
$cleaned = preg_replace('/^1234 => 4242.*/m', ';1234 => 4242,Example Mailbox,root@localhost', $content);
$cleaned = preg_replace('/^;\s*passwordlocation\s*=\s*spooldir/m', 'passwordlocation=spooldir', $cleaned);
$cleaned = preg_replace('/^;\s*externpassnotify\s*=.*/m', 'externpassnotify=/usr/bin/php /var/www/nexus-pbx/scripts/voicemail_pass_sync.php', $cleaned);
if (preg_match('/^;\s*passwordlocation\s*=\s*spooldir/m', $cleaned)) {
$cleaned = preg_replace('/^;\s*passwordlocation\s*=\s*spooldir/m', 'passwordlocation=spooldir', $cleaned);
} elseif (!str_contains($cleaned, 'passwordlocation=spooldir') && !str_contains($cleaned, 'passwordlocation = spooldir')) {
if (preg_match('/\[general\]/i', $cleaned)) {
$cleaned = preg_replace('/\[general\]/i', "[general]\npasswordlocation=spooldir", $cleaned, 1);
}
}
if (preg_match('/^;\s*externpassnotify\s*=.*/m', $cleaned)) {
$cleaned = preg_replace('/^;\s*externpassnotify\s*=.*/m', 'externpassnotify=/usr/bin/php /var/www/nexus-pbx/scripts/voicemail_pass_sync.php', $cleaned);
} elseif (!str_contains($cleaned, 'externpassnotify=')) {
if (preg_match('/\[general\]/i', $cleaned)) {
$cleaned = preg_replace('/\[general\]/i', "[general]\nexternpassnotify=/usr/bin/php /var/www/nexus-pbx/scripts/voicemail_pass_sync.php", $cleaned, 1);
}
}
if (!str_contains($cleaned, 'voicemail.custom.conf')) {
$cleaned = rtrim($cleaned) . "\n\n#tryinclude \"voicemail.custom.conf\"\n";
}
@@ -187,6 +223,47 @@ class SystemChecker
}
}
@mkdir('/var/spool/asterisk/voicemail/default', 0775, true);
// Liens vers les répertoires de sons Debian (/usr/share/asterisk/sounds -> /var/lib/asterisk/sounds)
@mkdir('/var/lib/asterisk/sounds', 0775, true);
@mkdir('/var/lib/asterisk/sounds/custom', 0775, true);
@mkdir('/var/lib/asterisk/moh', 0775, true);
foreach (['fr', 'en', 'es'] as $lang) {
$src = "/usr/share/asterisk/sounds/{$lang}";
$dst = "/var/lib/asterisk/sounds/{$lang}";
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');
}
// Synchroniser manager.conf avec le mot de passe AMI de la base SQLite si nécessaire (ex: après restauration de backup)
$mgrConf = '/etc/asterisk/manager.conf';
if (is_file($mgrConf) && is_writable($mgrConf)) {
try {
$db = Database::getConnection();
$st = $db->query('SELECT ami_user, ami_secret FROM settings WHERE id = 1')->fetch();
if (!empty($st['ami_secret'])) {
$sec = trim((string)$st['ami_secret']);
$usr = trim((string)($st['ami_user'] ?? 'pbxgui')) ?: 'pbxgui';
$content = (string)file_get_contents($mgrConf);
if (str_contains($content, "[{$usr}]")) {
$updated = preg_replace("/(\[{$usr}\][^\[]*secret\s*=\s*)([^\r\n]+)/i", "\${1}{$sec}", $content);
if ($updated !== null && $updated !== $content) {
file_put_contents($mgrConf, $updated);
@exec('asterisk -rx "manager reload" 2>/dev/null || true');
}
}
}
} catch (\Throwable) {}
}
$astConf = '/etc/asterisk/asterisk.conf';
if (is_file($astConf) && is_writable($astConf)) {
$content = (string)file_get_contents($astConf);
+36 -4
View File
@@ -298,17 +298,19 @@ class SystemMetrics
}
}
$seenExts = [];
foreach (preg_split('/\r?\n/', $rawContacts) ?: [] as $line) {
// Contact: 101/sip:101@10.10.167.162:5060 5dd9a21be8 Avail 9.234
// or with -nan: Contact: 102/sip:102@10.10.168.228:5060;x-ast-txp=trans bb0686ce80 Unavail -nan
if (preg_match('/Contact:\s+([^\s\/]+)\/sip:([^\s]+)\s+([a-f0-9]+)\s+(\S+)\s+(-?[0-9.]+|nan|-nan)/i', $line, $m)) {
// Supporte sip:, sips:, sans préfixe, tous types de hash et RTT (numérique, nan, N/A ou absent)
if (preg_match('/Contact:\s+([^\s\/]+)\/(?:sips?:)?([^\s]+)\s+([a-zA-Z0-9]+)\s+(\S+)(?:\s+(\S+))?/i', $line, $m)) {
$extNum = $m[1];
$uri = $m[2];
$status = $m[4];
$rttRaw = $m[5] ?? null;
$isAvail = (stripos($status, 'Avail') !== false && stripos($status, 'Unavail') === false) || stripos($status, 'NonQual') !== false;
if ($isAvail) {
$onlineCount++;
}
$seenExts[$extNum] = true;
$meta = $contactMeta[$extNum] ?? [];
$fullUri = $meta['uri'] ?? ("sip:" . $uri);
@@ -330,7 +332,37 @@ class SystemMetrics
'user_agent' => $userAgent,
'active_transport' => $activeTransport,
'status' => $status,
'rtt_ms' => is_numeric($m[5]) ? round((float)$m[5], 1) : null,
'rtt_ms' => ($rttRaw !== null && is_numeric($rttRaw)) ? round((float)$rttRaw, 1) : null,
];
}
}
// Filet de sécurité 100% infaillible : Tous les contacts enregistrés dans AstDB (/registrar/contact)
// non encore répertoriés (par exemple si qualify est désactivé ou RTT non disponible)
$nowTime = time();
foreach ($contactMeta as $extNum => $meta) {
if (isset($seenExts[$extNum])) {
continue;
}
$exp = (int)($meta['expiration_time'] ?? 0);
if ($exp === 0 || $exp > $nowTime) {
$onlineCount++;
$fullUri = $meta['uri'] ?? '';
$activeTransport = 'udp';
if (preg_match('/transport=(tls|wss|ws|tcp|udp)/i', $fullUri, $tm)) {
$activeTransport = strtolower($tm[1]);
} elseif (preg_match('/x-ast-txp=transport-(tls|wss|ws|tcp|udp)/i', $fullUri, $tm)) {
$activeTransport = strtolower($tm[1]);
} elseif (str_starts_with($fullUri, 'sips:')) {
$activeTransport = 'tls';
}
$contacts[] = [
'extension' => (string)$extNum,
'uri' => $fullUri,
'user_agent' => $meta['user_agent'] ?? '',
'active_transport' => $activeTransport,
'status' => 'Avail',
'rtt_ms' => null,
];
}
}
+10 -6
View File
@@ -1,12 +1,16 @@
{
"version": "1.3.9",
"version": "1.6.1",
"codename": "Helios",
"release_date": "2026-11-04",
"release_date": "2026-11-05",
"min_php_version": "8.2",
"changelog": [
"Mise à jour instantanée du numéro de version sur tous les badges sans aucun délai de cache navigateur",
"Invalidation automatique du cache local et rechargement propre anti-cache après chaque mise à jour",
"Affichage serveur natif du numéro de version installée directement dans le HTML généré",
"Suppression définitive de l'indication des identifiants et mot de passe par défaut sur l'écran de connexion"
"Identité visuelle officielle : intégration du nouveau logo NexusPBX 3D haute définition épuré et du favicon",
"Nouveau slogan télécom : « Supervision & Téléphonie IP • Powered by Asterisk »",
"Amélioration des scripts d'installation et de mise à jour : initialisation automatique des permissions et ACLs du module CDR (Master.csv)",
"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",
"Plafond d'expiration d'enregistrement SIP standard (maximum_expiration = 300s, default = 120s) garantissant la reconnexion automatique rapide de tous les postes"
]
}