Import
This commit is contained in:
1 parent
038a01ccb8
commit
eee6418ea4
5 files changed
+1232
No files matched your search
@@ -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é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ôle qualité</a>
|
||||||
|
<a href="duplicates.php" class="<?= $isActive('duplicates.php') ?>">Doublons & collisions</a>
|
||||||
|
<a href="edit_site.php" class="<?= $isActive('edit_site.php') ?>Édition de site</a>
|
||||||
|
<a href="operator_sites.php" class="<?= $isActive('operator_sites.php') ?>">Antennes par opérateur</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="nav-dropdown">
|
||||||
|
<button type="button" class="nav-dropdown-toggle" aria-expanded="false">Import & 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é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 & opé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 & sécurité <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é : <?= 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
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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 →</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 → 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 → ajout.</li>
|
||||||
|
<li>eNB totalement absent du staging → 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>
|
||||||
Reference in new issue
Block a user