L'interface webapp ne peut plus rester silencieusement périmée
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.
This commit is contained in:
@ -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://<ip-pi>: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
|
||||
|
||||
@ -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)
|
||||
// ============================================================
|
||||
|
||||
@ -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("/*")
|
||||
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user