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:
@ -257,14 +257,29 @@ def test_build_index_generates_html(tmp_path):
|
||||
assert "t.started === s.started" in app_js
|
||||
# Bouton « Compléter » : visualisations manquantes des dalles présentes
|
||||
# dans input/ (scope all_missing envoyé au preview et au generate).
|
||||
# « + Zone » et « Compléter » ne vivent que dans l'onglet Génération,
|
||||
# jamais dans la barre d'outils flottante de la carte.
|
||||
assert 'id="btnComplete"' in content
|
||||
map_tools = content.split('id="mapTools"', 1)[1].split('</nav>', 1)[0]
|
||||
assert 'btnGenZone' not in map_tools and 'btnComplete' not in map_tools
|
||||
tab_gen = content.split('id="tab-gen"', 1)[1].split('id="tab-tile"', 1)[0]
|
||||
assert 'id="btnGenZone"' in tab_gen and 'id="btnComplete"' in tab_gen
|
||||
assert "all_missing" in app_js and app_js.count("all_missing") >= 2
|
||||
# Dessin de zone au 1er plan : pane dédié au-dessus des tuiles (marqueurs
|
||||
# Leaflet, pane 600) pour rectangle et cellules fantômes.
|
||||
assert "createPane('genDraw')" in app_js
|
||||
assert app_js.count("pane: 'genDraw'") >= 3 # rectangle + 2 jeux de cellules
|
||||
assert 'assets/app.css' in content
|
||||
# Bouton d'actualisation des couches (détection des nouveautés sur disque) :
|
||||
# le ↻ resynchronise puis reconstruit la carte (mode webapp distante).
|
||||
assert 'id="layerRefresh"' in content
|
||||
assert "/api/layers" in app_js and "/api/sync" in app_js
|
||||
assert 'assets/app.js' in content
|
||||
# Autonomie hors ligne : Leaflet vendorisé localement, aucun CDN
|
||||
assert 'assets/vendor/leaflet/leaflet.js' in content
|
||||
assert 'assets/vendor/leaflet/leaflet.css' in content
|
||||
assert 'unpkg.com' not in content
|
||||
assert (assets / 'vendor' / 'leaflet' / 'leaflet.js').is_file()
|
||||
# Fin d'un run de génération : synchronisation puis rechargement (le
|
||||
# rechargement direct reste en secours si /api/sync est absent).
|
||||
assert "syncThenReload" in app_js
|
||||
@ -284,6 +299,23 @@ def test_build_index_generates_html(tmp_path):
|
||||
assert len(thumbs) >= 2 # au moins hillshade pour chaque tuile
|
||||
|
||||
|
||||
def test_mid_thumbnails_generated(tmp_path):
|
||||
"""Vignette intermédiaire 640 px générée et référencée dans l'index."""
|
||||
from lidar_pipeline.index import build_index
|
||||
|
||||
output_dir = tmp_path / "output"
|
||||
vis_dir = output_dir / "visualisations"
|
||||
vis_dir.mkdir(parents=True)
|
||||
_make_fake_viz_dir(vis_dir, "a", 1000, 6881, ('hillshade_multi',))
|
||||
|
||||
assert build_index(output_dir) is not None
|
||||
data = json.loads((output_dir / "index_tiles.json").read_text(encoding='utf-8'))
|
||||
viz = data['tiles'][0]['viz']['hillshade_multi']
|
||||
assert 'mid' in viz
|
||||
assert (output_dir / viz['mid']).exists()
|
||||
assert viz['mid'].endswith("_mid.jpg")
|
||||
|
||||
|
||||
def test_subtiled_viz_covers_generation_choices():
|
||||
"""Les viz générables depuis la carte sont sous-tuilées (affichage fluide)."""
|
||||
from lidar_pipeline.index import _CARTO_SUBTILED_VIZ
|
||||
@ -387,6 +419,8 @@ def test_build_subtiles_regenerates_stale_crops(tmp_path):
|
||||
hill_avif = sub_dir / "LHD_FXX_1000_6881_PTS_LAMB93_IGN69_r0p2_hillshade_multi_0_0.avif"
|
||||
aspect_avif = sub_dir / "LHD_FXX_1000_6881_PTS_LAMB93_IGN69_r0p2_aspect_0_0.avif"
|
||||
assert hill_avif.exists() and aspect_avif.exists()
|
||||
hill_mid = sub_dir / "LHD_FXX_1000_6881_PTS_LAMB93_IGN69_r0p2_hillshade_multi_0_0_mid.webp"
|
||||
assert hill_mid.exists()
|
||||
m_hill_1 = hill_avif.stat().st_mtime
|
||||
m_aspect_1 = aspect_avif.stat().st_mtime
|
||||
|
||||
|
||||
@ -583,3 +583,81 @@ def test_require_token():
|
||||
# Sans jeton configuré : tout passe (LAN de confiance)
|
||||
webapp.API_TOKEN = None
|
||||
assert webapp._require_token(None) is None
|
||||
|
||||
|
||||
class _FakeRequest:
|
||||
"""Requête minimale pour tester la restriction par IP (peer + XFF)."""
|
||||
|
||||
def __init__(self, host=None, xff=None):
|
||||
self.client = type("C", (), {"host": host})() if host else None
|
||||
self.headers = {"x-forwarded-for": xff} if xff else {}
|
||||
|
||||
|
||||
def test_generate_restricted_to_lan_cidr():
|
||||
"""/api/generate n'est autorisé que depuis LIDAR_REGEN_CIDR (défaut 192.168.1.0/24)."""
|
||||
from fastapi import HTTPException
|
||||
import lidar_pipeline.webapp as webapp
|
||||
old = webapp.REGEN_CIDR
|
||||
webapp.REGEN_CIDR = "192.168.1.0/24"
|
||||
try:
|
||||
# IP du réseau autorisé
|
||||
webapp._require_lan_for_generation(_FakeRequest(host="192.168.1.42"))
|
||||
# Reverse proxy local (pair dans le réseau) : X-Forwarded-For désigne
|
||||
# le client réel derrière lui — refusé s'il est hors réseau
|
||||
webapp._require_lan_for_generation(
|
||||
_FakeRequest(host="192.168.1.50", xff="192.168.1.10, 192.168.1.50"))
|
||||
for host, xff in (("10.0.0.5", None), ("8.8.8.8", None),
|
||||
("192.168.3.1", None), ("2001:db8::1", None),
|
||||
# XFF forgé par un client externe : ignoré (pair hors réseau)
|
||||
("8.8.8.8", "192.168.1.5"),
|
||||
# Client réel externe derrière le proxy local
|
||||
("192.168.1.50", "8.8.8.8"),
|
||||
# IP absente
|
||||
(None, None)):
|
||||
try:
|
||||
webapp._require_lan_for_generation(_FakeRequest(host=host, xff=xff))
|
||||
assert False, f"403 attendu pour host={host!r} xff={xff!r}"
|
||||
except HTTPException as e:
|
||||
assert e.status_code == 403
|
||||
assert "réseau local" in e.detail
|
||||
finally:
|
||||
webapp.REGEN_CIDR = old
|
||||
|
||||
|
||||
def test_generate_cidr_disabled(monkeypatch):
|
||||
"""LIDAR_REGEN_CIDR vide = restriction levée (tout client autorisé)."""
|
||||
import lidar_pipeline.webapp as webapp
|
||||
monkeypatch.setattr(webapp, "REGEN_CIDR", "")
|
||||
webapp._require_lan_for_generation(_FakeRequest(host="8.8.8.8"))
|
||||
webapp._require_lan_for_generation(_FakeRequest())
|
||||
|
||||
|
||||
def test_status_exposes_regen_allowed():
|
||||
"""/api/status indique à l'interface si le client peut générer."""
|
||||
import lidar_pipeline.webapp as webapp
|
||||
assert webapp.status(_FakeRequest(host="192.168.1.7"))["regen_allowed"] is True
|
||||
assert webapp.status(_FakeRequest(host="8.8.8.8"))["regen_allowed"] is False
|
||||
|
||||
|
||||
def test_auto_sync_once_skips_when_busy(monkeypatch):
|
||||
"""Le cycle de cache ne lance rien si un rebuild tourne déjà."""
|
||||
import time as _time
|
||||
import lidar_pipeline.webapp as webapp
|
||||
import lidar_pipeline.index as index_mod
|
||||
calls = []
|
||||
monkeypatch.setattr(index_mod, "build_index", lambda out: calls.append(out))
|
||||
monkeypatch.setattr(webapp, "SYNC_CMD", "exit 0")
|
||||
# Un cycle démarre bien (sync + rebuild)
|
||||
assert webapp._auto_sync_once() is True
|
||||
for _ in range(200):
|
||||
if not webapp.rebuild_status()["running"]:
|
||||
break
|
||||
_time.sleep(0.05)
|
||||
assert calls
|
||||
# Rebuild artificiellement occupé : le cycle suivant est sauté sans erreur
|
||||
webapp._rebuild["running"] = True
|
||||
try:
|
||||
assert webapp._auto_sync_once() is False
|
||||
assert len(calls) == 1
|
||||
finally:
|
||||
webapp._rebuild["running"] = False
|
||||
|
||||
Reference in New Issue
Block a user