ServiceStatus

←→

É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.

src/components/ServiceStatus.tsx
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&nbsp;: une altitude en un point
          connu, une tuile de carte ou quelques chemins. Réponse juste et rapide&nbsp;: il fonctionne. Juste mais lente&nbsp;: il est
          lent. Fausse ou absente après 8&nbsp;s&nbsp;: 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émentRôle
1BilanUne phrase qui résume les pannes et lenteurs, annoncée aux lecteurs d’écran (role="status").
2RetesterRelance tous les tests ; inactif 30 secondes après un test pour ménager les sources.
3PaysUn groupe par pays, avec son drapeau ; « Partout » en dernier.
4Ligne de servicePastille 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.
  • SHOULD
    Dire 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

PropTypeDéfaut
checksCheck[]—
initialResultsRecord<string, Result>—