Add Leaflet interactive map, tile generator compose, auto-sync cache, deploy docs
index.py rewritten as a continuous Leaflet map: rotated L93 tiles, stackable visualization layers (per-layer opacity, drag-reorder persisted in localStorage), tile info panel, live rebuild after each tile during a run. Leaflet is vendored in assets/vendor/ so the map works fully offline; georeferencing falls back rasterio -> pyproj -> affine so the lightweight webapp (no GDAL) is supported. docker-compose.worker.yml adds the tile generator service (full image + GPU) that remote webapps call via LIDAR_GENERATION_URL, plus a one-shot process profile. webapp.py gains LIDAR_AUTO_SYNC_SECONDS periodic cache refresh and LIDAR_REGEN_CIDR restricting generation to the local network. run.sh --serve-webapp now mounts ~/.ssh read-only so the rsync sync works. docs/DEPLOY_WEBAPP.md completed for Raspberry Pi deployment: prerequisites, git clone install, SSH key setup, first sync, update procedure and troubleshooting.
This commit is contained in:
@ -41,6 +41,7 @@ carte localement. LIDAR_API_TOKEN (machine de traitement) + LIDAR_REMOTE_TOKEN
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import math
|
||||
import os
|
||||
import subprocess
|
||||
@ -51,7 +52,7 @@ from pathlib import Path
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import Depends, FastAPI, Header, HTTPException
|
||||
from fastapi import Depends, FastAPI, Header, HTTPException, Request
|
||||
from fastapi.responses import FileResponse, JSONResponse
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
@ -73,6 +74,17 @@ API_TOKEN = os.environ.get("LIDAR_API_TOKEN") or None
|
||||
# Commande shell qui ramène les tuiles traitées dans output/ local (rsync
|
||||
# depuis la machine de traitement). Exécutée par /api/sync avant le rebuild.
|
||||
SYNC_CMD = os.environ.get("LIDAR_SYNC_CMD") or None
|
||||
# Réseau autorisé à lancer la (re)génération des tuiles (/api/generate) :
|
||||
# CIDR en notation standard, vide = restriction levée. L'IP du client est
|
||||
# l'IP de connexion (conservée par le DNAT Docker) ; si elle est dans le
|
||||
# réseau autorisé, X-Forwarded-For est honoré (reverse proxy local qui
|
||||
# expose la carte au-dehors — un client distant ne peut pas le forger
|
||||
# puisque sa connexion directe est déjà hors réseau).
|
||||
REGEN_CIDR = (os.environ.get("LIDAR_REGEN_CIDR", "192.168.1.0/24") or "").strip()
|
||||
# Cache local périodique : relance la synchronisation + le rebuild toutes
|
||||
# les N secondes (0 = désactivé). Sert au conteneur webapp seul, qui maintient
|
||||
# sa copie des tuiles de la machine de traitement.
|
||||
AUTO_SYNC_SECONDS = float(os.environ.get("LIDAR_AUTO_SYNC_SECONDS", "0") or 0)
|
||||
|
||||
# Résolutions générées par /api/generate (cf. _build_command). La détection des
|
||||
# tuiles à compléter exige ces mêmes résolutions : une tuile n'est complète
|
||||
@ -81,6 +93,27 @@ GENERATE_RESOLUTIONS = (0.5, 0.2)
|
||||
|
||||
app = FastAPI(title="Carte LiDAR — génération de zones")
|
||||
|
||||
# Visibilité de la configuration au démarrage (docker compose logs) : le
|
||||
# backend de génération est TOUJOURS défini par variable d'environnement.
|
||||
logger = logging.getLogger("lidar")
|
||||
if not logger.handlers:
|
||||
# Hors cli.py (uvicorn seul), le logger 'lidar' n'a aucun handler — en
|
||||
# ajouter un pour que la config soit visible dans les logs du conteneur.
|
||||
_h = logging.StreamHandler(sys.stdout)
|
||||
_h.setFormatter(logging.Formatter("%(message)s"))
|
||||
logger.addHandler(_h)
|
||||
logger.setLevel(logging.INFO)
|
||||
if GENERATION_URL:
|
||||
logger.info(f"Backend de génération des tuiles : {GENERATION_URL} "
|
||||
f"(LIDAR_GENERATION_URL)")
|
||||
else:
|
||||
logger.info("Backend de génération des tuiles : local — consultation "
|
||||
"autonome du cache, sans LIDAR_GENERATION_URL")
|
||||
if SYNC_CMD:
|
||||
logger.info(f"Cache local des tuiles : LIDAR_SYNC_CMD"
|
||||
+ (f" (rafraîchi toutes les {AUTO_SYNC_SECONDS:g}s)"
|
||||
if AUTO_SYNC_SECONDS > 0 else " (à la demande)"))
|
||||
|
||||
# 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"
|
||||
@ -116,6 +149,51 @@ def _require_token(x_lidar_token: str = Header(None)):
|
||||
raise HTTPException(401, "token d'API manquant ou invalide")
|
||||
|
||||
|
||||
def _ip_in_regen_cidr(ip):
|
||||
"""Vrai si l'IP appartient au CIDR autorisé (LIDAR_REGEN_CIDR)."""
|
||||
if not REGEN_CIDR:
|
||||
return True # restriction désactivée
|
||||
if not ip:
|
||||
return False
|
||||
import ipaddress
|
||||
try:
|
||||
return ipaddress.ip_address(ip) in ipaddress.ip_network(REGEN_CIDR, strict=False)
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
|
||||
def _client_ip(request):
|
||||
"""IP d'origine du client pour la restriction de génération.
|
||||
|
||||
Le pair direct doit être dans le réseau autorisé ; dans ce cas seulement,
|
||||
X-Forwarded-For (reverse proxy local) désigne le client réel derrière lui.
|
||||
Un client externe connecté en direct est refusé sur son IP de connexion,
|
||||
quel que soit le X-Forwarded-For qu'il annonce.
|
||||
"""
|
||||
if request is None:
|
||||
return None
|
||||
peer = getattr(request, "client", None)
|
||||
peer_ip = getattr(peer, "host", None) if peer else None
|
||||
if not _ip_in_regen_cidr(peer_ip):
|
||||
return peer_ip # hors réseau (ou inconnu) : jugé sur cette IP
|
||||
xff = request.headers.get("x-forwarded-for") if hasattr(request, "headers") else None
|
||||
if xff:
|
||||
first = xff.split(",")[0].strip()
|
||||
if first:
|
||||
return first
|
||||
return peer_ip
|
||||
|
||||
|
||||
def _require_lan_for_generation(request: Request):
|
||||
"""Réserve /api/generate aux clients du réseau local (LIDAR_REGEN_CIDR)."""
|
||||
ip = _client_ip(request)
|
||||
if not _ip_in_regen_cidr(ip):
|
||||
raise HTTPException(
|
||||
403, f"génération de tuiles réservée au réseau local"
|
||||
f"{f' ({REGEN_CIDR})' if REGEN_CIDR else ''} — "
|
||||
f"client : {ip or 'IP inconnue'}")
|
||||
|
||||
|
||||
def _proxy_api(method, path, payload=None, timeout=30):
|
||||
"""Transmet un appel d'API à la machine de traitement (mode webapp légère).
|
||||
|
||||
@ -365,11 +443,15 @@ def root():
|
||||
|
||||
|
||||
@app.get("/api/status")
|
||||
def status():
|
||||
def status(request: Request = None):
|
||||
# Droit de lancer une génération depuis cette IP (guide l'interface)
|
||||
regen_allowed = _ip_in_regen_cidr(_client_ip(request))
|
||||
if GENERATION_URL:
|
||||
# Webapp légère : l'état vient de la machine de traitement
|
||||
# Webapp légère : l'état vient de la machine de traitement ; le droit
|
||||
# de génération reste jugé sur l'IP du navigateur (localement).
|
||||
data = _proxy_api("GET", "/api/status", timeout=10)
|
||||
data["distant"] = True
|
||||
data["regen_allowed"] = regen_allowed
|
||||
return data
|
||||
from .progress import progress_snapshot
|
||||
with _job_lock:
|
||||
@ -381,6 +463,8 @@ def status():
|
||||
"returncode": _job["returncode"],
|
||||
"cmd": _job["cmd"],
|
||||
"log": _tail_log(40),
|
||||
# L'interface masque les boutons de génération hors réseau local
|
||||
"regen_allowed": regen_allowed,
|
||||
# Tuiles une par une (nom court, type, état) — agrégées depuis les
|
||||
# événements écrits par le pipeline (.generation.events.jsonl)
|
||||
"tiles": progress_snapshot(OUTPUT_DIR, viz_labels=_viz_step_labels()),
|
||||
@ -475,6 +559,36 @@ def _start_rebuild(with_sync):
|
||||
return {"demarré": True, "sync": bool(with_sync and SYNC_CMD)}
|
||||
|
||||
|
||||
def _auto_sync_once():
|
||||
"""Un cycle de cache local : synchronisation + rebuild, si rien ne tourne.
|
||||
|
||||
Retourne True si un cycle a été lancé, False si un rebuild était déjà en
|
||||
cours (le prochain cycle rattrapera).
|
||||
"""
|
||||
if _rebuild["running"]:
|
||||
return False
|
||||
try:
|
||||
_start_rebuild(with_sync=True)
|
||||
return True
|
||||
except HTTPException:
|
||||
return False # course perdue contre un lancement manuel : tant pis
|
||||
|
||||
|
||||
def _auto_sync_loop():
|
||||
"""Boucle du cache local : synchronise les tuiles périodiquement."""
|
||||
while True:
|
||||
time.sleep(AUTO_SYNC_SECONDS)
|
||||
_auto_sync_once()
|
||||
|
||||
|
||||
# Conteneur webapp seul (cache local des tuiles) : lancer la boucle de
|
||||
# synchronisation en arrière-plan si demandé. Sans LIDAR_SYNC_CMD il n'y a
|
||||
# rien à synchroniser — pas de boucle (juste le rebuild à la demande).
|
||||
if AUTO_SYNC_SECONDS > 0 and SYNC_CMD:
|
||||
threading.Thread(target=_auto_sync_loop, daemon=True,
|
||||
name="cache-tuiles").start()
|
||||
|
||||
|
||||
@app.post("/api/sync", dependencies=[Depends(_require_token)])
|
||||
def sync_and_rebuild():
|
||||
"""Synchronise les tuiles (LIDAR_SYNC_CMD) puis régénère la carte en arrière-plan.
|
||||
@ -578,7 +692,8 @@ def _build_command(tiles, regenerate=False, ground_class="ign", bare_earth=False
|
||||
return cmd
|
||||
|
||||
|
||||
@app.post("/api/generate", dependencies=[Depends(_require_token)])
|
||||
@app.post("/api/generate", dependencies=[Depends(_require_token),
|
||||
Depends(_require_lan_for_generation)])
|
||||
def generate(req: GenerateRequest):
|
||||
if GENERATION_URL:
|
||||
# Webapp légère : téléchargement + traitement sur la machine distante,
|
||||
|
||||
Reference in New Issue
Block a user