feat: ingestion automatique toutes les 6h + indicateur fraîcheur UI
- Symfony Scheduler (MainSchedule) : cron 0,6,12,18h UTC - IngestionScheduleMessage + IngestionScheduleHandler : pipeline complet ingestion → forecasts → impact scores pour toutes les zones Antilles - messenger.yaml : transport scheduler_main + async Redis (rs971_async) - entrypoint.sh : 2 workers en background (scheduler_main + async) - GET /api/status : date/heure de la dernière observation - SargassesMap : badge "Données du JJ/MM à HH:MM" en bas à gauche Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
20
backend/config/packages/messenger.yaml
Normal file
20
backend/config/packages/messenger.yaml
Normal file
@@ -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
|
||||||
34
backend/src/Controller/StatusController.php
Normal file
34
backend/src/Controller/StatusController.php
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Controller;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Connection;
|
||||||
|
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||||
|
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||||
|
use Symfony\Component\Routing\Attribute\Route;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/status — fraîcheur des données pour l'indicateur UI
|
||||||
|
*/
|
||||||
|
#[Route('/api/status', name: 'api_status', methods: ['GET'])]
|
||||||
|
class StatusController extends AbstractController
|
||||||
|
{
|
||||||
|
public function __construct(private Connection $connection) {}
|
||||||
|
|
||||||
|
public function __invoke(): JsonResponse
|
||||||
|
{
|
||||||
|
$row = $this->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,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
9
backend/src/Message/IngestionScheduleMessage.php
Normal file
9
backend/src/Message/IngestionScheduleMessage.php
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Message;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Message déclenché automatiquement par le Scheduler toutes les 6h.
|
||||||
|
* Lance le pipeline complet : ingestion Sentinel → forecasts → scores.
|
||||||
|
*/
|
||||||
|
final class IngestionScheduleMessage {}
|
||||||
80
backend/src/MessageHandler/IngestionScheduleHandler.php
Normal file
80
backend/src/MessageHandler/IngestionScheduleHandler.php
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\MessageHandler;
|
||||||
|
|
||||||
|
use App\Entity\SargassumObservation;
|
||||||
|
use App\Message\IngestionScheduleMessage;
|
||||||
|
use App\Service\Forecast\DriftSimulationService;
|
||||||
|
use App\Service\Forecast\ImpactScoreService;
|
||||||
|
use App\Service\Ingestion\IngestionService;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use Psr\Log\LoggerInterface;
|
||||||
|
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exécute le pipeline complet à chaque tick du scheduler :
|
||||||
|
* 1. Ingestion Sentinel-2/3 pour chaque zone Antilles
|
||||||
|
* 2. Calcul des forecasts de dérive pour les nouvelles observations
|
||||||
|
* 3. Calcul des ImpactScores pour tous les spots côtiers
|
||||||
|
*/
|
||||||
|
#[AsMessageHandler]
|
||||||
|
final class IngestionScheduleHandler
|
||||||
|
{
|
||||||
|
// Zones d'intérêt — même liste que IngestSentinelCommand
|
||||||
|
private const ZONES = [
|
||||||
|
'martinique' => [-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');
|
||||||
|
}
|
||||||
|
}
|
||||||
32
backend/src/Scheduler/MainSchedule.php
Normal file
32
backend/src/Scheduler/MainSchedule.php
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Scheduler;
|
||||||
|
|
||||||
|
use App\Message\IngestionScheduleMessage;
|
||||||
|
use Symfony\Component\Scheduler\Attribute\AsSchedule;
|
||||||
|
use Symfony\Component\Scheduler\RecurringMessage;
|
||||||
|
use Symfony\Component\Scheduler\Schedule;
|
||||||
|
use Symfony\Component\Scheduler\ScheduleProviderInterface;
|
||||||
|
use Symfony\Contracts\Cache\CacheInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Planifie l'ingestion satellite toutes les 6h.
|
||||||
|
* Le Schedule est mis en cache pour éviter les recréations inutiles.
|
||||||
|
*/
|
||||||
|
#[AsSchedule('main')]
|
||||||
|
final class MainSchedule implements ScheduleProviderInterface
|
||||||
|
{
|
||||||
|
private ?Schedule $schedule = null;
|
||||||
|
|
||||||
|
public function __construct(private CacheInterface $cache) {}
|
||||||
|
|
||||||
|
public function getSchedule(): Schedule
|
||||||
|
{
|
||||||
|
return $this->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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,5 +4,15 @@ set -e
|
|||||||
# Warmup du cache Symfony au démarrage (les vars d'env sont disponibles ici)
|
# 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
|
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)
|
# Lance FrankenPHP (comportement par défaut de l'image de base)
|
||||||
exec frankenphp run --config /etc/caddy/Caddyfile "$@"
|
exec frankenphp run --config /etc/caddy/Caddyfile "$@"
|
||||||
|
|||||||
@@ -40,6 +40,9 @@ export const api = {
|
|||||||
feedback: {
|
feedback: {
|
||||||
create: (data) => post('/feedback', data),
|
create: (data) => post('/feedback', data),
|
||||||
},
|
},
|
||||||
|
status: {
|
||||||
|
get: () => get('/status'),
|
||||||
|
},
|
||||||
push: {
|
push: {
|
||||||
vapidKey: () => get('/push/vapid-public-key'),
|
vapidKey: () => get('/push/vapid-public-key'),
|
||||||
subscribe: (data) => post('/push/subscribe', data),
|
subscribe: (data) => post('/push/subscribe', data),
|
||||||
|
|||||||
@@ -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 ── */
|
/* ── Spot markers ── */
|
||||||
.smap-marker {
|
.smap-marker {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|||||||
@@ -156,6 +156,18 @@ export default function SargassesMap({ onSpotSelect, selectedSpotId, activeStep,
|
|||||||
const [viewState, setViewState] = useState(INITIAL_VIEW);
|
const [viewState, setViewState] = useState(INITIAL_VIEW);
|
||||||
const [layerOpacity, setLayerOpacity] = useState(1);
|
const [layerOpacity, setLayerOpacity] = useState(1);
|
||||||
const [mapStyle, setMapStyle] = useState(FALLBACK_STYLE);
|
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
|
// Load cartoon map style
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -254,6 +266,14 @@ export default function SargassesMap({ onSpotSelect, selectedSpotId, activeStep,
|
|||||||
>
|
>
|
||||||
<NavigationControl position="bottom-right" />
|
<NavigationControl position="bottom-right" />
|
||||||
|
|
||||||
|
{dataDate && (
|
||||||
|
<div className="smap-freshness">
|
||||||
|
Données du {dataDate.toLocaleDateString('fr-FR', { day: '2-digit', month: '2-digit' })}
|
||||||
|
{' à '}
|
||||||
|
{dataDate.toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' })}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{obsGeoJSON && (
|
{obsGeoJSON && (
|
||||||
<Source id="observations" type="geojson" data={obsGeoJSON}>
|
<Source id="observations" type="geojson" data={obsGeoJSON}>
|
||||||
<Layer {...OBS_STYLES.fill} />
|
<Layer {...OBS_STYLES.fill} />
|
||||||
|
|||||||
Reference in New Issue
Block a user