Afficher l'IP client (x-forwarded-for) dans les logs des requêtes API
This commit is contained in:
@ -37,7 +37,7 @@
|
||||
### Module structure & data flow
|
||||
- `cli.py` → `pipeline.py` (LidarArchaeoPipeline) → per-file: `dtm.py` (classify + rasterize) → `visualizations.py` (17 products) → `rendering.py` (GeoTIFF→AVIF) → `index.py` (Leaflet map)
|
||||
- `gpu.py` provides CuPy/NumPy proxy (`xp`), lazy init, OOM fallback. `safe_gpu_call` wraps all non-IGN viz calls.
|
||||
- `webapp.py` (FastAPI) serves the map + `/api/generate` launches the pipeline as a subprocess. Two-machine mode: `LIDAR_GENERATION_URL` delegates to remote worker. `/api/export` assembles adjacent tiles into an image/PDF via `export.py` (local cache, no delegation).
|
||||
- `webapp.py` (FastAPI) serves the map + `/api/generate` launches the pipeline as a subprocess. Two-machine mode: `LIDAR_GENERATION_URL` delegates to remote worker. `/api/export` assembles adjacent tiles into an image/PDF via `export.py` (local cache, no delegation). `/api/presets` (GET/POST/DELETE) shares layer presets between browsers (`output/.presets.json`, localStorage echo in the interface as fallback).
|
||||
- `progress.py` writes JSONL events (O_APPEND, atomic) read by webapp for live progress.
|
||||
- `export.py` stitches adjacent tile visualizations into a seamless mosaic (PNG/JPEG/WebP) or multi-page PDF, Pillow-only for the lightweight webapp.
|
||||
|
||||
|
||||
@ -1389,6 +1389,11 @@ const STATS = {stats_json};
|
||||
_APP_CSS = """/* Interface de la carte LiDAR — barre latérale à onglets type SIG. */
|
||||
|
||||
:root {
|
||||
/* Le color-scheme impose au navigateur le rendu sombre des contrôles
|
||||
natifs (popup des <select>, barres de défilement) : sans cela, le
|
||||
menu déroulant des préconfigurations s'affiche en thème clair sur
|
||||
une interface sombre. */
|
||||
color-scheme: dark;
|
||||
--bg: #0b0e13;
|
||||
--panel: rgba(16, 20, 27, 0.93);
|
||||
--panel-border: rgba(255, 255, 255, 0.09);
|
||||
@ -1465,6 +1470,10 @@ select, input[type="checkbox"] { accent-color: var(--accent); }
|
||||
border: 1px solid var(--panel-border); border-radius: 6px;
|
||||
padding: 3px 6px; font-size: 12px; cursor: pointer;
|
||||
}
|
||||
.gen-select:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||
/* Options du menu déroulant : fallback pour les navigateurs sans
|
||||
color-scheme (le popup natif hérite de ces couleurs). */
|
||||
.gen-select option { background-color: #10141b; color: var(--text); }
|
||||
|
||||
/* --- Barre latérale (gauche, pleine hauteur) --- */
|
||||
#sidebar {
|
||||
@ -2246,10 +2255,49 @@ document.getElementById('osmRow').querySelector('.layer-name').title =
|
||||
// visibles, leurs niveaux de transparence (opacités des
|
||||
// visualisations + fond de carte OSM) et l'ordre de pile
|
||||
// (bas → haut). Les presets intégrés couvrent les couches du
|
||||
// panneau ; les presets personnels (persistés en localStorage)
|
||||
// se suppriment depuis la liste.
|
||||
// panneau ; les presets personnels sont stockés sur le serveur
|
||||
// (/api/presets, partagés entre tous les navigateurs connectés)
|
||||
// et en écho dans le localStorage du navigateur (repli si l'interface
|
||||
// est servie sans webapp).
|
||||
// ============================================================
|
||||
const PRESETS_LS = 'lidarPresets_v1';
|
||||
const PRESETS_URL = '/api/presets';
|
||||
// Écho local des presets : s'il n'y a pas de webapp (serveur statique),
|
||||
// ils ne vivent que dans le localStorage du navigateur.
|
||||
let customPresets = (() => {
|
||||
try {
|
||||
const p = JSON.parse(localStorage.getItem(PRESETS_LS) || '[]');
|
||||
return Array.isArray(p) ? p : [];
|
||||
} catch (e) { return []; }
|
||||
})();
|
||||
function persistCustomPresets() {
|
||||
try { localStorage.setItem(PRESETS_LS, JSON.stringify(customPresets)); } catch (e) { /* privé */ }
|
||||
}
|
||||
|
||||
// Les presets stockés sur le serveur sont partagés entre tous les
|
||||
// navigateurs connectés à la webapp ; à l'arrivée, fusion avec ceux
|
||||
// déjà en local (même id = la version serveur l'emporte). Webapp
|
||||
// injoignable : la liste locale est conservée telle quelle.
|
||||
function upsertCustomPreset(p) {
|
||||
const i = customPresets.findIndex(x => x.id === p.id);
|
||||
if (i === -1) customPresets.push(p); else customPresets[i] = p;
|
||||
}
|
||||
function removeCustomPreset(id) {
|
||||
customPresets = customPresets.filter(p => p.id !== id);
|
||||
}
|
||||
async function syncCustomPresets() {
|
||||
try {
|
||||
const r = await fetch(PRESETS_URL, { cache: 'no-store' });
|
||||
if (!r.ok) return;
|
||||
const data = await r.json();
|
||||
const server = Array.isArray(data.presets) ? data.presets : [];
|
||||
const byId = new Map(customPresets.map(p => [p.id, p]));
|
||||
for (const p of server) if (p && p.id) byId.set(p.id, p);
|
||||
customPresets = [...byId.values()];
|
||||
persistCustomPresets();
|
||||
renderPresetSelect(presetSelectEl.value);
|
||||
} catch (e) { /* pas de webapp : presets locaux uniquement */ }
|
||||
}
|
||||
// Les presets portent les niveaux de transparence : opacité de chaque
|
||||
// visualisation + fond de carte OSM (visibilité, opacité, mode sombre).
|
||||
const BUILTIN_PRESETS = [
|
||||
@ -2270,16 +2318,6 @@ const BUILTIN_PRESETS = [
|
||||
osm: { on: true, opacity: 0.85, dark: true } },
|
||||
];
|
||||
|
||||
function loadCustomPresets() {
|
||||
try {
|
||||
const p = JSON.parse(localStorage.getItem(PRESETS_LS) || '[]');
|
||||
return Array.isArray(p) ? p : [];
|
||||
} catch (e) { return []; }
|
||||
}
|
||||
function saveCustomPresets(list) {
|
||||
try { localStorage.setItem(PRESETS_LS, JSON.stringify(list)); } catch (e) { /* privé */ }
|
||||
}
|
||||
|
||||
// Niveaux de transparence d'un preset, lisibles dans la liste
|
||||
// (opacité de chaque visualisation + fond de carte OSM).
|
||||
function presetDetail(p) {
|
||||
@ -2331,7 +2369,7 @@ function renderPresetSelect(selectedValue) {
|
||||
o.textContent = p.label + ' — ' + presetDetail(p);
|
||||
presetSelectEl.appendChild(o);
|
||||
}
|
||||
const customs = loadCustomPresets();
|
||||
const customs = customPresets;
|
||||
if (customs.length) {
|
||||
const g = document.createElement('optgroup');
|
||||
g.label = 'Mes presets';
|
||||
@ -2352,7 +2390,7 @@ presetSelectEl.addEventListener('change', () => {
|
||||
if (!v) return;
|
||||
let p = null;
|
||||
if (v.startsWith('builtin:')) p = BUILTIN_PRESETS.find(x => x.label === v.slice(8));
|
||||
else if (v.startsWith('custom:')) p = loadCustomPresets().find(x => x.id === v.slice(7));
|
||||
else if (v.startsWith('custom:')) p = customPresets.find(x => x.id === v.slice(7));
|
||||
if (p) applyPreset(p);
|
||||
});
|
||||
|
||||
@ -2369,23 +2407,34 @@ presetSaveEl.addEventListener('click', () => {
|
||||
// Niveaux de transparence du fond de carte
|
||||
osm: { on: osm.on, opacity: osm.opacity, dark: osm.dark },
|
||||
};
|
||||
const customs = loadCustomPresets();
|
||||
customs.push(p);
|
||||
saveCustomPresets(customs);
|
||||
upsertCustomPreset(p);
|
||||
persistCustomPresets();
|
||||
renderPresetSelect('custom:' + p.id);
|
||||
// Persistance serveur (partagée entre navigateurs) ; en échec
|
||||
// (pas de webapp), le preset reste dans le stockage local.
|
||||
fetch(PRESETS_URL, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(p),
|
||||
}).then(r => {
|
||||
if (!r.ok) console.warn('Preset non enregistré sur le serveur : ' + r.status);
|
||||
}).catch(() => {});
|
||||
});
|
||||
|
||||
presetDeleteEl.addEventListener('click', () => {
|
||||
const v = presetSelectEl.value;
|
||||
if (!v.startsWith('custom:')) return;
|
||||
if (!confirm('Supprimer ce preset ?')) return;
|
||||
saveCustomPresets(loadCustomPresets().filter(p => p.id !== v.slice(7)));
|
||||
const id = v.slice(7);
|
||||
removeCustomPreset(id);
|
||||
persistCustomPresets();
|
||||
renderPresetSelect('');
|
||||
fetch(PRESETS_URL + '/' + encodeURIComponent(id), { method: 'DELETE' })
|
||||
.catch(() => {});
|
||||
});
|
||||
|
||||
renderPresetSelect();
|
||||
|
||||
// Barre latérale : repli + onglets (Couches / Génération)
|
||||
syncCustomPresets();
|
||||
const sidebar = document.getElementById('sidebar');
|
||||
function setSidebarCollapsed(collapsed) {
|
||||
sidebar.classList.toggle('collapsed', collapsed);
|
||||
|
||||
@ -111,6 +111,45 @@ def test_preview_rejects_unknown_viz():
|
||||
assert "inconnu" in e.detail
|
||||
|
||||
|
||||
def test_preset_sanitize_and_upsert(tmp_path, monkeypatch):
|
||||
"""Presets de couches : valeurs assainies, remplacées par identifiant."""
|
||||
import lidar_pipeline.webapp as webapp
|
||||
monkeypatch.setattr(webapp, "PRESETS_FILE", tmp_path / ".presets.json")
|
||||
req = webapp.PresetRequest(id="Relief v2!", label=" Reliefs ",
|
||||
on=["slope", "aspect"],
|
||||
opacity={"slope": 0.5, "aspect": "0.8", "lrm": "x"},
|
||||
osm={"on": True, "opacity": 1.5, "dark": True})
|
||||
p = webapp._sanitize_preset(req)
|
||||
assert p["id"] == "reliefv2"
|
||||
assert p["label"] == "Reliefs"
|
||||
assert p["opacity"] == {"slope": 0.5, "aspect": 0.8}
|
||||
assert p["osm"] == {"on": True, "opacity": 1.0, "dark": True}
|
||||
webapp.upsert_preset(req)
|
||||
assert [x["label"] for x in webapp.list_presets()["presets"]] == ["Reliefs"]
|
||||
# Même identifiant : remplacement, pas de doublon
|
||||
webapp.upsert_preset(webapp.PresetRequest(id="reliefv2", label="Reliefs v2",
|
||||
on=["slope"], opacity={}))
|
||||
got = webapp.list_presets()["presets"]
|
||||
assert len(got) == 1 and got[0]["label"] == "Reliefs v2"
|
||||
# Suppression inconnue : 404
|
||||
from fastapi import HTTPException
|
||||
webapp.delete_preset(got[0]["id"])
|
||||
assert webapp.list_presets()["presets"] == []
|
||||
try:
|
||||
webapp.delete_preset("reliefv2")
|
||||
assert False, "une HTTPException était attendue"
|
||||
except HTTPException as e:
|
||||
assert e.status_code == 404
|
||||
|
||||
|
||||
def test_preset_file_corrupted_is_ignored(tmp_path, monkeypatch):
|
||||
"""Un fichier de presets corrompu est ignoré (liste vide, pas d'exception)."""
|
||||
import lidar_pipeline.webapp as webapp
|
||||
monkeypatch.setattr(webapp, "PRESETS_FILE", tmp_path / ".presets.json")
|
||||
webapp.PRESETS_FILE.write_text("{pas du json", encoding="utf-8")
|
||||
assert webapp._load_presets() == []
|
||||
|
||||
|
||||
def test_build_command_resolutions_match_completeness():
|
||||
"""La commande -r et la détection de complétude utilisent les mêmes résolutions."""
|
||||
from lidar_pipeline.webapp import GENERATE_RESOLUTIONS, _build_command
|
||||
|
||||
@ -37,6 +37,12 @@ le téléchargement IGN). Endpoints :
|
||||
fichier en téléchargement. Marche aussi sur la webapp
|
||||
légère : les tuiles viennent du cache local, aucune
|
||||
délégation à la machine de traitement.
|
||||
GET /api/presets → presets de couches personnalisés (couches visibles,
|
||||
opacités, ordre, fond OSM) stockés sur le serveur et
|
||||
partagés par tous les navigateurs connectés
|
||||
POST /api/presets → créer/remplacer un preset de couches (même identifiant
|
||||
= remplacement)
|
||||
DELETE /api/presets/{id} → supprimer un preset de couches
|
||||
|
||||
Fichiers statiques : /assets (interface), /index_thumbs, /index_subtiles,
|
||||
/visualisations, /DTM.
|
||||
@ -128,6 +134,17 @@ else:
|
||||
logger.info("Backend de génération des tuiles : local — consultation "
|
||||
"autonome du cache, sans LIDAR_GENERATION_URL")
|
||||
|
||||
@app.middleware("http")
|
||||
async def _log_client_ip(request: Request, call_next):
|
||||
xff = request.headers.get("x-forwarded-for", "").split(",")[0].strip()
|
||||
peer = request.client.host if request.client else None
|
||||
ip = xff or peer
|
||||
path = request.url.path
|
||||
# Ne logguer que les requêtes API (pas les fichiers statiques)
|
||||
if path.startswith("/api/") or path == "/":
|
||||
logger.info(f"{request.method} {path} — client : {ip or '?'}")
|
||||
return await call_next(request)
|
||||
|
||||
# assets/ (CSS/JS de l'interface) est créé dès le démarrage pour que le monteur
|
||||
# statique soit actif même avant la première génération de l'index.
|
||||
_assets_dir = OUTPUT_DIR / "assets"
|
||||
@ -524,6 +541,99 @@ def _load_job_state():
|
||||
_load_job_state()
|
||||
|
||||
|
||||
# --- Presets de couches (partagés entre navigateurs) --------------------------
|
||||
# Stockés dans OUTPUT_DIR/.presets.json : un preset est un jeu nommé de
|
||||
# couches visibles + opacités + ordre de pile + fond OSM, créé depuis
|
||||
# l'interface (bouton 💾) et partagé par tous les navigateurs connectés à
|
||||
# cette webapp (y compris le Pi en mode deux machines). Le localStorage du
|
||||
# navigateur reste le repli quand l'interface est servie sans webapp.
|
||||
PRESETS_FILE = OUTPUT_DIR / ".presets.json"
|
||||
_presets_lock = threading.Lock()
|
||||
|
||||
|
||||
def _load_presets():
|
||||
"""Lis la liste des presets depuis disque (vide si absent/corrompu)."""
|
||||
try:
|
||||
data = json.loads(PRESETS_FILE.read_text(encoding="utf-8"))
|
||||
if isinstance(data, list):
|
||||
return data
|
||||
except (OSError, ValueError):
|
||||
pass
|
||||
return []
|
||||
|
||||
|
||||
def _save_presets(presets):
|
||||
"""Écrit la liste des presets atomiquement (fichier temporaire + replace)."""
|
||||
tmp = PRESETS_FILE.with_suffix(".tmp")
|
||||
tmp.write_text(json.dumps(presets, ensure_ascii=False), encoding="utf-8")
|
||||
os.replace(tmp, PRESETS_FILE)
|
||||
|
||||
|
||||
class PresetRequest(BaseModel):
|
||||
id: str = Field(..., description="identifiant du preset (attribué par l'interface)")
|
||||
label: str = Field(..., description="nom affiché dans l'interface")
|
||||
on: list = Field(default_factory=list, description="couches visibles (clés, ordre de pile)")
|
||||
opacity: dict = Field(default_factory=dict, description="opacité de chaque couche, 0–1")
|
||||
osm: dict = Field(default_factory=dict, description="fond de carte OSM : on, opacity, dark")
|
||||
|
||||
|
||||
def _sanitize_preset(req: PresetRequest) -> dict:
|
||||
"""Preset à stocker : label assaini, opacités bornées entre 0 et 1."""
|
||||
on = [str(k) for k in (req.on or []) if str(k).strip()]
|
||||
opacity = {}
|
||||
for k, v in (req.opacity or {}).items():
|
||||
try:
|
||||
opacity[str(k)] = min(1.0, max(0.0, float(v)))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
osm_in = req.osm if isinstance(req.osm, dict) else {}
|
||||
osm = {"on": bool(osm_in.get("on", False))}
|
||||
try:
|
||||
osm["opacity"] = min(1.0, max(0.0, float(osm_in["opacity"])))
|
||||
except (KeyError, TypeError, ValueError):
|
||||
pass
|
||||
if "dark" in osm_in:
|
||||
osm["dark"] = bool(osm_in["dark"])
|
||||
pid = re.sub(r"[^a-z0-9_-]", "", str(req.id).lower())
|
||||
return {
|
||||
"id": pid or "p" + str(int(time.time() * 1000)),
|
||||
"label": (str(req.label).strip() or "Preset")[:80],
|
||||
"on": on,
|
||||
"opacity": opacity,
|
||||
"osm": osm,
|
||||
}
|
||||
|
||||
|
||||
@app.get("/api/presets")
|
||||
def list_presets():
|
||||
"""Presets de couches personnalisés (partagés entre tous les navigateurs)."""
|
||||
with _presets_lock:
|
||||
return {"presets": _load_presets()}
|
||||
|
||||
|
||||
@app.post("/api/presets", dependencies=[Depends(_require_token)])
|
||||
def upsert_preset(req: PresetRequest):
|
||||
"""Crée ou remplace un preset de couches (même identifiant = remplacement)."""
|
||||
p = _sanitize_preset(req)
|
||||
with _presets_lock:
|
||||
presets = [x for x in _load_presets() if x.get("id") != p["id"]]
|
||||
presets.append(p)
|
||||
_save_presets(presets)
|
||||
return p
|
||||
|
||||
|
||||
@app.delete("/api/presets/{preset_id}", dependencies=[Depends(_require_token)])
|
||||
def delete_preset(preset_id: str):
|
||||
"""Supprime un preset de couches (404 s'il est inconnu)."""
|
||||
with _presets_lock:
|
||||
presets = _load_presets()
|
||||
kept = [x for x in presets if x.get("id") != preset_id]
|
||||
if len(kept) == len(presets):
|
||||
raise HTTPException(404, "preset inconnu")
|
||||
_save_presets(kept)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
def bbox_to_cells(w, s, e, n):
|
||||
"""Cellules L93 de 1 km (col, row) intersectant une bbox WGS84.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user