diff --git a/admin/_nav.php b/admin/_nav.php new file mode 100644 index 0000000..818ed52 --- /dev/null +++ b/admin/_nav.php @@ -0,0 +1,78 @@ + $activePage === $page ? 'active' : ''; + ?> + +
+
NetMonster Admin
+ + +
Connecté :
+
+ + 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; +} \ No newline at end of file diff --git a/admin/audit_log.php b/admin/audit_log.php new file mode 100644 index 0000000..4ee5078 --- /dev/null +++ b/admin/audit_log.php @@ -0,0 +1,169 @@ += ?'; + $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); +} +?> + + + + + Journal d'audit — NetMonster Admin + + + + + +
+

Journal d'audit

+

Historique en lecture seule des modifications effectuees dans l'administration. Ce journal ne peut pas etre modifie depuis l'interface.

+ +
+
+ + + + + + + Reinitialiser +
+
+ +
+

Entrees ()

+ + + + + + + + + + + + + + + + + + + + + + + + + + +
DateUtilisateurActionEntiteIDDetails
Aucune entree pour ces filtres.
+ + +
+
+ + \ No newline at end of file diff --git a/admin/backup.php b/admin/backup.php new file mode 100644 index 0000000..a8c6179 --- /dev/null +++ b/admin/backup.php @@ -0,0 +1,181 @@ +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"; +?> + + + + + Sauvegardes — NetMonster Admin + + + + + +
+

Sauvegardes de la base

+

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.

+ + +

+ +

+ + +
+

Commande de sauvegarde recommandee

+

A executer sur le serveur, avec un fichier .my.cnf contenant les identifiants (jamais dans ce fichier PHP ni dans un depot Git) :

+
+ +

Commande de restauration

+
+ +

Recommandations :

+ +
+ +
+

Journaliser une sauvegarde effectuee

+
+ + + + + + + +
+
+ +
+

Historique des sauvegardes journalisees

+ + + + + + + + + + + + + + + + + + + + +
DateUtilisateurLibelleTailleNote
Aucune sauvegarde journalisee.
+
+
+ + \ No newline at end of file diff --git a/admin/cellmapper_import.php b/admin/cellmapper_import.php new file mode 100644 index 0000000..1bfa802 --- /dev/null +++ b/admin/cellmapper_import.php @@ -0,0 +1,619 @@ + '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; +} +?> + + + +NetMonster Admin - Import CellMapper + + + + + + +
+

Import CellMapper (JSON)

+

+ 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). +

+ +

+

+ +
+

Nouvel import

+
+ + +
+ + + +
+ +
+ + +
+ +

+ 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 countries_settings.php. +

+ +
+

Selectionnez un fichier .json (taille maximale : 2 Mo)

+ +
+ + + + +
+
+ + +
+

Resultat de l'import — eNB () — TAC

+
+
cellule(s) analysee(s)
+
creee(s)
+
mise(s) a jour
+
deja a jour (ignoree(s))
+
+ +

Detail

+ +
+ + +

Aller a la page de validation →

+
+ + +
+

Rappel des regles appliquees

+ +
+
+ + + + \ No newline at end of file