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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

File diff suppressed because one or more lines are too long

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

View File

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

View File

@@ -0,0 +1,71 @@
<?php
namespace App\Tests\Unit\Service;
use App\Service\Forecast\ImpactScoreService;
use Doctrine\DBAL\Connection;
use Doctrine\ORM\EntityManagerInterface;
use PHPUnit\Framework\TestCase;
use Psr\Log\NullLogger;
use Symfony\Component\Cache\Adapter\ArrayAdapter;
use App\Service\Push\PushNotificationService;
class ImpactScoreServiceTest extends TestCase
{
private ImpactScoreService $service;
protected function setUp(): void
{
$connection = $this->createStub(Connection::class);
$em = $this->createStub(EntityManagerInterface::class);
$cache = new ArrayAdapter();
$push = $this->createStub(PushNotificationService::class);
$this->service = new ImpactScoreService(
$connection,
$em,
$cache,
new NullLogger(),
$push,
);
}
// ── scoreToLevel ──────────────────────────────────────────────────────────
#[\PHPUnit\Framework\Attributes\DataProvider('scoreLevelProvider')]
public function testScoreToLevel(int $score, string $expected): void
{
$method = new \ReflectionMethod(ImpactScoreService::class, 'scoreToLevel');
$result = $method->invoke($this->service, $score);
self::assertSame($expected, $result);
}
/** @return array<string, array{int, string}> */
public static function scoreLevelProvider(): array
{
return [
'score 0 → low' => [0, 'low'],
'score 30 → low' => [30, 'low'],
'score 31 → medium' => [31, 'medium'],
'score 70 → medium' => [70, 'medium'],
'score 71 → high' => [71, 'high'],
'score 100 → high' => [100, 'high'],
];
}
// ── emptyScore ────────────────────────────────────────────────────────────
public function testEmptyScoreReturnsCorrectStructure(): void
{
$method = new \ReflectionMethod(ImpactScoreService::class, 'emptyScore');
$result = $method->invoke($this->service);
self::assertSame(0, $result['score']);
self::assertSame('low', $result['level']);
self::assertNull($result['distance']);
self::assertSame(0.0, $result['density']);
self::assertSame('stable', $result['trend']);
self::assertNull($result['etaHours']);
}
}