diff --git a/docs/superpowers/specs/2026-09-09-unifi-site-manager-integration-design.md b/docs/superpowers/specs/2026-09-09-unifi-site-manager-integration-design.md index d012df5a6..79a992d27 100644 --- a/docs/superpowers/specs/2026-09-09-unifi-site-manager-integration-design.md +++ b/docs/superpowers/specs/2026-09-09-unifi-site-manager-integration-design.md @@ -27,6 +27,7 @@ console, donc un site. Le Site Manager voit tous les sites d'un compte. | 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, la bande en colonne** | Réutilise `HomarrDataTable`, le composant partagé déjà employé par les widgets beszel, docker et downloads. Tri par colonne gratuit, visibilité responsive documentée par la skill `datatable-migration` du dépôt, et lisible bien au-delà de dix sites. C'est aussi la forme qu'emploie l'interface Ubiquiti. La bande devient une colonne parmi d'autres : elle ne domine plus la lecture, mais elle reste présente et le widget tient à l'échelle. | | 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. | +| D6 | **État SD-WAN par site, sans la topologie** | Ajouté après coup à la demande de l'utilisateur. L'état des tunnels — connecté, hors ligne, ping, erreurs — est indexé par `siteId` et se greffe donc sur les lignes existantes. La topologie (réseaux nommés, sous-réseaux) reste hors v1 : une liste de quinze réseaux n'a pas sa place dans une ligne de tableau. | | 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 @@ -52,6 +53,8 @@ Une trentaine de chemins plausibles ont été sondés : |---|---| | `GET /v1/sites` | `siteId`, `hostId`, `meta.desc`, `meta.name`, `statistics.percentages.wanUptime`, `statistics.ispInfo.name`, `statistics.wans[*].externalIp`, `statistics.counts.{totalDevice,offlineDevice,pendingUpdateDevice}` | | `GET /v1/hosts` | `id`, `reportedState.deviceState`, `reportedState.firmwareUpdate` | +| `GET /v1/sd-wan-configs` | `id`, `name` — nécessaire pour atteindre le statut, qui est indexé par configuration | +| `GET /v1/sd-wan-configs/{id}/status` | `status.connections[].sites[]` → `siteId`, `status.{connected,offline,ping,errors}` ; `status.sites[]` → `siteId`, `errors`, `warnings` | Corrélation site ↔ host par `hostId`, présent des deux côtés. Contrairement à l'intégration `unifiOs`, aucune ruse par adresse MAC n'est nécessaire ici : la @@ -107,6 +110,18 @@ export interface UnifiSiteManagerSite { /** Dernière mesure de la fenêtre. `null` si le cloud n'en rapporte aucune. */ latencyMs: number | null; packetLossPct: number | null; + /** `null` si le site n'appartient à aucune configuration SD-WAN. */ + sdWan: UnifiSiteSdWanStatus | null; +} + +export interface UnifiSiteSdWanStatus { + configName: string; + connected: boolean; + offline: boolean; + /** `null` quand le cloud rapporte `0` — voir la note ci-dessous. */ + pingMs: number | null; + errorCount: number; + warningCount: number; } export type UnifiSiteHealthState = "healthy" | "degraded" | "down" | "unknown"; @@ -137,6 +152,18 @@ 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. +**`ping: 0` ne veut pas dire zéro milliseconde.** Relevé sur un tunnel +`connected: true` reliant deux sites géographiquement distants — une latence +nulle y est physiquement impossible. Le champ signifie donc « non mesuré », et le +contrat le traduit par `pingMs: null` plutôt que de propager un zéro que +l'interface afficherait comme une performance. + +**Un site peut appartenir à plusieurs configurations SD-WAN.** Le contrat n'en +porte qu'une : on retient la première configuration où le site apparaît, et le +nom de celle-ci accompagne l'état pour que la ligne reste interprétable. Si le +besoin de plusieurs se présente, c'est un élargissement du contrat, pas un +correctif. + **`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 @@ -208,19 +235,28 @@ const [sites, hosts] = await Promise.allSettled([ if (sites.status === "rejected") throw sites.reason; // pas de liste sans sites // 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 +const [metrics, sdWan] = await Promise.allSettled([ + POST /v1/isp-metrics/1h/query, // fenêtre 24 h, tous les sites + GET /v1/sd-wan-configs // puis /{id}/status pour chacune +]); +// Les deux sont des enrichissements : leur échec n'est jamais fatal. ``` -Trois appels, deux niveaux de criticité. +Quatre à cinq appels selon le nombre de configurations SD-WAN, deux niveaux de +criticité. `/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. +`/v1/hosts`, `isp-metrics` et le SD-WAN sont des enrichissements. Leur échec met +leur part à l'inconnu — `consoleUpdateAvailable` à `null`, les vingt-quatre +tranches à `"unknown"`, `sdWan` à `null` — et laisse le reste affiché. +`Promise.allSettled` plutôt que `Promise.all` sert exactement cette asymétrie. + +Le SD-WAN coûte deux appels : la liste des configurations, puis le statut de +chacune. Avec une seule configuration — le cas courant — cela fait deux requêtes. +Le plan borne ce nombre pour qu'un compte à dix configurations ne produise pas +onze requêtes par rafraîchissement. 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 @@ -257,6 +293,7 @@ Un compte réellement sans site est un cas distinct et légitime : il affiche un | Équipements | `deviceCount`, `offlineDeviceCount` | `en ligne / total`, en rouge si des équipements sont hors ligne | | Mises à jour | `pendingUpdateCount` | en jaune si > 0 ; `0` affiché normalement, c'est une vraie mesure | | Console | `consoleUpdateAvailable` | `—` si `null` — l'absence de host correspondant n'est pas « à jour » | +| SD-WAN | `sdWan` | `—` si le site n'est dans aucune configuration ; sinon état du tunnel, en rouge si hors ligne, en jaune s'il porte des erreurs. Le ping n'apparaît que s'il est mesuré | Colonnes triables. Visibilité responsive selon la largeur de tuile, suivant la convention documentée par la skill `datatable-migration` : les colonnes les moins @@ -304,6 +341,7 @@ widget `unifiOs`. | 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, SD-WAN | site absent de toute configuration → `sdWan` à `null` ; **`ping: 0` → `pingMs: null` et non `0`** ; erreurs et avertissements comptés ; site présent dans plusieurs configurations → la première est retenue | 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é | @@ -334,9 +372,11 @@ passer — dont un qui empêchait l'application de démarrer. 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). +- **Topologie SD-WAN** — l'**état** des tunnels entre dans la v1 (D6) ; la + topologie n'y entre pas. `/v1/sd-wan-configs/{id}` porte les sites, réseaux + nommés, sous-réseaux et l'état `paused` — vérifié complet — mais une liste de + quinze réseaux n'a pas sa place dans une ligne de tableau. Un widget dédié + reste possible et réutilisera le client de cette intégration. - **Écriture** — l'API expose des mutations ; l'intégration reste en lecture seule. - **Documentation Docusaurus et traductions au-delà de l'anglais** — fork personnel, même décision que l'intégration précédente. @@ -351,6 +391,8 @@ 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 | +| `ping: 0` interprété comme une latence nulle | Justesse | Traduit en `pingMs: null` au contrat, testé. Relevé sur un tunnel connecté entre deux sites distants, où zéro est physiquement impossible | +| Un compte à nombreuses configurations SD-WAN multiplie les requêtes | Fiabilité | Nombre de configurations interrogées borné par le plan ; au-delà, les sites concernés ont `sdWan` à `null` plutôt qu'une rafale de requêtes | | Le nombre de sites croît | Interface | Le tableau absorbe la croissance là où des cartes ou une liste se dégraderaient ; visibilité responsive des colonnes, `Site` et `Santé` toujours conservées | | La bande est étroite dans une colonne | Lisibilité | Conséquence directe de D3. L'infobulle par segment n'est donc pas un confort mais le moyen principal de lire une tranche | | 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 |