État des services, testés depuis le navigateur de la personne à l’ouverture et sur « Retester » ; regroupés par pays, avec l’effet d’une panne sur l’analyse.
stableÀ valider
// Titre : Trois sources, trois états
import ServiceStatus from '@pathou/components/ServiceStatus';
import type { Check, Probe } from '@pathou/lib/status/run';
// dans Pathou, chaque test interroge une vraie source (lib/status/checks.ts) ; ici, des réponses simulées
const answer = (ms: number, probe: Probe) => () => new Promise<Probe>((ok) => setTimeout(() => ok(probe), ms));
const checks: Check[] = [
{ id: 'ign', country: 'fr', category: 'altitude', label: 'Altitude IGN', slowMs: 2000, requests: 1, run: answer(300, { ok: true, answered: true }) },
{ id: 'ign-chemins', country: 'fr', category: 'chemins', label: 'Chemins IGN', slowMs: 400, requests: 1, run: answer(900, { ok: true, answered: true }) },
{ id: 'swisstopo', country: 'ch', category: 'altitude', label: 'Altitude swisstopo', slowMs: 2000, requests: 1, run: answer(500, { ok: false, answered: false }) },
];
export default function Exemple() {
return (
<div style={{ width: 420 }}>
<ServiceStatus checks={checks} />
</div>
);
}
Installation
Copie le fichier dans ton projet, ainsi que les tokens qu’il utilise.
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { buildChecks } from '../lib/status/checks';
import { RETEST_LOCK_MS, canRetest, effect, osmOverall, runStatus, summary, type Check, type Result, type State } from '../lib/status/run';
import { COUNTRY_LABEL, type CountryCode } from '../lib/sources/types';
import { FLAG } from './CountriesInfo';
const STATE_LABEL: Record<State, string> = { 'en-cours': 'Test…', fonctionne: 'Fonctionne', lent: 'Lent', 'en-panne': 'En panne' };
const pending = (id: string): Result => ({ id, state: 'en-cours', ms: null, answered: false });
/** `detail` : ligne de détail d'un groupe (serveur Overpass), sans texte d'effet : seule la ligne d'ensemble le porte. */
/** `as="div"` : en-tête d'un groupe, déjà dans son propre élément de liste (un <li> ne peut pas en contenir un autre). */
function Row({ c, r, detail, as: Tag = 'li' }: { c: Check; r: Result; detail?: boolean; as?: 'li' | 'div' }) {
const fx = detail ? null : effect(c, r.state);
return (
<Tag className={`svc-row is-${r.state}`}>
<span className="svc-dot" aria-hidden="true" />
<span className="svc-label">
{c.label}
{c.place && <span className="muted"> ({c.place})</span>}
{fx && <span className="svc-effect muted small">{fx}</span>}
</span>
<span className="svc-state">{STATE_LABEL[r.state]}</span>
</Tag>
);
}
/** Fenêtre « État des services » : chaque source testée depuis le navigateur, à l'ouverture et sur « Retester ». */
export default function ServiceStatus({ checks: given, initialResults }: { checks?: Check[]; initialResults?: Record<string, Result> }) {
const checks = useMemo(() => given ?? buildChecks(), [given]);
const [results, setResults] = useState<Record<string, Result>>(initialResults ?? {});
const [startedAt, setStartedAt] = useState<number | null>(null);
const [testedAt, setTestedAt] = useState<Date | null>(null);
const [, setTick] = useState(0);
const run = useRef<AbortController | null>(null);
const start = useCallback(() => {
run.current?.abort();
const ctl = new AbortController();
run.current = ctl;
setResults({});
setStartedAt(Date.now());
setTestedAt(new Date());
void runStatus(checks, { signal: ctl.signal, onResult: (r) => setResults((m) => ({ ...m, [r.id]: r })) });
}, [checks]);
useEffect(() => {
start();
return () => run.current?.abort();
}, [start]);
// réactive « Retester » à la fin du blocage
useEffect(() => {
if (startedAt === null) return;
const t = setTimeout(() => setTick((n) => n + 1), RETEST_LOCK_MS);
return () => clearTimeout(t);
}, [startedAt]);
const online = typeof navigator === 'undefined' ? true : navigator.onLine;
const get = (id: string) => results[id] ?? pending(id);
const groups: { key: CountryCode | 'partout'; rows: Check[] }[] = [];
for (const c of checks) {
const g = groups.find((x) => x.key === c.country);
if (g) g.rows.push(c);
else groups.push({ key: c.country, rows: [c] });
}
// Partout toujours en dernier
groups.sort((a, b) => Number(a.key === 'partout') - Number(b.key === 'partout'));
return (
<div className="svc">
<p className="svc-summary" role="status">
{summary(checks, results, online)}
</p>
<p className="muted small">
{testedAt && <>Testé à {testedAt.toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' })} depuis ton navigateur. </>}
<button className="hp-link" disabled={!canRetest(startedAt, Date.now())} onClick={start}>
Retester
</button>
</p>
{groups.map((g) => {
const osm = g.rows.filter((c) => c.category === 'osm');
const others = g.rows.filter((c) => c.category !== 'osm');
const flag = g.key !== 'partout' ? FLAG[g.key] : undefined;
return (
<section className="svc-country" key={g.key}>
<h3>
{flag && <img className="ci-flag" src={flag} alt="" width={18} height={18} />}
{g.key === 'partout' ? 'Partout' : COUNTRY_LABEL[g.key]}
</h3>
<ul>
{others.map((c) => (
<Row key={c.id} c={c} r={get(c.id)} />
))}
{osm.length > 0 && (
<li className="svc-group">
<Row
as="div"
c={{ ...osm[0], id: 'osm', label: 'Chemins OpenStreetMap', place: undefined }}
r={{ id: 'osm', state: osmOverall(osm.map((c) => get(c.id))), ms: null, answered: true }}
/>
<ul className="svc-sub">
{osm.map((c) => (
<Row key={c.id} c={c} r={get(c.id)} detail />
))}
</ul>
</li>
)}
</ul>
</section>
);
})}
<aside className="svc-info">
<svg className="svc-info-icon" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true">
<circle cx="8" cy="8" r="7" fill="none" stroke="currentColor" strokeWidth="1.4" />
<path d="M8 7v4.5M8 4.6v.1" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
</svg>
<p>
À l’ouverture de cette fenêtre, ton navigateur interroge chaque service une seule fois : une altitude en un point
connu, une tuile de carte ou quelques chemins. Réponse juste et rapide : il fonctionne. Juste mais lente : il est
lent. Fausse ou absente après 8 s : il est en panne, et Pathou passe à une source de secours.
</p>
</aside>
</div>
);
}
Tokens : --panel --text --muted --line --accent --chip --ok (non déterminé) --engage (non déterminé) --exigeant (non déterminé) --ease-out --font --status-beta --status-degrade
Utilisation
import ServiceStatus from './components/ServiceStatus';
<Modal title="État des services" onClose={close}>
<ServiceStatus />
</Modal>Anatomie
| # | Élément | Rôle |
|---|---|---|
| 1 | Bilan | Une phrase qui résume les pannes et lenteurs, annoncée aux lecteurs d’écran (role="status"). |
| 2 | Retester | Relance tous les tests ; inactif 30 secondes après un test pour ménager les sources. |
| 3 | Pays | Un groupe par pays, avec son drapeau ; « Partout » en dernier. |
| 4 | Ligne de service | Pastille de couleur, nom de la source, effet d’une panne, et état en texte. |
États
stateÉtat de chaque service.Guidelines
- MUSTÉcrire l’état en texte à côté de la pastille ; la couleur seule ne suffit pas.
- SHOULDDire pour chaque panne ce que Pathou fait à la place (source de secours, analyse plus lente), pas seulement qu’elle est en panne.
Référence de l’API
| Prop | Type | Défaut |
|---|---|---|
checks | Check[] | — |
initialResults | Record<string, Result> | — |