Peupler le cache de la webapp à la visualisation, sans rsync
Les images (visualisations, vignettes, sous-tuiles) sont rapatriées depuis la machine de traitement par HTTP au moment où elles sont affichées, puis servies depuis le disque : une image absente est téléchargée, une image périmée ( ?v= plus récent que sa mtime, tuile régénérée) est rafraîchie. /api/tiles fusionne l'index du worker (cache 60 s) avec le cache local, si bien que les tuiles non cachées apparaissent quand même sur la carte. Worker hors ligne : disjoncteur 2 min après 3 échecs, index local seul, /api/status répond running=null — aucune erreur cliente.
This commit is contained in:
@ -293,6 +293,139 @@ def test_tiles_endpoint_stamp_diffing(tmp_path, monkeypatch):
|
|||||||
assert webapp.tiles_data(stamp=full["stamp"] - 10)["tiles"] is not None
|
assert webapp.tiles_data(stamp=full["stamp"] - 10)["tiles"] is not None
|
||||||
|
|
||||||
|
|
||||||
|
def test_tiles_endpoint_merges_remote_index(tmp_path, monkeypatch):
|
||||||
|
"""L'index du worker prime ; le cache local complète les positions perdues."""
|
||||||
|
import json
|
||||||
|
import lidar_pipeline.webapp as webapp
|
||||||
|
monkeypatch.setattr(webapp, "OUTPUT_DIR", tmp_path)
|
||||||
|
monkeypatch.setattr(webapp, "GENERATION_URL", "http://distant:8973")
|
||||||
|
(tmp_path / "index_tiles.json").write_text(json.dumps({
|
||||||
|
"tiles": [
|
||||||
|
{"col": 1054, "row": 6882, "resolution": 0.2, "viz": {}}, # commun
|
||||||
|
{"col": 1050, "row": 6882, "resolution": 0.2, "viz": {}}, # local seul
|
||||||
|
],
|
||||||
|
"viz_meta": {"local": {"label": "Locale"}},
|
||||||
|
"stats": {},
|
||||||
|
}), encoding="utf-8")
|
||||||
|
import os
|
||||||
|
remote_stamp = os.stat(tmp_path / "index_tiles.json").st_mtime + 1000
|
||||||
|
monkeypatch.setattr(webapp, "_remote_tiles_data", lambda: {
|
||||||
|
"stamp": remote_stamp,
|
||||||
|
"tiles": [
|
||||||
|
# 1054 régénéré côté worker : sa version (viz remplie) doit primer
|
||||||
|
{"col": 1054, "row": 6882, "resolution": 0.2, "viz": {"slope": {}}},
|
||||||
|
{"col": 1055, "row": 6882, "resolution": 0.2, "viz": {}}, # distant seul
|
||||||
|
],
|
||||||
|
"viz_meta": {"slope": {"label": "Pente"}},
|
||||||
|
})
|
||||||
|
d = webapp.tiles_data()
|
||||||
|
entries = {(t["col"], json.dumps(t["viz"], sort_keys=True)) for t in d["tiles"]}
|
||||||
|
assert (1054, '{"slope": {}}') in entries # version distante retenue
|
||||||
|
assert (1055, '{}') in entries # tuile non cachée visible
|
||||||
|
assert (1050, '{}') in entries # historique local conservé
|
||||||
|
assert d["viz_meta"]["slope"]["label"] == "Pente" # distant prime
|
||||||
|
assert d["viz_meta"]["local"]["label"] == "Locale" # local complété
|
||||||
|
assert d["stamp"] == remote_stamp # max des deux stamps
|
||||||
|
|
||||||
|
|
||||||
|
def test_tiles_endpoint_worker_offline_serves_local(tmp_path, monkeypatch):
|
||||||
|
"""Worker injoignable : index local servi seul, sans erreur."""
|
||||||
|
import json
|
||||||
|
import lidar_pipeline.webapp as webapp
|
||||||
|
monkeypatch.setattr(webapp, "OUTPUT_DIR", tmp_path)
|
||||||
|
monkeypatch.setattr(webapp, "GENERATION_URL", "http://distant:8973")
|
||||||
|
(tmp_path / "index_tiles.json").write_text(
|
||||||
|
json.dumps({"tiles": [{"col": 1054}], "viz_meta": {}, "stats": {}}),
|
||||||
|
encoding="utf-8")
|
||||||
|
monkeypatch.setattr(webapp, "_remote_tiles_data", lambda: None)
|
||||||
|
d = webapp.tiles_data()
|
||||||
|
assert d["tiles"][0]["col"] == 1054
|
||||||
|
assert d["stamp"] is not None
|
||||||
|
|
||||||
|
|
||||||
|
def test_worker_offline_breaker(monkeypatch):
|
||||||
|
"""3 échecs consécutifs → tentatives suspendues ; un succès réarme."""
|
||||||
|
import lidar_pipeline.webapp as webapp
|
||||||
|
monkeypatch.setattr(webapp, "_WORKER_HEALTH", {"fails": 0, "until": 0.0})
|
||||||
|
webapp._worker_mark(False)
|
||||||
|
webapp._worker_mark(False)
|
||||||
|
assert webapp._worker_offline() is False # pas encore armé
|
||||||
|
webapp._worker_mark(False)
|
||||||
|
assert webapp._worker_offline() is True # disjoncteur armé
|
||||||
|
until = webapp._WORKER_HEALTH["until"]
|
||||||
|
webapp._worker_mark(False)
|
||||||
|
assert webapp._WORKER_HEALTH["until"] == until # fenêtre non étendue
|
||||||
|
webapp._worker_mark(True)
|
||||||
|
assert webapp._worker_offline() is False # réarmé immédiatement
|
||||||
|
|
||||||
|
|
||||||
|
def test_safe_rel_path():
|
||||||
|
"""Seuls les chemins images des préfixes autorisés sont rapatrieables."""
|
||||||
|
from lidar_pipeline.webapp import _safe_rel_path
|
||||||
|
assert _safe_rel_path("/visualisations/dalle/img.avif") == \
|
||||||
|
"visualisations/dalle/img.avif"
|
||||||
|
assert _safe_rel_path("/index_thumbs/a_b.jpg") == "index_thumbs/a_b.jpg"
|
||||||
|
assert _safe_rel_path("/index_subtiles/x_0_1.avif") == "index_subtiles/x_0_1.avif"
|
||||||
|
assert _safe_rel_path("/DTM/x.tif") is None # préfixe interdit
|
||||||
|
assert _safe_rel_path("/assets/app.js") is None # préfixe interdit
|
||||||
|
assert _safe_rel_path("/visualisations/../x.avif") is None # traversal
|
||||||
|
assert _safe_rel_path("/visualisations/a%20b.avif") is None # caractères
|
||||||
|
|
||||||
|
|
||||||
|
def test_fetch_remote_to_cache(tmp_path, monkeypatch):
|
||||||
|
"""Rapatriement atomique depuis le worker ; échec sans fichier ni erreur."""
|
||||||
|
import io
|
||||||
|
import lidar_pipeline.webapp as webapp
|
||||||
|
monkeypatch.setattr(webapp, "OUTPUT_DIR", tmp_path)
|
||||||
|
monkeypatch.setattr(webapp, "GENERATION_URL", "http://distant:8973")
|
||||||
|
monkeypatch.setattr(webapp, "_WORKER_HEALTH", {"fails": 0, "until": 0.0})
|
||||||
|
|
||||||
|
class _Resp(io.BytesIO):
|
||||||
|
def __enter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, *a):
|
||||||
|
return False
|
||||||
|
|
||||||
|
captured = {}
|
||||||
|
|
||||||
|
def fake_urlopen(req, timeout=None):
|
||||||
|
captured["url"] = req.full_url
|
||||||
|
return _Resp(b"IMAGEDATA")
|
||||||
|
|
||||||
|
monkeypatch.setattr(webapp.urllib.request, "urlopen", fake_urlopen)
|
||||||
|
assert webapp._fetch_remote_to_cache("visualisations/dalle/img.avif") is True
|
||||||
|
p = tmp_path / "visualisations" / "dalle" / "img.avif"
|
||||||
|
assert p.read_bytes() == b"IMAGEDATA"
|
||||||
|
assert captured["url"] == "http://distant:8973/visualisations/dalle/img.avif"
|
||||||
|
assert not list(tmp_path.rglob("*.part")) # pas de résidu temporaire
|
||||||
|
assert webapp._WORKER_HEALTH["fails"] == 0 # succès → disjoncteur sain
|
||||||
|
|
||||||
|
def boom(req, timeout=None):
|
||||||
|
raise OSError("injoignable")
|
||||||
|
|
||||||
|
monkeypatch.setattr(webapp.urllib.request, "urlopen", boom)
|
||||||
|
assert webapp._fetch_remote_to_cache("visualisations/dalle/x.avif") is False
|
||||||
|
assert not (tmp_path / "visualisations" / "dalle" / "x.avif").exists()
|
||||||
|
assert webapp._WORKER_HEALTH["fails"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_status_worker_offline_no_error(monkeypatch):
|
||||||
|
"""Worker hors ligne : running=null (ignoré par l'UI), pas de 503."""
|
||||||
|
from fastapi import HTTPException
|
||||||
|
import lidar_pipeline.webapp as webapp
|
||||||
|
monkeypatch.setattr(webapp, "GENERATION_URL", "http://distant:8973")
|
||||||
|
|
||||||
|
def unreachable(method, path, payload=None, timeout=30):
|
||||||
|
raise HTTPException(503, "machine de traitement injoignable")
|
||||||
|
|
||||||
|
monkeypatch.setattr(webapp, "_proxy_api", unreachable)
|
||||||
|
d = webapp.status(_FakeRequest(host="192.168.1.7"))
|
||||||
|
assert d["running"] is None
|
||||||
|
assert d["worker_offline"] is True
|
||||||
|
assert d["regen_allowed"] is True
|
||||||
|
|
||||||
|
|
||||||
def test_viz_step_names_match_pipeline():
|
def test_viz_step_names_match_pipeline():
|
||||||
"""Les noms acceptés par l'API sont exactement les étapes VIZ_STEPS du pipeline."""
|
"""Les noms acceptés par l'API sont exactement les étapes VIZ_STEPS du pipeline."""
|
||||||
from lidar_pipeline.webapp import _viz_step_names
|
from lidar_pipeline.webapp import _viz_step_names
|
||||||
|
|||||||
@ -51,10 +51,13 @@ import json
|
|||||||
import logging
|
import logging
|
||||||
import math
|
import math
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
|
import urllib.parse
|
||||||
|
import urllib.request
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
@ -132,11 +135,152 @@ if SYNC_CMD:
|
|||||||
# statique soit actif même avant la première génération de l'index.
|
# statique soit actif même avant la première génération de l'index.
|
||||||
_assets_dir = OUTPUT_DIR / "assets"
|
_assets_dir = OUTPUT_DIR / "assets"
|
||||||
_assets_dir.mkdir(parents=True, exist_ok=True)
|
_assets_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# --- Cache à la demande : la visualisation peuple le cache local -------------
|
||||||
|
# Sans LIDAR_SYNC_CMD (pas de rsync), les images viennent de la machine de
|
||||||
|
# traitement par HTTP : une image absente du disque — ou plus ancienne que la
|
||||||
|
# version ?v= référencée (mtime ms de la source sur le worker) — est téléchar-
|
||||||
|
# gée depuis GENERATION_URL, écrite sur disque puis servie. Requêtes concur-
|
||||||
|
# rentes dédupliquées (verrou par fichier), flux réseau plafonné (sémaphore).
|
||||||
|
from fastapi.staticfiles import StaticFiles as _StaticFiles
|
||||||
|
from starlette.concurrency import run_in_threadpool
|
||||||
|
|
||||||
|
_ONDEMAND_PREFIXES = ("visualisations", "index_thumbs", "index_subtiles")
|
||||||
|
_FETCH_SEMAPHORE = threading.Semaphore(4)
|
||||||
|
_FETCH_LOCKS = {}
|
||||||
|
_FETCH_LOCKS_GUARD = threading.Lock()
|
||||||
|
_MAX_CACHE_BYTES = 256 * 1024 * 1024 # garde-fou par fichier (dalle AVIF ~35 Mo)
|
||||||
|
|
||||||
|
# Disjoncteur : la machine de traitement n'est pas toujours allumée. Après
|
||||||
|
# quelques échecs consécutifs, les tentatives (index + images) sont suspendues
|
||||||
|
# 2 min — réponses locales instantanées, aucun martèlement ni erreur cliente.
|
||||||
|
_WORKER_HEALTH = {"fails": 0, "until": 0.0}
|
||||||
|
_WORKER_OFFLINE_AFTER = 3
|
||||||
|
_WORKER_OFFLINE_SECONDS = 120.0
|
||||||
|
|
||||||
|
|
||||||
|
def _worker_offline():
|
||||||
|
return time.time() < _WORKER_HEALTH["until"]
|
||||||
|
|
||||||
|
|
||||||
|
def _worker_mark(ok):
|
||||||
|
if ok:
|
||||||
|
_WORKER_HEALTH.update(fails=0, until=0.0)
|
||||||
|
else:
|
||||||
|
_WORKER_HEALTH["fails"] += 1
|
||||||
|
if (_WORKER_HEALTH["fails"] >= _WORKER_OFFLINE_AFTER
|
||||||
|
and _WORKER_HEALTH["until"] == 0.0):
|
||||||
|
_WORKER_HEALTH["until"] = time.time() + _WORKER_OFFLINE_SECONDS
|
||||||
|
logger.info("Machine de traitement injoignable — "
|
||||||
|
"rapatriement à la demande suspendu 2 min")
|
||||||
|
|
||||||
|
|
||||||
|
def _version_from_query(scope):
|
||||||
|
"""Version ?v= (mtime ms de la source) demandée par le client, ou None."""
|
||||||
|
try:
|
||||||
|
qs = urllib.parse.parse_qs(scope.get("query_string", b"").decode("ascii"))
|
||||||
|
v = qs.get("v", [None])[0]
|
||||||
|
return int(v) if v and v.isdigit() else None
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_rel_path(url_path):
|
||||||
|
"""Chemin relatif validé (préfixe autorisé, composants sains), ou None."""
|
||||||
|
rel = url_path.lstrip("/")
|
||||||
|
prefix = rel.split("/", 1)[0]
|
||||||
|
if prefix not in _ONDEMAND_PREFIXES:
|
||||||
|
return None
|
||||||
|
parts = [p for p in rel.split("/") if p not in ("", ".")]
|
||||||
|
if not parts or any(p == ".." or not re.fullmatch(r"[A-Za-z0-9_.-]+", p)
|
||||||
|
for p in parts):
|
||||||
|
return None
|
||||||
|
return "/".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch_remote_to_cache(rel_path):
|
||||||
|
"""Télécharge GENERATION_URL/rel_path vers OUTPUT_DIR/rel_path (atomique)."""
|
||||||
|
if _worker_offline():
|
||||||
|
return False
|
||||||
|
dest = OUTPUT_DIR / rel_path
|
||||||
|
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
tmp = dest.with_name(dest.name + ".part")
|
||||||
|
url = f"{GENERATION_URL}/{urllib.parse.quote(rel_path)}"
|
||||||
|
try:
|
||||||
|
with _FETCH_SEMAPHORE:
|
||||||
|
req = urllib.request.Request(url, headers={"User-Agent": "lidar-webapp-cache"})
|
||||||
|
with urllib.request.urlopen(req, timeout=60) as r, open(tmp, "wb") as f:
|
||||||
|
total = 0
|
||||||
|
while True:
|
||||||
|
chunk = r.read(1 << 20)
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
total += len(chunk)
|
||||||
|
if total > _MAX_CACHE_BYTES:
|
||||||
|
raise IOError(f"fichier trop volumineux : {rel_path}")
|
||||||
|
f.write(chunk)
|
||||||
|
os.replace(tmp, dest)
|
||||||
|
_worker_mark(True)
|
||||||
|
logger.info(f"Cache à la demande : {rel_path} rapatrié "
|
||||||
|
f"({total / 1e6:.1f} Mo)")
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
_worker_mark(False)
|
||||||
|
tmp.unlink(missing_ok=True)
|
||||||
|
logger.debug(f"Cache à la demande : échec {rel_path} : {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
class _OnDemandStaticFiles(_StaticFiles):
|
||||||
|
"""Montage statique qui peuple le cache depuis la machine de traitement.
|
||||||
|
|
||||||
|
Fichier local absent, ou périmé (?v= plus récent que sa mtime) → téléchar-
|
||||||
|
gement puis service ; sans GENERATION_URL ou en cas d'échec, comportement
|
||||||
|
statique normal (404, ou version locale périmée plutôt que rien).
|
||||||
|
|
||||||
|
url_prefix : préfixe du montage (ex. visualisations) — dans une app
|
||||||
|
montée, scope["path"] est le sous-chemin sans préfixe, on le reconstruit.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, *, directory, url_prefix):
|
||||||
|
super().__init__(directory=directory)
|
||||||
|
self.url_prefix = url_prefix
|
||||||
|
|
||||||
|
async def get_response(self, path, scope):
|
||||||
|
want = None
|
||||||
|
if GENERATION_URL:
|
||||||
|
rel = _safe_rel_path(f"/{self.url_prefix}/{path}")
|
||||||
|
if rel is not None:
|
||||||
|
local = OUTPUT_DIR / rel
|
||||||
|
if not local.is_file():
|
||||||
|
want = rel
|
||||||
|
else:
|
||||||
|
v = _version_from_query(scope)
|
||||||
|
if v is not None and int(local.stat().st_mtime * 1000) + 500 < v:
|
||||||
|
want = rel # tuile régénérée sur le worker depuis le cache
|
||||||
|
if want is not None:
|
||||||
|
with _FETCH_LOCKS_GUARD:
|
||||||
|
lock = _FETCH_LOCKS.setdefault(want, threading.Lock())
|
||||||
|
# Téléchargement bloquant hors de la boucle d'événements ; le
|
||||||
|
# verrou par fichier déduplique les requêtes concurrentes.
|
||||||
|
await run_in_threadpool(self._fetch_under_lock, want, lock)
|
||||||
|
return await super().get_response(path, scope)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _fetch_under_lock(rel, lock):
|
||||||
|
with lock:
|
||||||
|
return _fetch_remote_to_cache(rel)
|
||||||
|
|
||||||
|
|
||||||
for name in ("index_thumbs", "index_subtiles", "visualisations", "DTM"):
|
for name in ("index_thumbs", "index_subtiles", "visualisations", "DTM"):
|
||||||
_dir = OUTPUT_DIR / name
|
_dir = OUTPUT_DIR / name
|
||||||
if _dir.exists():
|
_dir.mkdir(parents=True, exist_ok=True)
|
||||||
from fastapi.staticfiles import StaticFiles
|
if name in _ONDEMAND_PREFIXES and GENERATION_URL:
|
||||||
app.mount(f"/{name}", StaticFiles(directory=str(_dir)), name=name)
|
app.mount(f"/{name}",
|
||||||
|
_OnDemandStaticFiles(directory=str(_dir), url_prefix=name),
|
||||||
|
name=name)
|
||||||
|
else:
|
||||||
|
app.mount(f"/{name}", _StaticFiles(directory=str(_dir)), name=name)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/assets/{file_path:path}")
|
@app.get("/assets/{file_path:path}")
|
||||||
@ -492,8 +636,15 @@ def status(request: Request = None):
|
|||||||
regen_allowed = _ip_in_regen_cidr(_client_ip(request))
|
regen_allowed = _ip_in_regen_cidr(_client_ip(request))
|
||||||
if GENERATION_URL:
|
if GENERATION_URL:
|
||||||
# Webapp légère : l'état vient de la machine de traitement ; le droit
|
# 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).
|
# de génération reste jugé sur l'IP du navigateur (localement). Hors
|
||||||
data = _proxy_api("GET", "/api/status", timeout=10)
|
# ligne : running=null (l'interface ignore, aucun faux « terminé »).
|
||||||
|
try:
|
||||||
|
data = _proxy_api("GET", "/api/status", timeout=10)
|
||||||
|
except HTTPException as e:
|
||||||
|
if e.status_code != 503:
|
||||||
|
raise
|
||||||
|
return {"running": None, "distant": True, "worker_offline": True,
|
||||||
|
"regen_allowed": regen_allowed}
|
||||||
data["distant"] = True
|
data["distant"] = True
|
||||||
data["regen_allowed"] = regen_allowed
|
data["regen_allowed"] = regen_allowed
|
||||||
return data
|
return data
|
||||||
@ -535,25 +686,84 @@ def available_layers():
|
|||||||
return found
|
return found
|
||||||
|
|
||||||
|
|
||||||
|
_REMOTE_INDEX = {"data": None, "fetched": 0.0}
|
||||||
|
_REMOTE_INDEX_TTL = 60.0
|
||||||
|
|
||||||
|
|
||||||
|
def _tile_entry_key(t):
|
||||||
|
"""Clé d'unicité d'une tuile affichée (position, résolution, quadrant)."""
|
||||||
|
return (t.get("col"), t.get("row"), t.get("resolution"),
|
||||||
|
t.get("sub_i"), t.get("sub_j"))
|
||||||
|
|
||||||
|
|
||||||
|
def _remote_tiles_data():
|
||||||
|
"""Index de la machine de traitement (cache 60 s), ou None si injoignable.
|
||||||
|
|
||||||
|
Une panne ne remonte pas : dernière réponse connue servie telle quelle
|
||||||
|
(stale-while-error), et le disjoncteur suspend les tentatives.
|
||||||
|
"""
|
||||||
|
if not GENERATION_URL or _worker_offline():
|
||||||
|
return _REMOTE_INDEX["data"]
|
||||||
|
now = time.time()
|
||||||
|
if _REMOTE_INDEX["fetched"] > now - _REMOTE_INDEX_TTL:
|
||||||
|
return _REMOTE_INDEX["data"]
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(f"{GENERATION_URL}/api/tiles", timeout=4) as r:
|
||||||
|
data = json.loads(r.read().decode("utf-8"))
|
||||||
|
_worker_mark(True)
|
||||||
|
_REMOTE_INDEX.update({"data": data, "fetched": now})
|
||||||
|
except Exception as e:
|
||||||
|
_worker_mark(False)
|
||||||
|
_REMOTE_INDEX["fetched"] = now # ne pas marteler à chaque requête
|
||||||
|
logger.debug(f"Index distant injoignable : {e}")
|
||||||
|
return _REMOTE_INDEX["data"]
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/tiles")
|
@app.get("/api/tiles")
|
||||||
def tiles_data(stamp: Optional[float] = None):
|
def tiles_data(stamp: Optional[float] = None):
|
||||||
"""Données de carte (index_tiles.json) — tuiles visibles sans recharger.
|
"""Données de carte — cache local + index de la machine de traitement.
|
||||||
|
|
||||||
Le pipeline lancé par /api/generate tourne avec --incremental-index : il
|
Le pipeline lancé par /api/generate tourne avec --incremental-index : il
|
||||||
réécrit ce fichier après chaque tuile terminée. La carte sonde cet
|
réécrit index_tiles.json après chaque tuile terminée. La carte sonde cet
|
||||||
endpoint pendant un run et fusionne les nouveautés. Avec ?stamp=X : la
|
endpoint et fusionne les nouveautés. Sur la webapp légère, l'index du
|
||||||
réponse est allégée ({tiles: null}) tant que le fichier n'a pas changé.
|
worker (cache 60 s) fait autorité : les tuiles absentes du cache local y
|
||||||
|
figurent quand même — leurs images sont rapatriées à la visualisation par
|
||||||
|
le montage statique à la demande. Worker hors ligne : cache local seul,
|
||||||
|
sans erreur. Avec ?stamp=X : réponse allégée ({tiles: null}) si rien n'a
|
||||||
|
changé (le stamp combine les deux index).
|
||||||
"""
|
"""
|
||||||
path = OUTPUT_DIR / "index_tiles.json"
|
path = OUTPUT_DIR / "index_tiles.json"
|
||||||
try:
|
try:
|
||||||
current = path.stat().st_mtime
|
local_stamp = path.stat().st_mtime
|
||||||
if stamp is not None and abs(current - stamp) < 1e-4:
|
|
||||||
return {"stamp": current, "tiles": None}
|
|
||||||
data = json.loads(path.read_text(encoding="utf-8"))
|
data = json.loads(path.read_text(encoding="utf-8"))
|
||||||
except (OSError, ValueError):
|
except (OSError, ValueError):
|
||||||
|
local_stamp = None
|
||||||
|
data = None
|
||||||
|
remote = _remote_tiles_data()
|
||||||
|
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
|
||||||
|
if stamp is not None and current is not None and abs(current - stamp) < 1e-4:
|
||||||
|
return {"stamp": current, "tiles": None}
|
||||||
|
if data is None and remote is None:
|
||||||
return {"stamp": None, "tiles": None}
|
return {"stamp": None, "tiles": None}
|
||||||
data["stamp"] = current
|
# L'index distant prime (tuiles régénérées : ?v= neuf → re-téléchargement) ;
|
||||||
return data
|
# le local complète les positions que le worker n'a plus (historique).
|
||||||
|
merged = list((remote or {}).get("tiles") or [])
|
||||||
|
keys = {_tile_entry_key(t) for t in merged}
|
||||||
|
for t in (data or {}).get("tiles") or []:
|
||||||
|
k = _tile_entry_key(t)
|
||||||
|
if k not in keys:
|
||||||
|
merged.append(t)
|
||||||
|
keys.add(k)
|
||||||
|
viz_meta = dict((data or {}).get("viz_meta") or {})
|
||||||
|
viz_meta.update((remote or {}).get("viz_meta") or {})
|
||||||
|
return {
|
||||||
|
"tiles": merged,
|
||||||
|
"viz_meta": viz_meta,
|
||||||
|
"stats": (remote or data or {}).get("stats") or {},
|
||||||
|
"stamp": current,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
# --- Synchronisation + rebuild de l'index en arrière-plan -------------------
|
# --- Synchronisation + rebuild de l'index en arrière-plan -------------------
|
||||||
|
|||||||
Reference in New Issue
Block a user