Sélection de tuiles au clic, file d'attente et rafraîchissement fiable

- Clic sur la carte pour choisir des dalles précises, même sans données
  existantes (via /api/cell) ; les zones dessinées s'ajoutent à la
  sélection au lieu de la remplacer
- Une demande lancée pendant un run part en file d'attente côté serveur
  et démarre à la fin du travail en cours (plus de refus 409), file
  vidable depuis l'interface
- Tuiles et interface toujours fraîches : images servies sans cache
  navigateur (revalidation 304), rechargement de la carte seulement une
  fois le rebuild de l'index terminé
- serve-webapp.sh : sous-commande update retirée, documentation de
  déploiement corrigée en conséquence
This commit is contained in:
Antoine Jacquin
2026-09-16 22:37:53 +02:00
parent 26c05319fd
commit f024514427
6 changed files with 636 additions and 128 deletions

View File

@ -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.<module>`
- test case: `docker run --rm lidar-lidar python3 -m pytest -v --pyargs lidar_pipeline.tests.<module>::<TestClass>::<test_method>`

View File

@ -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).

View File

@ -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 = """<!DOCTYPE html>
<!-- Actions Zone / Sélection / Compléter : réservées à cet onglet -->
<div class="gen-actions">
<button id="btnGenZone" title="Générer une nouvelle zone : dessiner un rectangle, téléchargement IGN + traitement" hidden>+ Zone</button>
<button id="btnGenSel" title="Régénérer des tuiles précises : cliquer des dalles sur la carte pour les ajouter ou les retirer" hidden>↻ Sélectionner des tuiles</button>
<button id="btnGenSel" title="Sélectionner des tuiles précises, à générer ou régénérer : un clic sur la carte ajoute ou retire la dalle (les zones dessinées s'ajoutent à la sélection)" hidden>Sélectionner des tuiles</button>
<button id="btnComplete" title="Compléter : générer les visualisations manquantes sur toutes les dalles présentes dans input/ (sans téléchargement)" hidden><span class="ok-ic">✓</span> Compléter</button>
</div>
@ -1342,7 +1345,8 @@ _HTML_TEMPLATE = """<!DOCTYPE html>
<div class="queue-head">
<span class="panel-title">File de génération</span>
<span id="genQueueCount"></span>
<button id="genStop" class="icon-btn" title="Arrêter la génération en cours" hidden>■</button>
<button id="genStop" class="icon-btn" title="Arrêter la génération en cours (la file d'attente n'est pas touchée)" hidden>■</button>
<button id="genQueueDrop" class="icon-btn" title="Retirer les demandes en attente (elles démarreraient après le run en cours sinon)" hidden>⏏</button>
<button id="genQueueClear" class="icon-btn" title="Retirer les tâches terminées">Vider</button>
</div>
<div id="genQueueBody"></div>
@ -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<br>Échap pour terminer la sélection'
? 'Mode sélection : clic sur la carte = ajouter / retirer une tuile<br>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({

View File

@ -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

View File

@ -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():

View File

@ -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