This commit is contained in:
Voxan committed 2026-10-21 22:00:44 +02:00
1 parent bb2686ef62
commit a84acfc38f
5 files changed
+1468

No files matched your search

+249
View File
@@ -0,0 +1,249 @@
<?php
/**
* admin/manual_add.php
* Menu de navigation mis a jour (ajout Import .ntm, Edition de site).
*/
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();
$operators = $db->query('SELECT id, country_id, mcc, mnc, brand FROM operators ORDER BY brand')->fetchAll(PDO::FETCH_ASSOC);
$successMessage = null;
$errorMessage = null;
function composeLocationDesc(string $tech, ?string $enb, ?string $band, ?string $frequency, $azimuth, ?string $bandwidth, string $address): string
{
$techLabel = ['2G' => 'GSM', '3G' => 'UMTS', '4G' => 'LTE', '5G' => 'NR', 'CDMA' => 'CDMA'][$tech] ?? $tech;
$segments = [];
if ($enb !== null && $enb !== '') {
$segments[] = "eNB ID $enb";
}
$radioLine = trim($techLabel . ' ' . ($band ?? '') . ' ' . ($frequency ?? ''));
if ($radioLine !== $techLabel) {
$segments[] = $radioLine;
}
if ($azimuth !== null && $azimuth !== '') {
$segments[] = $azimuth . '°';
}
if ($bandwidth !== null && $bandwidth !== '') {
$segments[] = $bandwidth;
}
if ($address !== '') {
$segments[] = $address;
}
return implode(' - ', $segments);
}
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
require_csrf_token();
$operatorId = (int)($_POST['operator_id'] ?? 0);
$lat = (float)($_POST['lat'] ?? 0);
$lon = (float)($_POST['lon'] ?? 0);
$addressDesc = trim($_POST['address_desc'] ?? '');
$batchLabel = trim($_POST['batch_label'] ?? ('Saisie manuelle - ' . $addressDesc));
$techs = $_POST['cell_tech'] ?? [];
$cellIds = $_POST['cell_id'] ?? [];
$areaCodes = $_POST['cell_area_code'] ?? [];
$subIds = $_POST['cell_sub_id'] ?? [];
$subIds2 = $_POST['cell_sub_id2'] ?? [];
$arfcns = $_POST['cell_arfcn'] ?? [];
$bands = $_POST['cell_band'] ?? [];
$frequencies = $_POST['cell_frequency'] ?? [];
$azimuths = $_POST['cell_azimuth'] ?? [];
$bandwidths = $_POST['cell_bandwidth'] ?? [];
if (!$operatorId || !$lat || !$lon || empty($techs)) {
$errorMessage = "Operateur, coordonnees et au moins une cellule sont obligatoires.";
} else {
$opStmt = $db->prepare('SELECT mcc, mnc FROM operators WHERE id = ?');
$opStmt->execute([$operatorId]);
$op = $opStmt->fetch(PDO::FETCH_ASSOC);
if (!$op) {
$errorMessage = "Operateur invalide.";
} else {
$db->beginTransaction();
try {
$batchStmt = $db->prepare('INSERT INTO staging_batches (label, created_by) VALUES (?, ?)');
$batchStmt->execute([$batchLabel, $user]);
$batchId = (int)$db->lastInsertId();
$insertStmt = $db->prepare('
INSERT INTO staging_cells
(batch_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, source, status)
VALUES
(:batch_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, "manual_form", "pending")
');
$count = 0;
foreach ($techs as $i => $tech) {
if ($tech === '' || $cellIds[$i] === '' || $areaCodes[$i] === '') continue;
$enb = $subIds2[$i] !== '' ? $subIds2[$i] : null;
$band = $bands[$i] !== '' ? $bands[$i] : null;
$frequency = $frequencies[$i] !== '' ? $frequencies[$i] : null;
$azimuth = $azimuths[$i] !== '' ? (int)$azimuths[$i] : null;
$bandwidth = $bandwidths[$i] !== '' ? $bandwidths[$i] : null;
$composedLocation = composeLocationDesc($tech, $enb, $band, $frequency, $azimuth, $bandwidth, $addressDesc);
$insertStmt->execute([
':batch_id' => $batchId, ':tech' => $tech, ':mcc' => $op['mcc'], ':mnc' => $op['mnc'], ':operator_guess' => $operatorId,
':cell_id' => $cellIds[$i], ':area_code' => $areaCodes[$i],
':sub_id' => $subIds[$i] !== '' ? $subIds[$i] : null, ':sub_id2' => $enb,
':band' => $band, ':frequency' => $frequency, ':azimuth' => $azimuth, ':bandwidth' => $bandwidth,
':address_desc' => $addressDesc,
':lat' => $lat, ':lon' => $lon, ':location_desc' => $composedLocation,
':arfcn' => $arfcns[$i] !== '' ? $arfcns[$i] : null,
]);
$count++;
}
$db->commit();
$successMessage = "$count cellule(s) envoyee(s) en validation (lot #$batchId).";
} catch (Throwable $e) {
$db->rollBack();
$errorMessage = "Erreur : " . $e->getMessage();
}
}
}
}
?>
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8"><title>NetMonster Admin - Ajout manuel</title>
<link rel="stylesheet" href="assets/theme.css">
<style>
table#cellsTable th, table#cellsTable td { font-size: .78rem; }
table#cellsTable input, table#cellsTable select { font-size: .78rem; padding: .35rem; }
.preview-line { font-family: monospace; font-size: .78rem; color: var(--text-muted); background: var(--surface-alt); padding: .3rem .5rem; border-radius: 4px; margin-top: .2rem; }
</style>
</head>
<body>
<?php render_admin_nav('manual_add.php', $user); ?>
<div class="page-wrap">
<h1>Ajout manuel d'un site</h1>
<p class="page-subtitle">Un site peut porter plusieurs cellules/secteurs, chacune avec sa propre bande, frequence, azimut et largeur de bande.</p>
<?php if ($successMessage): ?><p class="msg-ok"><?= htmlspecialchars($successMessage) ?></p><?php endif; ?>
<?php if ($errorMessage): ?><p class="msg-err"><?= htmlspecialchars($errorMessage) ?></p><?php endif; ?>
<form method="post" id="manualForm">
<?= csrf_field() ?>
<fieldset>
<legend>Localisation du site</legend>
<div class="form-grid wide-3">
<div>
<label>Operateur</label>
<select name="operator_id" required>
<option value="">-- choisir --</option>
<?php foreach ($operators as $o): ?><option value="<?= $o['id'] ?>"><?= htmlspecialchars($o['brand'] . ' (' . $o['mcc'] . '-' . $o['mnc'] . ')') ?></option><?php endforeach; ?>
</select>
</div>
<div><label>Latitude</label><input type="number" step="0.000001" name="lat" required></div>
<div><label>Longitude</label><input type="number" step="0.000001" name="lon" required></div>
</div>
<div class="form-grid" style="margin-top:0.6rem;">
<div><label>Adresse / lieu (ex: E25 Echangeur Burange/Hellange, Dudelange)</label><input type="text" name="address_desc" placeholder="Description du lieu"></div>
<div><label>Libelle du lot (optionnel)</label><input type="text" name="batch_label"></div>
</div>
</fieldset>
<fieldset>
<legend>Cellules / secteurs de ce site</legend>
<table class="data-table" id="cellsTable">
<thead>
<tr>
<th>Techno</th><th>Cell ID</th><th>Area code</th>
<th>Sub ID (BSIC/PSC/PCI)</th><th>eNB/RNC</th>
<th>Bande</th><th>Frequence</th><th>Azimut (°)</th><th>Largeur bande</th>
<th>ARFCN</th><th></th>
</tr>
</thead>
<tbody id="cellsBody"></tbody>
</table>
<button type="button" class="btn-primary" onclick="addCellRow()">+ Ajouter une cellule</button>
<p class="page-subtitle" style="margin-top:.8rem;">Apercu du champ Location genere pour la premiere ligne :</p>
<div class="preview-line" id="previewLine">—</div>
</fieldset>
<button type="submit" class="primary">Envoyer en validation</button>
</form>
</div>
<script>
const techLabels = { '2G': 'GSM', '3G': 'UMTS', '4G': 'LTE', '5G': 'NR', 'CDMA': 'CDMA' };
function addCellRow() {
const tbody = document.getElementById('cellsBody');
const tr = document.createElement('tr');
tr.innerHTML = `
<td><select name="cell_tech[]" required onchange="updatePreview()">
<option value="">--</option>
<option value="2G">2G</option><option value="3G">3G</option>
<option value="4G">4G</option><option value="5G">5G</option><option value="CDMA">CDMA</option>
</select></td>
<td><input type="text" name="cell_id[]" required></td>
<td><input type="text" name="cell_area_code[]" required></td>
<td><input type="text" name="cell_sub_id[]"></td>
<td><input type="text" name="cell_sub_id2[]" placeholder="ex: 4620" oninput="updatePreview()"></td>
<td><input type="text" name="cell_band[]" placeholder="ex: B7" oninput="updatePreview()"></td>
<td><input type="text" name="cell_frequency[]" placeholder="ex: 700MHz" oninput="updatePreview()"></td>
<td><input type="number" name="cell_azimuth[]" min="0" max="359" placeholder="ex: 280" oninput="updatePreview()"></td>
<td><input type="text" name="cell_bandwidth[]" placeholder="ex: 10MHz" oninput="updatePreview()"></td>
<td><input type="text" name="cell_arfcn[]"></td>
<td><button type="button" class="reject-small" onclick="this.closest('tr').remove(); updatePreview();">Retirer</button></td>
`;
tbody.appendChild(tr);
tr.querySelector('select[name="cell_tech[]"]').addEventListener('change', updatePreview);
updatePreview();
}
function updatePreview() {
const firstRow = document.querySelector('#cellsBody tr');
const preview = document.getElementById('previewLine');
if (!firstRow) { preview.textContent = '—'; return; }
const tech = firstRow.querySelector('[name="cell_tech[]"]').value;
const enb = firstRow.querySelector('[name="cell_sub_id2[]"]').value;
const band = firstRow.querySelector('[name="cell_band[]"]').value;
const freq = firstRow.querySelector('[name="cell_frequency[]"]').value;
const az = firstRow.querySelector('[name="cell_azimuth[]"]').value;
const bw = firstRow.querySelector('[name="cell_bandwidth[]"]').value;
const address = document.querySelector('[name="address_desc"]').value;
const techLabel = techLabels[tech] || tech || '';
const segments = [];
if (enb) segments.push('eNB ID ' + enb);
const radioLine = [techLabel, band, freq].filter(Boolean).join(' ');
if (radioLine && radioLine !== techLabel) segments.push(radioLine);
if (az) segments.push(az + '°');
if (bw) segments.push(bw);
if (address) segments.push(address);
preview.textContent = segments.length ? segments.join(' - ') : '—';
}
document.querySelector('[name="address_desc"]')?.addEventListener('input', updatePreview);
addCellRow();
</script>
</body>
</html>
+197
View File
@@ -0,0 +1,197 @@
<?php
/**
* admin/map_layers.php
*
* Gestion des couches cartographiques (visibilite, couleur, ordre
* d'affichage) utilisees par la carte publique/consultation. Priorite
* "Faible" dans la roadmap : fonctionnalites simples de configuration,
* avec CSRF systematique sur toute ecriture et confirmation avant
* suppression d'une couche.
*
* Hypothese de schema :
* map_layers(id, name, layer_key, color, is_visible, sort_order,
* description)
*/
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';
function sanitize_hex_color(string $color): ?string
{
$color = trim($color);
return preg_match('/^#[0-9a-fA-F]{6}$/', $color) ? $color : null;
}
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
require_csrf_token();
$action = $_POST['action'] ?? '';
if ($action === 'create_layer') {
$name = trim((string)($_POST['name'] ?? ''));
$layerKey = trim((string)($_POST['layer_key'] ?? ''));
$color = sanitize_hex_color((string)($_POST['color'] ?? '#3388ff'));
$description = trim((string)($_POST['description'] ?? ''));
if ($name === '' || !preg_match('/^[a-z0-9_\-]+$/', $layerKey)) {
$feedback = 'Nom ou clé de couche invalide (clé··: lettres minuscules, chiffres, - et _ uniquement).';
$feedbackType = 'error';
} elseif ($color === null) {
$feedback = 'Couleur invalide (format attendu : #rrggbb).';
$feedbackType = 'error';
} else {
try {
$stmt = $db->prepare(
'INSERT INTO map_layers (name, layer_key, color, is_visible, sort_order, description)
VALUES (?, ?, ?, 1, (SELECT COALESCE(MAX(sort_order), 0) + 1 FROM map_layers AS m2), ?)'
);
$stmt->execute([$name, $layerKey, $color, $description]);
$feedback = 'Couche créée.';
} catch (Throwable $e) {
$feedback = 'Erreur lors de la création (clé·· déjà utilisée ?) : ' . $e->getMessage();
$feedbackType = 'error';
}
}
} elseif ($action === 'toggle_visibility') {
$layerId = filter_input(INPUT_POST, 'layer_id', FILTER_VALIDATE_INT);
if ($layerId) {
$stmt = $db->prepare('UPDATE map_layers SET is_visible = 1 - is_visible WHERE id = ?');
$stmt->execute([$layerId]);
$feedback = 'Visibilité·· mise à jour.';
}
} elseif ($action === 'update_color') {
$layerId = filter_input(INPUT_POST, 'layer_id', FILTER_VALIDATE_INT);
$color = sanitize_hex_color((string)($_POST['color'] ?? ''));
if ($layerId && $color !== null) {
$stmt = $db->prepare('UPDATE map_layers SET color = ? WHERE id = ?');
$stmt->execute([$color, $layerId]);
$feedback = 'Couleur mise à jour.';
} else {
$feedback = 'Couleur invalide.';
$feedbackType = 'error';
}
} elseif ($action === 'delete_layer') {
$layerId = filter_input(INPUT_POST, 'layer_id', FILTER_VALIDATE_INT);
$confirmed = isset($_POST['confirm']) && $_POST['confirm'] === '1';
if (!$confirmed || !$layerId) {
$feedback = 'Suppression refusé··e : confirmation manquante.';
$feedbackType = 'error';
} else {
$stmt = $db->prepare('DELETE FROM map_layers WHERE id = ? LIMIT 1');
$stmt->execute([$layerId]);
$feedback = $stmt->rowCount() > 0 ? 'Couche supprimé··e.' : 'Couche introuvable.';
}
}
}
$layers = $db->query('SELECT id, name, layer_key, color, is_visible, sort_order, description FROM map_layers ORDER BY sort_order ASC')->fetchAll(PDO::FETCH_ASSOC);
$user = current_admin_username();
?>
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<title>Couches cartographiques — NetMonster Admin</title>
<link rel="stylesheet" href="assets/theme.css">
</head>
<body>
<?php render_admin_nav('map_layers.php', $user); ?>
<main class="container">
<h1>Couches cartographiques</h1>
<p>Gé··rez les couches affich ées sur la carte (visibilité··, couleur, ordre). Toute suppression demande une confirmation explicite.</p>
<?php if ($feedback !== null): ?>
<div class="alert alert-<?= $feedbackType === 'error' ? 'error' : 'success' ?>">
<?= htmlspecialchars($feedback, ENT_QUOTES, 'UTF-8') ?>
</div>
<?php endif; ?>
<section class="card">
<h2>Nouvelle couche</h2>
<form method="post">
<?= csrf_field() ?>
<input type="hidden" name="action" value="create_layer">
<label>Nom affiché··e
<input type="text" name="name" required maxlength="100">
</label>
<label>Clé·· technique
<input type="text" name="layer_key" required pattern="[a-z0-9_\-]+" placeholder="ex: sites_4g">
</label>
<label>Couleur
<input type="color" name="color" value="#3388ff">
</label>
<label>Description
<input type="text" name="description" maxlength="255">
</label>
<button type="submit" class="btn btn-primary">Cré··er</button>
</form>
</section>
<section class="card">
<h2>Couches existantes (<?= count($layers) ?>)</h2>
<table class="table">
<thead>
<tr>
<th>Ordre</th>
<th>Nom</th>
<th>Clé··</th>
<th>Couleur</th>
<th>Visible</th>
<th>Description</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<?php foreach ($layers as $layer): ?>
<tr>
<td><?= (int)$layer['sort_order'] ?></td>
<td><?= htmlspecialchars((string)$layer['name'], ENT_QUOTES, 'UTF-8') ?></td>
<td><code><?= htmlspecialchars((string)$layer['layer_key'], ENT_QUOTES, 'UTF-8') ?></code></td>
<td>
<form method="post" style="display:inline-flex;gap:.5rem;align-items:center">
<?= csrf_field() ?>
<input type="hidden" name="action" value="update_color">
<input type="hidden" name="layer_id" value="<?= (int)$layer['id'] ?>">
<input type="color" name="color" value="<?= htmlspecialchars((string)$layer['color'], ENT_QUOTES, 'UTF-8') ?>" onchange="this.form.submit()">
</form>
</td>
<td>
<form method="post">
<?= csrf_field() ?>
<input type="hidden" name="action" value="toggle_visibility">
<input type="hidden" name="layer_id" value="<?= (int)$layer['id'] ?>">
<button type="submit" class="btn btn-secondary">
<?= ((int)$layer['is_visible'] === 1) ? 'Visible' : 'Masqué··e' ?>
</button>
</form>
</td>
<td><?= htmlspecialchars((string)$layer['description'], ENT_QUOTES, 'UTF-8') ?></td>
<td>
<form method="post" onsubmit="return confirm('Confirmer la suppression de cette couche ?');">
<?= csrf_field() ?>
<input type="hidden" name="action" value="delete_layer">
<input type="hidden" name="confirm" value="1">
<input type="hidden" name="layer_id" value="<?= (int)$layer['id'] ?>">
<button type="submit" class="btn btn-danger">Supprimer</button>
</form>
</td>
</tr>
<?php endforeach; ?>
<?php if (empty($layers)): ?>
<tr><td colspan="7">Aucune couche configuré··e.</td></tr>
<?php endif; ?>
</tbody>
</table>
</section>
</main>
</body>
</html>
+185
View File
@@ -0,0 +1,185 @@
<?php
/**
* admin/operator_sites.php
*
* Vue en lecture seule des antennes par operateur. Aucune ecriture en
* base : uniquement des filtres GET et des requetes preparees en
* lecture. Protegee par require_admin_auth() comme toutes les pages du
* dossier /admin.
*
* Hypothese de schema :
* operators(id, name, mcc, mnc, country_id)
* countries(id, name, iso_code)
* sites(id, name, lat, lon, operator_id, country_id)
* cells(id, site_id, technology, band, frequency, azimuth, enb, cell_id)
*/
declare(strict_types=1);
require_once __DIR__ . '/admin_auth.php';
require_once __DIR__ . '/../lib/db.php';
require_admin_auth();
$db = pdo();
$countries = $db->query('SELECT id, name FROM countries ORDER BY name')->fetchAll(PDO::FETCH_ASSOC);
$countryId = filter_input(INPUT_GET, 'country_id', FILTER_VALIDATE_INT) ?: null;
$operatorsSql = 'SELECT id, name, mcc, mnc, country_id FROM operators';
$operatorsParams = [];
if ($countryId) {
$operatorsSql .= ' WHERE country_id = ?';
$operatorsParams[] = $countryId;
}
$operatorsSql .= ' ORDER BY name';
$stmt = $db->prepare($operatorsSql);
$stmt->execute($operatorsParams);
$operators = $stmt->fetchAll(PDO::FETCH_ASSOC);
$operatorId = filter_input(INPUT_GET, 'operator_id', FILTER_VALIDATE_INT) ?: null;
$page = max(1, filter_input(INPUT_GET, 'page', FILTER_VALIDATE_INT) ?: 1);
$perPage = 50;
$offset = ($page - 1) * $perPage;
$sites = [];
$totalSites = 0;
$stats = null;
if ($operatorId) {
$countStmt = $db->prepare('SELECT COUNT(*) FROM sites WHERE operator_id = ?');
$countStmt->execute([$operatorId]);
$totalSites = (int)$countStmt->fetchColumn();
$stmt = $db->prepare(
'SELECT s.id, s.name, s.lat, s.lon,
(SELECT COUNT(*) FROM cells c WHERE c.site_id = s.id) AS cell_count
FROM sites s
WHERE s.operator_id = ?
ORDER BY s.name
LIMIT :limit OFFSET :offset'
);
$stmt->bindValue(1, $operatorId, PDO::PARAM_INT);
$stmt->bindValue(':limit', $perPage, PDO::PARAM_INT);
$stmt->bindValue(':offset', $offset, PDO::PARAM_INT);
$stmt->execute();
$sites = $stmt->fetchAll(PDO::FETCH_ASSOC);
$statStmt = $db->prepare(
"SELECT c.technology, COUNT(*) AS total
FROM cells c
INNER JOIN sites s ON s.id = c.site_id
WHERE s.operator_id = ?
GROUP BY c.technology
ORDER BY total DESC"
);
$statStmt->execute([$operatorId]);
$stats = $statStmt->fetchAll(PDO::FETCH_ASSOC);
}
$totalPages = max(1, (int)ceil($totalSites / $perPage));
$user = current_admin_username();
?>
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<title>Antennes par opé··rateur — NetMonster Admin</title>
<link rel="stylesheet" href="assets/theme.css">
</head>
<body>
<?php render_admin_nav('operator_sites.php', $user); ?>
<main class="container">
<h1>Antennes par opé··rateur</h1>
<p>Vue de consultation, sans action d'ecriture. Filtrez par pays puis par operateur pour lister les sites et leur nombre de cellules.</p>
<section class="card">
<form method="get" class="filters">
<label>Pays
<select name="country_id" onchange="this.form.submit()">
<option value="">Tous</option>
<?php foreach ($countries as $c): ?>
<option value="<?= (int)$c['id'] ?>" <?= $countryId === (int)$c['id'] ? 'selected' : '' ?>>
<?= htmlspecialchars((string)$c['name'], ENT_QUOTES, 'UTF-8') ?>
</option>
<?php endforeach; ?>
</select>
</label>
<label>Opé··rateur
<select name="operator_id" onchange="this.form.submit()">
<option value="">— Choisir —</option>
<?php foreach ($operators as $op): ?>
<option value="<?= (int)$op['id'] ?>" <?= $operatorId === (int)$op['id'] ? 'selected' : '' ?>>
<?= htmlspecialchars((string)$op['name'], ENT_QUOTES, 'UTF-8') ?>
(MCC <?= htmlspecialchars((string)$op['mcc'], ENT_QUOTES, 'UTF-8') ?> / MNC <?= htmlspecialchars((string)$op['mnc'], ENT_QUOTES, 'UTF-8') ?>)
</option>
<?php endforeach; ?>
</select>
</label>
</form>
</section>
<?php if ($operatorId): ?>
<section class="card">
<h2>Ré··partition par technologie</h2>
<?php if (empty($stats)): ?>
<p>Aucune cellule pour cet opé··rateur.</p>
<?php else: ?>
<table class="table">
<thead><tr><th>Technologie</th><th>Nombre de cellules</th></tr></thead>
<tbody>
<?php foreach ($stats as $s): ?>
<tr>
<td><?= htmlspecialchars((string)$s['technology'], ENT_QUOTES, 'UTF-8') ?></td>
<td><?= (int)$s['total'] ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
</section>
<section class="card">
<h2>Sites (<?= $totalSites ?>)</h2>
<table class="table">
<thead>
<tr>
<th>Nom</th>
<th>Latitude</th>
<th>Longitude</th>
<th>Cellules</th>
<th>Dé··tail</th>
</tr>
</thead>
<tbody>
<?php foreach ($sites as $site): ?>
<tr>
<td><?= htmlspecialchars((string)$site['name'], ENT_QUOTES, 'UTF-8') ?></td>
<td><?= htmlspecialchars((string)$site['lat'], ENT_QUOTES, 'UTF-8') ?></td>
<td><?= htmlspecialchars((string)$site['lon'], ENT_QUOTES, 'UTF-8') ?></td>
<td><?= (int)$site['cell_count'] ?></td>
<td><a class="btn btn-secondary" href="edit_site.php?id=<?= (int)$site['id'] ?>">Ouvrir</a></td>
</tr>
<?php endforeach; ?>
<?php if (empty($sites)): ?>
<tr><td colspan="5">Aucun site pour cet opé··rateur.</td></tr>
<?php endif; ?>
</tbody>
</table>
<nav class="pagination">
<?php for ($p = 1; $p <= $totalPages; $p++): ?>
<a href="operator_sites.php?operator_id=<?= (int)$operatorId ?>&country_id=<?= (int)$countryId ?>&page=<?= $p ?>" class="<?= $p === $page ? 'active' : '' ?>"><?= $p ?></a>
<?php endfor; ?>
</nav>
</section>
<?php else: ?>
<section class="card">
<p>Sé··lectionnez un opé··rateur pour afficher ses sites.</p>
</section>
<?php endif; ?>
</main>
</body>
</html>
+383
View File
@@ -0,0 +1,383 @@
<?php
/**
* admin/review.php
*
* Corrige suite a l'audit du 20/09/2026 :
* - Le flux WMS cadastre est encode en JSON avec JSON_HEX_* au lieu
* d'addslashes() (memes raisons que edit_site.php).
* - Le catch silencieux de validate_group loggue desormais l'erreur
* et affiche un message a l'administrateur au lieu de rediriger
* silencieusement comme si tout s'etait bien passe.
* - La popup de la carte (groupes en attente) est reconstruite en DOM
* (textContent) plutot qu'en template literal HTML.
*/
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();
function deriveSiteKey(array $row): string
{
$mcc = $row['mcc'];
$mnc = $row['mnc'];
if ($row['tech'] === '4G' && !empty($row['sub_id2'])) {
return "enb:{$mcc}:{$mnc}:{$row['area_code']}:{$row['sub_id2']}";
}
$latR = $row['lat'] !== null ? round((float)$row['lat'], 4) : 'na';
$lonR = $row['lon'] !== null ? round((float)$row['lon'], 4) : 'na';
return "geo:{$mcc}:{$mnc}:{$row['area_code']}:{$latR}:{$lonR}";
}
$flashError = null;
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
require_csrf_token();
$action = $_POST['action'] ?? '';
if ($action === 'validate_group') {
$stagingIds = array_map('intval', $_POST['staging_ids'] ?? []);
$lat = (float)($_POST['lat'] ?? 0); $lon = (float)($_POST['lon'] ?? 0);
$locationDesc = $_POST['location_desc'] ?? ''; $operatorId = (int)($_POST['operator_id'] ?? 0);
// Verifie que l'operateur declare existe reellement avant toute ecriture.
$opCheck = $db->prepare('SELECT country_id FROM operators WHERE id = ?');
$opCheck->execute([$operatorId]);
$countryId = $opCheck->fetchColumn();
if ($stagingIds && $operatorId && $countryId !== false) {
$db->beginTransaction();
try {
$near = $db->prepare('SELECT id FROM sites WHERE operator_id = ? AND ABS(lat - ?) < 0.0003 AND ABS(lon - ?) < 0.0003 LIMIT 1');
$near->execute([$operatorId, $lat, $lon]);
$siteId = $near->fetchColumn();
if (!$siteId) {
$addrStmt = $db->prepare('SELECT address_desc FROM staging_cells WHERE id = ? LIMIT 1');
$addrStmt->execute([$stagingIds[0]]);
$addressDesc = $addrStmt->fetchColumn() ?: null;
$insertSite = $db->prepare('INSERT INTO sites (country_id, operator_id, lat, lon, location_desc, address_desc, created_by) VALUES (?, ?, ?, ?, ?, ?, ?)');
$insertSite->execute([$countryId, $operatorId, $lat, $lon, $locationDesc, $addressDesc, $user]);
$siteId = (int)$db->lastInsertId();
}
$fetchStaging = $db->prepare('SELECT * FROM staging_cells WHERE id = ? AND status = "pending"');
$insertCell = $db->prepare('
INSERT INTO cells (site_id, tech, mcc, mnc, cell_id, area_code, sub_id, sub_id2, band, frequency, azimuth, bandwidth, arfcn, validated_by)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
site_id = VALUES(site_id), band = VALUES(band), frequency = VALUES(frequency),
azimuth = VALUES(azimuth), bandwidth = VALUES(bandwidth),
validated_by = VALUES(validated_by), validated_at = NOW()
');
$markValidated = $db->prepare('UPDATE staging_cells SET status = "validated", reviewed_at = NOW(), reviewed_by = ? WHERE id = ?');
foreach ($stagingIds as $sid) {
$fetchStaging->execute([$sid]);
$cell = $fetchStaging->fetch(PDO::FETCH_ASSOC);
if (!$cell) continue;
$insertCell->execute([
$siteId, $cell['tech'], $cell['mcc'], $cell['mnc'], $cell['cell_id'], $cell['area_code'],
$cell['sub_id'], $cell['sub_id2'], $cell['band'], $cell['frequency'], $cell['azimuth'], $cell['bandwidth'],
$cell['arfcn'], $user,
]);
$markValidated->execute([$user, $sid]);
}
$db->commit();
} catch (Throwable $e) {
$db->rollBack();
// Corrige : l'echec n'est plus silencieux. On loggue le
// detail technique cote serveur et on informe
// l'administrateur qu'aucune validation n'a ete
// appliquee, plutot que de rediriger comme si de rien
// n'etait alors que la transaction a echoue.
error_log('[review] validate_group: ' . $e->getMessage());
$flashError = "La validation du site a echoue et a ete annulee (aucune donnee ecrite). Consultez les journaux du serveur si le probleme persiste.";
}
} else {
$flashError = "Validation refusee : operateur invalide ou selection de cellules vide.";
}
}
if ($action === 'reject') {
$stagingId = (int)($_POST['staging_id'] ?? 0);
try {
$db->prepare('UPDATE staging_cells SET status = "rejected", reviewed_at = NOW(), reviewed_by = ?, reject_reason = ? WHERE id = ? AND status = "pending"')
->execute([$user, $_POST['reject_reason'] ?? null, $stagingId]);
} catch (Throwable $e) {
error_log('[review] reject: ' . $e->getMessage());
$flashError = 'Le rejet de cette cellule a echoue.';
}
}
if ($action === 'add_missing_cell') {
// Verifie que l'operateur/pays declares existent reellement (coherence
// MCC/MNC) avant d'inserer une nouvelle ligne staging manuelle.
$operatorId = (int)($_POST['operator_id'] ?? 0);
$opCheck = $db->prepare('SELECT mcc, mnc FROM operators WHERE id = ?');
$opCheck->execute([$operatorId]);
$op = $opCheck->fetch(PDO::FETCH_ASSOC);
if ($op && in_array($_POST['tech'] ?? '', ['2G', '3G', '4G', '5G', 'CDMA'], true)) {
try {
$db->prepare('
INSERT INTO staging_cells (tech, mcc, mnc, operator_guess, cell_id, area_code, sub_id, sub_id2, band, frequency, azimuth, bandwidth, lat, lon, location_desc, arfcn, source, status)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, "manual_form", "pending")
')->execute([
$_POST['tech'], $op['mcc'], $op['mnc'], $operatorId,
$_POST['cell_id'], $_POST['area_code'],
$_POST['sub_id'] !== '' ? $_POST['sub_id'] : null, $_POST['sub_id2'] !== '' ? $_POST['sub_id2'] : null,
$_POST['band'] !== '' ? $_POST['band'] : null, $_POST['frequency'] !== '' ? $_POST['frequency'] : null,
$_POST['azimuth'] !== '' ? (int)$_POST['azimuth'] : null, $_POST['bandwidth'] !== '' ? $_POST['bandwidth'] : null,
$_POST['lat'], $_POST['lon'], $_POST['location_desc'], $_POST['arfcn'] !== '' ? $_POST['arfcn'] : null,
]);
} catch (Throwable $e) {
error_log('[review] add_missing_cell: ' . $e->getMessage());
$flashError = "L'ajout de la cellule manquante a echoue.";
}
} else {
$flashError = "Ajout refuse : operateur ou technologie invalide.";
}
}
if ($flashError !== null) {
$_SESSION['review_flash_error'] = $flashError;
}
header('Location: review.php' . (isset($_GET['country']) ? '?country=' . urlencode($_GET['country']) : ''));
exit;
}
$flashError = $_SESSION['review_flash_error'] ?? null;
unset($_SESSION['review_flash_error']);
$countryFilter = $_GET['country'] ?? ''; $operatorFilter = $_GET['operator'] ?? '';
$sql = '
SELECT sc.*, o.brand AS operator_brand, c.name AS country_name
FROM staging_cells sc
LEFT JOIN operators o ON o.id = sc.operator_guess
LEFT JOIN countries c ON c.mcc = sc.mcc
WHERE sc.status = "pending"
';
$params = [];
if ($countryFilter !== '') { $sql .= ' AND c.iso2 = ?'; $params[] = $countryFilter; }
if ($operatorFilter !== '') { $sql .= ' AND sc.operator_guess = ?'; $params[] = $operatorFilter; }
$sql .= ' ORDER BY sc.submitted_at DESC LIMIT 500';
$stmt = $db->prepare($sql); $stmt->execute($params);
$allPending = $stmt->fetchAll(PDO::FETCH_ASSOC);
$groups = [];
foreach ($allPending as $row) {
$key = deriveSiteKey($row);
$groups[$key]['cells'][] = $row;
$groups[$key]['operator_brand'] = $row['operator_brand'];
$groups[$key]['operator_id'] = $row['operator_guess'];
$groups[$key]['mcc'] = $row['mcc']; $groups[$key]['mnc'] = $row['mnc'];
$groups[$key]['lat'] = $groups[$key]['lat'] ?? $row['lat'];
$groups[$key]['lon'] = $groups[$key]['lon'] ?? $row['lon'];
$groups[$key]['location_desc'] = $groups[$key]['location_desc'] ?? $row['location_desc'];
$groups[$key]['country_name'] = $row['country_name'];
$groups[$key]['has_dup'] = ($groups[$key]['has_dup'] ?? false) || (bool)$row['possible_dup_of'];
}
$operators = $db->query('SELECT id, mcc, mnc, brand FROM operators ORDER BY brand')->fetchAll(PDO::FETCH_ASSOC);
$countries = $db->query('SELECT iso2, name, mcc, default_lat, default_lon, default_zoom, cadastre_wms_url, cadastre_wms_layer, cadastre_attribution_html, cadastre_srs FROM countries ORDER BY name')->fetchAll(PDO::FETCH_ASSOC);
$activeCountry = null;
if ($countryFilter !== '') {
foreach ($countries as $c) { if ($c['iso2'] === $countryFilter) { $activeCountry = $c; break; } }
} elseif (!empty($groups)) {
$firstMcc = reset($groups)['mcc'];
foreach ($countries as $c) { if ($c['mcc'] === $firstMcc) { $activeCountry = $c; break; } }
}
if (!$activeCountry) {
$activeCountry = ['default_lat' => 20, 'default_lon' => 0, 'default_zoom' => 2, 'cadastre_wms_url' => null, 'cadastre_wms_layer' => null, 'cadastre_attribution_html' => null, 'cadastre_srs' => 'EPSG:3857'];
}
$mapPoints = [];
foreach ($groups as $key => $g) {
if ($g['lat'] && $g['lon']) {
$mapPoints[] = ['key' => $key, 'lat' => (float)$g['lat'], 'lon' => (float)$g['lon'], 'operator' => $g['operator_brand'], 'count' => count($g['cells']), 'has_dup' => $g['has_dup']];
}
}
$hasCadastre = !empty($activeCountry['cadastre_wms_url']) && !empty($activeCountry['cadastre_wms_layer']);
$totalPending = count($allPending);
$totalDup = count(array_filter($allPending, fn($r) => (bool)$r['possible_dup_of']));
?>
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8"><title>NetMonster Admin - Validation</title>
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" crossorigin="" />
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js" crossorigin=""></script>
<link rel="stylesheet" href="assets/theme.css">
</head>
<body>
<?php render_admin_nav('review.php', $user); ?>
<div class="page-wrap">
<h1>Validation groupee par site</h1>
<p class="page-subtitle">Verifiez chaque site avant qu'il ne rejoigne la base finale.</p>
<?php if ($flashError): ?><p class="msg-err"><?= htmlspecialchars($flashError, ENT_QUOTES, 'UTF-8') ?></p><?php endif; ?>
<div class="stats-bar">
<div class="stat-chip"><strong><?= count($groups) ?></strong>site(s) en attente</div>
<div class="stat-chip"><strong><?= $totalPending ?></strong>ligne(s) au total</div>
<div class="stat-chip"><strong><?= $totalDup ?></strong>doublon(s) potentiel(s)</div>
</div>
<div class="map-controls <?= $hasCadastre ? '' : 'disabled' ?>">
<label><input type="checkbox" id="toggleCadastre" <?= $hasCadastre ? 'checked' : 'disabled' ?>>
Afficher le cadastre<?= $hasCadastre ? '' : ' (non configure pour ce pays)' ?></label>
</div>
<div id="map"></div>
<form class="filters" method="get">
<select name="country" onchange="this.form.submit()">
<option value="">Tous les pays</option>
<?php foreach ($countries as $c): ?>
<option value="<?= htmlspecialchars($c['iso2'], ENT_QUOTES, 'UTF-8') ?>" <?= $countryFilter === $c['iso2'] ? 'selected' : '' ?>><?= htmlspecialchars($c['name'], ENT_QUOTES, 'UTF-8') ?></option>
<?php endforeach; ?>
</select>
<select name="operator" onchange="this.form.submit()">
<option value="">Tous les operateurs</option>
<?php foreach ($operators as $o): ?>
<option value="<?= $o['id'] ?>" <?= (string)$operatorFilter === (string)$o['id'] ? 'selected' : '' ?>><?= htmlspecialchars($o['brand'], ENT_QUOTES, 'UTF-8') ?> (<?= htmlspecialchars($o['mcc'].'-'.$o['mnc'], ENT_QUOTES, 'UTF-8') ?>)</option>
<?php endforeach; ?>
</select>
</form>
<?php foreach ($groups as $key => $group): ?>
<div class="group-card" id="group-<?= htmlspecialchars($key, ENT_QUOTES, 'UTF-8') ?>">
<div class="group-header">
<h3><?= htmlspecialchars($group['operator_brand'] ?? 'Operateur inconnu', ENT_QUOTES, 'UTF-8') ?> &middot; <?= htmlspecialchars($group['country_name'] ?? '?', ENT_QUOTES, 'UTF-8') ?> &middot; <?= htmlspecialchars((string)$group['lat'], ENT_QUOTES, 'UTF-8') ?>, <?= htmlspecialchars((string)$group['lon'], ENT_QUOTES, 'UTF-8') ?></h3>
<div>
<span class="badge badge-info"><?= count($group['cells']) ?> cellule<?= count($group['cells']) > 1 ? 's' : '' ?></span>
<?php if ($group['has_dup']): ?><span class="badge badge-dup">Doublons potentiels</span><?php endif; ?>
</div>
</div>
<table class="cells-table">
<tr><th>Techno</th><th>Cell ID</th><th>Area code</th><th>Sub ID</th><th>eNB/RNC</th><th>Bande</th><th>Frequence</th><th>Azimut</th><th>Largeur</th><th>ARFCN</th><th>Dup.</th><th></th></tr>
<?php foreach ($group['cells'] as $c): ?>
<tr>
<td><?= htmlspecialchars((string)$c['tech'], ENT_QUOTES, 'UTF-8') ?></td>
<td><?= htmlspecialchars((string)$c['cell_id'], ENT_QUOTES, 'UTF-8') ?></td>
<td><?= htmlspecialchars((string)$c['area_code'], ENT_QUOTES, 'UTF-8') ?></td>
<td><?= htmlspecialchars((string)($c['sub_id'] ?? ''), ENT_QUOTES, 'UTF-8') ?></td>
<td><?= htmlspecialchars((string)($c['sub_id2'] ?? ''), ENT_QUOTES, 'UTF-8') ?></td>
<td><?= htmlspecialchars((string)($c['band'] ?? ''), ENT_QUOTES, 'UTF-8') ?></td>
<td><?= htmlspecialchars((string)($c['frequency'] ?? ''), ENT_QUOTES, 'UTF-8') ?></td>
<td><?= $c['azimuth'] !== null ? htmlspecialchars((string)$c['azimuth'], ENT_QUOTES, 'UTF-8') . '°' : '' ?></td>
<td><?= htmlspecialchars((string)($c['bandwidth'] ?? ''), ENT_QUOTES, 'UTF-8') ?></td>
<td><?= htmlspecialchars((string)($c['arfcn'] ?? ''), ENT_QUOTES, 'UTF-8') ?></td>
<td><?= $c['possible_dup_of'] ? '#' . htmlspecialchars((string)$c['possible_dup_of'], ENT_QUOTES, 'UTF-8') : '&mdash;' ?></td>
<td>
<form method="post" style="display:inline">
<?= csrf_field() ?>
<input type="hidden" name="action" value="reject">
<input type="hidden" name="staging_id" value="<?= htmlspecialchars((string)$c['id'], ENT_QUOTES, 'UTF-8') ?>">
<button type="submit" class="reject-small" onclick="return confirm('Rejeter ?')">Rejeter</button>
</form>
</td>
</tr>
<?php endforeach; ?>
</table>
<div class="actions-row">
<form method="post">
<?= csrf_field() ?>
<?php foreach ($group['cells'] as $c): ?><input type="hidden" name="staging_ids[]" value="<?= htmlspecialchars((string)$c['id'], ENT_QUOTES, 'UTF-8') ?>"><?php endforeach; ?>
<input type="hidden" name="action" value="validate_group">
<input type="hidden" name="lat" value="<?= htmlspecialchars((string)$group['lat'], ENT_QUOTES, 'UTF-8') ?>">
<input type="hidden" name="lon" value="<?= htmlspecialchars((string)$group['lon'], ENT_QUOTES, 'UTF-8') ?>">
<input type="hidden" name="location_desc" value="<?= htmlspecialchars((string)$group['location_desc'], ENT_QUOTES, 'UTF-8') ?>">
<input type="hidden" name="operator_id" value="<?= htmlspecialchars((string)$group['operator_id'], ENT_QUOTES, 'UTF-8') ?>">
<button type="submit" class="validate">Valider le site complet</button>
</form>
<button type="button" class="add-missing" onclick="toggleMissingForm('<?= htmlspecialchars($key, ENT_QUOTES, 'UTF-8') ?>')">+ Ajouter un secteur / une bande manquante</button>
</div>
<div class="missing-form" id="missing-<?= htmlspecialchars($key, ENT_QUOTES, 'UTF-8') ?>">
<form method="post">
<?= csrf_field() ?>
<input type="hidden" name="action" value="add_missing_cell">
<input type="hidden" name="mcc" value="<?= htmlspecialchars($group['mcc'], ENT_QUOTES, 'UTF-8') ?>">
<input type="hidden" name="mnc" value="<?= htmlspecialchars($group['mnc'], ENT_QUOTES, 'UTF-8') ?>">
<input type="hidden" name="operator_id" value="<?= htmlspecialchars((string)$group['operator_id'], ENT_QUOTES, 'UTF-8') ?>">
<input type="hidden" name="lat" value="<?= htmlspecialchars((string)$group['lat'], ENT_QUOTES, 'UTF-8') ?>">
<input type="hidden" name="lon" value="<?= htmlspecialchars((string)$group['lon'], ENT_QUOTES, 'UTF-8') ?>">
<input type="hidden" name="location_desc" value="<?= htmlspecialchars((string)$group['location_desc'], ENT_QUOTES, 'UTF-8') ?>">
<div class="missing-grid">
<label>Techno<select name="tech" required><option value="2G">2G</option><option value="3G">3G</option><option value="4G" selected>4G</option><option value="5G">5G</option><option value="CDMA">CDMA</option></select></label>
<label>Cell ID<input type="text" name="cell_id" required></label>
<label>Area code<input type="text" name="area_code" required></label>
<label>Sub ID (BSIC/PSC/PCI)<input type="text" name="sub_id"></label>
<label>eNB/RNC<input type="text" name="sub_id2"></label>
<label>Bande<input type="text" name="band" placeholder="ex: B7"></label>
<label>Frequence<input type="text" name="frequency" placeholder="ex: 700MHz"></label>
<label>Azimut<input type="number" name="azimuth" min="0" max="359" placeholder="ex: 280"></label>
<label>Largeur de bande<input type="text" name="bandwidth" placeholder="ex: 10MHz"></label>
<label>ARFCN<input type="text" name="arfcn"></label>
</div>
<button type="submit" class="validate" style="margin-top:0.8rem;">Ajouter cette cellule au site</button>
</form>
</div>
</div>
<?php endforeach; ?>
</div>
<script>
function toggleMissingForm(key){const el=document.getElementById('missing-'+key);el.style.display=el.style.display==='block'?'none':'block';}
const map = L.map('map').setView([<?= (float)$activeCountry['default_lat'] ?>, <?= (float)$activeCountry['default_lon'] ?>], <?= (int)$activeCountry['default_zoom'] ?>);
L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {maxZoom:19, attribution:'&copy; <a href="https://www.openstreetmap.org/copyright" target="_blank">OpenStreetMap</a> contributors'}).addTo(map);
<?php if ($hasCadastre): ?>
const cadastreLayer = L.tileLayer.wms(
<?= json_encode($activeCountry['cadastre_wms_url'], JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT) ?>,
{
layers: <?= json_encode($activeCountry['cadastre_wms_layer'], JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT) ?>,
format: 'image/png',
transparent: true,
opacity: 0.6,
attribution: <?= json_encode($activeCountry['cadastre_attribution_html'] ?? '', JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT) ?>
}
).addTo(map);
document.getElementById('toggleCadastre').addEventListener('change', function(){ if(this.checked){map.addLayer(cadastreLayer);}else{map.removeLayer(cadastreLayer);} });
<?php endif; ?>
const groupPoints = <?= json_encode($mapPoints, JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT) ?>;
function buildGroupPopup(point) {
const container = document.createElement('div');
const title = document.createElement('strong');
title.textContent = point.operator ?? '?';
container.appendChild(title);
container.appendChild(document.createElement('br'));
const count = document.createElement('span');
count.textContent = `${point.count} cellule(s)`;
container.appendChild(count);
if (point.has_dup) {
container.appendChild(document.createElement('br'));
const dup = document.createElement('span');
dup.textContent = 'doublon potentiel';
container.appendChild(dup);
}
return container;
}
groupPoints.forEach(g => {
const color = g.has_dup ? '#e0392f' : '#2f5fe0';
L.circleMarker([g.lat, g.lon], {radius:9, color, fillColor:color, fillOpacity:0.7, weight:2}).addTo(map)
.bindPopup(buildGroupPopup(g))
.on('click', () => document.getElementById('group-' + g.key)?.scrollIntoView({behavior:'smooth'}));
});
if (groupPoints.length > 0) map.fitBounds(L.latLngBounds(groupPoints.map(g => [g.lat, g.lon])), {padding:[30,30]});
</script>
</body>
</html>
+454
View File
@@ -0,0 +1,454 @@
<?php
/**
* admin/search_site.php
*
* Recherche d'une antenne par eNB/RNC, Cell ID, bande, frequence ou adresse.
* Affiche les cellules regroupees par site et les azimuts sur une carte Leaflet.
*
* Corrige suite a l'audit du 20/09/2026 : les popups Leaflet (marqueurs
* de site et secteurs d'azimut) construisaient du HTML par
* concatenation de template literals a partir de donnees issues de la
* base (operateur, pays, adresse, bande, frequence...). Un contenu
* malveillant stocke via import .ntm ou saisie manuelle pouvait donc
* s'executer comme JavaScript dans le navigateur de l'administrateur.
* Les popups utilisent maintenant des elements DOM avec textContent.
*/
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();
$query = trim((string)($_GET['q'] ?? ''));
$countryFilter = trim((string)($_GET['country'] ?? ''));
$operatorFilter = trim((string)($_GET['operator'] ?? ''));
$techFilter = trim((string)($_GET['tech'] ?? ''));
$selectedSiteId = (int)($_GET['site_id'] ?? 0);
$countries = $db->query('SELECT id, iso2, name FROM countries ORDER BY name')->fetchAll(PDO::FETCH_ASSOC);
$operators = $db->query('SELECT id, mcc, mnc, brand FROM operators ORDER BY brand')->fetchAll(PDO::FETCH_ASSOC);
$sites = [];
$selectedSite = null;
$selectedCells = [];
if ($query !== '' || $countryFilter !== '' || $operatorFilter !== '' || $techFilter !== '' || $selectedSiteId > 0) {
$sql = '
SELECT
s.id AS site_id,
s.lat,
s.lon,
s.location_desc,
s.address_desc,
s.country_id,
s.operator_id,
o.brand AS operator_brand,
o.mcc AS operator_mcc,
o.mnc AS operator_mnc,
c.name AS country_name,
cl.id AS cell_pk,
cl.mcc,
cl.mnc,
cl.tech,
cl.cell_id,
cl.area_code,
cl.sub_id,
cl.sub_id2,
cl.band,
cl.frequency,
cl.azimuth,
cl.bandwidth,
cl.arfcn
FROM sites s
JOIN operators o ON o.id = s.operator_id
JOIN countries c ON c.id = s.country_id
JOIN cells cl ON cl.site_id = s.id
WHERE 1=1
';
$params = [];
if ($query !== '') {
$sql .= '
AND (
cl.sub_id2 LIKE ?
OR cl.cell_id LIKE ?
OR cl.sub_id LIKE ?
OR cl.band LIKE ?
OR cl.frequency LIKE ?
OR s.location_desc LIKE ?
OR s.address_desc LIKE ?
)
';
$like = '%' . $query . '%';
array_push($params, $like, $like, $like, $like, $like, $like, $like);
}
if ($countryFilter !== '') {
$sql .= ' AND c.iso2 = ?';
$params[] = $countryFilter;
}
if ($operatorFilter !== '') {
$sql .= ' AND o.id = ?';
$params[] = (int)$operatorFilter;
}
if ($techFilter !== '') {
$sql .= ' AND cl.tech = ?';
$params[] = $techFilter;
}
if ($selectedSiteId > 0) {
$sql .= ' AND s.id = ?';
$params[] = $selectedSiteId;
}
$sql .= ' ORDER BY s.id, cl.sub_id2, cl.tech, cl.band, cl.frequency, cl.id LIMIT 1000';
$stmt = $db->prepare($sql);
$stmt->execute($params);
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
foreach ($rows as $row) {
$siteId = (int)$row['site_id'];
if (!isset($sites[$siteId])) {
$sites[$siteId] = [
'site_id' => $siteId,
'lat' => (float)$row['lat'],
'lon' => (float)$row['lon'],
'location_desc' => (string)($row['location_desc'] ?? ''),
'address_desc' => (string)($row['address_desc'] ?? ''),
'operator_brand' => (string)$row['operator_brand'],
'country_name' => (string)$row['country_name'],
'cell_count' => 0,
'cells' => [],
];
}
$cell = [
'cell_pk' => (int)$row['cell_pk'],
'mcc' => (string)($row['mcc'] ?? ''),
'mnc' => (string)($row['mnc'] ?? ''),
'tech' => (string)($row['tech'] ?? ''),
'cell_id' => (string)($row['cell_id'] ?? ''),
'area_code' => (string)($row['area_code'] ?? ''),
'sub_id' => (string)($row['sub_id'] ?? ''),
'sub_id2' => (string)($row['sub_id2'] ?? ''),
'band' => (string)($row['band'] ?? ''),
'frequency' => (string)($row['frequency'] ?? ''),
'azimuth' => $row['azimuth'] !== null && $row['azimuth'] !== '' ? (float)$row['azimuth'] : null,
'bandwidth' => (string)($row['bandwidth'] ?? ''),
'arfcn' => (string)($row['arfcn'] ?? ''),
];
$sites[$siteId]['cells'][] = $cell;
$sites[$siteId]['cell_count']++;
}
$sites = array_values($sites);
if ($selectedSiteId > 0 && isset($sites[0]) && (int)$sites[0]['site_id'] === $selectedSiteId) {
$selectedSite = $sites[0];
$selectedCells = $selectedSite['cells'];
} elseif (count($sites) === 1) {
$selectedSite = $sites[0];
$selectedCells = $selectedSite['cells'];
}
}
function h(mixed $value): string
{
return htmlspecialchars((string)$value, ENT_QUOTES, 'UTF-8');
}
function techColor(string $tech): string
{
return match (strtoupper($tech)) {
'2G' => '#eab308',
'3G' => '#2563eb',
'4G' => '#16a34a',
'5G' => '#9333ea',
'CDMA' => '#ea580c',
default => '#64748b',
};
}
$mapSites = $selectedSite ? [$selectedSite] : $sites;
?>
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>NetMonster Admin - Recherche antenne</title>
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" crossorigin>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js" crossorigin></script>
<link rel="stylesheet" href="assets/theme.css">
<style>
.search-layout { display:grid; grid-template-columns:minmax(300px, 38%) minmax(420px, 62%); gap:1rem; align-items:start; }
.search-card { margin-bottom:1rem; }
.search-form { display:grid; grid-template-columns:2fr 1fr 1fr 1fr auto; gap:.55rem; align-items:end; }
.search-form label { display:block; font-size:.78rem; margin-bottom:.25rem; color:var(--muted,#64748b); }
.search-form input, .search-form select { width:100%; box-sizing:border-box; }
.results-list { display:flex; flex-direction:column; gap:.6rem; max-height:650px; overflow:auto; }
.site-result { border:1px solid #dce2ea; border-radius:8px; padding:.75rem; background:#fff; cursor:pointer; transition:.15s; }
.site-result:hover, .site-result.selected { border-color:var(--primary); box-shadow:0 0 0 2px rgba(37,99,235,.12); }
.site-result h3 { margin:0 0 .3rem; font-size:1rem; }
.site-result p { margin:.2rem 0; font-size:.82rem; color:#526174; }
.site-result .meta { display:flex; gap:.4rem; flex-wrap:wrap; margin-top:.45rem; }
.badge { display:inline-block; padding:.16rem .42rem; border-radius:999px; color:#fff; font-size:.72rem; font-weight:600; }
.badge-neutral { background:#64748b; }
#searchMap { height:650px; min-height:420px; border-radius:var(--radius-sm); background:#e8eaf0; }
.map-card { position:sticky; top:1rem; }
.legend { display:flex; gap:.8rem; flex-wrap:wrap; margin-top:.65rem; font-size:.76rem; }
.legend span { display:inline-flex; align-items:center; gap:.25rem; }
.legend i { width:10px; height:10px; border-radius:50%; display:inline-block; }
.cells-card { margin-top:1rem; overflow:auto; }
.cells-card table { min-width:950px; }
.azimuth-cell { white-space:nowrap; font-weight:600; }
.empty-state { padding:1.2rem; text-align:center; color:#64748b; }
.site-actions { display:flex; gap:.5rem; margin-top:.65rem; flex-wrap:wrap; }
.popup-title { font-weight: 700; }
.popup-line { margin: 0; }
@media (max-width:1000px) { .search-layout { grid-template-columns:1fr; } .map-card { position:static; } .search-form { grid-template-columns:1fr 1fr; } #searchMap { height:480px; } }
@media (max-width:600px) { .search-form { grid-template-columns:1fr; } }
</style>
</head>
<body>
<?php render_admin_nav('search_site.php', $user); ?>
<div class="page-wrap">
<h1>Recherche antenne / eNB</h1>
<p class="page-subtitle">Recherchez un eNB, une cellule, une bande, une frequence ou une adresse. Les secteurs avec azimut sont dessines sur la carte.</p>
<div class="card search-card">
<form class="search-form" method="get">
<div>
<label for="q">Recherche</label>
<input id="q" type="search" name="q" value="<?= h($query) ?>" placeholder="eNB, Cell ID, bande, frequence, adresse..." autofocus>
</div>
<div>
<label for="country">Pays</label>
<select id="country" name="country">
<option value="">Tous les pays</option>
<?php foreach ($countries as $country): ?>
<option value="<?= h($country['iso2']) ?>" <?= $countryFilter === $country['iso2'] ? 'selected' : '' ?>><?= h($country['name']) ?></option>
<?php endforeach; ?>
</select>
</div>
<div>
<label for="operator">Operateur</label>
<select id="operator" name="operator">
<option value="">Tous les operateurs</option>
<?php foreach ($operators as $operator): ?>
<option value="<?= (int)$operator['id'] ?>" <?= $operatorFilter === (string)$operator['id'] ? 'selected' : '' ?>><?= h($operator['brand']) ?></option>
<?php endforeach; ?>
</select>
</div>
<div>
<label for="tech">Technologie</label>
<select id="tech" name="tech">
<option value="">Toutes</option>
<?php foreach (['2G','3G','4G','5G','CDMA'] as $tech): ?>
<option value="<?= $tech ?>" <?= $techFilter === $tech ? 'selected' : '' ?>><?= $tech ?></option>
<?php endforeach; ?>
</select>
</div>
<button type="submit" class="primary">Rechercher</button>
</form>
</div>
<div class="search-layout">
<div>
<div class="card">
<h2>Antennes trouvees (<?= count($sites) ?>)</h2>
<?php if (!$query && !$countryFilter && !$operatorFilter && !$techFilter): ?>
<div class="empty-state">Saisissez un eNB, un Cell ID, une bande ou une adresse.</div>
<?php elseif (empty($sites)): ?>
<div class="empty-state">Aucune antenne trouvee.</div>
<?php else: ?>
<div class="results-list">
<?php foreach ($sites as $site): ?>
<?php $isSelected = $selectedSite && (int)$selectedSite['site_id'] === (int)$site['site_id']; ?>
<a class="site-result <?= $isSelected ? 'selected' : '' ?>" href="?<?= h(http_build_query(['q' => $query, 'country' => $countryFilter, 'operator' => $operatorFilter, 'tech' => $techFilter, 'site_id' => $site['site_id']])) ?>">
<h3><?= h($site['operator_brand']) ?> — <?= h($site['country_name']) ?></h3>
<p><strong>eNB/RNC :</strong> <?= h($site['cells'][0]['sub_id2'] ?: 'non renseigne') ?></p>
<p><?= h($site['address_desc'] ?: $site['location_desc'] ?: 'Adresse non renseignee') ?></p>
<p><strong><?= (int)$site['cell_count'] ?></strong> cellule(s) — <?= h((string)$site['lat']) ?>, <?= h((string)$site['lon']) ?></p>
<div class="meta">
<?php foreach (array_values(array_unique(array_filter(array_map(static fn(array $cell): string => $cell['tech'], $site['cells'])))) as $tech): ?>
<span class="badge" style="background:<?= h(techColor($tech)) ?>"><?= h($tech) ?></span>
<?php endforeach; ?>
<?php $azimuthCount = count(array_filter($site['cells'], static fn(array $cell): bool => $cell['azimuth'] !== null)); ?>
<span class="badge badge-neutral"><?= $azimuthCount ?> azimut(s)</span>
</div>
</a>
<?php endforeach; ?>
</div>
<?php endif; ?>
</div>
<?php if ($selectedSite): ?>
<div class="card cells-card">
<h2>Cellules de l'antenne</h2>
<p class="page-subtitle"><?= h($selectedSite['operator_brand']) ?> — <?= h($selectedSite['address_desc'] ?: $selectedSite['location_desc']) ?></p>
<div class="site-actions"><a class="primary" href="edit_site.php?site_id=<?= (int)$selectedSite['site_id'] ?>">Modifier cette antenne</a></div>
<table class="data-table">
<tr><th>Techno</th><th>Cell ID</th><th>eNB/RNC</th><th>Bande</th><th>Frequence</th><th>Largeur</th><th>Azimut</th><th>ARFCN</th></tr>
<?php foreach ($selectedCells as $cell): ?>
<tr>
<td><span class="badge" style="background:<?= h(techColor($cell['tech'])) ?>"><?= h($cell['tech']) ?></span></td>
<td><?= h($cell['cell_id']) ?></td>
<td><?= h($cell['sub_id2']) ?></td>
<td><?= h($cell['band']) ?></td>
<td><?= h($cell['frequency']) ?></td>
<td><?= h($cell['bandwidth']) ?></td>
<td class="azimuth-cell"><?= $cell['azimuth'] === null ? '—' : h(rtrim(rtrim(number_format((float)$cell['azimuth'], 2, '.', ''), '0'), '.')) . '°' ?></td>
<td><?= h($cell['arfcn']) ?></td>
</tr>
<?php endforeach; ?>
</table>
</div>
<?php endif; ?>
</div>
<div class="card map-card">
<h2>Carte des azimuts</h2>
<div id="searchMap"></div>
<div class="legend">
<?php foreach (['2G','3G','4G','5G','CDMA'] as $tech): ?><span><i style="background:<?= h(techColor($tech)) ?>"></i><?= $tech ?></span><?php endforeach; ?>
</div>
</div>
</div>
</div>
<script>
const mapSites = <?= json_encode($mapSites, JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_NUMERIC_CHECK) ?>;
const techColors = { '2G': '#eab308', '3G': '#2563eb', '4G': '#16a34a', '5G': '#9333ea', 'CDMA': '#ea580c' };
function destinationPoint(lat, lon, bearing, distanceMeters) {
const radius = 6371000;
const angularDistance = distanceMeters / radius;
const bearingRad = bearing * Math.PI / 180;
const latRad = lat * Math.PI / 180;
const lonRad = lon * Math.PI / 180;
const destLat = Math.asin(Math.sin(latRad) * Math.cos(angularDistance) + Math.cos(latRad) * Math.sin(angularDistance) * Math.cos(bearingRad));
const destLon = lonRad + Math.atan2(Math.sin(bearingRad) * Math.sin(angularDistance) * Math.cos(latRad), Math.cos(angularDistance) - Math.sin(latRad) * Math.sin(destLat));
return [destLat * 180 / Math.PI, destLon * 180 / Math.PI];
}
function sectorPoints(lat, lon, azimuth, radius, opening) {
const points = [[lat, lon]];
const start = azimuth - opening / 2;
const end = azimuth + opening / 2;
for (let angle = start; angle <= end; angle += 4) {
points.push(destinationPoint(lat, lon, angle, radius));
}
points.push(destinationPoint(lat, lon, end, radius));
points.push([lat, lon]);
return points;
}
/**
* Construit la popup d'un site via des elements DOM (textContent),
* jamais via innerHTML/template literal, pour empecher toute
* injection HTML/JS a partir de donnees stockees en base.
*/
function buildSitePopup(site) {
const container = document.createElement('div');
const title = document.createElement('div');
title.className = 'popup-title';
title.textContent = `${site.operator_brand ?? ''} — ${site.country_name ?? ''}`;
container.appendChild(title);
const addr = document.createElement('div');
addr.className = 'popup-line';
addr.textContent = site.address_desc || site.location_desc || 'Adresse non renseignee';
container.appendChild(addr);
const count = document.createElement('div');
count.className = 'popup-line';
count.textContent = `${site.cell_count} cellule(s)`;
container.appendChild(count);
return container;
}
/**
* Construit la popup d'un secteur (polygone d'azimut) via des
* elements DOM (textContent), meme logique que buildSitePopup.
*/
function buildSectorPopup(cell) {
const container = document.createElement('div');
const title = document.createElement('div');
title.className = 'popup-title';
title.textContent = `${cell.tech || ''} ${cell.band || ''}`.trim();
container.appendChild(title);
const lines = [
`Cell ID : ${cell.cell_id || ''}`,
`eNB/RNC : ${cell.sub_id2 || ''}`,
`Frequence : ${cell.frequency || ''}`,
`Largeur : ${cell.bandwidth || ''}`,
`Azimut : ${cell.azimuth}°`,
];
lines.forEach(text => {
const line = document.createElement('div');
line.className = 'popup-line';
line.textContent = text;
container.appendChild(line);
});
return container;
}
const mapElement = document.getElementById('searchMap');
const defaultCenter = [49.8153, 6.1296];
let mapCenter = defaultCenter;
let mapZoom = 8;
if (mapSites.length > 0) {
mapCenter = [Number(mapSites[0].lat), Number(mapSites[0].lon)];
mapZoom = mapSites.length === 1 ? 16 : 10;
}
const searchMap = L.map(mapElement).setView(mapCenter, mapZoom);
L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
maxZoom: 19,
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright" target="_blank">OpenStreetMap</a> contributors'
}).addTo(searchMap);
const bounds = [];
mapSites.forEach(site => {
const lat = Number(site.lat);
const lon = Number(site.lon);
if (!Number.isFinite(lat) || !Number.isFinite(lon)) return;
const marker = L.marker([lat, lon]).addTo(searchMap);
marker.bindPopup(buildSitePopup(site));
bounds.push([lat, lon]);
(site.cells || []).forEach(cell => {
if (cell.azimuth === null || cell.azimuth === '' || !Number.isFinite(Number(cell.azimuth))) return;
const azimuth = Number(cell.azimuth);
const sectorColor = techColors[String(cell.tech).toUpperCase()] || '#64748b';
const polygon = L.polygon(sectorPoints(lat, lon, azimuth, 250, 65), { color: sectorColor, fillColor: sectorColor, fillOpacity: 0.28, weight: 2 }).addTo(searchMap);
polygon.bindPopup(buildSectorPopup(cell));
bounds.push(...sectorPoints(lat, lon, azimuth, 250, 65));
});
});
if (bounds.length > 1) searchMap.fitBounds(bounds, { padding: [25, 25] });
setTimeout(() => searchMap.invalidateSize(), 100);
</script>
</body>
</html>