feat: phase A — backup B2, healthcheck /api/status, vector tiles Tippecanoe
- A1: scripts/backup-postgres.sh — pg_dump quotidien compressé → Backblaze B2 (rclone), rétention 30j - A2: StatusController retourne HTTP 503 + healthy/alertReason si dernière observation > 6h - A4: Dockerfile installe tippecanoe, GenerateTilesCommand génère 5 tilesets (obs + h6/12/24/48), Caddyfile sert /tiles/* sans fallback SPA, SargassesMap.jsx passe en sources vector tiles statiques - chore: backend/public/assets/ ajouté au .gitignore (build artifacts) - chore: setup vitest frontend + ImpactScoreServiceTest (session précédente) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
151
backend/src/Command/GenerateTilesCommand.php
Normal file
151
backend/src/Command/GenerateTilesCommand.php
Normal file
@@ -0,0 +1,151 @@
|
||||
<?php
|
||||
|
||||
namespace App\Command;
|
||||
|
||||
use Doctrine\DBAL\Connection;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
|
||||
#[AsCommand(
|
||||
name: 'app:generate-tiles',
|
||||
description: 'Génère les vector tiles (Tippecanoe) pour observations et prédictions',
|
||||
)]
|
||||
class GenerateTilesCommand extends Command
|
||||
{
|
||||
private const TILE_HORIZONS = [6, 12, 24, 48];
|
||||
private const MIN_ZOOM = 5;
|
||||
private const MAX_ZOOM = 12;
|
||||
|
||||
public function __construct(
|
||||
private Connection $connection,
|
||||
#[Autowire('%kernel.project_dir%')] private string $projectDir,
|
||||
private LoggerInterface $logger,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
$io->title('Génération vector tiles');
|
||||
|
||||
$tilesDir = $this->projectDir . '/public/tiles';
|
||||
|
||||
// Observations
|
||||
$io->section('Observations');
|
||||
$geojson = $this->queryObservationsGeoJSON();
|
||||
if ($geojson !== null) {
|
||||
$this->runTippecanoe($geojson, $tilesDir . '/observations', 'observations', $io);
|
||||
} else {
|
||||
$io->warning('Aucune observation récente — tiles non générées.');
|
||||
}
|
||||
|
||||
// Prévisions par horizon
|
||||
foreach (self::TILE_HORIZONS as $horizon) {
|
||||
$io->section("Prévisions H+{$horizon}");
|
||||
$geojson = $this->queryForecastsGeoJSON($horizon);
|
||||
if ($geojson !== null) {
|
||||
$this->runTippecanoe($geojson, $tilesDir . '/forecasts/h' . $horizon, 'forecasts', $io);
|
||||
} else {
|
||||
$io->warning("Aucune prévision H+{$horizon} — tiles non générées.");
|
||||
}
|
||||
}
|
||||
|
||||
$io->success('Génération terminée.');
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
private function queryObservationsGeoJSON(): ?string
|
||||
{
|
||||
$rows = $this->connection->fetchAllAssociative(
|
||||
"SELECT ST_AsGeoJSON(geometry) AS geometry, afai_mean AS afai
|
||||
FROM sargassum_observation
|
||||
WHERE detected_at >= NOW() - INTERVAL '30 days'
|
||||
AND afai_mean > 0
|
||||
ORDER BY detected_at DESC"
|
||||
);
|
||||
|
||||
return $this->buildFeatureCollection($rows, [
|
||||
'afai' => static fn($r) => (float) $r['afai'],
|
||||
]);
|
||||
}
|
||||
|
||||
private function queryForecastsGeoJSON(int $horizon): ?string
|
||||
{
|
||||
$rows = $this->connection->fetchAllAssociative(
|
||||
"SELECT ST_AsGeoJSON(geometry) AS geometry, confidence
|
||||
FROM sargassum_forecast
|
||||
WHERE time_horizon = :horizon
|
||||
AND computed_at >= NOW() - INTERVAL '30 days'
|
||||
ORDER BY computed_at DESC",
|
||||
['horizon' => $horizon]
|
||||
);
|
||||
|
||||
return $this->buildFeatureCollection($rows, [
|
||||
'confidence' => static fn($r) => (float) $r['confidence'],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<array<string, mixed>> $rows
|
||||
* @param array<string, callable(array<string, mixed>): mixed> $props
|
||||
*/
|
||||
private function buildFeatureCollection(array $rows, array $props): ?string
|
||||
{
|
||||
if (empty($rows)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$features = [];
|
||||
foreach ($rows as $row) {
|
||||
$properties = [];
|
||||
foreach ($props as $key => $fn) {
|
||||
$properties[$key] = $fn($row);
|
||||
}
|
||||
$features[] = [
|
||||
'type' => 'Feature',
|
||||
'geometry' => json_decode($row['geometry'], true, 512, JSON_THROW_ON_ERROR),
|
||||
'properties' => $properties,
|
||||
];
|
||||
}
|
||||
|
||||
return json_encode(
|
||||
['type' => 'FeatureCollection', 'features' => $features],
|
||||
JSON_THROW_ON_ERROR
|
||||
);
|
||||
}
|
||||
|
||||
private function runTippecanoe(string $geojson, string $outputDir, string $layer, SymfonyStyle $io): void
|
||||
{
|
||||
$tmpFile = sys_get_temp_dir() . '/tiles_' . uniqid() . '.geojson';
|
||||
file_put_contents($tmpFile, $geojson);
|
||||
|
||||
try {
|
||||
$cmd = sprintf(
|
||||
'tippecanoe -e %s --force --layer=%s -Z%d -z%d --drop-densest-as-needed --quiet %s 2>&1',
|
||||
escapeshellarg($outputDir),
|
||||
escapeshellarg($layer),
|
||||
self::MIN_ZOOM,
|
||||
self::MAX_ZOOM,
|
||||
escapeshellarg($tmpFile)
|
||||
);
|
||||
|
||||
exec($cmd, $cmdOutput, $exitCode);
|
||||
|
||||
if ($exitCode !== 0) {
|
||||
$errorMsg = implode("\n", $cmdOutput);
|
||||
$this->logger->error('tippecanoe failed', ['output' => $errorMsg, 'dir' => $outputDir]);
|
||||
$io->error("Tippecanoe a échoué : {$errorMsg}");
|
||||
} else {
|
||||
$io->success("→ {$outputDir}");
|
||||
}
|
||||
} finally {
|
||||
@unlink($tmpFile);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -29,7 +29,27 @@ class StatusController extends AbstractController
|
||||
ORDER BY requested_at DESC LIMIT 1"
|
||||
);
|
||||
|
||||
// Healthcheck : données fraîches si dernière observation < 6h
|
||||
$healthy = false;
|
||||
$alertReason = null;
|
||||
|
||||
if ($obs !== false && $obs['detected_at'] !== null) {
|
||||
$detectedAt = new \DateTimeImmutable($obs['detected_at'], new \DateTimeZone('UTC'));
|
||||
$now = new \DateTimeImmutable('now', new \DateTimeZone('UTC'));
|
||||
$ageSeconds = $now->getTimestamp() - $detectedAt->getTimestamp();
|
||||
|
||||
if ($ageSeconds <= 6 * 3600) {
|
||||
$healthy = true;
|
||||
} else {
|
||||
$alertReason = 'stale_data';
|
||||
}
|
||||
} else {
|
||||
$alertReason = 'no_data';
|
||||
}
|
||||
|
||||
return $this->json([
|
||||
'healthy' => $healthy,
|
||||
'alertReason' => $alertReason,
|
||||
'lastObservation' => $obs !== false ? [
|
||||
'detectedAt' => $obs['detected_at'],
|
||||
'source' => $obs['source'],
|
||||
@@ -43,6 +63,6 @@ class StatusController extends AbstractController
|
||||
'finishedAt' => $job['processed_at'],
|
||||
'retryCount' => (int) $job['retry_count'],
|
||||
] : null,
|
||||
]);
|
||||
], $healthy ? 200 : 503);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user