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:
Gwadaking
2026-04-10 03:40:32 -04:00
parent 1d8771cdcb
commit 9712cb54db
20 changed files with 1653 additions and 872 deletions

View 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);
}
}
}