diff --git a/AGENTS.md b/AGENTS.md index 5cc6842..b3242d7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -5,7 +5,7 @@ - build webapp légère (Raspberry Pi, déploiement 2 machines — cf. `docs/DEPLOY_WEBAPP.md`): `docker compose -f docker-compose.webapp.yml up -d --build` (image `Dockerfile.webapp`, sans PDAL/GPU) - build générateur de tuiles (machine de traitement): `docker compose -f docker-compose.worker.yml up -d --build` (service `worker`, API pour les webapp distantes) - simulation locale du mode deux machines : `docker compose -f docker-compose.local-2m.yml up -d --build` — worker GPU sur :8974 + webapp légère sur :8973 avec son PROPRE cache `output-webapp/` peuplé à la demande depuis le worker. Permet de rebuild l'interface sans toucher au worker, et réciproquement. Résolution 0,2 m uniquement (GENERATE_RESOLUTIONS). -- stack webapp (machine légère): `./serve-webapp.sh [start|stop|restart|status|sync|logs|update]`, config dans `webapp.env` (modèle `webapp.env.example`, ignoré par git) +- stack webapp (machine légère): `./serve-webapp.sh [start|stop|restart|status|sync|logs]`, config dans `webapp.env` (modèle `webapp.env.example`, ignoré par git) ; mise à jour = `git pull` puis rebuild image + `restart` (cf. `docs/DEPLOY_WEBAPP.md`) - test all: `./run.sh --test` (rebuild automatique de l'image avant les tests ; en `docker run` direct, rebuild manuellement d'abord) - test file: `docker run --rm lidar-lidar python3 -m pytest -v --pyargs lidar_pipeline.tests.` - test case: `docker run --rm lidar-lidar python3 -m pytest -v --pyargs lidar_pipeline.tests.::::` diff --git a/docs/DEPLOY_WEBAPP.md b/docs/DEPLOY_WEBAPP.md index 14b4f68..298ca1d 100644 --- a/docs/DEPLOY_WEBAPP.md +++ b/docs/DEPLOY_WEBAPP.md @@ -114,9 +114,9 @@ nano webapp.env # LIDAR_GENERATION_URL, jeton... ``` Sous-commandes : `stop`, `restart` (relit `webapp.env`), `status` (conteneur -+ état du rebuild), `sync` (rebuild + vignettes), `logs`, `update` -(`git pull` + rebuild + redémarrage). Port hôte via `WEBAPP_PORT` dans -`webapp.env` ou l'environnement (`WEBAPP_PORT=9000 ./serve-webapp.sh`). ++ état du rebuild), `sync` (rebuild + vignettes), `logs`. Port hôte via +`WEBAPP_PORT` dans `webapp.env` ou l'environnement (`WEBAPP_PORT=9000 +./serve-webapp.sh`). **b. `run.sh`** — conteneur au premier plan (Ctrl-C arrête tout) : pratique pour tester, les variables se passent depuis l'environnement hôte, `~/.ssh` @@ -238,19 +238,20 @@ des générations. ## Mise à jour -Avec `serve-webapp.sh`, une seule commande (`git pull`, rebuild de l'image, -redémarrage) : - -```bash -./serve-webapp.sh update -``` - -Sinon, sur le Pi, après un `git pull` : +Sur le Pi, après un `git pull` : ```bash docker compose -f docker-compose.webapp.yml up -d --build # rebuild + redémarrage ``` +Avec `serve-webapp.sh`, l'image est à reconstruire explicitement avant de +redémarrer (le script ne reconstruit qu'au premier lancement) : + +```bash +docker build -f Dockerfile.webapp -t lidar-webapp . +./serve-webapp.sh restart +``` + 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. @@ -318,5 +319,5 @@ chaque lancement ; il suffit de relancer la commande. Le cache de tuiles - `Dockerfile.webapp`, `docker-compose.webapp.yml` : webapp légère ARM64 (FastAPI + Pillow AVIF natif + pyproj, ~200 Mo). - `serve-webapp.sh`, `webapp.env.example` : gestion de la stack webapp sur - le Pi (démarrage/arrêt/sync/logs/update), configuration locale dans + le Pi (démarrage/arrêt/sync/logs), configuration locale dans `webapp.env` (ignoré par git). diff --git a/lidar_pipeline/index.py b/lidar_pipeline/index.py index beca26b..0c8ecdd 100644 --- a/lidar_pipeline/index.py +++ b/lidar_pipeline/index.py @@ -12,8 +12,11 @@ Fonctionnalités : réordonnancement par glisser-déposer (persisté en localStorage). - Panneau d'infos par tuile : résolution, méthode de classification du sol (lue depuis output/DTM/*_dtm_method.txt), dates et tailles des fichiers. - - Génération de nouvelles zones via l'API web (./run.sh --serve) : - dessin d'un rectangle → téléchargement IGN + traitement. + - Génération via l'API web (./run.sh --serve) : dessin d'un rectangle + (téléchargement IGN + traitement) ou sélection cumulative au clic — + chaque tuile individuelle, avec ou sans données existantes ; les zones + et les clics s'additionnent, et une demande lancée pendant un run part + en file d'attente côté serveur (jamais de coupure du travail en place). - Onglet Export : sélection de dalles sur la carte → mosaïque jointive (image ou PDF multi-couches) téléchargeable pour consultation sur téléphone (/api/export de la webapp, module export.py). @@ -1294,7 +1297,7 @@ _HTML_TEMPLATE = """
- +
@@ -1342,7 +1345,8 @@ _HTML_TEMPLATE = """
File de génération - + +
@@ -1779,6 +1783,9 @@ body.zooming .leaflet-lidarTiles-pane { will-change: transform; } .gen-task.running .gen-task-dot { background: var(--accent); animation: gen-pulse 1.1s ease-in-out infinite; } +.gen-task.queued .gen-task-dot { + background: var(--muted); animation: gen-pulse 2.2s ease-in-out infinite; +} .gen-task.done .gen-task-dot { background: #39d98a; } .gen-task.failed .gen-task-dot, .gen-task.stopped .gen-task-dot { background: var(--danger); } @keyframes gen-pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.25; } } @@ -1788,6 +1795,7 @@ body.zooming .leaflet-lidarTiles-pane { will-change: transform; } } .gen-task-state { font-size: 11px; white-space: nowrap; } .gen-task.running .gen-task-state { color: var(--accent); } +.gen-task.queued .gen-task-state { color: var(--muted); } .gen-task.done .gen-task-state { color: #39d98a; } .gen-task.failed .gen-task-state, .gen-task.stopped .gen-task-state { color: var(--danger); } .gen-task-time { @@ -2053,7 +2061,7 @@ function registerTile(t) { }).addTo(map); mk.on('click', () => { if (exportMode) { toggleExportSel(t); return; } - if (genSelMode) { toggleGenSel(t); return; } + if (genSelMode) { toggleGenSelCell(t.col, t.row, t.corners); return; } if (!genMode) showTileInfo(t); }); const el = mk.getElement(); @@ -2799,7 +2807,10 @@ function saveViewHashSoon() { } window.addEventListener('pagehide', () => { clearTimeout(viewHashTimer); saveViewHash(); }); map.on('moveend', () => { updateImages(); saveViewHashSoon(); }); -map.on('click', hideTileInfo); +map.on('click', e => { + if (genSelMode) { pickGenSelCell(e.latlng); return; } + hideTileInfo(); +}); map.whenReady(() => layoutTiles()); // ============================================================ @@ -2975,12 +2986,16 @@ const genBadge = document.getElementById('genBadge'); const genStop = document.getElementById('genStop'); const genQueueBody = document.getElementById('genQueueBody'); const genQueueClear = document.getElementById('genQueueClear'); +const genQueueDrop = document.getElementById('genQueueDrop'); const genQueueCount = document.getElementById('genQueueCount'); let genMode = false, drawStart = null, drawRect = null; let pendingTiles = [], pendingRegen = false, pendingCompleteAll = false; const ghostCells = []; let genSelMode = false; -const genSel = new Set(); // clés "col,row" des dalles retenues pour la régénération +const genSel = new Set(); // clés "col,row" des dalles retenues (clics + zones cumulés) +const genSelCorners = new Map(); // clé "col,row" → coins GPS (dalles sans données) +const genSelPolys = new Map(); // clé "col,row" → polygone Leaflet de surlignage +const GEN_MAX_CELLS = 400; // garde-fou serveur : ~400 km² max par demande // Affiche la barre de confirmation de génération, dans l'onglet Génération // (bascule automatique sur cet onglet). @@ -3041,7 +3056,7 @@ function exitGenMode() { genBtn.addEventListener('click', () => { if (genMode) { exitGenMode(); return; } clearGhosts(); - clearGenSel(); + pendingCompleteAll = false; // nouvelle zone : le scope global « Compléter » est levé genBar.classList.remove('show'); genMode = true; map.dragging.disable(); @@ -3076,37 +3091,60 @@ map.on('mouseup', e => { viz: selectedGenViz() }) }).then(r => r.json()).then(d => { if (drawRect) { drawRect.remove(); drawRect = null; } - pendingTiles = (d.cells || []).map(c => [c.col, c.row]); pendingCompleteAll = false; // zone dessinée : le scope global est levé + // La zone S'AJOUTE à la sélection en cours (zones successives, clics + // individuels) : les tuiles déjà retenues sont conservées, jamais perdues. + let added = 0; + for (const c of (d.cells || [])) { + const k = genSelKey(c.col, c.row); + if (!genSel.has(k)) { genSel.add(k); added++; } + if (c.corners) genSelCorners.set(k, c.corners); + } + refreshGenSelHighlights(); + pendingTiles = genSelCells(); updateGenClassInfo(); - (d.cells || []).forEach(c => ghostCells.push( - L.polygon(c.corners, { color: '#e94560', weight: 1, dashArray: '4 4', fillOpacity: 0.08, pane: 'genDraw' }).addTo(map) - )); - if (!pendingTiles.length) { + if (!genSel.size) { genMsg.textContent = 'Zone déjà complète pour les visualisations choisies' + (genRegen.checked ? '' : ' (cochez « Régénérer » pour forcer le retraitement)'); genGo.style.display = 'none'; return; } - genMsg.textContent = pendingTiles.length + ' tuile(s) à ' + - (genRegen.checked ? 'régénérer' : 'générer ou compléter (visualisations manquantes)') + - (d.capped ? ' (zone tronquée à 400)' : ''); - genGo.style.display = ''; + genMsg.textContent = 'Zone : ' + added + ' tuile(s) ajoutée(s)' + + (d.capped ? ' (zone tronquée à 400)' : '') + + ' — ' + genSel.size + ' tuile(s) sélectionnée(s) au total' + + (genSel.size > GEN_MAX_CELLS + ? ' (maximum ' + GEN_MAX_CELLS + ' par lancement : retirez des tuiles)' : ''); + genGo.style.display = genSel.size > GEN_MAX_CELLS ? 'none' : ''; }).catch(() => { showGenBar('API indisponible'); }); }); -// « Sélectionner des tuiles » : mode régénération ciblée — le clic sur une -// dalle l'ajoute ou la retire (surlignage bleu), toutes résolutions confondues -// (les sous-tuiles 0,2 m comptent pour une seule dalle). La barre de -// confirmation permet ensuite de choisir les couches et les options avant de -// lancer la régénération (--force --force-classification) sur la sélection. +// « Sélectionner des tuiles » : sélection cumulative, tuile par tuile — le +// clic sur la carte ajoute ou retire la dalle de 1 km sous le curseur, avec +// ou sans données existantes (téléchargement + génération à la validation ; +// les sous-tuiles 0,2 m comptent pour une seule dalle). Les zones dessinées +// s'ajoutent à la même sélection. La barre de confirmation permet de choisir +// les couches et les options avant de lancer (--force implicite sur une +// sélection clic par clic : retraitement ciblé des tuiles choisies). function genSelKey(col, row) { return col + ',' + row; } function refreshGenSelHighlights() { + const markerCells = new Set(); for (const entry of markers.values()) { + const k = genSelKey(entry.t.col, entry.t.row); + markerCells.add(k); const div = entryDiv(entry); - if (!div) continue; - div.classList.toggle('genSel', genSel.has(genSelKey(entry.t.col, entry.t.row))); + if (div) div.classList.toggle('genSel', genSel.has(k)); + } + // Dalles sans données (aucun marqueur) : surlignage en polygone + for (const [k, poly] of genSelPolys) { + if (!genSel.has(k) || markerCells.has(k)) { poly.remove(); genSelPolys.delete(k); } + } + for (const k of genSel) { + if (markerCells.has(k) || genSelPolys.has(k)) continue; + const corners = genSelCorners.get(k); + if (!corners) continue; + genSelPolys.set(k, L.polygon(corners, + { color: '#5aa9e6', weight: 1.5, fillOpacity: 0.15, pane: 'genDraw' }).addTo(map)); } } @@ -3115,16 +3153,21 @@ function genSelCells() { } function updateGenSelBar() { - showGenBar(genSel.size - ? genSel.size + ' tuile(s) sélectionnée(s) — options puis Lancer' - : 'Mode sélection : clic sur dalle = ajouter / retirer'); - genGo.style.display = genSel.size ? '' : 'none'; + showGenBar(!genSel.size + ? 'Mode sélection : clic sur la carte = ajouter / retirer une tuile' + : genSel.size > GEN_MAX_CELLS + ? genSel.size + ' tuile(s) sélectionnée(s) — maximum ' + GEN_MAX_CELLS + ' par lancement' + : genSel.size + ' tuile(s) sélectionnée(s) — options puis Lancer'); + genGo.style.display = genSel.size && genSel.size <= GEN_MAX_CELLS ? '' : 'none'; } -function toggleGenSel(t) { - const key = genSelKey(t.col, t.row); +function toggleGenSelCell(col, row, corners) { + const key = genSelKey(col, row); if (genSel.has(key)) genSel.delete(key); - else genSel.add(key); + else { + genSel.add(key); + if (corners) genSelCorners.set(key, corners); + } refreshGenSelHighlights(); pendingTiles = genSelCells(); pendingRegen = true; @@ -3133,14 +3176,26 @@ function toggleGenSel(t) { updateGenClassInfo(); } +// Clic sur la carte hors dalle affichée : le serveur convertit le point +// WGS84 en dalle L93 de 1 km — permet de sélectionner des tuiles qui +// n'existent pas encore (aucun marqueur, aucune donnée). +function pickGenSelCell(latlng) { + fetch('/api/cell?lat=' + latlng.lat + '&lng=' + latlng.lng) + .then(r => r.ok ? r.json() : null) + .then(d => { + if (d && d.col !== null && d.col !== undefined && d.row !== undefined) + toggleGenSelCell(d.col, d.row, d.corners); + }).catch(() => { /* hors ligne : le clic suivant réessaiera */ }); +} + function setGenSelMode(on) { genSelMode = on; document.body.classList.toggle('genSel', on); if (btnGenSel) { btnGenSel.classList.toggle('primary', on); btnGenSel.innerHTML = on - ? '✓ Sélection en cours — cliquer des dalles' - : '↻ Sélectionner des tuiles'; + ? '✓ Sélection en cours — cliquer sur la carte' + : 'Sélectionner des tuiles'; } // Mobile : le tiroir latéral couvre la carte — on le referme pour laisser // la place aux clics (l'utilisateur le rouvre via ☰ pour valider). @@ -3149,7 +3204,7 @@ function setGenSelMode(on) { } if (hintChip) { hintChip.innerHTML = on - ? 'Mode sélection : clic sur dalle = ajouter / retirer
Échap pour terminer la sélection' + ? 'Mode sélection : clic sur la carte = ajouter / retirer une tuile
La sélection se cumule avec les zones dessinées — Échap pour terminer' : hintDefaultHtml; } if (on) { @@ -3167,6 +3222,7 @@ function clearGenSel() { if (genSelMode) setGenSelMode(false); if (genSel.size) { genSel.clear(); + genSelCorners.clear(); refreshGenSelHighlights(); } } @@ -3224,13 +3280,18 @@ genGo.addEventListener('click', () => { bare_earth: genBareEarth ? genBareEarth.checked : false, reclassify: genReclass ? genReclass.checked : false }) }).then(r => r.ok ? r.json() : r.json().then(j => Promise.reject(j.detail || ('HTTP ' + r.status)))) - .then(() => { + .then(d => { const verb = isRegen ? 'régénération' : pendingCompleteAll ? 'complétion' : 'génération'; pendingCompleteAll = false; pendingRegen = false; if (fromSel) clearGenSel(); - return startGenTask(nTiles + ' tuile(s) — ' + verb); + const label = nTiles + ' tuile(s) — ' + verb; + // Un run est déjà en cours : la demande est partie en file d'attente + // côté serveur et démarrera à sa fin, sans couper le travail en place. + if (d && d['demarré'] === false && d.en_file) + return startGenTask(label, { qid: d.qid, position: d.en_file }); + return startGenTask(label); }) .catch(msg => { genGo.style.display = 'none'; showGenBar('Erreur : ' + msg); }); }); @@ -3507,7 +3568,11 @@ function genTaskEl(t) { label.title = t.label; label.textContent = t.label; const state = document.createElement('span'); state.className = 'gen-task-state'; const tiles = t.tiles || []; - if (t.state === 'running') { + if (t.state === 'queued') { + state.textContent = t.position + ? 'En file — position ' + t.position + : 'En file d\'attente'; + } else if (t.state === 'running') { const finished = tiles.filter(x => x.state === 'done' || x.state === 'failed').length; state.textContent = tiles.length ? 'En cours — ' + finished + '/' + tiles.length + ' tuile(s)' @@ -3559,21 +3624,27 @@ function renderGenQueue() { // Plus récentes en haut for (const t of [...genTasks].reverse()) genQueueBody.appendChild(genTaskEl(t)); const running = genTasks.filter(t => t.state === 'running').length; + const queued = genTasks.filter(t => t.state === 'queued').length; genBadge.hidden = running === 0; genBadge.textContent = running > 0 ? String(running) : ''; if (genStop) { genStop.hidden = running === 0; genStop.disabled = false; } + const active = running + queued; genQueueCount.textContent = genTasks.length - ? (running ? running + ' en cours / ' + genTasks.length : genTasks.length + ' tâche(s)') + ? (active ? active + ' en cours / ' + genTasks.length : genTasks.length + ' tâche(s)') : ''; genQueue.hidden = genTasks.length === 0; } -function startGenTask(labelText) { +function startGenTask(labelText, opts) { const now = new Date(); + const qid = opts ? opts.qid : undefined; genTasks.push({ id: Date.now(), label: labelText, time: now.toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' }), - state: 'running', returncode: null, log: [], logOpen: false, tiles: [], + // Demande partie en file d'attente serveur : suivie jusqu'à son tour + state: qid !== undefined ? 'queued' : 'running', + qid: qid, position: opts ? opts.position : undefined, + returncode: null, log: [], logOpen: false, tiles: [], }); saveGenTasks(); renderGenQueue(); pollGenStatus(); } @@ -3664,7 +3735,12 @@ function mergeTilesData(data) { } } if (changed || metaChanged) renderLayerPanel(); - if (changed) { syncTileLayers(); layoutTiles(); } + if (changed) { + syncTileLayers(); layoutTiles(); + // Une dalle qui vient d'apparaître prend le relais du polygone de + // sélection (surlignage du marqueur désormais possible) + refreshGenSelHighlights(); + } } function pollLiveTiles() { const q = liveTilesStamp != null ? '?stamp=' + liveTilesStamp : ''; @@ -3683,8 +3759,31 @@ function pollGenStatus() { // Réponse d'erreur (ex: machine de traitement injoignable) : ignorer, // le sondage reprendra — ne pas marquer les tâches en échec. if (typeof d.running !== 'boolean') return; + // Demandes en file côté serveur : bouton de retrait + if (genQueueDrop) genQueueDrop.hidden = !(d.queue && d.queue.length); + // Tâches en file : démarrent quand le serveur attaque leur run (qid), + // « Interrompu » si la demande est retirée sans avoir tourné. + const serverQids = new Set((d.queue || []).map(q => q.id)); + for (const t of genTasks) { + if (t.state !== 'queued') continue; + const gone = !serverQids.has(t.qid); + if (d.running && d.qid === t.qid) { + // Le run de cette demande a démarré : les tâches « en cours » plus + // anciennes appartenaient au run précédent, forcément terminé (le + // serveur n'exécute qu'un run à la fois). + for (const o of genTasks) { + if (o !== t && o.state === 'running') { o.state = 'done'; o.returncode = 0; } + } + t.state = 'running'; t.started = d.started; t.position = undefined; + } else if (d.running && d.qid == null && gone) { + t.state = 'running'; t.started = d.started; t.position = undefined; + } else if (!d.running && gone) { + t.state = 'stopped'; + } + } const running = genTasks.filter(t => t.state === 'running'); - if (!running.length) { + const queued = genTasks.filter(t => t.state === 'queued'); + if (!running.length && !queued.length) { clearInterval(genPollTimer); genPollTimer = null; return; } @@ -3695,8 +3794,14 @@ function pollGenStatus() { if (d.tiles && d.tiles.length) t.tiles = d.tiles; } if (!d.running) { - clearInterval(genPollTimer); genPollTimer = null; finishRunningTasks(d); + if (genTasks.some(t => t.state === 'queued')) { + // Des demandes attendent encore leur tour : poursuivre la veille + // (le rechargement de fin de run attendra la fin de la file). + saveGenTasks(); renderGenQueue(); + return; + } + clearInterval(genPollTimer); genPollTimer = null; // Sauvegarde la vue courante pour qu'elle survive au rechargement // (sinon fitView() recadre sur l'ensemble des tuiles et le zoom part à zéro). try { @@ -3715,23 +3820,31 @@ function pollGenStatus() { // Après un run : reconstruire vignettes et carte depuis le cache à la demande // (/api/sync — sur la webapp légère, les tuiles proviennent de la machine de // traitement), puis recharger. /api/sync se réduit à un rebuild local : même -// flux partout. Rechargement de secours après 30 min. +// flux partout. Le rechargement attend la fin de CE rebuild (done >= started, +// horodatage serveur retourné au POST) : sans cela, un sondage trop tôt +// lirait le done du rebuild précédent et rechargerait une carte périmée. +// Rechargement de secours après 30 min. function syncThenReload() { - fetch('/api/sync', { method: 'POST' }).then(r => r.ok ? r.json() : null).then(() => { - const t0 = Date.now(); - const check = setInterval(() => { - fetch('/api/sync').then(r => r.ok ? r.json() : null).then(s => { - if (!s || !s.running || Date.now() - t0 > 30 * 60 * 1000) { - clearInterval(check); - location.reload(); - } - }).catch(() => { clearInterval(check); location.reload(); }); - }, 2000); - }).catch(() => location.reload()); + fetch('/api/sync', { method: 'POST' }) + .then(r => r.ok ? r.json() : null) + .then(d => { + const started = (d && d.started) || 0; + const t0 = Date.now(); + const check = setInterval(() => { + fetch('/api/sync').then(r => r.ok ? r.json() : null).then(s => { + const finished = s && !s.running + && (!started || (s.done || 0) >= started); + if (finished || (s && s.error) || Date.now() - t0 > 30 * 60 * 1000) { + clearInterval(check); + location.reload(); + } + }).catch(() => { clearInterval(check); location.reload(); }); + }, 2000); + }).catch(() => location.reload()); } genQueueClear.addEventListener('click', () => { - genTasks = genTasks.filter(t => t.state === 'running'); + genTasks = genTasks.filter(t => t.state === 'running' || t.state === 'queued'); saveGenTasks(); renderGenQueue(); }); @@ -3748,15 +3861,37 @@ if (genStop) genStop.addEventListener('click', () => { }); }); +// Retrait des demandes en file d'attente côté serveur : le run en cours +// continue, les demandes non démarrées sont abandonnées. +if (genQueueDrop) genQueueDrop.addEventListener('click', () => { + genQueueDrop.disabled = true; + fetch('/api/queue/clear', { method: 'POST' }) + .then(r => r.ok ? r.json() : r.json().then(j => Promise.reject(j.detail || ('HTTP ' + r.status)))) + .then(() => { + genQueueDrop.disabled = false; + genQueueDrop.hidden = true; + if (!genPollTimer) pollGenStatus(); // marquer les tâches « Interrompu » + }) + .catch(msg => { + genQueueDrop.disabled = false; + showGenBar('Erreur file d\'attente : ' + msg); + }); +}); + renderGenQueue(); // Reprend le suivi si une tâche tournait avant un rechargement de la page, // adopte une génération lancée hors interface (API directe), ou réaffiche le // dernier run terminé après un redémarrage du serveur (état rechargé du // fichier .generation.job.json). La clé `started` évite les doublons au // rechargement : un même run n'est adopté qu'une fois. -if (genTasks.some(t => t.state === 'running')) pollGenStatus(); +if (genTasks.some(t => t.state === 'running' || t.state === 'queued')) pollGenStatus(); else fetch('/api/status').then(r => r.ok ? r.json() : null).then(s => { if (!s || genTasks.some(t => t.started === s.started)) return; + // Déjà suivie par une tâche (en cours ou en file côté serveur) : pas + // d'adoption en doublon d'un run lancé depuis cet onglet. + const trackedQids = new Set(genTasks.filter(t => t.qid !== undefined).map(t => t.qid)); + if (s.qid != null && trackedQids.has(s.qid)) return; + if ((s.queue || []).some(q => trackedQids.has(q.id))) return; const now = new Date().toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' }); if (s.running) { genTasks.push({ diff --git a/lidar_pipeline/tests/test_webapp.py b/lidar_pipeline/tests/test_webapp.py index 6e76d66..be17fe3 100644 --- a/lidar_pipeline/tests/test_webapp.py +++ b/lidar_pipeline/tests/test_webapp.py @@ -294,6 +294,75 @@ def test_build_command_ground_classification(): assert ("--force-classification" in cmd) == reclassify +def test_rebuild_flag_synchronous(monkeypatch): + """Le rebuild est marqué running dès le POST (pas après coup). + + Un GET /api/sync juste après le POST doit voir le rebuild en cours : + sinon il lirait le done du rebuild précédent et l'interface + rechargerait une carte périmée (bug de rafraîchissement). + """ + import threading + import time + import lidar_pipeline.webapp as webapp + import lidar_pipeline.index as index_mod + + go = threading.Event() + + def slow_build(out): + go.wait(2) # rebuild artificiellement long + + monkeypatch.setattr(index_mod, "build_index", slow_build) + saved = dict(webapp._rebuild) + try: + d = webapp._start_rebuild() + assert d["demarré"] is True + assert d["started"] <= time.time() + # Déjà « running » au retour du POST, avant la fin du fil + assert webapp.rebuild_status()["running"] is True + assert webapp.rebuild_status()["done"] is None + go.set() + for _ in range(100): + if not webapp.rebuild_status()["running"]: + break + time.sleep(0.05) + s = webapp.rebuild_status() + assert s["running"] is False + assert s["done"] is not None and s["done"] >= d["started"] + finally: + webapp._rebuild.update(saved) + + +def test_static_mounts_served_no_cache(tmp_path): + """Les montages d'images servent Cache-Control: no-cache. + + Le ?v= des URLs suit la mtime de la source, pas celle du fichier servi + (vignette recalculée, cache webapp rapatrié) : sans revalidation imposée, + le cache heuristique du navigateur peut afficher l'ancien rendu. + """ + import asyncio + from starlette.routing import Mount + import lidar_pipeline.webapp as webapp + + assert issubclass(webapp._OnDemandStaticFiles, webapp._NoCacheStaticFiles) + mounted = {r.path: r for r in webapp.app.routes if isinstance(r, Mount)} + for name in ("index_thumbs", "index_subtiles", "visualisations", "DTM"): + route = mounted.get(f"/{name}") + assert route is not None, f"montage /{name} absent" + assert isinstance(route.app, webapp._NoCacheStaticFiles) + + # L'en-tête est bien posé sur la réponse servie + (tmp_path / "x.jpg").write_bytes(b"1") + srv = webapp._NoCacheStaticFiles(directory=str(tmp_path)) + + async def fetch(): + scope = {"type": "http", "method": "GET", "path": "/x.jpg", + "headers": [], "query_string": b""} + return await srv.get_response("x.jpg", scope) + + resp = asyncio.run(fetch()) + assert resp.headers["cache-control"] == "no-cache, must-revalidate" + + def test_stop_generation(monkeypatch): """/api/stop termine le processus en cours (SIGTERM) ou refuse (409).""" import time @@ -333,6 +402,117 @@ def test_stop_generation(monkeypatch): webapp._job.update(saved) +def test_point_to_cell_matches_bbox_cells(): + """point_to_cell désigne la cellule contenant le point (cohérent bbox_to_cells).""" + from lidar_pipeline.webapp import point_to_cell, bbox_to_cells + from rasterio.warp import transform as warp_transform + # Centre de la dalle 1054,6882 en L93 → WGS84 + lons, lats = warp_transform('EPSG:2154', 'EPSG:4326', [1054500], [6881500]) + lon, lat = lons[0], lats[0] + assert point_to_cell(lon, lat) == (1054, 6882) + # Une petite bbox autour du même point donne la même cellule, elle seule + cells = bbox_to_cells(lon - 1e-4, lat - 1e-4, lon + 1e-4, lat + 1e-4) + assert cells == [(1054, 6882)] + # Point vers le bord est de la dalle suivante + lons, lats = warp_transform('EPSG:2154', 'EPSG:4326', [1055800], [6881500]) + assert point_to_cell(lons[0], lats[0]) == (1055, 6882) + + +def test_api_cell_returns_corners(): + """/api/cell (clic carte) renvoie la dalle L93 et ses coins GPS.""" + from lidar_pipeline.webapp import cell_at_point + from rasterio.warp import transform as warp_transform + lons, lats = warp_transform('EPSG:2154', 'EPSG:4326', [1054500], [6881500]) + d = cell_at_point(lat=lats[0], lng=lons[0]) + assert d["col"] == 1054 and d["row"] == 6882 + assert len(d["corners"]) == 4 # SW, SE, NE, NW + + +def test_generate_enqueues_while_running(tmp_path, monkeypatch): + """Une demande pendant un run part en file (plus de 409, rien n'est coupé).""" + import time + import lidar_pipeline.webapp as webapp + + class FakeProc: + pid = 4242 + def poll(self): + return None # en cours + def wait(self, timeout=None): + return None + + saved_job = dict(webapp._job) + saved_queue = list(webapp._queue) + monkeypatch.setattr(webapp, "QUEUE_FILE", tmp_path / "queue.json") + try: + webapp._job.update({"proc": FakeProc(), "started": time.time(), + "returncode": None, "cmd": ["x"], "finished": None, + "qid": None}) + webapp._queue.clear() + req = webapp.GenerateRequest(tiles=[[1054, 6882]], viz=["aspect"]) + d = webapp.generate(req) + assert d["demarré"] is False + assert d["en_file"] == 1 and d["tuiles"] == 1 and d["qid"] is not None + assert len(webapp._queue) == 1 + assert webapp._queue[0]["req"]["tiles"] == [[1054, 6882]] + # La file survit à un « redémarrage » du serveur + webapp._queue.clear() + webapp._load_queue() + assert len(webapp._queue) == 1 + # Retrait des demandes en attente (le run en cours n'est pas touché) + assert webapp.queue_clear() == {"retirées": 1} + assert webapp._queue == [] + finally: + webapp._job.update(saved_job) + webapp._queue[:] = saved_queue + + +def test_start_next_queued_launches_after_run(tmp_path, monkeypatch): + """Serveur libre + file non vide : la demande suivante démarre (fidélité qid).""" + import time + import lidar_pipeline.webapp as webapp + + captured = {} + + def fake_popen(cmd, **kwargs): + captured["cmd"] = cmd + + class _P: + def wait(self): + return 0 + + def poll(self): + return 0 + return _P() + + import subprocess as _real_subprocess + + class _SubprocessStub: + Popen = staticmethod(fake_popen) + run = staticmethod(_real_subprocess.run) + STDOUT = _real_subprocess.STDOUT + + monkeypatch.setattr(webapp, "subprocess", _SubprocessStub) + monkeypatch.setattr(webapp, "INPUT_DIR", tmp_path) + monkeypatch.setattr(webapp, "OUTPUT_DIR", tmp_path) + monkeypatch.setattr(webapp, "LOG_FILE", tmp_path / "gen.log") + monkeypatch.setattr(webapp, "QUEUE_FILE", tmp_path / "queue.json") + saved_job = dict(webapp._job) + saved_queue = list(webapp._queue) + try: + webapp._job.update({"proc": None, "started": None, "returncode": 0, + "cmd": None, "finished": time.time(), "qid": None}) + webapp._queue[:] = [{"id": 7, "queued_at": 1.0, + "req": {"tiles": [[1054, 6882]], "viz": ["aspect"]}}] + assert webapp._start_next_queued() is True + assert webapp._queue == [] # la demande est consommée + assert webapp._job["qid"] == 7 # le run porte l'identifiant de file + assert "--fetch-tiles" in captured["cmd"] + assert "1054,6882" in captured["cmd"] + finally: + webapp._job.update(saved_job) + webapp._queue[:] = saved_queue + + def test_build_command_default_viz_aspect(): """Sans choix de visualisation, la commande génère uniquement aspect.""" from lidar_pipeline.webapp import _build_command diff --git a/lidar_pipeline/webapp.py b/lidar_pipeline/webapp.py index 7985f70..f0cbe4f 100644 --- a/lidar_pipeline/webapp.py +++ b/lidar_pipeline/webapp.py @@ -85,6 +85,7 @@ OUTPUT_DIR = Path(os.environ.get("LIDAR_OUTPUT_DIR", "/data/output")) PORT = int(os.environ.get("LIDAR_PORT", "8973")) LOG_FILE = OUTPUT_DIR / ".generation.log" JOB_FILE = OUTPUT_DIR / ".generation.job.json" +QUEUE_FILE = OUTPUT_DIR / ".generation.queue.json" MAX_CELLS = 400 # garde-fou : ~400 km² max par demande (zones dessinées) MAX_CELLS_ALL = 10000 # garde-fou : passe globale « tout compléter » (dalles déjà présentes) MAX_EXPORT_TILES = 64 # garde-fou : dalles assemblables par export (mémoire) @@ -245,7 +246,25 @@ def _fetch_remote_to_cache(rel_path): return False -class _OnDemandStaticFiles(_StaticFiles): +class _NoCacheStaticFiles(_StaticFiles): + """Fichiers statiques servis sans cache navigateur (revalidation 304). + + Les images (visualisations, vignettes, sous-tuiles, DTM) n'ont pas + toujours une URL invalidée après régénération : le ?v= suit la mtime de + la SOURCE, pas celle du fichier servi (vignette recalculée après coup, + cache webapp rapatrié…). Sans Cache-Control, le cache heuristique du + navigateur peut continuer d'afficher l'ancien rendu sur la même URL. + no-cache force la revalidation à chaque affichage — ETag/Last-Modified + la rend quasi gratuite quand le fichier n'a pas changé (réponse 304). + """ + + async def get_response(self, path, scope): + resp = await super().get_response(path, scope) + resp.headers["Cache-Control"] = "no-cache, must-revalidate" + return resp + + +class _OnDemandStaticFiles(_NoCacheStaticFiles): """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- @@ -294,7 +313,7 @@ for name in ("index_thumbs", "index_subtiles", "visualisations", "DTM"): _OnDemandStaticFiles(directory=str(_dir), url_prefix=name), name=name) else: - app.mount(f"/{name}", _StaticFiles(directory=str(_dir)), name=name) + app.mount(f"/{name}", _NoCacheStaticFiles(directory=str(_dir)), name=name) @app.get("/assets/{file_path:path}") @@ -507,7 +526,7 @@ class ExportRequest(BaseModel): # --- État du job de génération ------------------------------------------- -_job = {"proc": None, "started": None, "returncode": None, "cmd": None, "finished": None} +_job = {"proc": None, "started": None, "returncode": None, "cmd": None, "finished": None, "qid": None} _job_lock = threading.Lock() @@ -518,7 +537,7 @@ def _save_job_state(): plus le dernier run et la file de génération reste vide au rechargement. Best-effort : n'acquiert pas le verrou (appelé aussi sous verrou). """ - state = {k: _job.get(k) for k in ("started", "returncode", "cmd", "finished")} + state = {k: _job.get(k) for k in ("started", "returncode", "cmd", "finished", "qid")} try: JOB_FILE.write_text(json.dumps(state), encoding="utf-8") except OSError: @@ -536,10 +555,54 @@ def _load_job_state(): _job["returncode"] = state.get("returncode") _job["cmd"] = state.get("cmd") _job["finished"] = state.get("finished") + _job["qid"] = state.get("qid") _load_job_state() +# --- File d'attente des demandes de génération ----------------------------- +# Une demande arrivée pendant un run n'est plus rejetée (ancien 409) : elle +# part en file et démarre automatiquement à la fin du run en cours — le +# travail déjà lancé n'est jamais coupé. La file survit aux redémarrages du +# serveur (fichier .generation.queue.json) et est vidable via /api/queue/clear. +_queue = [] +_queue_seq = 0 + + +def _save_queue(): + """Écrit la file d'attente sur disque (atomique, best-effort).""" + try: + tmp = QUEUE_FILE.with_suffix(".tmp") + tmp.write_text(json.dumps(_queue), encoding="utf-8") + os.replace(tmp, QUEUE_FILE) + except OSError: + pass + + +def _load_queue(): + """Recharge la file d'attente au démarrage (et son compteur d'ids).""" + global _queue_seq + try: + data = json.loads(QUEUE_FILE.read_text(encoding="utf-8")) + if isinstance(data, list): + _queue[:] = [it for it in data + if isinstance(it, dict) and isinstance(it.get("req"), dict)] + except (OSError, ValueError): + pass + _queue_seq = max((it.get("id", 0) for it in _queue), default=0) + + +def _queue_summary(): + """Aperçu de la file pour /api/status (sans la requête brute).""" + return [{"id": it.get("id"), + "tuiles": len(it["req"].get("tiles") or []), + "toutes": bool(it["req"].get("all_missing")), + "en_attente_depuis": it.get("queued_at")} + for it in _queue] + + +_load_queue() + # --- Presets de couches (partagés entre navigateurs) -------------------------- # Stockés dans OUTPUT_DIR/.presets.json : un preset est un jeu nommé de @@ -665,6 +728,28 @@ def bbox_to_cells(w, s, e, n): return [(c, r) for r in rows for c in cols] +def point_to_cell(lon, lat): + """Cellule L93 de 1 km (col, row) contenant un point WGS84. + + Cohérent avec bbox_to_cells : une cellule (col, row) couvre + X ∈ [col, col+1] km, Y ∈ [row-1, row] km — sert au clic carte pour + sélectionner une dalle précise, même sans données existantes. + """ + from .index import _approx_wgs84_to_l93 + try: + from rasterio.warp import transform as warp_transform + x, y = warp_transform("EPSG:4326", "EPSG:2154", [lon], [lat]) + x, y = x[0], y[0] + except ImportError: + try: + from pyproj import Transformer + transformer = Transformer.from_crs("EPSG:4326", "EPSG:2154", always_xy=True) + x, y = transformer.transform(lon, lat) + except ImportError: + x, y = _approx_wgs84_to_l93(lon, lat) + return int(math.floor(x / 1000)), int(math.floor(y / 1000)) + 1 + + def processed_cells(output_dir): """Ensemble des cellules (col, row) ayant déjà des visualisations.""" from .index import scan_tiles @@ -764,6 +849,12 @@ def status(request: Request = None): data["regen_allowed"] = regen_allowed return data from .progress import progress_snapshot + with _job_lock: + pending = bool(_queue) + if pending: + # File d'attente non vide et serveur libre (ex. redémarrage du + # conteneur en cours de file) : la demande suivante repart. + _start_next_queued() with _job_lock: proc = _job["proc"] running = proc is not None and proc.poll() is None @@ -772,6 +863,8 @@ def status(request: Request = None): "started": _job["started"], "returncode": _job["returncode"], "cmd": _job["cmd"], + "qid": _job.get("qid"), + "queue": _queue_summary(), "log": _tail_log(40), # L'interface masque les boutons de génération hors réseau local "regen_allowed": regen_allowed, @@ -896,15 +989,24 @@ _rebuild = {"running": False, "error": None, "done": None, "phase": None} def _start_rebuild(): - """Lance en arrière-plan le rebuild de l'index (vignettes + carte).""" + """Lance en arrière-plan le rebuild de l'index (vignettes + carte). + + L'état running est posé de façon SYNCHRONE, avant le fil : un GET + /api/sync juste après le POST doit voir le rebuild en cours — sinon il + lirait le done du rebuild précédent et l'interface rechargerait une + carte périmée. Le timestamp started retourné permet au client + d'attendre la fin de CE rebuild précis (done >= started). + """ if _rebuild["running"]: raise HTTPException(409, "un rebuild est déjà en cours") + started = time.time() + _rebuild.update({"running": True, "error": None, "done": None, + "phase": "index"}) + # Index distant (mode deux machines) : cache 60 s vidé pour que le + # premier /api/tiles suivant reflète l'état du worker sans attendre. + _REMOTE_INDEX["fetched"] = 0.0 def _run(): - _rebuild["running"] = True - _rebuild["error"] = None - _rebuild["done"] = None - _rebuild["phase"] = "index" try: from .index import build_index build_index(OUTPUT_DIR) @@ -916,7 +1018,7 @@ def _start_rebuild(): _rebuild["done"] = time.time() threading.Thread(target=_run, daemon=True).start() - return {"demarré": True} + return {"demarré": True, "started": started} @app.post("/api/sync", dependencies=[Depends(_require_token)]) @@ -984,6 +1086,24 @@ def preview(req: PreviewRequest): return {"count": len(todo), "capped": capped, "cells": todo} +@app.get("/api/cell") +def cell_at_point(lat: float, lng: float): + """Dalle L93 de 1 km contenant un point WGS84 (clic sur la carte). + + Retourne col, row et les coins GPS de la dalle — permet de sélectionner + une tuile individuelle même sans données existantes (téléchargement + + génération à la validation). + """ + if GENERATION_URL: + # Webapp légère : la conversion précise vit sur la machine de traitement + return _proxy_api("GET", f"/api/cell?lat={lat}&lng={lng}") + from .index import attach_gps_bounds + col, row = point_to_cell(lng, lat) + tile = {"col": col, "row": row} + attach_gps_bounds([tile]) + return {"col": col, "row": row, "corners": tile.get("corners") or []} + + def _build_command(tiles, regenerate=False, ground_class="ign", bare_earth=False, ign_classes="sol", viz=None, reclassify=False): """Commande de génération : téléchargement IGN + traitement des fichiers. @@ -1029,15 +1149,13 @@ def _build_command(tiles, regenerate=False, ground_class="ign", bare_earth=False return cmd -@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, - # qui valide les paramètres et renvoie son propre état de job. - data = _proxy_api("POST", "/api/generate", json.loads(req.json())) - data["distant"] = True - return data +def _resolve_request(req): + """Valide une demande de génération et résout sa liste de tuiles. + + Retourne (tuiles [(col, row)], viz [noms d'étapes]). Réexécutée au + démarrage de chaque demande sortie de file : les dalles présentes dans + input/ peuvent avoir changé entre la mise en file et le départ du run. + """ names = _viz_step_names() viz = [v for v in (req.viz or []) if v] or ["aspect"] invalid = [v for v in viz if v not in names] @@ -1067,41 +1185,124 @@ def generate(req: GenerateRequest): raise HTTPException( 400, f"méthode de classification invalide : {req.ground_class!r} " f"(attendu : {', '.join(GROUND_CLASS_METHODS)})") + return tiles, viz + +def _launch_job(tiles, viz, req, qid=None): + """Démarre un run du pipeline. À appeler sous _job_lock, serveur libre. + + Retourne la commande lancée. qid : identifiant de file d'attente quand + la demande vient de la file (suivi côté interface). + """ + cmd = _build_command(tiles, regenerate=req.regenerate, ground_class=req.ground_class, + bare_earth=req.bare_earth, ign_classes=req.ign_classes, + viz=viz, reclassify=req.reclassify) + LOG_FILE.parent.mkdir(parents=True, exist_ok=True) + log_fh = open(LOG_FILE, "w", encoding="utf-8") + # Nouveau run : journal d'événements remis à zéro (les tuiles affichées + # dans la file correspondent au run qui démarre, pas au précédent) + from .progress import reset_events + reset_events(OUTPUT_DIR) + _job.update({"proc": None, "started": time.time(), "returncode": None, + "cmd": cmd, "finished": None, "qid": qid}) + _save_job_state() + # start_new_session : le pipeline et ses workers/PDAL forment leur + # propre groupe de processus — /api/stop peut le tuer en bloc sans + # toucher la webapp, et le gestionnaire SIGTERM du pipeline (killpg) + # reste confiné à son groupe. + p = subprocess.Popen(cmd, stdout=log_fh, stderr=subprocess.STDOUT, + cwd="/app" if Path("/app").exists() else None, + start_new_session=True) + + def _watch(): + rc = p.wait() + log_fh.close() + with _job_lock: + _job["returncode"] = rc + _job["finished"] = time.time() + _save_job_state() + # Le run est fini : la demande suivante de la file peut partir. + _start_next_queued() + + threading.Thread(target=_watch, daemon=True).start() + _job["proc"] = p + return cmd + + +def _start_next_queued(): + """Démarre la première demande de la file dès que le serveur est libre. + + Les demandes devenues invalides (ex. plus aucune tuile à compléter) + sont ignorées ; une demande repoussée en tête de file si un run part + entre-temps. Ne coupe jamais le run en cours. + """ + while True: + with _job_lock: + proc = _job["proc"] + if proc is not None and proc.poll() is None: + return False + if not _queue: + return False + item = _queue.pop(0) + _save_queue() + try: + req = GenerateRequest(**item["req"]) + tiles, viz = _resolve_request(req) + except HTTPException as e: + logger.warning("Demande en file ignorée : %s", e.detail) + continue + with _job_lock: + proc = _job["proc"] + if proc is not None and proc.poll() is None: + _queue.insert(0, item) # un autre run est parti : remise en tête + _save_queue() + return False + _launch_job(tiles, viz, req, qid=item.get("id")) + return True + + +@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, + # qui valide les paramètres, met en file et renvoie son propre état. + data = _proxy_api("POST", "/api/generate", json.loads(req.json())) + data["distant"] = True + return data + tiles, viz = _resolve_request(req) with _job_lock: proc = _job["proc"] - if proc is not None and proc.poll() is None: - raise HTTPException(409, "une génération est déjà en cours") - cmd = _build_command(tiles, regenerate=req.regenerate, ground_class=req.ground_class, bare_earth=req.bare_earth, ign_classes=req.ign_classes, viz=viz, reclassify=req.reclassify) - LOG_FILE.parent.mkdir(parents=True, exist_ok=True) - log_fh = open(LOG_FILE, "w", encoding="utf-8") - # Nouveau run : journal d'événements remis à zéro (les tuiles affichées - # dans la file correspondent au run qui démarre, pas au précédent) - from .progress import reset_events - reset_events(OUTPUT_DIR) - _job.update({"proc": None, "started": time.time(), "returncode": None, "cmd": cmd, "finished": None}) - _save_job_state() - # start_new_session : le pipeline et ses workers/PDAL forment leur - # propre groupe de processus — /api/stop peut le tuer en bloc sans - # toucher la webapp, et le gestionnaire SIGTERM du pipeline (killpg) - # reste confiné à son groupe. - p = subprocess.Popen(cmd, stdout=log_fh, stderr=subprocess.STDOUT, - cwd="/app" if Path("/app").exists() else None, - start_new_session=True) - - def _watch(): - rc = p.wait() - log_fh.close() - with _job_lock: - _job["returncode"] = rc - _job["finished"] = time.time() - _save_job_state() - - threading.Thread(target=_watch, daemon=True).start() - _job["proc"] = p + running = proc is not None and proc.poll() is None + if running: + # Un run est en cours : la demande part en file d'attente et + # démarrera à sa fin — jamais de coupure du travail en place. + global _queue_seq + _queue_seq += 1 + item = {"id": _queue_seq, "req": json.loads(req.json()), + "queued_at": time.time()} + _queue.append(item) + _save_queue() + return {"demarré": False, "en_file": len(_queue), "qid": item["id"], + "tuiles": len(tiles)} + cmd = _launch_job(tiles, viz, req) return {"demarré": True, "tuiles": len(tiles), "commande": " ".join(cmd)} +@app.post("/api/queue/clear", dependencies=[Depends(_require_token), + Depends(_require_lan_for_generation)]) +def queue_clear(): + """Retire les demandes en attente (le run en cours n'est pas touché).""" + if GENERATION_URL: + # Webapp légère : la file vit sur la machine de traitement + return _proxy_api("POST", "/api/queue/clear") + with _job_lock: + n = len(_queue) + _queue.clear() + _save_queue() + return {"retirées": n} + + @app.post("/api/stop", dependencies=[Depends(_require_token), Depends(_require_lan_for_generation)]) def stop_generation(): diff --git a/serve-webapp.sh b/serve-webapp.sh index 4119c5e..affc223 100755 --- a/serve-webapp.sh +++ b/serve-webapp.sh @@ -23,7 +23,6 @@ # ./serve-webapp.sh status état du conteneur et de la dernière sync # ./serve-webapp.sh sync forcer un rebuild + régénération des vignettes # ./serve-webapp.sh logs suivre les logs du conteneur -# ./serve-webapp.sh update git pull, rebuild de l'image, redémarrer set -e SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" @@ -174,13 +173,6 @@ cmd_sync() { esac } -cmd_update() { - echo "Mise à jour du code (git pull)..." - git -C "$SCRIPT_DIR" pull --ff-only - build_image - cmd_start -} - case "$CMD" in start) cmd_start ;; stop) cmd_stop ;; @@ -188,9 +180,8 @@ case "$CMD" in status) cmd_status ;; sync) cmd_sync ;; logs) exec docker logs -f "$CONTAINER" ;; - update) cmd_update ;; *) - echo "Usage : $0 [start|stop|restart|status|sync|logs|update]" >&2 + echo "Usage : $0 [start|stop|restart|status|sync|logs]" >&2 exit 1 ;; esac