diff --git a/backend/config/packages/messenger.yaml b/backend/config/packages/messenger.yaml new file mode 100644 index 0000000..7dc610a --- /dev/null +++ b/backend/config/packages/messenger.yaml @@ -0,0 +1,20 @@ +framework: + messenger: + transports: + # Transport du scheduler — traité en synchrone dans le worker scheduler_main + scheduler_main: + dsn: 'scheduler://main' + + # Transport async Redis pour les messages lourds (ingestion) + async: + dsn: '%env(REDIS_URL)%' + options: + stream: rs971_async + retry_strategy: + max_retries: 3 + delay: 60000 # 1 min entre chaque retry + multiplier: 2 + + routing: + # L'ingestion planifiée tourne dans le worker async (pas dans le worker scheduler) + App\Message\IngestionScheduleMessage: async diff --git a/backend/src/Controller/StatusController.php b/backend/src/Controller/StatusController.php new file mode 100644 index 0000000..9dac2c7 --- /dev/null +++ b/backend/src/Controller/StatusController.php @@ -0,0 +1,34 @@ +connection->fetchAssociative( + 'SELECT detected_at, source, coverage_area + FROM sargassum_observation + ORDER BY detected_at DESC LIMIT 1' + ); + + return $this->json([ + 'lastObservation' => $row !== false ? [ + 'detectedAt' => $row['detected_at'], + 'source' => $row['source'], + 'coverageKm2' => $row['coverage_area'] !== null ? (float) $row['coverage_area'] : null, + ] : null, + ]); + } +} diff --git a/backend/src/Message/IngestionScheduleMessage.php b/backend/src/Message/IngestionScheduleMessage.php new file mode 100644 index 0000000..56cfb76 --- /dev/null +++ b/backend/src/Message/IngestionScheduleMessage.php @@ -0,0 +1,9 @@ + [-61.30, 14.30, -60.70, 14.90], + 'guadeloupe' => [-61.90, 15.80, -61.00, 16.60], + 'sainte-lucie' => [-61.10, 13.70, -60.80, 14.20], + 'barbade' => [-59.80, 13.00, -59.30, 13.40], + 'saint-martin' => [-63.20, 17.80, -62.90, 18.20], + ]; + + public function __construct( + private IngestionService $ingestion, + private DriftSimulationService $drift, + private ImpactScoreService $scores, + private EntityManagerInterface $em, + private LoggerInterface $logger, + ) {} + + public function __invoke(IngestionScheduleMessage $message): void + { + $date = new \DateTimeImmutable('yesterday'); + $this->logger->info('Scheduled ingestion started', ['date' => $date->format('Y-m-d')]); + + // Étape 1 : ingestion de toutes les zones + foreach (self::ZONES as $name => $bbox) { + try { + $this->ingestion->ingestWithFallback($bbox, $date); + $this->logger->info('Zone ingested', ['zone' => $name]); + } catch (\Throwable $e) { + $this->logger->error('Ingestion failed', ['zone' => $name, 'error' => $e->getMessage()]); + } + } + + // Étape 2 : forecasts pour les observations sans prévision + $observations = $this->em->getRepository(SargassumObservation::class) + ->createQueryBuilder('o') + ->leftJoin('o.forecasts', 'f') + ->where('f.id IS NULL') + ->orderBy('o.detectedAt', 'DESC') + ->setMaxResults(10) + ->getQuery() + ->getResult(); + + foreach ($observations as $observation) { + try { + $forecasts = $this->drift->computeForecasts($observation); + $this->scores->computeForObservation($observation, $forecasts); + $this->logger->info('Forecasts computed', ['observation' => (string) $observation->getId()]); + } catch (\Throwable $e) { + $this->logger->error('Forecast failed', [ + 'observation' => (string) $observation->getId(), + 'error' => $e->getMessage(), + ]); + } + } + + $this->logger->info('Scheduled ingestion completed'); + } +} diff --git a/backend/src/Scheduler/MainSchedule.php b/backend/src/Scheduler/MainSchedule.php new file mode 100644 index 0000000..2d0ced2 --- /dev/null +++ b/backend/src/Scheduler/MainSchedule.php @@ -0,0 +1,32 @@ +schedule ??= (new Schedule()) + ->add( + // 06:00, 12:00, 18:00, 00:00 UTC — données satellite disponibles ~2h après le passage + RecurringMessage::cron('0 0,6,12,18 * * *', new IngestionScheduleMessage()) + ) + ->stateful($this->cache); + } +} diff --git a/docker/php/entrypoint.sh b/docker/php/entrypoint.sh index 9ee5d53..bf97b32 100644 --- a/docker/php/entrypoint.sh +++ b/docker/php/entrypoint.sh @@ -4,5 +4,15 @@ set -e # Warmup du cache Symfony au démarrage (les vars d'env sont disponibles ici) php bin/console cache:warmup --env=prod --no-debug 2>&1 || true +# Worker scheduler — réveille le pipeline toutes les 6h +php bin/console messenger:consume scheduler_main \ + --env=prod --no-debug --memory-limit=64M \ + 2>&1 | tee -a var/log/scheduler.log & + +# Worker async — exécute les messages d'ingestion déclenchés par le scheduler +php bin/console messenger:consume async \ + --env=prod --no-debug --memory-limit=256M \ + 2>&1 | tee -a var/log/worker.log & + # Lance FrankenPHP (comportement par défaut de l'image de base) exec frankenphp run --config /etc/caddy/Caddyfile "$@" diff --git a/frontend/src/api/client.js b/frontend/src/api/client.js index 0a3b583..09d4a03 100644 --- a/frontend/src/api/client.js +++ b/frontend/src/api/client.js @@ -40,6 +40,9 @@ export const api = { feedback: { create: (data) => post('/feedback', data), }, + status: { + get: () => get('/status'), + }, push: { vapidKey: () => get('/push/vapid-public-key'), subscribe: (data) => post('/push/subscribe', data), diff --git a/frontend/src/components/Map/SargassesMap.css b/frontend/src/components/Map/SargassesMap.css index c116488..771db57 100644 --- a/frontend/src/components/Map/SargassesMap.css +++ b/frontend/src/components/Map/SargassesMap.css @@ -1,3 +1,18 @@ +/* ── Indicateur fraîcheur données ── */ +.smap-freshness { + position: absolute; + bottom: 28px; + left: 10px; + background: rgba(255, 255, 255, 0.82); + backdrop-filter: blur(4px); + border-radius: 6px; + padding: 4px 10px; + font-size: 11px; + color: #555; + pointer-events: none; + z-index: 10; +} + /* ── Spot markers ── */ .smap-marker { cursor: pointer; diff --git a/frontend/src/components/Map/SargassesMap.jsx b/frontend/src/components/Map/SargassesMap.jsx index adfe5b5..2065327 100644 --- a/frontend/src/components/Map/SargassesMap.jsx +++ b/frontend/src/components/Map/SargassesMap.jsx @@ -156,6 +156,18 @@ export default function SargassesMap({ onSpotSelect, selectedSpotId, activeStep, const [viewState, setViewState] = useState(INITIAL_VIEW); const [layerOpacity, setLayerOpacity] = useState(1); const [mapStyle, setMapStyle] = useState(FALLBACK_STYLE); + const [dataDate, setDataDate] = useState(null); + + // Fraîcheur des données + useEffect(() => { + api.status.get() + .then(s => { + if (s.lastObservation?.detectedAt) { + setDataDate(new Date(s.lastObservation.detectedAt)); + } + }) + .catch(() => {}); + }, []); // Load cartoon map style useEffect(() => { @@ -254,6 +266,14 @@ export default function SargassesMap({ onSpotSelect, selectedSpotId, activeStep, > + {dataDate && ( +
+ Données du {dataDate.toLocaleDateString('fr-FR', { day: '2-digit', month: '2-digit' })} + {' à '} + {dataDate.toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' })} +
+ )} + {obsGeoJSON && (