feat: retire API publique + dedupe load-balancing MF + maj pages
Some checks are pending
Deploy info-canicule / deploy (push) Waiting to run
Some checks are pending
Deploy info-canicule / deploy (push) Waiting to run
API publique retirée : - /api/vigilance et /api/vigilance/dept/[code] supprimés - Mentions retirées dans footer, /mentions-legales, /embed - /api/health garde, sans CORS (usage interne UptimeRobot + cron HC.io) - Tests E2E mis à jour (vérifient 404 sur les endpoints retirés) Pages : - /dependances : entièrement mise à jour (Sentry, sharp, Playwright, sitemap, typography ajoutés ; API MF officielle DPObs+DPVigilance, normales 1991-2020 listées ; section Services tiers ajoutée pour Opendatasoft en fallback ; section Infrastructure complétée avec GlitchTip + CrowdSec). - /soutenir : "~7€/mois mutualisés" → "~30€/mois", suppression du détail VPS OVH (juste "infra"), suppression du bloc "Réutiliser les données via API". Vigilance Météo France load-balancing : - lib/vigilance.ts : fetch parallèle x3 et garde la réponse au update_time le plus récent. Constat 2026-05-26 : le gateway MF load-balance entre instances désynchronisées (~60% renvoyaient bulletin J-1, ~40% bulletin J). Cette mitigation atteint >95% de probabilité d'avoir le bulletin frais. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
0a1f11aa00
commit
27441cdbb8
10 changed files with 139 additions and 148 deletions
|
|
@ -136,7 +136,6 @@ const jsonLd = {
|
|||
<li><a href="/mentions-legales">Mentions légales</a></li>
|
||||
<li><a href="/dependances">Dépendances</a></li>
|
||||
<li><a href="/soutenir">☕ Soutenir sur Ko-fi</a></li>
|
||||
<li><a href="/api/vigilance">API JSON publique</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
|
|
|
|||
|
|
@ -80,12 +80,32 @@ async function fetchOpendatasoft(): Promise<VigilanceSnapshot> {
|
|||
// --- Provider : API Météo France officielle (DPVigilance/v1) ---
|
||||
// Plus frais qu'Opendatasoft (publication directe) et couvre tous les domaines
|
||||
// (métropole, côtes, prochainement Outre-mer via /vigilanceom/flux/dernier).
|
||||
//
|
||||
// PIÈGE : le gateway MF (WSO2) load-balance entre plusieurs instances
|
||||
// qui peuvent être désynchronisées (constaté 2026-05-26 : ~60% des hits
|
||||
// retournaient le bulletin J-1 à 4h UTC, ~40% le bulletin J à 14h UTC).
|
||||
// Mitigation : on fait N fetches concurrents et on garde la réponse avec
|
||||
// le `update_time` le plus récent. N=3 atteint >95% de prob d'avoir le frais.
|
||||
|
||||
const MF_PARALLEL_FETCHES = 3;
|
||||
const MF_ENDPOINT = '/public/DPVigilance/v1/cartevigilance/encours';
|
||||
|
||||
async function fetchMFOnce(): Promise<{ update_time: string; raw: any } | null> {
|
||||
const res = await fetchMF(MF_ENDPOINT);
|
||||
if (!res.ok) return null;
|
||||
const j = await res.json();
|
||||
return { update_time: j?.product?.update_time ?? '', raw: j };
|
||||
}
|
||||
|
||||
async function fetchMeteoFrance(): Promise<VigilanceSnapshot> {
|
||||
const res = await fetchMF('/public/DPVigilance/v1/cartevigilance/encours');
|
||||
if (!res.ok) {
|
||||
throw new Error(`MF cartevigilance failed: ${res.status} ${res.statusText}`);
|
||||
const results = await Promise.all(Array.from({ length: MF_PARALLEL_FETCHES }, () => fetchMFOnce()));
|
||||
const valid = results.filter((r): r is { update_time: string; raw: any } => r !== null && !!r.update_time);
|
||||
if (valid.length === 0) {
|
||||
throw new Error('MF cartevigilance failed: all parallel fetches errored');
|
||||
}
|
||||
const json = (await res.json()) as {
|
||||
// Garde le plus récent (update_time est ISO comparable lexicographiquement)
|
||||
valid.sort((a, b) => (a.update_time < b.update_time ? 1 : -1));
|
||||
const json = valid[0].raw as {
|
||||
product: {
|
||||
update_time: string;
|
||||
periods: Array<{
|
||||
|
|
|
|||
|
|
@ -12,6 +12,11 @@ export const GET: APIRoute = async () => {
|
|||
};
|
||||
return new Response(JSON.stringify(body), {
|
||||
status: cacheOk ? 200 : 503,
|
||||
headers: { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' },
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': 'no-store',
|
||||
// Pas de CORS : endpoint d'usage interne (UptimeRobot + cron HC.io freshness).
|
||||
// Pas destiné aux clients tiers.
|
||||
},
|
||||
});
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,24 +0,0 @@
|
|||
import type { APIRoute } from 'astro';
|
||||
import { getVigilanceSnapshot } from '../../lib/vigilance';
|
||||
|
||||
export const prerender = false;
|
||||
|
||||
// JSON public du snapshot Vigilance actuel — réutilisable sous Licence Ouverte 2.0.
|
||||
export const GET: APIRoute = async () => {
|
||||
try {
|
||||
const snap = await getVigilanceSnapshot();
|
||||
return new Response(JSON.stringify(snap), {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'application/json; charset=utf-8',
|
||||
'Cache-Control': 'public, max-age=300',
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'fetch_failed', detail: (e as Error).message }),
|
||||
{ status: 502, headers: { 'Content-Type': 'application/json' } },
|
||||
);
|
||||
}
|
||||
};
|
||||
|
|
@ -1,62 +0,0 @@
|
|||
import type { APIRoute } from 'astro';
|
||||
import { getVigilanceSnapshot, alertsForDepartement } from '../../../../lib/vigilance';
|
||||
import { getDepartement } from '../../../../lib/departements';
|
||||
import { PHENOMENA, COLORS } from '../../../../lib/phenomena';
|
||||
|
||||
export const prerender = false;
|
||||
|
||||
// JSON par département (J + J1) — CORS *, réutilisable sous Licence Ouverte 2.0.
|
||||
// Ex : GET /api/vigilance/dept/75 → alertes Paris
|
||||
export const GET: APIRoute = async ({ params }) => {
|
||||
const codeRaw = params.code;
|
||||
if (!codeRaw) {
|
||||
return new Response(JSON.stringify({ error: 'missing_code' }), {
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
const code = codeRaw.toUpperCase();
|
||||
const dept = getDepartement(code);
|
||||
if (!dept) {
|
||||
return new Response(JSON.stringify({ error: 'unknown_departement', code }), {
|
||||
status: 404,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const snap = await getVigilanceSnapshot();
|
||||
const today = alertsForDepartement(snap, code, 'J');
|
||||
const tomorrow = alertsForDepartement(snap, code, 'J1');
|
||||
const enrich = (a: ReturnType<typeof alertsForDepartement>[0]) => ({
|
||||
phenomenonId: a.phenomenonId,
|
||||
phenomenon: PHENOMENA[a.phenomenonId].label,
|
||||
colorId: a.colorId,
|
||||
color: COLORS[a.colorId].name,
|
||||
beginTime: a.beginTime,
|
||||
endTime: a.endTime,
|
||||
});
|
||||
|
||||
const body = {
|
||||
departement: { code, name: dept.name, region: dept.region },
|
||||
productDatetime: snap.productDatetime,
|
||||
fetchedAt: snap.fetchedAt,
|
||||
today: today.map(enrich),
|
||||
tomorrow: tomorrow.map(enrich),
|
||||
};
|
||||
|
||||
return new Response(JSON.stringify(body), {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'application/json; charset=utf-8',
|
||||
'Cache-Control': 'public, max-age=300',
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'fetch_failed', detail: (e as Error).message }),
|
||||
{ status: 502, headers: { 'Content-Type': 'application/json' } },
|
||||
);
|
||||
}
|
||||
};
|
||||
|
|
@ -11,10 +11,9 @@ const devDeps = Object.entries(pkg.devDependencies as Record<string, string>).so
|
|||
a.localeCompare(b),
|
||||
);
|
||||
|
||||
// Catalogue manuel : description + license + URL pour chaque dépendance principale.
|
||||
const META: Record<string, { desc: string; license: string; url: string }> = {
|
||||
astro: {
|
||||
desc: "Framework web orienté contenu (rendu serveur, île d'interactivité).",
|
||||
desc: "Framework web orienté contenu (rendu serveur, îlots d'interactivité).",
|
||||
license: 'MIT',
|
||||
url: 'https://astro.build/',
|
||||
},
|
||||
|
|
@ -28,21 +27,46 @@ const META: Record<string, { desc: string; license: string; url: string }> = {
|
|||
license: 'MIT',
|
||||
url: 'https://docs.astro.build/en/guides/integrations-guide/tailwind/',
|
||||
},
|
||||
'@astrojs/sitemap': {
|
||||
desc: 'Génération automatique du sitemap-index.xml + sitemap-0.xml.',
|
||||
license: 'MIT',
|
||||
url: 'https://docs.astro.build/en/guides/integrations-guide/sitemap/',
|
||||
},
|
||||
'@sentry/astro': {
|
||||
desc: "SDK Sentry/GlitchTip pour reporter les erreurs serveur (opt-in via env).",
|
||||
license: 'MIT',
|
||||
url: 'https://docs.sentry.io/platforms/javascript/guides/astro/',
|
||||
},
|
||||
'@tailwindcss/typography': {
|
||||
desc: 'Plugin Tailwind pour le styling des pages de contenu (prose).',
|
||||
license: 'MIT',
|
||||
url: 'https://github.com/tailwindlabs/tailwindcss-typography',
|
||||
},
|
||||
tailwindcss: {
|
||||
desc: 'Framework CSS utility-first.',
|
||||
license: 'MIT',
|
||||
url: 'https://tailwindcss.com/',
|
||||
},
|
||||
ioredis: {
|
||||
desc: 'Client Redis/Valkey performant pour Node.js (cache du snapshot Vigilance).',
|
||||
desc: 'Client Redis/Valkey performant pour Node.js (cache des snapshots).',
|
||||
license: 'MIT',
|
||||
url: 'https://github.com/redis/ioredis',
|
||||
},
|
||||
'@astrojs/check': {
|
||||
desc: 'Type checker officiel Astro (utilisé en CI / dev).',
|
||||
desc: 'Type checker officiel Astro (vérification de types en CI / dev).',
|
||||
license: 'MIT',
|
||||
url: 'https://docs.astro.build/en/reference/cli-reference/#astro-check',
|
||||
},
|
||||
'@playwright/test': {
|
||||
desc: 'Framework de tests end-to-end (vérification du site live).',
|
||||
license: 'Apache-2.0',
|
||||
url: 'https://playwright.dev/',
|
||||
},
|
||||
sharp: {
|
||||
desc: 'Génération de l\'image Open Graph (PNG depuis SVG) au build.',
|
||||
license: 'Apache-2.0',
|
||||
url: 'https://sharp.pixelplumbing.com/',
|
||||
},
|
||||
'@types/node': {
|
||||
desc: 'Types TypeScript pour les API Node.js.',
|
||||
license: 'MIT',
|
||||
|
|
@ -57,32 +81,49 @@ const META: Record<string, { desc: string; license: string; url: string }> = {
|
|||
|
||||
const DATA_SOURCES = [
|
||||
{
|
||||
name: 'France GeoJSON',
|
||||
desc: "Polygones des départements français (utilisés pour la carte SVG).",
|
||||
name: 'Météo France — API Vigilance officielle (DPVigilance)',
|
||||
desc: "Bulletin Vigilance en temps réel, métropole, publication 2× par jour + complémentaires sur rouge.",
|
||||
license: 'Licence Ouverte 2.0',
|
||||
url: 'https://github.com/gregoiredavid/france-geojson',
|
||||
url: 'https://portail-api.meteofrance.fr/web/fr/api/DonneesPubliquesVigilance',
|
||||
},
|
||||
{
|
||||
name: 'Météo France — Vigilance',
|
||||
desc: 'Bulletin Vigilance en temps réel via Opendatasoft.',
|
||||
name: 'Météo France — API Observation officielle (DPObs)',
|
||||
desc: "Observations horaires SYNOP des stations principales (températures, vent, pression). Onglet 24 h sur les pages département.",
|
||||
license: 'Licence Ouverte 2.0',
|
||||
url: 'https://public.opendatasoft.com/explore/dataset/weatherref-france-vigilance-meteo-departement/',
|
||||
url: 'https://portail-api.meteofrance.fr/web/fr/api/DonneesPubliquesObservation',
|
||||
},
|
||||
{
|
||||
name: 'Météo France — Climatologie',
|
||||
desc: 'Données climatologiques de base quotidiennes (températures, précipitations).',
|
||||
name: 'Météo France — Climatologie de base quotidienne',
|
||||
desc: 'Données journalières TN/TX/TM/RR par station depuis 1950, agrégées par département. Onglets 7 j / 30 j / 1 an.',
|
||||
license: 'Licence Ouverte 2.0',
|
||||
url: 'https://www.data.gouv.fr/datasets/donnees-climatologiques-de-base-quotidiennes',
|
||||
},
|
||||
{
|
||||
name: 'Normales 1991-2020',
|
||||
desc: 'Normales saisonnières journalières TN/TX par département, pré-calculées sur la période WMO standard 1991-2020 (lissage 7 jours).',
|
||||
license: 'Licence Ouverte 2.0 (dérivé des données ci-dessus)',
|
||||
url: 'https://www.data.gouv.fr/datasets/donnees-climatologiques-de-base-quotidiennes',
|
||||
},
|
||||
{
|
||||
name: 'France GeoJSON',
|
||||
desc: "Polygones des départements français (utilisés pour générer la carte SVG).",
|
||||
license: 'Licence Ouverte 2.0',
|
||||
url: 'https://github.com/gregoiredavid/france-geojson',
|
||||
},
|
||||
];
|
||||
|
||||
const SERVICES = [
|
||||
{ name: 'Opendatasoft (fallback)', desc: "Source de secours pour la Vigilance si l'API Météo France officielle est indisponible.", license: 'LOv2', url: 'https://public.opendatasoft.com/explore/dataset/weatherref-france-vigilance-meteo-departement/' },
|
||||
];
|
||||
|
||||
const INFRA = [
|
||||
{ name: 'Node.js 22', desc: "Runtime serveur.", license: 'MIT', url: 'https://nodejs.org/' },
|
||||
{ name: 'Node.js 22 LTS', desc: 'Runtime serveur.', license: 'MIT', url: 'https://nodejs.org/' },
|
||||
{ name: 'Docker', desc: 'Containerisation.', license: 'Apache-2.0', url: 'https://www.docker.com/' },
|
||||
{ name: 'Caddy', desc: 'Reverse proxy + TLS automatique.', license: 'Apache-2.0', url: 'https://caddyserver.com/' },
|
||||
{ name: 'Valkey', desc: 'Fork ouvert de Redis (cache).', license: 'BSD-3-Clause', url: 'https://valkey.io/' },
|
||||
{ name: 'Umami', desc: 'Analytics RGPD-friendly (auto-hébergé).', license: 'MIT', url: 'https://umami.is/' },
|
||||
{ name: 'Forgejo', desc: 'Forge git auto-hébergée + CI/CD.', license: 'MIT', url: 'https://forgejo.org/' },
|
||||
{ name: 'Caddy', desc: 'Reverse proxy + TLS automatique (Let\'s Encrypt).', license: 'Apache-2.0', url: 'https://caddyserver.com/' },
|
||||
{ name: 'Valkey', desc: 'Fork ouvert de Redis (cache des snapshots).', license: 'BSD-3-Clause', url: 'https://valkey.io/' },
|
||||
{ name: 'Umami', desc: 'Analytics RGPD-friendly (auto-hébergé, sans cookies).', license: 'MIT', url: 'https://umami.is/' },
|
||||
{ name: 'GlitchTip', desc: 'Error tracking compatible Sentry (auto-hébergé).', license: 'MIT', url: 'https://glitchtip.com/' },
|
||||
{ name: 'CrowdSec', desc: 'Protection contre les attaques (rate-limit, ban des IPs malveillantes).', license: 'MIT', url: 'https://www.crowdsec.net/' },
|
||||
];
|
||||
---
|
||||
|
||||
|
|
@ -113,7 +154,31 @@ const INFRA = [
|
|||
</thead>
|
||||
<tbody>
|
||||
{DATA_SOURCES.map((s) => (
|
||||
<tr class="border-t border-slate-200">
|
||||
<tr class="border-t border-slate-200 align-top">
|
||||
<td class="px-3 py-2 font-medium">
|
||||
<a href={s.url} rel="noopener">{s.name}</a>
|
||||
</td>
|
||||
<td class="px-3 py-2 text-slate-700">{s.desc}</td>
|
||||
<td class="px-3 py-2 text-slate-600">{s.license}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold">Services tiers</h2>
|
||||
<table class="mt-3 w-full text-sm">
|
||||
<thead class="bg-slate-100 text-left text-slate-700">
|
||||
<tr>
|
||||
<th class="px-3 py-2">Service</th>
|
||||
<th class="px-3 py-2">Description</th>
|
||||
<th class="px-3 py-2">Licence</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{SERVICES.map((s) => (
|
||||
<tr class="border-t border-slate-200 align-top">
|
||||
<td class="px-3 py-2 font-medium">
|
||||
<a href={s.url} rel="noopener">{s.name}</a>
|
||||
</td>
|
||||
|
|
@ -140,7 +205,7 @@ const INFRA = [
|
|||
{deps.map(([name, version]) => {
|
||||
const m = META[name];
|
||||
return (
|
||||
<tr class="border-t border-slate-200">
|
||||
<tr class="border-t border-slate-200 align-top">
|
||||
<td class="px-3 py-2 font-mono">
|
||||
{m ? <a href={m.url} rel="noopener">{name}</a> : name}
|
||||
</td>
|
||||
|
|
@ -161,6 +226,7 @@ const INFRA = [
|
|||
<tr>
|
||||
<th class="px-3 py-2">Package</th>
|
||||
<th class="px-3 py-2">Version</th>
|
||||
<th class="px-3 py-2">Description</th>
|
||||
<th class="px-3 py-2">Licence</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
|
@ -168,11 +234,12 @@ const INFRA = [
|
|||
{devDeps.map(([name, version]) => {
|
||||
const m = META[name];
|
||||
return (
|
||||
<tr class="border-t border-slate-200">
|
||||
<tr class="border-t border-slate-200 align-top">
|
||||
<td class="px-3 py-2 font-mono">
|
||||
{m ? <a href={m.url} rel="noopener">{name}</a> : name}
|
||||
</td>
|
||||
<td class="px-3 py-2 text-slate-600">{version}</td>
|
||||
<td class="px-3 py-2 text-slate-700">{m?.desc ?? '—'}</td>
|
||||
<td class="px-3 py-2 text-slate-600">{m?.license ?? '—'}</td>
|
||||
</tr>
|
||||
);
|
||||
|
|
@ -193,7 +260,7 @@ const INFRA = [
|
|||
</thead>
|
||||
<tbody>
|
||||
{INFRA.map((i) => (
|
||||
<tr class="border-t border-slate-200">
|
||||
<tr class="border-t border-slate-200 align-top">
|
||||
<td class="px-3 py-2 font-medium">
|
||||
<a href={i.url} rel="noopener">{i.name}</a>
|
||||
</td>
|
||||
|
|
|
|||
|
|
@ -71,15 +71,13 @@ const snippet = `<iframe
|
|||
</div>
|
||||
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold">API JSON brute (alternative)</h2>
|
||||
<h2 class="text-xl font-semibold">Besoin d'un accès programmatique ?</h2>
|
||||
<p class="mt-2 text-sm text-slate-700">
|
||||
Si tu préfères afficher la Vigilance avec ton propre design, l'endpoint
|
||||
<code class="rounded bg-slate-100 px-1">/api/vigilance/dept/[code]</code>
|
||||
renvoie un JSON CORS *, prêt à parser :
|
||||
Ce site ne fournit pas d'API JSON publique. Si tu veux requêter la Vigilance Météo France
|
||||
de manière programmatique, inscris-toi directement sur le
|
||||
<a href="https://portail-api.meteofrance.fr/web/fr/api/DonneesPubliquesVigilance" rel="noopener">portail API officiel Météo France</a>
|
||||
(gratuit, ~5 min) — c'est la même source que celle utilisée par ce site.
|
||||
</p>
|
||||
<pre class="mt-2 overflow-x-auto rounded bg-slate-900 p-4 text-xs text-slate-100"><code>fetch('{baseUrl}/api/vigilance/dept/{sample.code}')
|
||||
.then(r => r.json())
|
||||
.then(data => console.log(data.today)); // [{ phenomenon, color, ... }]</code></pre>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
|
|
|
|||
|
|
@ -112,8 +112,9 @@ export const prerender = false;
|
|||
Météo France et la licence.
|
||||
</p>
|
||||
<p>
|
||||
L'endpoint <code>/api/vigilance</code> diffuse le snapshot courant en JSON (CORS *), pour
|
||||
réutilisation par tout site ou application qui le souhaite.
|
||||
Pour consulter la Vigilance Météo France de manière programmatique, utilisez directement
|
||||
l'<a href="https://portail-api.meteofrance.fr/web/fr/api/DonneesPubliquesVigilance" rel="noopener">API officielle Météo France</a>
|
||||
(gratuite après inscription).
|
||||
</p>
|
||||
</section>
|
||||
</Base>
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ export const prerender = false;
|
|||
<h1 class="text-3xl font-bold sm:text-4xl">Soutenir Info Canicule</h1>
|
||||
<p class="mt-2 max-w-2xl text-slate-600">
|
||||
Le site est gratuit, sans publicité et sans traceurs commerciaux. Si vous le trouvez utile,
|
||||
vous pouvez aider à couvrir l'hébergement (~7 €/mois mutualisés sur plusieurs projets perso).
|
||||
vous pouvez contribuer aux frais d'infrastructure (~30 €/mois).
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
|
@ -38,7 +38,7 @@ export const prerender = false;
|
|||
|
||||
<h2>Ce que les dons financent</h2>
|
||||
<ul>
|
||||
<li>Location du VPS OVH qui héberge le site (~7 €/mois, mutualisés avec d'autres projets perso).</li>
|
||||
<li>Frais d'infrastructure mensuels (~30 €/mois).</li>
|
||||
<li>Nom de domaine annuel.</li>
|
||||
<li>Café pour les soirées de maintenance 🥲.</li>
|
||||
</ul>
|
||||
|
|
@ -59,10 +59,6 @@ export const prerender = false;
|
|||
(proches âgés, personnes isolées) pour qui les conseils canicule peuvent faire une vraie
|
||||
différence.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Réutiliser les données</strong> : l'endpoint <code>/api/vigilance</code> diffuse le
|
||||
snapshot en JSON (CORS *), réutilisable librement sous Licence Ouverte 2.0.
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<p class="text-sm text-slate-500">
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue