docs: spec — merge ISP metrics into the site health list

Proposition de l'utilisateur, retenue : fusionner les métriques ISP avec
la barre « Internet Health » pour une liste d'état rapide des sites.

Elle inverse la décision D4. La barre avait été écartée au motif que
`internetIssues` est un tableau creux dont il faudrait reconstituer les
périodes saines — vrai de cette source, mais `isp-metrics` fournit la
même information déjà dense, une entrée par site, vérifié. La barre
coûte alors une boucle de coloration, pas un moteur de graphe.

D3 change aussi : liste d'état plutôt que tableau, la bande au centre
plutôt qu'en colonne. HomarrDataTable n'a donc plus lieu d'être, et le
tri disparaît. Limite assumée et consignée : au-delà de cinq ou six
sites la forme tableau redeviendrait préférable.

Point de justesse ajouté au contrat : une tranche horaire sans mesure
vaut "unknown" et jamais "healthy". Un site enregistré depuis douze
heures n'a que douze tranches sur vingt-quatre ; les peindre en vert
affirmerait un bon fonctionnement jamais mesuré.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
zacharyandClaude Opus 5 committed 2026-09-13 22:21:43 +02:00
1 parent 1c602a305b
commit a3b7ca9a8c
1 file changed
+106 -43
@@ -25,8 +25,8 @@ console, donc un site. Le Site Manager voit tous les sites d'un compte.
|---|---|---|
| D1 | **Deux intégrations indépendantes** | `unifiOs` garde sa clé cloud optionnelle pour son bloc ISP ; `unifiSiteManager` a la sienne. Chacune reste installable et supprimable seule. Coût accepté : la clé est saisie deux fois si l'utilisateur emploie les deux, et deux appelants interrogent le cloud. |
| D2 | **Vue d'ensemble des sites d'abord** | Les captures fournies montrent trois vues — liste des sites, métriques ISP historisées, topologie SD-WAN. Ce sont trois projets. Celui-ci construit aussi le socle (intégration, client, routeur) dont les deux autres dépendront. |
| D3 | **Tableau, pas cartes** | Réutilise `HomarrDataTable`, le composant partagé déjà employé par les widgets beszel, docker et downloads. Tri gratuit, visibilité responsive documentée par la skill `datatable-migration` du dépôt, et lisible au-delà de trois sites. |
| D4 | **Pas de barre « Internet Health » en v1** | Les autres colonnes sont des valeurs directes ; celle-ci est un mini-graphe déguisé en colonne (voir §3.3). Reportée, pas abandonnée. |
| D3 | **Liste d'état, pas tableau** | La bande de santé est l'élément qu'on lit en premier ; un tableau la réduirait à une colonne parmi d'autres. Une ligne par site : nom, bande pleine largeur, puis une ligne de chiffres discrète. Coût assumé : pas de tri, et la lisibilité se dégrade au-delà de cinq ou six sites (§12). |
| D4 | **La bande « Internet Health » EST dans la v1** | Décision inversée en cours de conception. Elle avait été écartée au motif que `internetIssues` est un tableau creux dont il faudrait reconstituer les périodes saines. C'est vrai de cette source — mais `isp-metrics` fournit la même information **déjà dense** (§3.3). La bande coûte alors une boucle de coloration, pas un moteur de graphe. |
| D5 | **Rafraîchissement 5 min** | Les compteurs cloud n'évoluent pas plus vite. À contraster avec `unifiOs`, dont la source locale a justifié 5 s. |
## 3. Constatations vérifiées sur l'API
@@ -57,21 +57,31 @@ Corrélation site ↔ host par `hostId`, présent des deux côtés. Contrairemen
l'intégration `unifiOs`, aucune ruse par adresse MAC n'est nécessaire ici : la
clé de jointure est explicite.
### 3.3 Ce que la v1 ne consomme pas, et pourquoi
### 3.3 Deux sources pour la santé, une seule utilisable
`statistics.internetIssues` alimente la barre « Internet Health » de l'interface
Ubiquiti. Sa forme a été décodée : un tableau **creux** d'anomalies, chacune
portant un `index` de tranche de 5 minutes, et soit `highLatency` avec
L'interface Ubiquiti affiche une barre « Internet Health ». Deux champs peuvent
la produire, et le choix entre eux décide du coût de la fonctionnalité.
**`statistics.internetIssues` — écarté.** Un tableau **creux** d'anomalies,
chacune portant un `index` de tranche de 5 min, et soit `highLatency` avec
`latencyAvgMs`/`latencyMaxMs`, soit `notReported` avec un `count`. L'index se
convertit en horodatage par `index × 300` secondes — vérifié.
convertit par `index × 300` secondes — vérifié. Mais les périodes **saines** n'y
sont pas : il faudrait les reconstituer par différence avec la fenêtre.
Rendre la barre suppose donc : décoder les index, **reconstituer les périodes
saines** entre les anomalies (elles ne sont pas dans la réponse), et dessiner une
frise dans quelques dizaines de pixels. C'est un graphe, pas une colonne. Reporté
par D4.
**`GET /v1/isp-metrics/{5m|1h}` — retenu.** Vérifié : renvoie **une entrée par
site** (`hostId`, `siteId`) avec une série **dense**, chaque période portant
`downtime`, `packetLoss`, `avgLatency`, `maxLatency`, `uptime` et `ispName`.
Granularité `1h` : 30 jours d'historique disponible. Granularité `5m` : environ
24 h.
Également hors v1 : `/v1/isp-metrics` (projet 2) et `/v1/sd-wan-configs`
(projet 3), dont les données ont été vérifiées disponibles et complètes.
La v1 consomme donc `isp-metrics` en `1h` sur une fenêtre de 24 h, soit
**24 segments par site**. Rien à reconstituer, rien à décoder.
**Taille.** Le `GET` non borné pèse 216 Ko en `1h` et 133 Ko en `5m`. La v1 passe
par `POST /v1/isp-metrics/1h/query`, qui accepte plusieurs sites et une fenêtre
bornée dans une seule requête.
Reste hors v1 : `/v1/sd-wan-configs` (projet 3), données vérifiées complètes.
## 4. Contrat de données
@@ -91,6 +101,20 @@ export interface UnifiSiteManagerSite {
pendingUpdateCount: number;
/** Mise à jour de la CONSOLE. `null` si aucun host ne correspond au site. */
consoleUpdateAvailable: boolean | null;
/** 24 tranches horaires, la plus ancienne en premier. Toujours 24 entrées :
* une tranche sans mesure vaut `"unknown"`, jamais `"healthy"`. */
health: UnifiSiteHealthSegment[];
/** Dernière mesure de la fenêtre. `null` si le cloud n'en rapporte aucune. */
latencyMs: number | null;
packetLossPct: number | null;
}
export type UnifiSiteHealthState = "healthy" | "degraded" | "down" | "unknown";
export interface UnifiSiteHealthSegment {
/** Début de la tranche horaire. */
at: Date;
state: UnifiSiteHealthState;
}
export interface UnifiSiteManagerOverview {
@@ -103,10 +127,20 @@ signifie « aucun host ne correspond à ce site », ce qui n'est pas la même ch
que `false` (« la console est à jour »). Le widget doit les distinguer, comme il
distingue partout ailleurs l'inconnu du connu.
**Dérivation de `state`, par ordre de gravité décroissante :** `downtime > 0`
→ `"down"` ; sinon `packetLoss > 0` → `"degraded"` ; sinon `"healthy"`. Une
tranche de la fenêtre pour laquelle le cloud ne rapporte rien → `"unknown"`.
**Cette dernière règle est le point délicat.** Un site enregistré depuis douze
heures n'a que douze tranches sur vingt-quatre. Les remplir en vert affirmerait
douze heures de bon fonctionnement qui n'ont jamais été mesurées. Le gris dit
« on ne sait pas », ce qui est la vérité — même principe que le tiret cadratin
ailleurs dans ces widgets.
**`pendingUpdateCount` et `consoleUpdateAvailable` ne mesurent pas la même
chose.** Le premier compte les équipements réseau dont le firmware est en
retard ; le second dit si UniFi OS lui-même a une version disponible. L'interface
Ubiquiti les affiche en deux colonnes séparées ; le contrat fait de même.
L'interface Ubiquiti les sépare ; le contrat fait de même.
## 5. Architecture
@@ -171,19 +205,26 @@ const [sites, hosts] = await Promise.allSettled([
GET /v1/sites, // source obligatoire
GET /v1/hosts, // enrichissement
]);
if (sites.status === "rejected") throw sites.reason; // pas de liste sans sites
if (sites.status === "rejected") throw sites.reason; // pas de tableau sans sites
// hosts rejeté → consoleUpdateAvailable vaut null partout, le tableau s'affiche
// Séquentiel : la requête bornée exige les hostId/siteId de l'étape précédente.
const metrics = await POST /v1/isp-metrics/1h/query // fenêtre 24 h, tous les sites
.catch(() => null); // enrichissement, jamais fatal
```
Les deux appels partent en parallèle, mais leurs échecs ne se valent pas.
`/v1/sites` porte le tableau : son échec doit remonter, sinon le widget
afficherait « aucun site » alors qu'il n'a rien pu lire. `/v1/hosts` ne porte
qu'une colonne : son échec la met à `null` — inconnue, et non « à jour » — et
laisse le reste intact.
Trois appels, deux niveaux de criticité.
`Promise.allSettled` plutôt que `Promise.all` précisément pour rendre cette
asymétrie possible : `all` ferait échouer le tout sur l'échec de l'enrichissement.
`/v1/sites` porte la liste : son échec doit remonter, sinon le widget afficherait
« aucun site » alors qu'il n'a rien pu lire.
`/v1/hosts` et `isp-metrics` sont des enrichissements. Leur échec met leur part à
l'inconnu — `consoleUpdateAvailable` à `null`, les vingt-quatre tranches à
`"unknown"` — et laisse le reste affiché. `Promise.allSettled` plutôt que
`Promise.all` sert exactement cette asymétrie.
L'appel aux métriques est **séquentiel** et non parallèle : la requête bornée a
besoin des identifiants renvoyés par `/v1/sites`. C'est le prix de ne pas
rapatrier les 216 Ko de la variante non bornée.
## 7. Cache et gestion d'erreurs
@@ -196,7 +237,7 @@ asymétrie possible : `all` ferait échouer le tout sur l'échec de l'enrichisse
**Différence de fond avec `unifiOs`.** Là-bas, le cloud était un enrichissement
optionnel : son indisponibilité vidait un bloc et le widget continuait. Ici le
cloud **est** la source. Un échec est un vrai échec, et le widget doit afficher
une erreur — jamais un tableau vide, qui se lirait comme « aucun site », c'est-à-dire
une erreur — jamais une liste vide, qui se lirait comme « aucun site », c'est-à-dire
comme une information alors qu'il n'y en a aucune.
Un compte réellement sans site est un cas distinct et légitime : il affiche un
@@ -204,30 +245,48 @@ Un compte réellement sans site est un cas distinct et légitime : il affiche un
## 8. Widget
`HomarrDataTable`, une ligne par site :
Une liste, pas un tableau. Une entrée par site :
| Colonne | Source | Note |
|---|---|---|
| Site | `name` | toujours visible |
| Uptime | `wanUptimePct` | `—` si `null` |
| ISP | `ispName` | `—` si `null` |
| IP publique | `externalIps` | joint par ` · ` ; `—` si vide |
| Équipements | `deviceCount`, `offlineDeviceCount` | `en ligne / total`, en rouge si des équipements sont hors ligne |
| Mises à jour | `pendingUpdateCount` | en jaune si > 0, masqué si 0 |
| Console | `consoleUpdateAvailable` | `—` si `null` — l'absence de host correspondant n'est pas « à jour » |
```
● Nom du site 11 ms · 0 %
████████████░░░░████████████████████████████████
Nom FAI · 203.0.113.10 6/6 équipements · 1 maj · console à jour
```
Colonnes triables. Visibilité responsive selon la largeur de tuile, suivant la
convention documentée par la skill `datatable-migration` du dépôt : les colonnes
les moins essentielles disparaissent en premier, la colonne Site jamais.
- **Pastille d'état** en tête : couleur de la tranche la plus récente.
- **Bande de santé** pleine largeur, vingt-quatre segments d'une heure, du plus
ancien au plus récent. Vert sain, orange dégradé, rouge coupé, gris inconnu.
Segments jointifs avec un filet de séparation, pour qu'on distingue deux
tranches consécutives de même couleur.
- **Latence et perte** à droite du nom, en encre atténuée. `—` si aucune mesure.
- **Ligne de détail** discrète : FAI, IP publique, équipements en ligne / total,
mises à jour d'équipements en attente, état de mise à jour de la console.
Badge de fraîcheur en tête, comme sur le widget `unifiOs`, alimenté par
`updatedAt` et `useIntegrationConnected`.
Chaque valeur inconnue s'affiche `—`, jamais zéro ni « à jour ».
**Couleurs.** À valider par le validateur de palette avant écriture, en thème
clair et sombre, comme pour le graphe de débit. Les quatre états doivent rester
distinguables en vision déficiente : la bande n'a pas d'autre encodage que la
couleur, ce qui en fait le cas le plus exigeant du projet. Si un jeu à quatre
états ne passe pas, l'infobulle par segment devient obligatoire, pas optionnelle.
**Infobulle par segment** : heure de la tranche, latence moyenne, perte. C'est
aussi ce qui rend la bande lisible sans distinguer les couleurs.
**Badge de fraîcheur** en tête de widget, alimenté par `updatedAt`, comme sur le
widget `unifiOs`.
**Limite assumée.** La forme liste se dégrade au-delà de cinq ou six sites : la
tuile devient un long défilement. C'est le prix de mettre la santé au centre
plutôt que dans une colonne, et c'est un choix explicite (D3). Au-delà, la forme
tableau redeviendrait préférable.
## 9. Tests
| Niveau | Couvre | Réseau |
|---|---|---|
| Unitaire, mappers | mapping d'un site complet ; site sans WAN ; site sans `hostId` ; host absent → `consoleUpdateAvailable` à `null` et non `false` ; compte sans aucun site | Non |
| Unitaire, santé | dérivation `down` > `degraded` > `healthy` ; **fenêtre incomplète → tranches `"unknown"`, jamais `"healthy"`** ; fenêtre toujours longue de 24 ; métriques absentes → 24 tranches `"unknown"` | Non |
| Unitaire, garde | **`unifi-site-manager-types.ts` n'importe pas `@homarr/common/server`** — la seule forme de test qui aurait attrapé le défaut de l'intégration précédente | Non |
| Intégration | séquence complète sur fixtures anonymisées ; `/v1/hosts` en échec laisse le tableau affiché ; `/v1/sites` en échec propage l'erreur | Mocké |
| Manuel | vérification contre le compte réel, hors CI | Oui |
@@ -252,9 +311,11 @@ passer — dont un qui empêchait l'application de démarrer.
## 11. Hors périmètre
- **Barre « Internet Health »** — décodée (§3.3), reportée par D4.
- **Métriques ISP historisées** — projet 2. Données vérifiées disponibles via
`/v1/isp-metrics/{5m|1h}` et son endpoint `query`.
- **Vue d'analyse ISP** — projet 2. La v1 consomme déjà `isp-metrics` pour la
bande de santé ; ce qui reste hors périmètre est l'**outil d'analyse** de la
seconde capture : sélecteur de fenêtre 1D à 1M, courbes de latence superposées
par site, filtres par ISP, par perte et par uptime. Un résumé et un instrument
d'analyse ne sont pas deux tailles du même widget.
- **Topologie SD-WAN** — projet 3. Données vérifiées complètes via
`/v1/sd-wan-configs/{id}` (sites, réseaux nommés, sous-réseaux, état `paused`)
et `/{id}/status` (connectivité par tunnel, ping, erreurs).
@@ -272,4 +333,6 @@ passer — dont un qui empêchait l'application de démarrer.
| La clé cloud voit tous les sites du compte, y compris non possédés | Sécurité | Assumé et **voulu** ici : c'est une vue multi-sites. Contrairement à `unifiOs`, aucun filtrage n'est souhaitable — mais la documentation du widget doit dire ce qu'il expose |
| Limitation de débit sur `api.ui.com` | Fiabilité | Cadence 5 min, deux appels par cycle |
| Deux intégrations interrogent le cloud en parallèle | Fiabilité | Conséquence acceptée de D1 ; caches indépendants, ~24 requêtes/heure au total |
| Le nombre de sites croît | Interface | Un tableau tient mieux que des cartes ; visibilité responsive des colonnes |
| Le nombre de sites croît | Interface | Limite connue et assumée (D3) : au-delà de cinq ou six sites la liste devient un long défilement. Le basculement vers une forme tableau est le remède, et reste possible sans toucher au contrat |
| Quatre états de santé distingués par la seule couleur | Accessibilité | Palette validée avant écriture ; infobulle par segment rendue obligatoire si le jeu à quatre états ne passe pas les contrôles de vision déficiente |
| Une tranche sans mesure affichée comme saine | Justesse | État `"unknown"` explicite au contrat, testé ; la fenêtre porte toujours 24 entrées |