This commit is contained in:
Voxan committed 2026-10-21 22:00:06 +02:00
1 parent 038a01ccb8
commit eee6418ea4
5 files changed
+1232

No files matched your search

+78
View File
@@ -0,0 +1,78 @@
<?php
declare(strict_types=1);
function render_admin_nav(string $activePage, string $user): void
{
$isActive = static fn(string $page): string => $activePage === $page ? 'active' : '';
?>
<link rel="stylesheet" href="assets/navigation.css">
<div class="topbar admin-topbar">
<div class="brand"><span class="dot"></span>NetMonster Admin</div>
<button class="nav-toggle" type="button" aria-expanded="false" aria-controls="adminNavigation">
<span class="sr-only">Ouvrir le menu</span><span></span><span></span><span></span>
</button>
<nav id="adminNavigation" class="admin-navigation">
<a href="index.php" class="<?= $isActive('index.php') ?>">Accueil</a>
<div class="nav-dropdown">
<button type="button" class="nav-dropdown-toggle" aria-expanded="false">Donn&eacute;es <span aria-hidden="true">▾</span></button>
<div class="nav-dropdown-menu">
<a href="search_site.php" class="<?= $isActive('search_site.php') ?>">Recherche antenne</a>
<a href="data_quality.php" class="<?= $isActive('data_quality.php') ?>">Contr&ocirc;le qualit&eacute;</a>
<a href="duplicates.php" class="<?= $isActive('duplicates.php') ?>">Doublons &amp; collisions</a>
<a href="edit_site.php" class="<?= $isActive('edit_site.php') ?>&Eacute;dition de site</a>
<a href="operator_sites.php" class="<?= $isActive('operator_sites.php') ?>">Antennes par op&eacute;rateur</a>
</div>
</div>
<div class="nav-dropdown">
<button type="button" class="nav-dropdown-toggle" aria-expanded="false">Import &amp; validation <span aria-hidden="true">▾</span></button>
<div class="nav-dropdown-menu">
<a href="import_ntm.php" class="<?= $isActive('import_ntm.php') ?>">Importer un fichier .ntm</a>
<a href="cellmapper_import.php" class="<?= $isActive('cellmapper_import.php') ?>">Import CellMapper</a>
<a href="manual_add.php" class="<?= $isActive('manual_add.php') ?>">Ajout manuel</a>
<a href="review.php" class="<?= $isActive('review.php') ?>">Validation des donn&eacute;es</a>
<a href="import_history.php" class="<?= $isActive('import_history.php') ?>">Historique des imports</a>
</div>
</div>
<div class="nav-dropdown">
<button type="button" class="nav-dropdown-toggle" aria-expanded="false">Configuration <span aria-hidden="true">▾</span></button>
<div class="nav-dropdown-menu">
<a href="countries_settings.php" class="<?= $isActive('countries_settings.php') ?>">Pays &amp; op&eacute;rateurs</a>
<a href="map_layers.php" class="<?= $isActive('map_layers.php') ?>">Couches cartographiques</a>
<a href="export.php" class="<?= $isActive('export.php') ?>">Export</a>
</div>
</div>
<div class="nav-dropdown">
<button type="button" class="nav-dropdown-toggle" aria-expanded="false">Suivi &amp; s&eacute;curit&eacute; <span aria-hidden="true">▾</span></button>
<div class="nav-dropdown-menu">
<a href="statistics.php" class="<?= $isActive('statistics.php') ?>">Statistiques</a>
<a href="audit_log.php" class="<?= $isActive('audit_log.php') ?>">Journal d'audit</a>
<a href="backup.php" class="<?= $isActive('backup.php') ?>">Sauvegardes</a>
</div>
</div>
</nav>
<div class="user">Connect&eacute; : <?= htmlspecialchars($user, ENT_QUOTES, 'UTF-8') ?></div>
</div>
<script>
(() => {
const toggle = document.querySelector('.nav-toggle');
const navigation = document.getElementById('adminNavigation');
if (!toggle || !navigation) return;
toggle.addEventListener('click', () => {
const open = navigation.classList.toggle('is-open');
toggle.setAttribute('aria-expanded', open ? 'true' : 'false');
});
document.querySelectorAll('.nav-dropdown-toggle').forEach((button) => {
button.addEventListener('click', () => {
const dropdown = button.closest('.nav-dropdown');
const open = dropdown.classList.toggle('is-open');
button.setAttribute('aria-expanded', open ? 'true' : 'false');
});
});
document.addEventListener('click', (event) => {
if (!(event.target instanceof Element) || event.target.closest('.nav-dropdown')) return;
document.querySelectorAll('.nav-dropdown.is-open').forEach((dropdown) => dropdown.classList.remove('is-open'));
});
})();
</script>
<?php
}
+185
View File
@@ -0,0 +1,185 @@
<?php
/**
* admin/admin_auth.php
*
* Barriere d'authentification pour tout le dossier /admin, durcie pour
* la production :
* - Cookies de session Secure/HttpOnly/SameSite=Strict.
* - Expiration automatique par inactivite (30 minutes).
* - Verrouillage temporaire apres echecs de connexion repetes, stocke
* en BASE DE DONNEES (table login_attempts) et non plus en session
* PHP : un attaquant qui vide ses cookies ou ouvre une navigation
* privee ne peut plus remettre son compteur d'essais a zero.
* - Deux seuils separes : un par couple (identifiant, IP) pour cibler
* le bruteforce sur un compte precis, un autre par IP seule (plus
* permissif) pour eviter qu'un tiers sur la meme IP Tailscale ne
* bloque tout le monde en se trompant sur un identifiant.
* - IP lue exclusivement depuis REMOTE_ADDR : X-Forwarded-For n'est
* PAS utilise ici car aucun reverse proxy de confiance n'est
* declare devant Apache dans cette installation. Si un jour un
* reverse proxy est mis en place, il faudra le configurer
* explicitement (mod_remoteip + RemoteIPTrustedProxy) avant de
* faire confiance a cet en-tete.
* - Charge automatiquement lib/csrf.php pour que csrf_field() et
* require_csrf_token() soient disponibles partout ou admin_auth
* est inclus.
*
* Necessite la migration sql/migration_security_hardening.sql (table
* login_attempts).
*
* Note : la separation par roles (admin/reviewer) est volontairement
* absente de cette version. Tant qu'un seul compte administrateur est
* utilise, l'ajouter partiellement donnerait une fausse impression de
* securite. A traiter dans une migration dediee le jour ou un second
* compte avec des droits restreints sera necessaire.
*/
declare(strict_types=1);
const SESSION_IDLE_TIMEOUT = 1800; // 30 minutes
// Seuil cible sur un identifiant precis, depuis une IP precise :
// protege un compte contre un bruteforce dirige.
const MAX_LOGIN_ATTEMPTS_PER_ACCOUNT = 5;
const LOCKOUT_SECONDS_PER_ACCOUNT = 300; // 5 minutes
// Seuil plus large sur l'IP seule, tous identifiants confondus :
// protege contre un scan large depuis une meme source, sans bloquer
// tout le monde des le premier compte en echec sur une IP partagee
// (ex: plusieurs appareils derriere la meme sortie Tailscale).
const MAX_LOGIN_ATTEMPTS_PER_IP = 30;
const LOCKOUT_SECONDS_PER_IP = 300; // 5 minutes
const LOGIN_ATTEMPTS_WINDOW_SECONDS = 900; // fenetre glissante de 15 minutes
if (session_status() === PHP_SESSION_NONE) {
// IMPORTANT : 'secure' => true exige HTTPS. Si vous testez en HTTP pur
// sur une VM sans certificat, passez temporairement cette valeur a
// false, sinon le cookie de session ne sera jamais transmis et la
// connexion semblera "ne pas fonctionner" (boucle de redirection vers
// login.php). Remettez-le a true avant la mise en production reelle.
session_set_cookie_params([
'lifetime' => 0,
'path' => '/admin',
'secure' => true,
'httponly' => true,
'samesite' => 'Strict',
]);
session_start();
}
require_once __DIR__ . '/../lib/csrf.php';
function require_admin_auth(): void
{
if (empty($_SESSION['admin_user_id'])) {
header('Location: /admin/login.php');
exit;
}
if (!empty($_SESSION['last_activity']) && (time() - $_SESSION['last_activity']) > SESSION_IDLE_TIMEOUT) {
session_unset();
session_destroy();
header('Location: /admin/login.php?timeout=1');
exit;
}
$_SESSION['last_activity'] = time();
}
function current_admin_username(): string
{
return $_SESSION['admin_username'] ?? 'unknown';
}
/**
* IP du visiteur, lue exclusivement depuis REMOTE_ADDR. Ne fait
* volontairement pas confiance a X-Forwarded-For : sur cette
* installation, Apache recoit les connexions directement (ou via
* Tailscale), sans reverse proxy declare qui nettoierait cet en-tete.
* Un client pourrait sinon forger cet en-tete pour usurper une IP et
* contourner le rate limiting.
*/
function client_ip_address(): string
{
return $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0';
}
function normalize_login_identifier(string $identifier): string
{
return mb_strtolower(trim($identifier));
}
/**
* Verifie si l'identifiant+IP OU l'IP seule ont depasse leur seuil
* respectif d'echecs sur la fenetre glissante. Stocke en base, donc
* non contournable en changeant de session/cookie cote client.
*/
function is_login_locked_out(PDO $db, string $identifier): bool
{
$normalized = normalize_login_identifier($identifier);
$ip = client_ip_address();
$windowStart = date('Y-m-d H:i:s', time() - LOGIN_ATTEMPTS_WINDOW_SECONDS);
// Seuil cible : ce compte precis, depuis cette IP precise.
$accountStmt = $db->prepare(
'SELECT COUNT(*), MAX(attempted_at) FROM login_attempts
WHERE username_normalized = ? AND ip_address = ?
AND success = 0 AND attempted_at >= ?'
);
$accountStmt->execute([$normalized, $ip, $windowStart]);
[$accountFailures, $accountLastAttempt] = $accountStmt->fetch(PDO::FETCH_NUM);
if ((int)$accountFailures >= MAX_LOGIN_ATTEMPTS_PER_ACCOUNT
&& $accountLastAttempt !== null
&& (time() - strtotime($accountLastAttempt)) < LOCKOUT_SECONDS_PER_ACCOUNT
) {
return true;
}
// Seuil large : toute cette IP, tous identifiants confondus.
$ipStmt = $db->prepare(
'SELECT COUNT(*), MAX(attempted_at) FROM login_attempts
WHERE ip_address = ? AND success = 0 AND attempted_at >= ?'
);
$ipStmt->execute([$ip, $windowStart]);
[$ipFailures, $ipLastAttempt] = $ipStmt->fetch(PDO::FETCH_NUM);
if ((int)$ipFailures >= MAX_LOGIN_ATTEMPTS_PER_IP
&& $ipLastAttempt !== null
&& (time() - strtotime($ipLastAttempt)) < LOCKOUT_SECONDS_PER_IP
) {
return true;
}
return false;
}
function record_login_attempt(PDO $db, string $identifier, bool $success, bool $turnstileVerified = false): void
{
$stmt = $db->prepare(
'INSERT INTO login_attempts (username_normalized, ip_address, success, turnstile_verified)
VALUES (?, ?, ?, ?)'
);
$stmt->execute([
normalize_login_identifier($identifier),
client_ip_address(),
$success ? 1 : 0,
$turnstileVerified ? 1 : 0,
]);
}
function attempt_admin_login(PDO $db, string $username, string $password): bool
{
$stmt = $db->prepare('SELECT id, username, password_hash FROM admin_users WHERE username = ?');
$stmt->execute([$username]);
$user = $stmt->fetch(PDO::FETCH_ASSOC);
if ($user && password_verify($password, $user['password_hash'])) {
$_SESSION['admin_user_id'] = $user['id'];
$_SESSION['admin_username'] = $user['username'];
$_SESSION['last_activity'] = time();
session_regenerate_id(true);
return true;
}
return false;
}
+169
View File
@@ -0,0 +1,169 @@
<?php
/**
* admin/audit_log.php
*
* Historique des modifications effectuees dans l'admin (creation,
* edition, suppression de sites/cellules, fusions, imports valides).
* Page en lecture seule : l'audit log ne doit jamais pouvoir etre
* modifie ou supprime depuis l'interface, pour garder une tracabilite
* fiable (regle de securite implicite : integrite de la piste d'audit).
*
* Hypothese de schema (a adapter si necessaire) :
* audit_log(id, created_at, admin_username, action, entity_type,
* entity_id, details)
*
* Cette page ne fait AUCUNE ecriture en base : c'est un simple
* consultateur filtrant, protege uniquement par require_admin_auth().
* Aucun jeton CSRF n'est necessaire car aucun POST ne modifie l'etat.
*/
declare(strict_types=1);
require_once __DIR__ . '/admin_auth.php';
require_once __DIR__ . '/../lib/db.php';
require_once __DIR__ . '/_nav.php';
require_admin_auth();
$db = pdo();
$filterUser = trim((string)filter_input(INPUT_GET, 'user', FILTER_UNSAFE_RAW));
$filterAction = trim((string)filter_input(INPUT_GET, 'action', FILTER_UNSAFE_RAW));
$filterEntity = trim((string)filter_input(INPUT_GET, 'entity_type', FILTER_UNSAFE_RAW));
$dateFrom = trim((string)filter_input(INPUT_GET, 'date_from', FILTER_UNSAFE_RAW));
$dateTo = trim((string)filter_input(INPUT_GET, 'date_to', FILTER_UNSAFE_RAW));
$where = [];
$params = [];
if ($filterUser !== '') {
$where[] = 'admin_username LIKE ?';
$params[] = '%' . $filterUser . '%';
}
if ($filterAction !== '') {
$where[] = 'action = ?';
$params[] = $filterAction;
}
if ($filterEntity !== '') {
$where[] = 'entity_type = ?';
$params[] = $filterEntity;
}
if ($dateFrom !== '' && preg_match('/^\d{4}-\d{2}-\d{2}$/', $dateFrom)) {
$where[] = 'created_at >= ?';
$params[] = $dateFrom . ' 00:00:00';
}
if ($dateTo !== '' && preg_match('/^\d{4}-\d{2}-\d{2}$/', $dateTo)) {
$where[] = 'created_at <= ?';
$params[] = $dateTo . ' 23:59:59';
}
$whereSql = $where ? ('WHERE ' . implode(' AND ', $where)) : '';
$page = max(1, filter_input(INPUT_GET, 'page', FILTER_VALIDATE_INT) ?: 1);
$perPage = 50;
$offset = ($page - 1) * $perPage;
$countStmt = $db->prepare("SELECT COUNT(*) FROM audit_log $whereSql");
$countStmt->execute($params);
$total = (int)$countStmt->fetchColumn();
$sql = "SELECT id, created_at, admin_username, action, entity_type, entity_id, details
FROM audit_log
$whereSql
ORDER BY created_at DESC
LIMIT $perPage OFFSET $offset";
$stmt = $db->prepare($sql);
$stmt->execute($params);
$entries = $stmt->fetchAll(PDO::FETCH_ASSOC);
$totalPages = max(1, (int)ceil($total / $perPage));
$user = current_admin_username();
function build_query_string(array $overrides): string
{
$base = [
'user' => $_GET['user'] ?? '',
'action' => $_GET['action'] ?? '',
'entity_type' => $_GET['entity_type'] ?? '',
'date_from' => $_GET['date_from'] ?? '',
'date_to' => $_GET['date_to'] ?? '',
'page' => $_GET['page'] ?? 1,
];
$merged = array_merge($base, $overrides);
return http_build_query($merged);
}
?>
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<title>Journal d'audit — NetMonster Admin</title>
<link rel="stylesheet" href="assets/theme.css">
</head>
<body>
<?php render_admin_nav('audit_log.php', $user); ?>
<main class="container">
<h1>Journal d'audit</h1>
<p>Historique en lecture seule des modifications effectuees dans l'administration. Ce journal ne peut pas etre modifie depuis l'interface.</p>
<section class="card">
<form method="get" class="filters">
<label>Utilisateur
<input type="text" name="user" value="<?= htmlspecialchars($filterUser, ENT_QUOTES, 'UTF-8') ?>">
</label>
<label>Action
<input type="text" name="action" value="<?= htmlspecialchars($filterAction, ENT_QUOTES, 'UTF-8') ?>" placeholder="create, update, delete...">
</label>
<label>Type d'entite
<input type="text" name="entity_type" value="<?= htmlspecialchars($filterEntity, ENT_QUOTES, 'UTF-8') ?>" placeholder="site, cell, import...">
</label>
<label>Du
<input type="date" name="date_from" value="<?= htmlspecialchars($dateFrom, ENT_QUOTES, 'UTF-8') ?>">
</label>
<label>Au
<input type="date" name="date_to" value="<?= htmlspecialchars($dateTo, ENT_QUOTES, 'UTF-8') ?>">
</label>
<button type="submit" class="btn btn-primary">Filtrer</button>
<a href="audit_log.php" class="btn btn-secondary">Reinitialiser</a>
</form>
</section>
<section class="card">
<h2>Entrees (<?= $total ?>)</h2>
<table class="table">
<thead>
<tr>
<th>Date</th>
<th>Utilisateur</th>
<th>Action</th>
<th>Entite</th>
<th>ID</th>
<th>Details</th>
</tr>
</thead>
<tbody>
<?php foreach ($entries as $entry): ?>
<tr>
<td><?= htmlspecialchars((string)$entry['created_at'], ENT_QUOTES, 'UTF-8') ?></td>
<td><?= htmlspecialchars((string)$entry['admin_username'], ENT_QUOTES, 'UTF-8') ?></td>
<td><?= htmlspecialchars((string)$entry['action'], ENT_QUOTES, 'UTF-8') ?></td>
<td><?= htmlspecialchars((string)$entry['entity_type'], ENT_QUOTES, 'UTF-8') ?></td>
<td><?= (int)$entry['entity_id'] ?></td>
<td><code><?= htmlspecialchars((string)$entry['details'], ENT_QUOTES, 'UTF-8') ?></code></td>
</tr>
<?php endforeach; ?>
<?php if (empty($entries)): ?>
<tr><td colspan="6">Aucune entree pour ces filtres.</td></tr>
<?php endif; ?>
</tbody>
</table>
<nav class="pagination">
<?php for ($p = 1; $p <= $totalPages; $p++): ?>
<a href="audit_log.php?<?= build_query_string(['page' => $p]) ?>" class="<?= $p === $page ? 'active' : '' ?>"><?= $p ?></a>
<?php endfor; ?>
</nav>
</section>
</main>
</body>
</html>
+181
View File
@@ -0,0 +1,181 @@
<?php
/**
* admin/backup.php
*
* Preparation des sauvegardes de la base. Cette page NE stocke jamais
* d'identifiants de base en clair dans le HTML ni dans les logs, et ne
* lance aucune commande shell directement depuis une requete HTTP pour
* eviter tout risque d'injection de commande : elle genere uniquement
* une commande mysqldump PRETE A COPIER, a executer manuellement ou via
* une tache cron/systemd timer sur le serveur.
*
* Regle de securite appliquee ici :
* - Pas de shell_exec()/exec()/proc_open() sur une entree utilisateur.
* - Pas de mot de passe DB affiche : on reference une variable
* d'environnement ou un fichier .my.cnf externe.
* - Seule action en base : enregistrement de metadonnees de sauvegarde
* (date, taille, note) dans une table de suivi, jamais le contenu.
*
* CORRIGE (20/09/2026, suite a erreur 500 en usage reel) :
* - require_once _nav.php manquait, provoquant une erreur fatale
* "Call to undefined function render_admin_nav()".
* - La table backup_log necessaire a cette page n'existait pas :
* voir admin/sql/migration_add_missing_tables.sql.
*
* Corrige suite a l'audit du 20/09/2026 : le detail technique des
* exceptions n'est plus affiche a l'ecran ; il est journalise via
* error_log().
*
* Hypothese de schema : backup_log(id, created_at, admin_username,
* label, note, size_bytes_reported)
*/
declare(strict_types=1);
require_once __DIR__ . '/admin_auth.php';
require_once __DIR__ . '/../lib/db.php';
require_once __DIR__ . '/_nav.php';
require_admin_auth();
$db = pdo();
$feedback = null;
$feedbackType = 'success';
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
require_csrf_token();
$action = $_POST['action'] ?? '';
$confirmed = isset($_POST['confirm']) && $_POST['confirm'] === '1';
if ($action === 'log_backup') {
if (!$confirmed) {
$feedback = 'Enregistrement refuse : confirmation manquante.';
$feedbackType = 'error';
} else {
$label = trim((string)($_POST['label'] ?? ''));
$note = trim((string)($_POST['note'] ?? ''));
$sizeBytes = filter_input(INPUT_POST, 'size_bytes', FILTER_VALIDATE_INT) ?: null;
if ($label === '') {
$feedback = 'Le libelle de la sauvegarde est obligatoire.';
$feedbackType = 'error';
} else {
try {
$stmt = $db->prepare(
'INSERT INTO backup_log (created_at, admin_username, label, note, size_bytes_reported)
VALUES (NOW(), ?, ?, ?, ?)'
);
$stmt->execute([current_admin_username(), $label, $note, $sizeBytes]);
$feedback = 'Sauvegarde enregistree dans le journal.';
} catch (Throwable $e) {
error_log('[backup] log_backup: ' . $e->getMessage());
$feedback = 'Une erreur est survenue lors de l\'enregistrement.';
$feedbackType = 'error';
}
}
}
}
}
$history = [];
try {
$history = $db->query(
'SELECT id, created_at, admin_username, label, note, size_bytes_reported
FROM backup_log
ORDER BY created_at DESC
LIMIT 50'
)->fetchAll(PDO::FETCH_ASSOC);
} catch (Throwable $e) {
error_log('[backup] history query: ' . $e->getMessage());
$feedback = 'La table backup_log est introuvable ou inaccessible. Consultez les journaux du serveur.';
$feedbackType = 'error';
}
$user = current_admin_username();
$today = date('Ymd_His');
$dumpCommand = "mysqldump --defaults-extra-file=/etc/netmonster/.my.cnf --single-transaction --routines --triggers netmonster | gzip > /var/backups/netmonster/netmonster_{$today}.sql.gz";
$restoreCommand = "gunzip -c /var/backups/netmonster/netmonster_{$today}.sql.gz | mysql --defaults-extra-file=/etc/netmonster/.my.cnf netmonster";
?>
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<title>Sauvegardes — NetMonster Admin</title>
<link rel="stylesheet" href="assets/theme.css">
</head>
<body>
<?php render_admin_nav('backup.php', $user); ?>
<main class="page-wrap">
<h1>Sauvegardes de la base</h1>
<p class="page-subtitle">Cette page n'execute aucune commande sur le serveur. Elle fournit une commande prete a l'emploi et journalise les sauvegardes realisees manuellement ou par cron.</p>
<?php if ($feedback !== null): ?>
<p class="<?= $feedbackType === 'error' ? 'msg-err' : 'msg-ok' ?>">
<?= htmlspecialchars($feedback, ENT_QUOTES, 'UTF-8') ?>
</p>
<?php endif; ?>
<section class="card">
<h2>Commande de sauvegarde recommandee</h2>
<p>A executer sur le serveur, avec un fichier <code>.my.cnf</code> contenant les identifiants (jamais dans ce fichier PHP ni dans un depot Git) :</p>
<pre><?= htmlspecialchars($dumpCommand, ENT_QUOTES, 'UTF-8') ?></pre>
<h2>Commande de restauration</h2>
<pre><?= htmlspecialchars($restoreCommand, ENT_QUOTES, 'UTF-8') ?></pre>
<p>Recommandations :</p>
<ul>
<li>Stockez <code>.my.cnf</code> avec les permissions <code>600</code>, proprietaire du compte de service uniquement.</li>
<li>Chiffrez l'archive si elle est copiee hors du serveur (ex. <code>gpg --symmetric</code>).</li>
<li>Automatisez via un timer systemd ou une tache cron dediee, jamais depuis cette interface web.</li>
<li>Testez regulierement la restauration sur un environnement isole.</li>
</ul>
</section>
<section class="card">
<h2>Journaliser une sauvegarde effectuee</h2>
<form method="post" onsubmit="return confirm('Confirmer l\'enregistrement de cette sauvegarde dans le journal ?');">
<?= csrf_field() ?>
<input type="hidden" name="action" value="log_backup">
<input type="hidden" name="confirm" value="1">
<label>Libelle
<input type="text" name="label" required placeholder="ex: backup_hebdo_2026_09_20">
</label>
<label>Taille rapportee (octets, optionnel)
<input type="number" name="size_bytes" min="0">
</label>
<label>Note
<textarea name="note" rows="2" placeholder="Contexte, emplacement de stockage..."></textarea>
</label>
<button type="submit" class="primary">Enregistrer dans le journal</button>
</form>
</section>
<section class="card">
<h2>Historique des sauvegardes journalisees</h2>
<table class="data-table">
<tr>
<th>Date</th>
<th>Utilisateur</th>
<th>Libelle</th>
<th>Taille</th>
<th>Note</th>
</tr>
<?php foreach ($history as $row): ?>
<tr>
<td><?= htmlspecialchars((string)$row['created_at'], ENT_QUOTES, 'UTF-8') ?></td>
<td><?= htmlspecialchars((string)$row['admin_username'], ENT_QUOTES, 'UTF-8') ?></td>
<td><?= htmlspecialchars((string)$row['label'], ENT_QUOTES, 'UTF-8') ?></td>
<td><?= $row['size_bytes_reported'] !== null ? number_format((float)$row['size_bytes_reported'], 0, ',', ' ') . ' o' : '—' ?></td>
<td><?= htmlspecialchars((string)$row['note'], ENT_QUOTES, 'UTF-8') ?></td>
</tr>
<?php endforeach; ?>
<?php if (empty($history)): ?>
<tr><td colspan="5" class="no-cadastre">Aucune sauvegarde journalisee.</td></tr>
<?php endif; ?>
</table>
</section>
</main>
</body>
</html>
+619
View File
@@ -0,0 +1,619 @@
<?php
/**
* admin/cellmapper_import.php
*
* Import d'un export JSON CellMapper (un site = un eNB LTE) pour
* completer/creer des cellules dans staging_cells.
*
* MODIFICATION (21/09/2026) : ajout d'un selecteur de bande 4G manuel
* avec remplissage automatique de l'EARFCN selon le mapping operateur.
* Les operateurs utilisant des EARFCN differents sur la meme bande,
* la deduction automatique depuis l'EARFCN n'est plus fiable.
*
* Regles fonctionnelles validees avec l'utilisateur :
* - CellMapper fournit uniquement des donnees RADIO fiables :
* cell_id (via "Sector"), PCI (stocke dans sub_id, comme .ntm),
* EARFCN (via "channels"), bande (deduite de l'EARFCN ou selectionnee),
* largeur de bande (via estimatedBandData / Bandwidth),
* TAC/area_code (via "regionID").
* - CellMapper NE fournit PAS de coordonnees/azimut/adresse fiables
* pour ce projet : ces champs ne sont JAMAIS ecrits ni ecrases.
* - Correspondance STRICTE sur (tech, mcc, mnc, sub_id2=eNB).
* - UPDATE des champs radio si cell_id existe deja en staging.
* - INSERT si nouveau cell_id sur un eNB existant ou nouveau.
* - Rien n'est jamais ecrit dans `cells`/`sites` ici.
*/
declare(strict_types=1);
require_once __DIR__ . '/admin_auth.php';
require_once __DIR__ . '/../lib/db.php';
require_once __DIR__ . '/_nav.php';
require_admin_auth();
$db = pdo();
$user = current_admin_username();
$errorMessage = null;
$successMessage = null;
$importSummary = null;
const MAX_JSON_SIZE_BYTES = 2 * 1024 * 1024;
// Bandes 4G LTE courantes (liste 3GPP complete)
const LTE_BANDS = [
'B1' => 'B1 (2100 MHz)',
'B2' => 'B2 (1900 MHz)',
'B3' => 'B3 (1800 MHz)',
'B4' => 'B4 (AWS)',
'B5' => 'B5 (850 MHz)',
'B7' => 'B7 (2600 MHz)',
'B8' => 'B8 (900 MHz)',
'B20' => 'B20 (800 MHz)',
'B28' => 'B28 (700 MHz)',
'B38' => 'B38 (TDD 2600)',
'B40' => 'B40 (TDD 2300)',
'B41' => 'B41 (TDD 2500)',
];
/**
* Recupere les mappings bande->EARFCN pour un operateur donne.
* Retourne un tableau associatif ['B20' => ['start'=>6200,'end'=>6349], ...]
*/
function getOperatorBandMappings(PDO $db, int $operatorId): array
{
$stmt = $db->prepare('SELECT band, earfcn_start, earfcn_end FROM operator_band_mappings WHERE operator_id = ?');
$stmt->execute([$operatorId]);
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
$mappings = [];
foreach ($rows as $row) {
$mappings[$row['band']] = [
'start' => (int)$row['earfcn_start'],
'end' => (int)$row['earfcn_end'],
];
}
return $mappings;
}
/**
* Deduit l'EARFCN pour une bande donnee selon le mapping operateur.
* Retourne l'EARFCN de debut de plage (valeur typique utilisee par l'operateur).
*/
function getEarfcnForBand(array $mappings, string $band): ?int
{
if (!isset($mappings[$band])) {
return null;
}
return $mappings[$band]['start'];
}
/**
* Convertit un EARFCN LTE en numero de bande 3GPP (fallback si pas de mapping operateur).
*/
function earfcnToBand(int $earfcn, string $tech): ?string
{
if ($tech !== '4G') {
return null;
}
static $ranges = [
[0, 599, 'B1'], [600, 1199, 'B2'], [1200, 1949, 'B3'], [1950, 2399, 'B4'],
[2400, 2649, 'B5'], [2650, 2749, 'B6'], [2750, 3449, 'B7'], [3450, 3799, 'B8'],
[3800, 4149, 'B9'], [4150, 4749, 'B10'], [4750, 4949, 'B11'], [5010, 5179, 'B12'],
[5180, 5279, 'B13'], [5280, 5379, 'B14'], [5730, 5849, 'B18'], [5850, 5999, 'B19'],
[6000, 6149, 'B20'], [6150, 6449, 'B21'], [6450, 6599, 'B22'], [6600, 7399, 'B23'],
[7500, 7699, 'B24'], [7700, 8039, 'B25'], [8040, 8689, 'B26'], [8690, 9039, 'B27'],
[9040, 9209, 'B28'], [9210, 9659, 'B29'], [9660, 9769, 'B30'], [9770, 9869, 'B31'],
[9870, 10359, 'B32'],
[36000, 36199, 'B33'], [36200, 36349, 'B34'], [36350, 36949, 'B35'],
[36950, 37549, 'B36'], [37550, 37749, 'B37'], [37750, 38249, 'B38'],
[38250, 38649, 'B39'], [38650, 39649, 'B40'], [39650, 41589, 'B41'],
[41590, 43589, 'B42'], [43590, 45589, 'B43'], [45590, 46589, 'B44'],
[46590, 46789, 'B45'], [46790, 54539, 'B46'], [54540, 55239, 'B47'],
[55240, 56739, 'B48'], [65536, 66435, 'B65'], [66436, 67335, 'B66'],
[67336, 67535, 'B67'], [67536, 67835, 'B68'], [67836, 68335, 'B69'],
[68336, 68585, 'B70'], [68586, 68935, 'B71'], [68936, 68985, 'B72'],
[68986, 69035, 'B73'], [69036, 69465, 'B74'], [69466, 70315, 'B75'],
[70316, 70365, 'B76'], [70366, 70545, 'B85'],
];
foreach ($ranges as [$lo, $hi, $band]) {
if ($earfcn >= $lo && $earfcn <= $hi) {
return $band;
}
}
return null;
}
function ratToTech(string $rat): ?string
{
$rat = strtoupper(trim($rat));
return match (true) {
str_contains($rat, 'NR') => '5G',
str_contains($rat, 'LTE') => '4G',
str_contains($rat, 'UMTS'), str_contains($rat, 'HSPA'), str_contains($rat, 'WCDMA') => '3G',
str_contains($rat, 'GSM'), str_contains($rat, 'EDGE'), str_contains($rat, 'GPRS') => '2G',
str_contains($rat, 'CDMA') => 'CDMA',
default => null,
};
}
function parseCellMapperJson(string $json): array
{
$decoded = json_decode($json, true);
if (json_last_error() !== JSON_ERROR_NONE) {
return [null, [], 'JSON invalide : ' . json_last_error_msg()];
}
$data = $decoded['responseData'] ?? $decoded;
if (!is_array($data)) {
return [null, [], 'Structure JSON inattendue : cle "responseData" absente ou invalide.'];
}
$rat = (string)($data['RAT'] ?? $data['RATSubType'] ?? '');
$tech = ratToTech($rat);
if ($tech === null) {
return [null, [], "Technologie radio non reconnue (RAT=\"$rat\")."];
}
$siteId = $data['siteID'] ?? null;
if ($siteId === null || !is_numeric($siteId)) {
return [null, [], 'Champ "siteID" (eNB) absent ou invalide dans le JSON.'];
}
$enb = (int)$siteId;
$regionId = $data['regionID'] ?? null;
$areaCode = ($regionId !== null && is_numeric($regionId)) ? (int)$regionId : null;
$provider = $data['Provider'] ?? [];
$providerId = $provider['providerID'] ?? null;
$cellsRaw = $data['cells'] ?? [];
if (!is_array($cellsRaw) || empty($cellsRaw)) {
return [null, [], 'Aucune cellule trouvee dans le champ "cells" du JSON.'];
}
$channels = $data['channels'] ?? [];
$channels = is_array($channels) ? array_values($channels) : [];
$entries = [];
foreach ($cellsRaw as $internalId => $cell) {
if (!is_array($cell) || !isset($cell['Sector'])) {
continue;
}
$entries[] = $cell;
}
usort($entries, static fn($a, $b) => ((int)$a['Sector']) <=> ((int)$b['Sector']));
$meta = [
'tech' => $tech,
'enb' => $enb,
'area_code' => $areaCode,
'provider_id' => $providerId !== null ? (int)$providerId : null,
'site_id_raw' => (string)$siteId,
];
$cells = [];
foreach ($entries as $index => $cell) {
$cellId = (int)$cell['Sector'];
$pci = isset($cell['PCI']) && is_numeric($cell['PCI']) ? (int)$cell['PCI'] : null;
$earfcn = null;
if (isset($channels[$index]) && is_numeric($channels[$index])) {
$earfcn = (int)$channels[$index];
}
$bandwidth = null;
if (isset($cell['Bandwidth']) && is_numeric($cell['Bandwidth'])) {
$bandwidth = ((int)$cell['Bandwidth']) . 'MHz';
}
$band = $earfcn !== null ? earfcnToBand($earfcn, $tech) : null;
$cells[] = [
'cell_id' => $cellId,
'pci' => $pci,
'arfcn' => $earfcn,
'band' => $band,
'bandwidth' => $bandwidth,
];
}
return [$meta, $cells, null];
}
function findStagingCellsForEnb(PDO $db, string $tech, string $mcc, string $mnc, int $enb): array
{
$stmt = $db->prepare(
'SELECT id, cell_id, sub_id, arfcn, band, bandwidth, area_code, status
FROM staging_cells
WHERE tech = ? AND mcc = ? AND mnc = ? AND sub_id2 = ?'
);
$stmt->execute([$tech, $mcc, $mnc, $enb]);
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
$byCellId = [];
foreach ($rows as $row) {
$byCellId[(int)$row['cell_id']] = $row;
}
return $byCellId;
}
// Traitement du formulaire
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
require_csrf_token();
$mcc = trim((string)($_POST['mcc'] ?? ''));
$mnc = trim((string)($_POST['mnc'] ?? ''));
$operatorId = (int)($_POST['operator_id'] ?? 0);
$selectedBand = trim((string)($_POST['band'] ?? ''));
$jsonInput = trim((string)($_POST['json_input'] ?? ''));
$uploadedFile = $_FILES['json_file'] ?? null;
if ($uploadedFile && $uploadedFile['error'] === UPLOAD_ERR_OK) {
if (!is_uploaded_file($uploadedFile['tmp_name'])) {
$errorMessage = 'Fichier invalide (upload non reconnu comme legitime).';
} elseif ($uploadedFile['size'] > MAX_JSON_SIZE_BYTES) {
$errorMessage = 'Le fichier depasse la limite de ' . (MAX_JSON_SIZE_BYTES / 1024 / 1024) . ' Mo.';
} else {
$jsonInput = (string)file_get_contents($uploadedFile['tmp_name']);
}
}
if ($errorMessage === null && $jsonInput === '') {
$errorMessage = 'Aucune donnee JSON fournie (ni fichier, ni texte colle).';
} elseif ($errorMessage === null && !preg_match('/^\d{3}$/', $mcc)) {
$errorMessage = 'MCC invalide (3 chiffres attendus).';
} elseif ($errorMessage === null && !preg_match('/^\d{2,3}$/', $mnc)) {
$errorMessage = 'MNC invalide (2 ou 3 chiffres attendus).';
} elseif ($errorMessage === null && strlen($jsonInput) > MAX_JSON_SIZE_BYTES) {
$errorMessage = 'Le contenu JSON colle depasse la limite de ' . (MAX_JSON_SIZE_BYTES / 1024 / 1024) . ' Mo.';
} else {
$mcc = str_pad($mcc, 3, '0', STR_PAD_LEFT);
$mnc = str_pad($mnc, 2, '0', STR_PAD_LEFT);
[$meta, $parsedCells, $parseError] = parseCellMapperJson($jsonInput);
if ($parseError !== null) {
$errorMessage = $parseError;
} else {
$tech = $meta['tech'];
$enb = $meta['enb'];
$areaCode = $meta['area_code'];
try {
// Si operator_id est fourni, recuperer ses mappings bande/EARFCN
$bandMappings = $operatorId > 0 ? getOperatorBandMappings($db, $operatorId) : [];
// Si une bande est selectionnee, deduire l'EARFCN du mapping operateur
$forcedEarfcn = null;
$forcedBand = null;
if ($selectedBand !== '' && !empty($bandMappings[$selectedBand])) {
$forcedBand = strtoupper($selectedBand);
$forcedEarfcn = getEarfcnForBand($bandMappings, $forcedBand);
}
$stmt = $db->prepare('SELECT id FROM operators WHERE mcc = ? AND mnc = ?');
$stmt->execute([$mcc, $mnc]);
$operatorIdFromDb = $stmt->fetchColumn();
$operatorIdFromDb = $operatorIdFromDb !== false ? (int)$operatorIdFromDb : null;
$existingByCellId = findStagingCellsForEnb($db, $tech, $mcc, $mnc, $enb);
$updateStmt = $db->prepare(
'UPDATE staging_cells
SET sub_id = ?, arfcn = ?, band = ?, bandwidth = ?, area_code = COALESCE(area_code, ?)
WHERE id = ?'
);
$insertStmt = $db->prepare(
'INSERT INTO staging_cells
(import_id, tech, mcc, mnc, operator_guess, cell_id, area_code, sub_id, sub_id2,
band, frequency, azimuth, bandwidth, address_desc,
lat, lon, location_desc, arfcn, raw_line, source, possible_dup_of, status)
VALUES
(NULL, ?, ?, ?, ?, ?, ?, ?, ?,
?, NULL, NULL, ?, NULL,
NULL, NULL, ?, ?, ?, "cellmapper_import", NULL, "pending")'
);
$db->beginTransaction();
$updated = 0;
$created = 0;
$skippedNoChange = 0;
$details = [];
foreach ($parsedCells as $cell) {
$cellId = $cell['cell_id'];
// Appliquer la bande et l'EARFCN forces si selectionnes
$bandToUse = $forcedBand ?? $cell['band'];
$arfcnToUse = $forcedEarfcn ?? $cell['arfcn'];
if (isset($existingByCellId[$cellId])) {
$existing = $existingByCellId[$cellId];
$noChange = (int)($existing['sub_id'] ?? -1) === ($cell['pci'] ?? -1)
&& (int)($existing['arfcn'] ?? -1) === ($arfcnToUse ?? -1)
&& ($existing['band'] ?? null) === $bandToUse
&& ($existing['bandwidth'] ?? null) === $cell['bandwidth']
&& ($existing['area_code'] !== null || $areaCode === null);
if ($noChange) {
$skippedNoChange++;
$details[] = "Cellule $cellId (eNB $enb) : deja a jour, aucune modification.";
continue;
}
$updateStmt->execute([
$cell['pci'],
$arfcnToUse,
$bandToUse,
$cell['bandwidth'],
$areaCode,
$existing['id'],
]);
$updated++;
$tacNote = $existing['area_code'] === null && $areaCode !== null ? ", TAC=$areaCode (complete)" : '';
$details[] = "Cellule $cellId (eNB $enb) : mise a jour (PCI={$cell['pci']}, ARFCN=$arfcnToUse, bande=$bandToUse$tacNote).";
} else {
$rawLine = sprintf(
'cellmapper:enb=%d;cell_id=%d;pci=%s;arfcn=%s;band=%s;bandwidth=%s;tac=%s',
$enb,
$cellId,
$cell['pci'] ?? 'null',
$arfcnToUse ?? 'null',
$bandToUse ?? 'null',
$cell['bandwidth'] ?? 'null',
$areaCode ?? 'null'
);
$insertStmt->execute([
$tech,
$mcc,
$mnc,
$operatorIdFromDb,
$cellId,
$areaCode,
$cell['pci'],
$enb,
$bandToUse,
$cell['bandwidth'],
'Import CellMapper : coordonnees/azimut/adresse a completer manuellement',
$arfcnToUse,
$rawLine,
]);
$created++;
$isNewEnb = empty($existingByCellId);
$details[] = "Cellule $cellId (eNB $enb) : creee" . ($isNewEnb ? ' (nouvel eNB, a localiser via cadastre)' : ' (nouvelle cellule sur eNB existant)') . ", TAC=" . ($areaCode ?? 'inconnu') . ".";
}
}
$db->commit();
$importSummary = [
'tech' => $tech,
'enb' => $enb,
'mcc' => $mcc,
'mnc' => $mnc,
'area_code' => $areaCode,
'total_parsed' => count($parsedCells),
'updated' => $updated,
'created' => $created,
'skipped_no_change' => $skippedNoChange,
'details' => $details,
];
$successMessage = sprintf(
'eNB %d (%s, MCC %s / MNC %s, TAC %s) : %d cellule(s) analysee(s) — %d creee(s), %d mise(s) a jour, %d deja a jour.',
$enb,
$tech,
$mcc,
$mnc,
$areaCode ?? 'inconnu',
count($parsedCells),
$created,
$updated,
$skippedNoChange
);
} catch (Throwable $e) {
if ($db->inTransaction()) {
$db->rollBack();
}
error_log('[cellmapper_import] ' . $e->getMessage());
$errorMessage = 'Une erreur est survenue lors du traitement. Aucune donnee n\'a ete modifiee. Consultez les journaux du serveur.';
}
}
}
}
// Charger les operateurs pour le select
$operators = $db->query(
'SELECT o.*, c.name AS country_name FROM operators o
JOIN countries c ON c.id = o.country_id
ORDER BY c.name, o.brand'
)->fetchAll(PDO::FETCH_ASSOC);
$operatorsByCountry = [];
foreach ($operators as $op) {
$operatorsByCountry[$op['country_id']][] = $op;
}
// Charger les mappings pour chaque operateur (pour le JS)
$bandMappingsByOperator = [];
$mappings = $db->query('SELECT * FROM operator_band_mappings ORDER BY operator_id, band')->fetchAll(PDO::FETCH_ASSOC);
foreach ($mappings as $m) {
$bandMappingsByOperator[$m['operator_id']][] = $m;
}
?>
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8"><title>NetMonster Admin - Import CellMapper</title>
<link rel="stylesheet" href="assets/theme.css">
<style>
.upload-zone { border: 2px dashed var(--border); border-radius: var(--radius); padding: 1.5rem; text-align: center; background: var(--surface-alt); }
.upload-zone input[type="file"] { margin-top: .8rem; }
.stats-inline { display: flex; gap: 1rem; flex-wrap: wrap; margin-top: 1rem; }
.detail-line { font-size: .82rem; color: var(--text-muted); font-family: monospace; padding: .2rem 0; border-bottom: 1px solid var(--border); }
.detail-line:last-child { border-bottom: none; }
textarea[name="json_input"] { width: 100%; font-family: monospace; font-size: .82rem; }
.form-row { display: flex; gap: 1rem; margin-bottom: 1rem; flex-wrap: wrap; }
.form-row label { flex: 1; min-width: 150px; }
</style>
</head>
<body>
<?php render_admin_nav('cellmapper_import.php', $user); ?>
<div class="page-wrap">
<h1>Import CellMapper (JSON)</h1>
<p class="page-subtitle">
Collez ou televersez l'export JSON d'un site CellMapper (un eNB). Selectionnez la bande 4G pour
remplir automatiquement l'EARFCN selon le mapping de l'operateur. Seuls les parametres radio
techniques sont extraits (Cell ID, PCI, EARFCN, bande, largeur de bande, TAC).
</p>
<?php if ($successMessage): ?><p class="msg-ok"><?= htmlspecialchars($successMessage, ENT_QUOTES, 'UTF-8') ?></p><?php endif; ?>
<?php if ($errorMessage): ?><p class="msg-err"><?= htmlspecialchars($errorMessage, ENT_QUOTES, 'UTF-8') ?></p><?php endif; ?>
<div class="card">
<h2>Nouvel import</h2>
<form method="post" enctype="multipart/form-data" id="cellmapper-form">
<?= csrf_field() ?>
<div class="form-row">
<label>MCC
<input type="text" name="mcc" pattern="\d{3}" maxlength="3" placeholder="270" required value="<?= htmlspecialchars((string)($_POST['mcc'] ?? ''), ENT_QUOTES, 'UTF-8') ?>">
</label>
<label>MNC
<input type="text" name="mnc" pattern="\d{2,3}" maxlength="3" placeholder="01" required value="<?= htmlspecialchars((string)($_POST['mnc'] ?? ''), ENT_QUOTES, 'UTF-8') ?>">
</label>
<label>Operateur (optionnel, pour mapping EARFCN)
<select name="operator_id" id="operator-select">
<option value="">-- selectionner --</option>
<?php foreach ($operators as $op): ?>
<option value="<?= (int)$op['id'] ?>" data-mcc="<?= htmlspecialchars($op['mcc'], ENT_QUOTES, 'UTF-8') ?>" data-mnc="<?= htmlspecialchars($op['mnc'], ENT_QUOTES, 'UTF-8') ?>">
<?= htmlspecialchars($op['brand'], ENT_QUOTES, 'UTF-8') ?> (<?= htmlspecialchars($op['country_name'], ENT_QUOTES, 'UTF-8') ?>)
</option>
<?php endforeach; ?>
</select>
</label>
</div>
<div class="form-row">
<label>Bande 4G (optionnel, remplit EARFCN auto)
<select name="band" id="band-select">
<option value="">-- deduire du JSON ou laisser vide --</option>
<?php foreach (LTE_BANDS as $bandCode => $bandLabel): ?>
<option value="<?= htmlspecialchars($bandCode, ENT_QUOTES, 'UTF-8') ?>"><?= htmlspecialchars($bandLabel, ENT_QUOTES, 'UTF-8') ?></option>
<?php endforeach; ?>
</select>
</label>
<label>EARFCN (rempli auto si bande + operateur selectionnes)
<input type="number" name="earfcn_auto" id="earfcn-auto" readonly placeholder="Automatique">
<input type="hidden" name="earfcn_forced" id="earfcn-forced">
</label>
</div>
<p class="page-subtitle" style="margin-top:-.5rem;">
MCC/MNC ne sont pas presents dans le JSON CellMapper : ils doivent etre saisis manuellement.
Si vous selectionnez un operateur et une bande, l'EARFCN sera rempli automatiquement selon
le mapping configure dans <code>countries_settings.php</code>.
</p>
<div class="upload-zone">
<p>Selectionnez un fichier <strong>.json</strong> (taille maximale : 2 Mo)</p>
<input type="file" name="json_file" accept=".json,application/json">
</div>
<label style="display:block;margin-top:1rem;">ou collez le JSON ici
<textarea name="json_input" rows="12" placeholder='{ "responseData": { "RAT": "LTE", "siteID": "4292", "regionID": "12", "cells": {...}, "channels": [...] } }'><?= htmlspecialchars((string)($_POST['json_input'] ?? ''), ENT_QUOTES, 'UTF-8') ?></textarea>
</label>
<button type="submit" class="primary" style="margin-top:1rem;">Analyser et integrer en staging</button>
</form>
</div>
<?php if ($importSummary): ?>
<div class="card">
<h2>Resultat de l'import — eNB <?= (int)$importSummary['enb'] ?> (<?= htmlspecialchars($importSummary['tech'], ENT_QUOTES, 'UTF-8') ?>) — TAC <?= htmlspecialchars((string)($importSummary['area_code'] ?? 'inconnu'), ENT_QUOTES, 'UTF-8') ?></h2>
<div class="stats-inline">
<div class="stat-chip"><strong><?= (int)$importSummary['total_parsed'] ?></strong>cellule(s) analysee(s)</div>
<div class="stat-chip"><strong><?= (int)$importSummary['created'] ?></strong>creee(s)</div>
<div class="stat-chip"><strong><?= (int)$importSummary['updated'] ?></strong>mise(s) a jour</div>
<div class="stat-chip"><strong><?= (int)$importSummary['skipped_no_change'] ?></strong>deja a jour (ignoree(s))</div>
</div>
<h3 style="margin-top:1.2rem;">Detail</h3>
<?php foreach ($importSummary['details'] as $line): ?>
<div class="detail-line"><?= htmlspecialchars($line, ENT_QUOTES, 'UTF-8') ?></div>
<?php endforeach; ?>
<p style="margin-top:1rem;"><a href="review.php" class="btn primary" style="text-decoration:none;display:inline-block;">Aller a la page de validation &rarr;</a></p>
</div>
<?php endif; ?>
<div class="card">
<h2>Rappel des regles appliquees</h2>
<ul>
<li>Correspondance stricte sur <code>(tech, mcc, mnc, eNB)</code> : jamais de melange entre operateurs differents.</li>
<li>Le TAC/area_code provient de <code>regionID</code> et n'est utilise que pour completer les lignes ou il est absent.</li>
<li>Cellule deja en staging avec le meme Cell ID &rarr; mise a jour des seuls champs radio (PCI, EARFCN, bande, largeur de bande, TAC si manquant).</li>
<li>Cellule non presente sur un eNB deja en staging &rarr; ajout.</li>
<li>eNB totalement absent du staging &rarr; ajout quand meme, a localiser ensuite via le cadastre.</li>
<li>Coordonnees, azimut et adresse ne sont jamais lus depuis CellMapper ni ecrases par cet import.</li>
<li>Si une bande est selectionnee, l'EARFCN est deduit du mapping operateur (sinon, deduction depuis le JSON).</li>
</ul>
</div>
</div>
<script>
// Mappings bande/EARFCN par operateur (injectes depuis PHP)
const bandMappingsByOperator = <?= json_encode($bandMappingsByOperator, JSON_UNESCAPED_UNICODE) ?>;
const operatorSelect = document.getElementById('operator-select');
const bandSelect = document.getElementById('band-select');
const earfcnAutoInput = document.getElementById('earfcn-auto');
const earfcnForcedInput = document.getElementById('earfcn-forced');
// Mettre a jour MCC/MNC quand un operateur est selectionne
operatorSelect.addEventListener('change', function() {
const selectedOption = this.options[this.selectedIndex];
const mcc = selectedOption.dataset.mcc || '';
const mnc = selectedOption.dataset.mnc || '';
if (mcc) document.querySelector('input[name="mcc"]').value = mcc;
if (mnc) document.querySelector('input[name="mnc"]').value = mnc;
// Re-calculer EARFCN si bande deja selectionnee
if (bandSelect.value) {
updateEarfcnFromBand();
}
});
// Mettre a jour EARFCN quand une bande est selectionnee
bandSelect.addEventListener('change', updateEarfcnFromBand);
function updateEarfcnFromBand() {
const operatorId = operatorSelect.value;
const band = bandSelect.value;
if (!operatorId || !band) {
earfcnAutoInput.value = '';
earfcnForcedInput.value = '';
return;
}
const mappings = bandMappingsByOperator[operatorId] || [];
const mapping = mappings.find(m => m.band === band);
if (mapping) {
// Utiliser le debut de plage comme EARFCN typique
earfcnAutoInput.value = mapping.earfcn_start;
earfcnForcedInput.value = mapping.earfcn_start;
} else {
earfcnAutoInput.value = 'Aucun mapping pour ' + band;
earfcnForcedInput.value = '';
}
}
</script>
</body>
</html>