Compare commits
3 commits
e72f25b33d
...
fcfc858299
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fcfc858299 | ||
|
|
8a89dbbac1 | ||
|
|
290f9be7b9 |
6 changed files with 268 additions and 50 deletions
58
CLAUDE.md
58
CLAUDE.md
|
|
@ -10,26 +10,54 @@ Hébergé sur le VPS Nocleus partagé (réseau `shared-net`, cache Valkey ACL `i
|
||||||
## Stack
|
## Stack
|
||||||
|
|
||||||
- Astro 5 SSR (`output: 'server'`, adapter `@astrojs/node` mode standalone)
|
- Astro 5 SSR (`output: 'server'`, adapter `@astrojs/node` mode standalone)
|
||||||
- TailwindCSS 3 (via `@astrojs/tailwind`)
|
- TailwindCSS 3 (via `@astrojs/tailwind`) + `@tailwindcss/typography`
|
||||||
|
- `@astrojs/sitemap` (statiques) + `src/pages/sitemap-departements.xml.ts` (les 96 dépts dynamiques)
|
||||||
|
- `@sentry/astro` opt-in via `SENTRY_DSN` (cible GlitchTip, sample 0.1, source maps non uploadées). Si DSN absent, intégration omise.
|
||||||
- ioredis pour le cache Valkey
|
- ioredis pour le cache Valkey
|
||||||
- TypeScript strict
|
- TypeScript strict
|
||||||
- Pas de DB Postgres (le snapshot Vigilance vit en cache, refresh toutes les 15 min)
|
- Pas de DB Postgres (toutes les données vivent en cache Valkey, refresh à la demande)
|
||||||
|
- Tests Playwright (`tests/`, `pnpm test:e2e` / `test:e2e:local`)
|
||||||
|
- OG image générée au build par `scripts/build-og-image.mjs` (sharp, devDep)
|
||||||
|
|
||||||
## Source de données
|
## Sources de données
|
||||||
|
|
||||||
- **Provider par défaut** : `weatherref-france-vigilance-meteo-departement` chez Opendatasoft.
|
Quatre flux indépendants, tous cachés en Valkey :
|
||||||
- URL : `https://public.opendatasoft.com/api/explore/v2.1/catalog/datasets/weatherref-france-vigilance-meteo-departement/records`
|
|
||||||
- Pas d'auth, JSON propre, ~1 000 records par snapshot (départements × phénomènes × échéances J/J1)
|
- **Vigilance** (`src/lib/vigilance.ts`) — Opendatasoft `weatherref-france-vigilance-meteo-departement` (Licence Ouverte 2.0, pas d'auth, ~1 000 records par snapshot, dépts × phénomènes × échéances J/J1). Provider Météo France officiel utilisable en fallback si la clé est dispo. URL : `https://public.opendatasoft.com/api/explore/v2.1/catalog/datasets/weatherref-france-vigilance-meteo-departement/records`.
|
||||||
- Licence Ouverte 2.0
|
- **Observations horaires SYNOP** (`src/lib/observations.ts`) — API Météo France, station SYNOP la plus proche du centroïde du dept, pré-calculé dans `src/data/stations-synop.json`. Alimente l'onglet « 24 h » sur les pages dept.
|
||||||
- **Migration possible** : API Vigilance Météo France officielle (portail-api.meteofrance.fr) si fraîcheur Opendatasoft insuffisante. Nécessite un token sur le portail Météo France. Abstraction `vigilance.ts` à découper en deux implémentations le jour venu.
|
- **Climato quotidienne** (`src/lib/climato.ts`) — fichiers CSV gzippés `data.gouv.fr/meteofrance/.../QUOT` (`latest-2025-2026` pour l'année courante, `previous-1950-2024` pour l'historique long). 365 jours glissants par dept. Cold-fetch optimisé : on skippe `previous-1950-2024` si `latest` suffit (cf. commit `ac46637`).
|
||||||
|
- **Normales 1991-2020** (`src/lib/normales.ts`) — JSON statique `src/data/normales.json` (généré par `scripts/build-normales.mjs`), indexé par dept × day-of-year (1..366), lissage 7 jours.
|
||||||
|
|
||||||
|
Abstraction `vigilance.ts` à découper en deux implémentations (Opendatasoft / Météo France officiel) le jour où on bascule.
|
||||||
|
|
||||||
## Schéma cache
|
## Schéma cache
|
||||||
|
|
||||||
| Clé Valkey | TTL | Contenu |
|
| Clé Valkey | TTL (frais → hard) | Contenu |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `info-canicule:vigilance:snapshot` | `VIGILANCE_CACHE_TTL` (défaut 900s) | `VigilanceSnapshot` complet (fetchedAt + productDatetime + alerts[]) |
|
| `vigilance:snapshot` | `VIGILANCE_CACHE_TTL` (défaut 900s) → ×6 | `VigilanceSnapshot` (fetchedAt + productDatetime + alerts[]) |
|
||||||
|
| `vigilance:snapshot:fallback` | idem | Snapshot Opendatasoft de secours si provider principal échoue |
|
||||||
|
| `vigilance:snapshot:last-good` | 30 jours | Dernier snapshot connu (filet de sécurité quand tous providers KO) |
|
||||||
|
| `mf:hourly:<dept>:<hours>` | 30 min → ×6 | `HourlySeries` (obs SYNOP sur fenêtre glissante) |
|
||||||
|
| `climato:<dept>` | 24h → ×6 | `ClimatoSeries` (365 jours) |
|
||||||
|
|
||||||
Le prefix `info-canicule:` est injecté par ioredis (`keyPrefix`), donc la clé réelle côté Valkey est `info-canicule:info-canicule:vigilance:snapshot`. Compatible avec l'ACL `~info-canicule:*` côté VPS.
|
Le prefix `info-canicule:` est injecté par ioredis (`keyPrefix`), donc la clé réelle côté Valkey est `info-canicule:<clé>` (l'ancien doc parlait d'un double prefix — c'était une erreur). Compatible avec l'ACL `~info-canicule:*` côté VPS.
|
||||||
|
|
||||||
|
**SWR** : `cacheOrFetch` stocke `{v, fu}` (valeur + freshUntil). Au-delà de `fu` mais avant l'expiration Valkey (= ttl × 6), la valeur est servie stale et un refresh est déclenché en background (lock in-process pour éviter le stampede). Un blip MF de ~25 min reste invisible côté visiteur. Voir `src/lib/cache.ts`.
|
||||||
|
|
||||||
|
**last-good** : à chaque fetch réussi, le snapshot Vigilance est aussi persisté en `:last-good` (TTL 30j). Si MF + Opendatasoft échouent en même temps et que `:snapshot`/`:fallback` sont expirés, on sert `:last-good` plutôt que de planter la page. Le visiteur voit la date du bulletin dans l'UI (déjà affichée) et constate l'ancienneté.
|
||||||
|
|
||||||
|
## Pages exposées
|
||||||
|
|
||||||
|
- `/` — home + carte/grille des dépts
|
||||||
|
- `/departement/[code]` — fiche dept avec onglets Vigilance / Observations SYNOP 24h / Climato 365j vs normales
|
||||||
|
- `/embed/` + `/embed/dept/[code]` — versions embarquables (iframe-friendly) pour intégration tierce
|
||||||
|
- `/conseils/` + `/conseils/registre-canicule` — fiches statiques
|
||||||
|
- `/api/health` — JSON `{status, cache, vigilance: {productDatetime, ageSeconds}}`. Usage : UptimeRobot + cron HC.io de fraîcheur. Pas de CORS, `Cache-Control: no-store`, renvoie 503 si Valkey KO.
|
||||||
|
- `/sitemap-index.xml` + `/sitemap-departements.xml` (les 96 dépts filtrés hors `@astrojs/sitemap` standard).
|
||||||
|
|
||||||
|
## Cache HTTP (en plus du cache Valkey)
|
||||||
|
|
||||||
|
Home et pages dept renvoient `Cache-Control: ... must-revalidate` (cf. commit `e72f25b`) pour autoriser le revalidation des CDN/navigateurs tout en bornant la fraîcheur. À garder cohérent avec le TTL Valkey.
|
||||||
|
|
||||||
## Pièges connus / à surveiller
|
## Pièges connus / à surveiller
|
||||||
|
|
||||||
|
|
@ -37,9 +65,11 @@ Le prefix `info-canicule:` est injecté par ioredis (`keyPrefix`), donc la clé
|
||||||
- **Echéance "J" = aujourd'hui, "J1" = demain**. Ne pas confondre avec un horizon en heures.
|
- **Echéance "J" = aujourd'hui, "J1" = demain**. Ne pas confondre avec un horizon en heures.
|
||||||
- **`phenomenon_id`** : 1=vent, 2=pluie, 3=orages, 5=neige/verglas, 6=canicule, 8=avalanches, 9=vagues-submersion. Pas de 4 ni 7.
|
- **`phenomenon_id`** : 1=vent, 2=pluie, 3=orages, 5=neige/verglas, 6=canicule, 8=avalanches, 9=vagues-submersion. Pas de 4 ni 7.
|
||||||
- **Andorre** (`domain_id = 99`) est inclus dans le flux Vigilance mais n'est pas un département français. Mappé comme `99 — Andorre (zone Vigilance)` côté front pour ne pas l'écarter silencieusement.
|
- **Andorre** (`domain_id = 99`) est inclus dans le flux Vigilance mais n'est pas un département français. Mappé comme `99 — Andorre (zone Vigilance)` côté front pour ne pas l'écarter silencieusement.
|
||||||
- **Corse** : codes `2A` et `2B`, pas `20`.
|
- **Corse** : codes `2A` et `2B`, pas `20`. Mais pour la **climato `previous-1950-2024`**, le fichier source est encore sous code `20` (split 2A/2B post-1976) — `buildUrl()` dans `climato.ts` gère ce cas.
|
||||||
|
- **SYNOP renvoie les températures en Kelvin**. `observations.ts` les convertit via `K2C()`. Piège classique si on ajoute un nouveau champ température.
|
||||||
- **DROM** : pas (encore) inclus dans le mapping `departements.ts` côté front. Vigilance Métropole only pour le MVP. Pour ajouter Outre-mer : étendre `DEPARTEMENTS` + tester si `domain_id` correspond aux codes 971-978.
|
- **DROM** : pas (encore) inclus dans le mapping `departements.ts` côté front. Vigilance Métropole only pour le MVP. Pour ajouter Outre-mer : étendre `DEPARTEMENTS` + tester si `domain_id` correspond aux codes 971-978.
|
||||||
- **Conseils par phénomène** (`advice.ts`) : texte curated depuis sante.gouv.fr et meteofrance.fr. À relire / actualiser périodiquement (au moins 1× par an).
|
- **Conseils par phénomène** (`advice.ts`) : texte curated depuis sante.gouv.fr et meteofrance.fr. À relire / actualiser périodiquement (au moins 1× par an).
|
||||||
|
- **Normales 1991-2020** : fichier `src/data/normales.json` committé. Régénération via `scripts/build-normales.mjs` quand la décennie de référence Météo France change (~tous les 10 ans, prochain ~2031).
|
||||||
- **Cache miss au boot** : si Valkey est down, `cacheOrFetch` log un warning mais re-fetch à chaque requête — pas de fallback persistant. Acceptable pour un service stateless, mais surveiller la latence Opendatasoft.
|
- **Cache miss au boot** : si Valkey est down, `cacheOrFetch` log un warning mais re-fetch à chaque requête — pas de fallback persistant. Acceptable pour un service stateless, mais surveiller la latence Opendatasoft.
|
||||||
|
|
||||||
## Déploiement
|
## Déploiement
|
||||||
|
|
@ -52,7 +82,7 @@ Le `.env.tmpl` est commit, le `.env` réel est matérialisé par `make env` (pas
|
||||||
|
|
||||||
- Vault item : `Infra/Météo France API`, field `api_key` (hidden) + `created_at` + `expires_at`.
|
- Vault item : `Infra/Météo France API`, field `api_key` (hidden) + `created_at` + `expires_at`.
|
||||||
- Le portail (portail-api.meteofrance.fr) propose une **API Key longue durée** (durée choisie à la création, max ~10 ans). Pas de flow OAuth2 client_credentials dispo gratuitement → on n'utilise QUE l'API Key, pas de refresh automatique.
|
- Le portail (portail-api.meteofrance.fr) propose une **API Key longue durée** (durée choisie à la création, max ~10 ans). Pas de flow OAuth2 client_credentials dispo gratuitement → on n'utilise QUE l'API Key, pas de refresh automatique.
|
||||||
- Code : `src/lib/meteofrance-auth.ts` — header `Authorization: Bearer ${METEOFRANCE_API_KEY}`.
|
- Code : `src/lib/meteofrance-auth.ts` — header `apikey: ${METEOFRANCE_API_KEY}` (PAS `Authorization: Bearer`, qui est le format des tokens OAuth2 courts du portail).
|
||||||
- Rotation : avant l'expiration notée dans le vault, regénérer côté portail, copier dans le vault, `make env` côté VPS, `docker compose up -d --force-recreate app`.
|
- Rotation : avant l'expiration notée dans le vault, regénérer côté portail, copier dans le vault, `make env` côté VPS, `docker compose up -d --force-recreate app`.
|
||||||
- Si la clé expire / est révoquée : l'onglet « 24 h » sur les pages dept ne s'affiche plus (skip silencieux), pas d'impact sur le reste du site. Erreur loggée côté serveur uniquement.
|
- Si la clé expire / est révoquée : l'onglet « Observations 24 h » (SYNOP) sur les pages dept ne s'affiche plus (skip silencieux), pas d'impact sur Vigilance, Climato ni le reste du site. Erreur loggée côté serveur uniquement.
|
||||||
- Si on veut être alerté : prévoir un cron HC.io qui curl `/api/vigilance` non, qui curl un endpoint test `MF` (à coder). Pour l'instant : se reposer sur la date `expires_at` du vault item.
|
- Si on veut être alerté : prévoir un cron HC.io qui curl `/api/vigilance` non, qui curl un endpoint test `MF` (à coder). Pour l'instant : se reposer sur la date `expires_at` du vault item.
|
||||||
|
|
|
||||||
57
scripts/check-mf-loadbalancer.mjs
Normal file
57
scripts/check-mf-loadbalancer.mjs
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
#!/usr/bin/env node
|
||||||
|
// Diagnostic ad-hoc : le gateway Météo France (WSO2) load-balance entre plusieurs
|
||||||
|
// instances qui peuvent être désynchronisées sur /DPVigilance/v1/cartevigilance/encours.
|
||||||
|
// Constaté 2026-05-26 (cf. vigilance.ts:84-89). Ce script déclenche N fetches
|
||||||
|
// séquentiels et tabule les `update_time` distincts pour décider si le hack
|
||||||
|
// MF_PARALLEL_FETCHES=3 est toujours nécessaire.
|
||||||
|
//
|
||||||
|
// Usage : METEOFRANCE_API_KEY=xxx node scripts/check-mf-loadbalancer.mjs [N=20]
|
||||||
|
|
||||||
|
const KEY = process.env.METEOFRANCE_API_KEY;
|
||||||
|
if (!KEY) {
|
||||||
|
console.error('METEOFRANCE_API_KEY required');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
const N = parseInt(process.argv[2] ?? '20', 10);
|
||||||
|
const URL = 'https://public-api.meteofrance.fr/public/DPVigilance/v1/cartevigilance/encours';
|
||||||
|
|
||||||
|
const counts = new Map();
|
||||||
|
const failures = [];
|
||||||
|
const start = Date.now();
|
||||||
|
|
||||||
|
for (let i = 0; i < N; i++) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(URL, { headers: { apikey: KEY, Accept: 'application/json' } });
|
||||||
|
if (!res.ok) {
|
||||||
|
failures.push(`#${i}: HTTP ${res.status}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const j = await res.json();
|
||||||
|
const t = j?.product?.update_time ?? '<missing>';
|
||||||
|
counts.set(t, (counts.get(t) ?? 0) + 1);
|
||||||
|
process.stdout.write(`${i + 1}/${N} → ${t}\n`);
|
||||||
|
} catch (e) {
|
||||||
|
failures.push(`#${i}: ${e.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const elapsed = ((Date.now() - start) / 1000).toFixed(1);
|
||||||
|
console.log(`\n=== Résumé (${N} fetches en ${elapsed}s) ===`);
|
||||||
|
const sorted = [...counts.entries()].sort((a, b) => (a[0] < b[0] ? 1 : -1));
|
||||||
|
for (const [t, c] of sorted) {
|
||||||
|
const bar = '█'.repeat(Math.round((c / N) * 40));
|
||||||
|
console.log(`${t} ${c.toString().padStart(3)}/${N} ${bar}`);
|
||||||
|
}
|
||||||
|
if (failures.length) {
|
||||||
|
console.log(`\nÉchecs: ${failures.length}`);
|
||||||
|
for (const f of failures) console.log(' ' + f);
|
||||||
|
}
|
||||||
|
console.log(`\nDistinct update_time: ${counts.size}`);
|
||||||
|
if (counts.size === 1) {
|
||||||
|
console.log('→ Pas de désynchro détectée sur cet échantillon. Tu peux probablement réduire MF_PARALLEL_FETCHES à 1.');
|
||||||
|
} else {
|
||||||
|
const newest = sorted[0]?.[0];
|
||||||
|
const newestCount = sorted[0]?.[1] ?? 0;
|
||||||
|
console.log(`→ Désynchro confirmée. Le bulletin le plus récent est ${newest} (${newestCount}/${N} = ${Math.round(newestCount/N*100)}%).`);
|
||||||
|
console.log(' Mitigation actuelle (MF_PARALLEL_FETCHES=3 + sort par update_time desc) reste justifiée.');
|
||||||
|
}
|
||||||
|
|
@ -19,11 +19,36 @@ function getClient(): Redis {
|
||||||
return client;
|
return client;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Envelope SWR : `v` = valeur, `fu` = freshUntil (timestamp ms).
|
||||||
|
// L'entrée Valkey expire via TTL `hardTtl` (= ratio fresh × STALE_RATIO),
|
||||||
|
// `fu` indique la limite "fraîche" en deçà de laquelle aucun refresh n'est tenté.
|
||||||
|
interface SwrEnvelope<T> {
|
||||||
|
v: T;
|
||||||
|
fu: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Combien de temps on est prêt à servir une valeur stale après freshUntil,
|
||||||
|
// en multiple du TTL frais. 6× = ex. 5 min frais → on garde 30 min en cache et
|
||||||
|
// refresh en background quand on dépasse 5 min.
|
||||||
|
const STALE_RATIO = 6;
|
||||||
|
|
||||||
|
// Locks in-process pour éviter le stampede en refresh background : N requêtes
|
||||||
|
// concurrentes sur la même clé qui dépasse freshUntil → 1 seul fetch.
|
||||||
|
const inflight = new Map<string, Promise<unknown>>();
|
||||||
|
|
||||||
|
function isEnvelope<T>(v: unknown): v is SwrEnvelope<T> {
|
||||||
|
return !!v && typeof v === 'object' && 'v' in (v as object) && 'fu' in (v as object);
|
||||||
|
}
|
||||||
|
|
||||||
export async function cacheGet<T>(key: string): Promise<T | null> {
|
export async function cacheGet<T>(key: string): Promise<T | null> {
|
||||||
try {
|
try {
|
||||||
const raw = await getClient().get(key);
|
const raw = await getClient().get(key);
|
||||||
if (!raw) return null;
|
if (!raw) return null;
|
||||||
return JSON.parse(raw) as T;
|
const parsed = JSON.parse(raw);
|
||||||
|
// Compat : ancienne shape (valeur brute sans envelope) → on la traite comme
|
||||||
|
// une valeur fraîche pour la durée résiduelle du TTL natif Valkey.
|
||||||
|
if (isEnvelope<T>(parsed)) return parsed.v;
|
||||||
|
return parsed as T;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn('[cache] get failed for', key, (err as Error).message);
|
console.warn('[cache] get failed for', key, (err as Error).message);
|
||||||
return null;
|
return null;
|
||||||
|
|
@ -32,22 +57,76 @@ export async function cacheGet<T>(key: string): Promise<T | null> {
|
||||||
|
|
||||||
export async function cacheSet<T>(key: string, value: T, ttlSec: number): Promise<void> {
|
export async function cacheSet<T>(key: string, value: T, ttlSec: number): Promise<void> {
|
||||||
try {
|
try {
|
||||||
await getClient().set(key, JSON.stringify(value), 'EX', ttlSec);
|
const env: SwrEnvelope<T> = { v: value, fu: Date.now() + ttlSec * 1000 };
|
||||||
|
await getClient().set(key, JSON.stringify(env), 'EX', ttlSec * STALE_RATIO);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn('[cache] set failed for', key, (err as Error).message);
|
console.warn('[cache] set failed for', key, (err as Error).message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stale-while-revalidate.
|
||||||
|
*
|
||||||
|
* - Cache miss : fetch synchrone, set, retourne.
|
||||||
|
* - Cache hit + fresh (now < freshUntil) : retourne immédiatement.
|
||||||
|
* - Cache hit + stale : retourne la valeur stale + déclenche un refresh
|
||||||
|
* background (lock in-process pour éviter le stampede). Si le fetch
|
||||||
|
* background échoue, on log mais on conserve la valeur stale en cache
|
||||||
|
* jusqu'à l'expiration `hardTtl` (= ttlSec × STALE_RATIO).
|
||||||
|
*
|
||||||
|
* Effet : les visiteurs ne paient jamais la latence d'un refetch tant qu'on
|
||||||
|
* a une valeur même stale. Sur Vigilance (5 min fresh, 30 min hard), un
|
||||||
|
* blip MF de 25 min reste invisible pour l'utilisateur.
|
||||||
|
*/
|
||||||
export async function cacheOrFetch<T>(
|
export async function cacheOrFetch<T>(
|
||||||
key: string,
|
key: string,
|
||||||
ttlSec: number,
|
ttlSec: number,
|
||||||
fetcher: () => Promise<T>,
|
fetcher: () => Promise<T>,
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
const cached = await cacheGet<T>(key);
|
const raw = await rawGet(key);
|
||||||
if (cached !== null) return cached;
|
if (raw === null) {
|
||||||
const fresh = await fetcher();
|
return doFetchAndSet(key, ttlSec, fetcher);
|
||||||
await cacheSet(key, fresh, ttlSec);
|
}
|
||||||
return fresh;
|
if (!isEnvelope<T>(raw)) {
|
||||||
|
// Ancienne shape : on la sert + on refresh en background pour migrer.
|
||||||
|
triggerBackgroundRefresh(key, ttlSec, fetcher);
|
||||||
|
return raw as T;
|
||||||
|
}
|
||||||
|
if (Date.now() < raw.fu) return raw.v;
|
||||||
|
triggerBackgroundRefresh(key, ttlSec, fetcher);
|
||||||
|
return raw.v;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function rawGet(key: string): Promise<unknown | null> {
|
||||||
|
try {
|
||||||
|
const raw = await getClient().get(key);
|
||||||
|
if (!raw) return null;
|
||||||
|
return JSON.parse(raw);
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[cache] get failed for', key, (err as Error).message);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doFetchAndSet<T>(key: string, ttlSec: number, fetcher: () => Promise<T>): Promise<T> {
|
||||||
|
const existing = inflight.get(key) as Promise<T> | undefined;
|
||||||
|
if (existing) return existing;
|
||||||
|
const p = (async () => {
|
||||||
|
const fresh = await fetcher();
|
||||||
|
await cacheSet(key, fresh, ttlSec);
|
||||||
|
return fresh;
|
||||||
|
})().finally(() => {
|
||||||
|
inflight.delete(key);
|
||||||
|
});
|
||||||
|
inflight.set(key, p);
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
function triggerBackgroundRefresh<T>(key: string, ttlSec: number, fetcher: () => Promise<T>): void {
|
||||||
|
if (inflight.has(key)) return;
|
||||||
|
void doFetchAndSet(key, ttlSec, fetcher).catch((err) => {
|
||||||
|
console.warn('[cache] background refresh failed for', key, (err as Error).message);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function pingCache(): Promise<boolean> {
|
export async function pingCache(): Promise<boolean> {
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,9 @@
|
||||||
// sans flow refresh) et (b) API Key permanente / longue durée. On utilise (b).
|
// sans flow refresh) et (b) API Key permanente / longue durée. On utilise (b).
|
||||||
//
|
//
|
||||||
// Configuration via env :
|
// Configuration via env :
|
||||||
// METEOFRANCE_API_KEY → clé permanente, envoyée en header `Authorization: Bearer <key>`
|
// METEOFRANCE_API_KEY → clé permanente, envoyée en header `apikey: <key>`
|
||||||
|
// (PAS `Authorization: Bearer` — c'est le format des
|
||||||
|
// tokens OAuth2 courts, pas des API Keys du portail).
|
||||||
//
|
//
|
||||||
// Quand la clé approche de l'expiration (cf. duration choisie à la création),
|
// Quand la clé approche de l'expiration (cf. duration choisie à la création),
|
||||||
// régénérer côté portail puis mettre à jour le vault `Infra/Météo France API`,
|
// régénérer côté portail puis mettre à jour le vault `Infra/Météo France API`,
|
||||||
|
|
|
||||||
|
|
@ -27,12 +27,19 @@ export interface DailyNormale {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Day-of-year en convention "leap calendar" (1..366), aligné sur le calendrier bissextile.
|
// Day-of-year en convention "leap calendar" (1..366), aligné sur le calendrier bissextile.
|
||||||
// Les années non-bissextiles : on bascule simplement le 1er mars au doy 60 si pas 29 fév.
|
// Extraction Y/M/D en Europe/Paris pour rester cohérent avec :
|
||||||
// Mais pour matcher l'indexation du JSON (1..366), on utilise une convention stable.
|
// - les pages SSR qui formatent en Europe/Paris (todayLabel, productDate),
|
||||||
|
// - les obs climato YYYY-MM-DD (parsées en UTC midnight, donc Paris == J ou J-1
|
||||||
|
// selon offset DST, mais ça représente toujours la "journée Paris" du jour
|
||||||
|
// suivant — assez proche pour le lookup normale).
|
||||||
|
// - `new Date()` (now) : en UTC, getUTC* renvoie hier entre 00h et 01h/02h Paris.
|
||||||
|
// Toujours utiliser le jour Paris-local.
|
||||||
export function dayOfYear(date: Date): number {
|
export function dayOfYear(date: Date): number {
|
||||||
const y = date.getUTCFullYear();
|
// sv-SE → format "YYYY-MM-DD"
|
||||||
const m = date.getUTCMonth() + 1;
|
const iso = date.toLocaleDateString('sv-SE', { timeZone: 'Europe/Paris' });
|
||||||
const d = date.getUTCDate();
|
const y = parseInt(iso.slice(0, 4), 10);
|
||||||
|
const m = parseInt(iso.slice(5, 7), 10);
|
||||||
|
const d = parseInt(iso.slice(8, 10), 10);
|
||||||
const cumulNonLeap = [0, 31, 59, 90, 120, 151, 181, 212, 243, 273, 304, 334];
|
const cumulNonLeap = [0, 31, 59, 90, 120, 151, 181, 212, 243, 273, 304, 334];
|
||||||
const cumulLeap = [0, 31, 60, 91, 121, 152, 182, 213, 244, 274, 305, 335];
|
const cumulLeap = [0, 31, 60, 91, 121, 152, 182, 213, 244, 274, 305, 335];
|
||||||
const isLeap = (y % 4 === 0 && y % 100 !== 0) || (y % 400 === 0);
|
const isLeap = (y % 4 === 0 && y % 100 !== 0) || (y % 400 === 0);
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { cacheOrFetch } from './cache';
|
import { cacheOrFetch, cacheGet, cacheSet } from './cache';
|
||||||
import type { ColorId, PhenomenonId } from './phenomena';
|
import type { ColorId, PhenomenonId } from './phenomena';
|
||||||
import { COLORS, PHENOMENA } from './phenomena';
|
import { COLORS, PHENOMENA } from './phenomena';
|
||||||
import { fetchMF, hasMeteoFranceCredentials } from './meteofrance-auth';
|
import { fetchMF, hasMeteoFranceCredentials } from './meteofrance-auth';
|
||||||
|
|
@ -23,9 +23,18 @@ const OPENDATASOFT_BASE =
|
||||||
'https://public.opendatasoft.com/api/explore/v2.1/catalog/datasets/weatherref-france-vigilance-meteo-departement/records';
|
'https://public.opendatasoft.com/api/explore/v2.1/catalog/datasets/weatherref-france-vigilance-meteo-departement/records';
|
||||||
|
|
||||||
const CACHE_KEY = 'vigilance:snapshot';
|
const CACHE_KEY = 'vigilance:snapshot';
|
||||||
|
// Snapshot longue durée, écrit à chaque fetch réussi (peu importe le provider).
|
||||||
|
// Sert de filet de sécurité quand cache short-TTL expiré + tous providers KO.
|
||||||
|
// 30 jours : large, mais c'est la valeur la plus récente qu'on ait obtenue ;
|
||||||
|
// le visiteur voit `productDatetime` qui révèle l'ancienneté du bulletin.
|
||||||
|
const CACHE_KEY_LAST_GOOD = 'vigilance:snapshot:last-good';
|
||||||
|
const LAST_GOOD_TTL = 30 * 24 * 60 * 60;
|
||||||
|
|
||||||
|
async function persistLastGood(snap: VigilanceSnapshot): Promise<void> {
|
||||||
|
await cacheSet(CACHE_KEY_LAST_GOOD, snap, LAST_GOOD_TTL);
|
||||||
|
}
|
||||||
|
|
||||||
async function fetchOpendatasoft(): Promise<VigilanceSnapshot> {
|
async function fetchOpendatasoft(): Promise<VigilanceSnapshot> {
|
||||||
const url = `${OPENDATASOFT_BASE}?limit=100&offset=0`;
|
|
||||||
const all: VigilanceAlert[] = [];
|
const all: VigilanceAlert[] = [];
|
||||||
let offset = 0;
|
let offset = 0;
|
||||||
let total = Infinity;
|
let total = Infinity;
|
||||||
|
|
@ -81,11 +90,16 @@ async function fetchOpendatasoft(): Promise<VigilanceSnapshot> {
|
||||||
// Plus frais qu'Opendatasoft (publication directe) et couvre tous les domaines
|
// Plus frais qu'Opendatasoft (publication directe) et couvre tous les domaines
|
||||||
// (métropole, côtes, prochainement Outre-mer via /vigilanceom/flux/dernier).
|
// (métropole, côtes, prochainement Outre-mer via /vigilanceom/flux/dernier).
|
||||||
//
|
//
|
||||||
// PIÈGE : le gateway MF (WSO2) load-balance entre plusieurs instances
|
// Historique : une désynchro avait été observée (snapshot Vigilance d'apparence
|
||||||
// qui peuvent être désynchronisées (constaté 2026-05-26 : ~60% des hits
|
// périmé) à >20h UTC le 2026-05-26 — loin des fenêtres de publication MF
|
||||||
// retournaient le bulletin J-1 à 4h UTC, ~40% le bulletin J à 14h UTC).
|
// (04h / 14h UTC). Vérification 2026-05-26 ~15h UTC (`scripts/check-mf-loadbalancer.mjs`,
|
||||||
// Mitigation : on fait N fetches concurrents et on garde la réponse avec
|
// 60 fetches concurrents) : 0 désynchro côté gateway WSO2. Origine plus probable
|
||||||
// le `update_time` le plus récent. N=3 atteint >95% de prob d'avoir le frais.
|
// = bruit local (cache Valkey servant un vieux snapshot, ou requête transitoire),
|
||||||
|
// pas le LB MF.
|
||||||
|
//
|
||||||
|
// Mitigation conservée prudemment : N fetches concurrents, on garde la réponse
|
||||||
|
// avec le `update_time` le plus récent. Coût négligeable (1 cold fetch / TTL).
|
||||||
|
// À retirer si on confirme plus tard que MF est stable (passer N à 1).
|
||||||
|
|
||||||
const MF_PARALLEL_FETCHES = 3;
|
const MF_PARALLEL_FETCHES = 3;
|
||||||
const MF_ENDPOINT = '/public/DPVigilance/v1/cartevigilance/encours';
|
const MF_ENDPOINT = '/public/DPVigilance/v1/cartevigilance/encours';
|
||||||
|
|
@ -175,40 +189,69 @@ function pickProvider(): { name: string; fn: () => Promise<VigilanceSnapshot> }
|
||||||
return { name: 'opendatasoft', fn: fetchOpendatasoft };
|
return { name: 'opendatasoft', fn: fetchOpendatasoft };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function fetchAndPersist(fn: () => Promise<VigilanceSnapshot>): Promise<VigilanceSnapshot> {
|
||||||
|
const snap = await fn();
|
||||||
|
// Best-effort : on n'attend pas, et on n'échoue pas si Valkey est down.
|
||||||
|
void persistLastGood(snap);
|
||||||
|
return snap;
|
||||||
|
}
|
||||||
|
|
||||||
export async function getVigilanceSnapshot(): Promise<VigilanceSnapshot> {
|
export async function getVigilanceSnapshot(): Promise<VigilanceSnapshot> {
|
||||||
const ttl = parseInt(process.env.VIGILANCE_CACHE_TTL ?? '900', 10);
|
const ttl = parseInt(process.env.VIGILANCE_CACHE_TTL ?? '900', 10);
|
||||||
const provider = pickProvider();
|
const provider = pickProvider();
|
||||||
try {
|
try {
|
||||||
return await cacheOrFetch(CACHE_KEY, ttl, provider.fn);
|
return await cacheOrFetch(CACHE_KEY, ttl, () => fetchAndPersist(provider.fn));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Fallback automatique vers l'autre provider en cas d'erreur (token expiré, MF API down, etc.)
|
// Fallback automatique vers l'autre provider en cas d'erreur (token expiré, MF API down, etc.)
|
||||||
if (provider.name === 'meteofrance') {
|
if (provider.name === 'meteofrance') {
|
||||||
console.warn('[vigilance] MF failed, falling back to opendatasoft:', (e as Error).message);
|
console.warn('[vigilance] MF failed, falling back to opendatasoft:', (e as Error).message);
|
||||||
return cacheOrFetch(CACHE_KEY + ':fallback', ttl, fetchOpendatasoft);
|
try {
|
||||||
|
return await cacheOrFetch(CACHE_KEY + ':fallback', ttl, () => fetchAndPersist(fetchOpendatasoft));
|
||||||
|
} catch (e2) {
|
||||||
|
const lastGood = await cacheGet<VigilanceSnapshot>(CACHE_KEY_LAST_GOOD);
|
||||||
|
if (lastGood) {
|
||||||
|
console.warn('[vigilance] both providers failed, serving last-good from', lastGood.fetchedAt);
|
||||||
|
return lastGood;
|
||||||
|
}
|
||||||
|
throw e2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// provider == opendatasoft (uniquement) : pas d'autre provider, on tente :last-good
|
||||||
|
const lastGood = await cacheGet<VigilanceSnapshot>(CACHE_KEY_LAST_GOOD);
|
||||||
|
if (lastGood) {
|
||||||
|
console.warn('[vigilance] opendatasoft failed, serving last-good from', lastGood.fetchedAt);
|
||||||
|
return lastGood;
|
||||||
}
|
}
|
||||||
throw e;
|
throw e;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Détermine quelle echeance du bulletin couvre l'instant présent.
|
* Détermine quelle echeance du bulletin couvre "aujourd'hui réel" pour le visiteur.
|
||||||
*
|
*
|
||||||
* Le bulletin Météo France publié à 16h Paris reste valide jusqu'au prochain
|
* Le bulletin Météo France publié à ~16h Paris reste valide jusqu'au prochain
|
||||||
* bulletin (~6h Paris le lendemain). Pendant la nuit (de minuit à 6h), on se
|
* bulletin (~6h Paris le lendemain). Entre minuit et la publication du suivant,
|
||||||
* retrouve techniquement dans le J+1 du bulletin, qui correspond pour
|
* la journée calendaire de `productDatetime` (= "J" du bulletin) correspond à
|
||||||
* l'utilisateur à "aujourd'hui réel".
|
* "hier" pour le visiteur — on doit alors basculer sur J1.
|
||||||
*
|
*
|
||||||
* Logique : si l'instant présent est avant la fin du J, on utilise J ; sinon J1.
|
* Comparaison strictement sur le jour calendaire en Europe/Paris :
|
||||||
|
* - si jour(now) == jour(productDatetime) → echeance = 'J'
|
||||||
|
* - sinon (bulletin de la veille servi avant la prochaine publication) → 'J1'
|
||||||
|
*
|
||||||
|
* ⚠ Ne pas se baser sur `endTime` des alertes filtrées par echeance J :
|
||||||
|
* en jour calme (toutes alertes vert), aucune alerte J n'est présente dans
|
||||||
|
* le snapshot → l'ancienne impl renvoyait J1 par défaut et l'UI affichait
|
||||||
|
* "demain" partout. Corrigé en s'appuyant sur productDatetime.
|
||||||
*/
|
*/
|
||||||
|
function parisDateKey(d: Date): string {
|
||||||
|
return d.toLocaleDateString('sv-SE', { timeZone: 'Europe/Paris' });
|
||||||
|
}
|
||||||
|
|
||||||
export function currentEcheance(snapshot: VigilanceSnapshot): 'J' | 'J1' {
|
export function currentEcheance(snapshot: VigilanceSnapshot): 'J' | 'J1' {
|
||||||
const now = new Date().toISOString();
|
if (!snapshot.productDatetime) return 'J';
|
||||||
const jEnd = snapshot.alerts
|
const bulletinDay = parisDateKey(new Date(snapshot.productDatetime));
|
||||||
.filter((a) => a.echeance === 'J')
|
const nowDay = parisDateKey(new Date());
|
||||||
.map((a) => a.endTime)
|
return nowDay === bulletinDay ? 'J' : 'J1';
|
||||||
.sort()
|
|
||||||
.slice(-1)[0];
|
|
||||||
if (jEnd && now < jEnd) return 'J';
|
|
||||||
return 'J1';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function maxColorByDepartement(snapshot: VigilanceSnapshot, echeance: 'J' | 'J1' = 'J'): Map<string, ColorId> {
|
export function maxColorByDepartement(snapshot: VigilanceSnapshot, echeance: 'J' | 'J1' = 'J'): Map<string, ColorId> {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue