From 64eaad751c3ebb46fa8795fc118c306827d517e0 Mon Sep 17 00:00:00 2001 From: Antoine Jacquin Date: Mon, 21 Sep 2026 23:43:19 +0200 Subject: [PATCH] =?UTF-8?q?L'interface=20webapp=20ne=20peut=20plus=20reste?= =?UTF-8?q?r=20silencieusement=20p=C3=A9rim=C3=A9e?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit La webapp légère bâchait app.js/app.css dans l'image au build : après une évolution de l'interface, le Pi servait l'ancienne carte sans aucun signal (tuiles invisibles, vieux fichiers). Désormais l'interface embarque sa version (hash CSS+JS) que le serveur de données publie via /api/tiles : en cas de divergence, bandeau « interface périmée » dans la carte + log d'alerte côté webapp. Les assets sont servis en revalidation (jamais immutable) et une page ouverte depuis plus de 15 min se recharge seule au retour au premier plan — l'ancienne interface n'est plus jamais affichée après un déploiement. --- docs/DEPLOY_WEBAPP.md | 20 ++++++++ lidar_pipeline/index.py | 72 +++++++++++++++++++++++++-- lidar_pipeline/tests/test_index.py | 24 +++++++-- lidar_pipeline/tests/test_webapp.py | 75 ++++++++++++++++++++++++++++- lidar_pipeline/webapp.py | 65 +++++++++++++++++++++---- 5 files changed, 237 insertions(+), 19 deletions(-) diff --git a/docs/DEPLOY_WEBAPP.md b/docs/DEPLOY_WEBAPP.md index 79d2872..2821c2a 100644 --- a/docs/DEPLOY_WEBAPP.md +++ b/docs/DEPLOY_WEBAPP.md @@ -302,6 +302,22 @@ Avec `run.sh --serve-webapp`, l'image est reconstruite automatiquement à chaque lancement ; il suffit de relancer la commande. Le cache de tuiles (`output/`) n'est pas affecté par les mises à jour d'image. +### Interface de la carte : version embarquée + +Les CSS/JS de l'interface sont **bâchés dans l'image** au build +(`Dockerfile.webapp`) : une évolution de l'interface (`lidar_pipeline/index.py`) +ne s'applique sur le Pi qu'après rebuild de l'image. Le décalage est détecté +automatiquement plutôt que de rester silencieux (tuiles invisibles) : + +- l'interface embarque sa version (`ui_version` = hash CSS+JS source) et + compare à celle du serveur de données (`/api/tiles` → `ui_version`, + publiée dans `index_tiles.json` par le worker) : en cas de divergence, + un **bandeau rouge « Interface périmée »** s'affiche en haut de la carte ; +- la webapp consigne un avertissement une fois par paire de versions : + `Interface webapp périmée (webapp X, serveur Y) — reconstruire l'image`. + +Réparation : `git pull` + rebuild de l'image (ci-dessus). + ## Dépannage - **État d'un rebuild** : `curl http://:8973/api/sync` → `running`, @@ -324,6 +340,10 @@ chaque lancement ; il suffit de relancer la commande. Le cache de tuiles `"running": false`, ou `./serve-webapp.sh status`) puis recharger la page ; le bouton ↻ de la carte enchaîne sync + vignettes automatiquement. +- **Bandeau « Interface périmée » en haut de la carte** (ou tuiles invisibles + sans explication) : l'image webapp est plus ancienne que l'interface du + worker (assets bâchés au build). Réparer : `git pull` + + `docker compose -f docker-compose.webapp.yml up -d --build` sur le Pi. - **Port 8973 déjà pris** : `WEBAPP_PORT=9000 ./serve-webapp.sh` (ou la variable dans `webapp.env`), `--serve-webapp 9000` avec `run.sh`, ou changer le mapping `ports:` en compose (le conteneur écoute toujours sur diff --git a/lidar_pipeline/index.py b/lidar_pipeline/index.py index bffc5d1..eee7a3b 100644 --- a/lidar_pipeline/index.py +++ b/lidar_pipeline/index.py @@ -1319,7 +1319,8 @@ def build_index(output_dir, output_format='avif', generate_overview=True): 'default_layers': default_layers_present(all_viz_keys), 'default_opacity': dict(DEFAULT_OPACITY), 'default_blend': dict(DEFAULT_BLEND), - 'n_tiles': len(display_tiles)}, + 'n_tiles': len(display_tiles), + 'ui_version': ui_version()}, }, ensure_ascii=False), encoding='utf-8') _write_assets(output_dir) @@ -1337,6 +1338,19 @@ def build_index(output_dir, output_format='avif', generate_overview=True): return html_path +def ui_version(): + """Version de l'interface : hash court du contenu source CSS+JS. + + Embarqué dans le app.js servi (__UI_VERSION__ remplacé par + _write_ui_assets) et dans index_tiles.json (stats) : le navigateur + compare sa propre version à celle annoncée par le serveur de données + (/api/tiles) et signale une webapp périmée (assets bâchés au build de + l'image, cf. Dockerfile.webapp — l'image doit être reconstruite après + toute évolution de l'interface). + """ + return hashlib.sha1((_APP_CSS + _APP_JS).encode('utf-8')).hexdigest()[:10] + + def _write_ui_assets(assets_dir): """Écrit les fichiers statiques de l'interface (CSS + JS) dans `assets_dir`. @@ -1349,7 +1363,11 @@ def _write_ui_assets(assets_dir): assets_dir = Path(assets_dir) assets_dir.mkdir(parents=True, exist_ok=True) (assets_dir / 'app.css').write_text(_APP_CSS, encoding='utf-8') - (assets_dir / 'app.js').write_text(_APP_JS, encoding='utf-8') + # __UI_VERSION__ : hash du contenu source (ui_version), substitué à + # l'écriture — le JS compare cette version à celle du serveur de données + # et affiche un bandeau si la webapp est périmée. + (assets_dir / 'app.js').write_text( + _APP_JS.replace('__UI_VERSION__', ui_version()), encoding='utf-8') src_vendor = Path(__file__).parent / 'assets' / 'vendor' dst_vendor = assets_dir / 'vendor' if src_vendor.is_dir(): @@ -1411,8 +1429,7 @@ def _render_html(tiles, global_bbox, all_viz_keys, output_format, overview=None) # une URL neuve à chaque évolution du CSS/JS force le rechargement chez # tous les navigateurs, même ceux qui ont une entrée de cache antérieure # aux en-têtes no-cache de la webapp. - assets_v = hashlib.sha1( - (_APP_CSS + _APP_JS).encode('utf-8')).hexdigest()[:10] + assets_v = ui_version() # Options du sélecteur de génération/régénération : les couches du panneau # (PANEL_VIZ si restreint, sinon le registre complet — mêmes libellés, @@ -2174,6 +2191,28 @@ _APP_JS = r"""'use strict'; // VIZ_META : {clé: {label}} des visualisations disponibles // STATS : {default_viz, default_layers, default_opacity, n_tiles} +// Version de l'interface (hash source CSS+JS, __UI_VERSION__ substitué par +// _write_ui_assets) : comparée à la version du serveur de données +// (/api/tiles → ui_version). Mismatch = webapp périmée (image bâchée au +// build, non reconstruite après une évolution de l'interface) → bandeau. +const UI_VERSION = "__UI_VERSION__"; + +function checkUiVersion(data) { + if (!data || !data.ui_version || data.ui_version === UI_VERSION) return; + let banner = document.getElementById('uiStaleBanner'); + if (banner) return; + banner = document.createElement('div'); + banner.id = 'uiStaleBanner'; + banner.style.cssText = 'position:fixed;top:0;left:0;right:0;z-index:10000;' + + 'background:#e94560;color:#fff;font:13px/1.5 sans-serif;padding:6px 14px;' + + 'box-shadow:0 2px 8px rgba(0,0,0,.35)'; + banner.textContent = '⚠ Interface périmée (webapp ' + UI_VERSION + + ', serveur ' + data.ui_version + ') : des tuiles peuvent ne pas ' + + 's\'afficher. Rebuild de la webapp requis : git pull && docker compose ' + + 'up -d --build'; + document.body.appendChild(banner); +} + const viewport = document.getElementById('viewport'); if (typeof L === 'undefined') { @@ -4418,6 +4457,7 @@ function pollLiveTiles() { const q = liveTilesStamp != null ? '?stamp=' + liveTilesStamp : ''; fetch('/api/tiles' + q).then(r => r.ok ? r.json() : null).then(d => { if (!d) return; + checkUiVersion(d); liveTilesStamp = d.stamp; if (d.tiles) mergeTilesData(d); }).catch(() => {}); @@ -4616,6 +4656,30 @@ setInterval(() => { if (!document.hidden && !genPollTimer) pollLiveTiles(); }, 15000); +// ============================================================ +// Garde anti-page périmée (garantie : l'ancienne interface n'est +// jamais affichée) +// ============================================================ +// Un onglet laissé ouvert pendant un déploiement webapp continuerait +// d'exécuter le JS ancien. Dès que la page a plus de 15 min, son retour +// au premier plan (onglet mobile, fenêtre) déclenche le rechargement : +// le document étant servi no-store, le nouvel index.html amène le +// ?v= neuf et donc le JS à jour. Vue et file de génération sont +// conservées ; pas de rechargement pendant un run suivi (genPollTimer). +const loadedAt = Date.now(); +function reloadStalePage() { + if (genPollTimer || Date.now() - loadedAt < 15 * 60 * 1000) return; + try { + const c = map.getCenter(); + sessionStorage.setItem('lidarMap', JSON.stringify({ lat: c.lat, lng: c.lng, zoom: map.getZoom() })); + } catch (e) { /* sessionStorage indisponible : pas grave */ } + console.info("Page chargée depuis plus de 15 min — rechargement automatique pour l'interface à jour"); + location.reload(); +} +document.addEventListener('visibilitychange', + () => { if (!document.hidden) reloadStalePage(); }); +window.addEventListener('focus', reloadStalePage); + // ============================================================ // Détection de nouvelles couches (run de génération en cours) // ============================================================ diff --git a/lidar_pipeline/tests/test_index.py b/lidar_pipeline/tests/test_index.py index 7402d9a..b8575b1 100644 --- a/lidar_pipeline/tests/test_index.py +++ b/lidar_pipeline/tests/test_index.py @@ -307,11 +307,19 @@ def test_build_index_generates_html(tmp_path): # les tuiles terminées sans recharger (mode incrémental du pipeline). assert "/api/tiles" in app_js and "pollLiveTiles" in app_js assert "registerTile" in app_js and "mergeTilesData" in app_js + # Garde anti-page périmée : un onglet ouvert depuis plus de 15 min + # (JS ancien après un déploiement) se recharge au retour au premier + # plan — l'ancienne interface n'est jamais affichée. + assert "reloadStalePage" in app_js and "visibilitychange" in app_js # Export des données de carte (servi par /api/tiles) data = json.loads((output_dir / "index_tiles.json").read_text(encoding='utf-8')) assert data['tiles'] and data['tiles'][0]['corners'] assert 'aspect' in data['viz_meta'] assert data['stats']['n_tiles'] == len(data['tiles']) + # Version de l'interface exportée : la webapp légère détecte une image + # périmée (assets bâchés au build, non reconstruite après évolution) + from lidar_pipeline.index import ui_version + assert data['stats']['ui_version'] == ui_version() # Vérifie les vignettes générées thumb_dir = output_dir / "index_thumbs" assert thumb_dir.is_dir() @@ -320,13 +328,23 @@ def test_build_index_generates_html(tmp_path): def test_write_ui_assets(tmp_path): - """_write_ui_assets écrit app.js/app.css dans le dossier cible (bâchés image).""" - from lidar_pipeline.index import _write_ui_assets, _APP_JS + """_write_ui_assets écrit app.js/app.css dans le dossier cible (bâchés image). + + app.js a __UI_VERSION__ substitué par la version réelle (ui_version) : + le JS compare cette version à celle du serveur de données et signale + une webapp périmée (bandeau, cf. checkUiVersion). + """ + from lidar_pipeline.index import _write_ui_assets, _APP_JS, ui_version dst = tmp_path / "ui" _write_ui_assets(dst) + v = ui_version() + assert v == ui_version() and len(v) == 10 js = (dst / "app.js").read_text(encoding="utf-8") - assert js == _APP_JS + assert js == _APP_JS.replace("__UI_VERSION__", v) + assert v in js and "__UI_VERSION__" not in js + # Le placeholder et le check sont embarqués dans la source + assert '"__UI_VERSION__"' in _APP_JS and "checkUiVersion" in _APP_JS assert (dst / "app.css").read_text(encoding="utf-8").startswith("/*") diff --git a/lidar_pipeline/tests/test_webapp.py b/lidar_pipeline/tests/test_webapp.py index 8a5b82f..2489834 100644 --- a/lidar_pipeline/tests/test_webapp.py +++ b/lidar_pipeline/tests/test_webapp.py @@ -629,8 +629,11 @@ def test_tiles_endpoint_stamp_diffing(tmp_path, monkeypatch): import lidar_pipeline.webapp as webapp monkeypatch.setattr(webapp, "OUTPUT_DIR", tmp_path) - # Fichier absent (aucune tuile encore indexée) → réponse vide - assert webapp.tiles_data() == {"stamp": None, "tiles": None} + # Fichier absent (aucune tuile encore indexée) → réponse vide, avec la + # version de l'interface (le JS embarqué détecte une webapp périmée) + r = webapp.tiles_data() + assert r["stamp"] is None and r["tiles"] is None + assert r["ui_version"] == webapp._ui_version_local() # Fichier présent → données complètes puis réponse allégée au stamp (tmp_path / "index_tiles.json").write_text( @@ -691,6 +694,74 @@ def test_tiles_endpoint_merges_remote_index(tmp_path, monkeypatch): assert d["stamp"] == remote_stamp # max des deux stamps +def test_tiles_endpoint_advertises_ui_version(tmp_path, monkeypatch): + """/api/tiles publie ui_version : la version du serveur de données prime. + + Le JS embarqué (checkUiVersion) la compare à sa propre version et + affiche un bandeau quand la webapp est périmée (assets bâchés dans + l'image au build, image non reconstruite après une évolution). + """ + import lidar_pipeline.webapp as webapp + from lidar_pipeline.index import ui_version + monkeypatch.setattr(webapp, "OUTPUT_DIR", tmp_path) + # Pas de distant (mode mono-machine) : la version de cette webapp + d = webapp.tiles_data() + assert d["ui_version"] == ui_version() + # Distant (mode deux machines) : sa version prime, réponse allégée comprise + monkeypatch.setattr(webapp, "GENERATION_URL", "http://distant:8973") + monkeypatch.setattr(webapp, "_remote_tiles_data", lambda: { + "stamp": 1.0, "tiles": [], + "stats": {"ui_version": "remote0000"}, + }) + d = webapp.tiles_data() + assert d["ui_version"] == "remote0000" + light = webapp.tiles_data(stamp=d["stamp"]) + assert light["tiles"] is None and light["ui_version"] == "remote0000" + + +def test_warn_ui_drift_once_per_version_pair(tmp_path, monkeypatch, caplog): + """Avertissement de drift : une fois par paire de versions, sans faux positif. + + Le navigateur affiche lui aussi un bandeau ; ce log aide l'opérateur + (cf. Dockerfile.webapp : l'interface est bâchée au build de l'image). + """ + import logging + import lidar_pipeline.webapp as webapp + monkeypatch.setattr(webapp, "_UI_DRIFT_WARNED", set()) + local = webapp._ui_version_local() + with caplog.at_level(logging.WARNING): + webapp._warn_ui_drift({"stats": {"ui_version": "autrev0000"}}) + webapp._warn_ui_drift({"stats": {"ui_version": "autrev0000"}}) + # Versions alignées (mono-machine) : pas d'avertissement + webapp._warn_ui_drift({"stats": {"ui_version": local}}) + # Index antérieur à l'ajout de la version : pas d'avertissement + webapp._warn_ui_drift({"stats": {}}) + warned = [r for r in caplog.records + if r.getMessage().startswith("Interface webapp périmée")] + assert len(warned) == 1 + + +def test_assets_never_immutable(): + """/assets revalidé systématiquement (no-cache), même avec ?v=. + + ?v= ne garantit plus l'immuabilité : les assets suivent l'IMAGE, pas le + cache de tuiles — un rebuild change le contenu SOUS le même ?v= de + l'index.html en attente de régénération. Un cache immutable bloquerait + alors l'ancien JS chez les navigateurs qui détiennent encore l'index + antérieur (premier affichage après déploiement) ; la revalidation + ETag/mtime les fait se mettre à jour. + """ + import lidar_pipeline.webapp as webapp + assert (webapp._assets_dir / "app.js").is_file() + for v in (None, "c1d46e3abc"): + resp = webapp.assets("app.js", v=v) + assert resp.headers["Cache-Control"] == "no-cache, must-revalidate" + import pytest + from fastapi import HTTPException + with pytest.raises(HTTPException): + webapp.assets("introuvable.js", v=None) + + def test_tiles_endpoint_worker_offline_serves_local(tmp_path, monkeypatch): """Worker injoignable : index local servi seul, sans erreur.""" import json diff --git a/lidar_pipeline/webapp.py b/lidar_pipeline/webapp.py index f6c57bd..148873f 100644 --- a/lidar_pipeline/webapp.py +++ b/lidar_pipeline/webapp.py @@ -333,20 +333,22 @@ for name in ("index_thumbs", "index_subtiles", "index_overview", @app.get("/assets/{file_path:path}") def assets(file_path: str, v: str = None): - """Sert les fichiers de l'interface sans cache (régénérés à chaque rebuild). + """Sert les fichiers de l'interface en revalidation (jamais immutable). - Exception : ?v= (hash de contenu posé par build_index) → cache immutable. + ?v= est le hash de contenu posé par build_index — mais les assets + suivent l'IMAGE, pas le cache de tuiles : un rebuild de l'image change + le contenu SOUS le même ?v= de l'index.html en attente de régénération. + Un cache immutable les bloquerait alors chez les navigateurs qui + détiennent encore l'index.html antérieur (premier affichage après + déploiement = ancien JS). no-cache → revalidation ETag/mtime quasi + gratuite (304) ; si le fichier a changé (mtime neuve après rebuild), + le navigateur récupère le contenu frais même depuis un index.html périmé. """ base = _assets_dir.resolve() p = (_assets_dir / file_path).resolve() if base not in p.parents or not p.is_file(): raise HTTPException(404, f"asset introuvable : {file_path}") - # ?v= : hash de contenu hexadécimal posé par build_index (l'URL change - # à chaque évolution du CSS/JS) → cache immutable. - cache = ("public, max-age=31536000, immutable" - if v - else "no-cache, must-revalidate") - return FileResponse(str(p), headers={"Cache-Control": cache}) + return FileResponse(str(p), headers={"Cache-Control": "no-cache, must-revalidate"}) # Méthodes de classification du sol acceptées (mêmes valeurs que --ground-classification). @@ -966,6 +968,39 @@ def available_layers(): _REMOTE_INDEX = {"data": None, "fetched": 0.0} +_LOCAL_UI_VERSION = None +_UI_DRIFT_WARNED = set() + + +def _ui_version_local(): + """Version du JS/CSS bâti dans cette webapp (index.ui_version), en cache.""" + global _LOCAL_UI_VERSION + if _LOCAL_UI_VERSION is None: + from .index import ui_version + _LOCAL_UI_VERSION = ui_version() + return _LOCAL_UI_VERSION + + +def _warn_ui_drift(remote_data): + """Dérive de version webapp/serveur de données : avertit une fois par paire. + + L'interface de la webapp est bâchée au build de l'image (cf. + Dockerfile.webapp) : sans rebuild, les tuiles produites par un worker + plus récent ne s'affichent pas. Le navigateur signale le décalage lui + aussi (bandeau « interface périmée ») ; ce log aide l'opérateur. + """ + remote_ver = ((remote_data or {}).get("stats") or {}).get("ui_version") + local_ver = _ui_version_local() + if not remote_ver or remote_ver == local_ver: + return + key = (local_ver, remote_ver) + if key in _UI_DRIFT_WARNED: + return + _UI_DRIFT_WARNED.add(key) + logger.warning( + f"Interface webapp périmée (webapp {local_ver}, " + f"serveur {remote_ver}) — reconstruire l'image : " + f"git pull && docker compose up -d --build") _REMOTE_INDEX_TTL = 60.0 @@ -991,6 +1026,7 @@ def _remote_tiles_data(): data = json.loads(r.read().decode("utf-8")) _worker_mark(True) _REMOTE_INDEX.update({"data": data, "fetched": now}) + _warn_ui_drift(data) except Exception as e: _worker_mark(False) _REMOTE_INDEX["fetched"] = now # ne pas marteler à chaque requête @@ -1022,10 +1058,17 @@ def tiles_data(stamp: Optional[float] = None): remote_stamp = (remote or {}).get("stamp") stamps = [s for s in (local_stamp, remote_stamp) if s is not None] current = max(stamps) if stamps else None + # Version de l'interface du SERVEUR DE DONNÉES (le worker en mode deux + # machines, soi-même sinon) : le JS embarqué compare sa propre version + # (index.ui_version, __UI_VERSION__) et affiche un bandeau si la webapp + # est périmée (assets bâchés au build, image non reconstruite). + ui_ver = (((remote or {}).get("stats") + or (data or {}).get("stats") or {}).get("ui_version") + or _ui_version_local()) if stamp is not None and current is not None and abs(current - stamp) < 1e-4: - return {"stamp": current, "tiles": None} + return {"stamp": current, "tiles": None, "ui_version": ui_ver} if data is None and remote is None: - return {"stamp": None, "tiles": None} + return {"stamp": None, "tiles": None, "ui_version": ui_ver} # L'index distant prime (tuiles régénérées : ?v= neuf → re-téléchargement) ; # le local complète les positions que le worker n'a plus (historique). merged = list((remote or {}).get("tiles") or []) @@ -1053,6 +1096,8 @@ def tiles_data(stamp: Optional[float] = None): "overview": merged_overview, "viz_meta": viz_meta, "stats": (remote or data or {}).get("stats") or {}, + # Version de l'interface du serveur de données (cf. plus haut). + "ui_version": ui_ver, "stamp": current, }