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 new file mode 100644 index 000000000..0d4455cc4 --- /dev/null +++ b/docs/superpowers/specs/2026-09-09-unifi-site-manager-integration-design.md @@ -0,0 +1,275 @@ +# Intégration UniFi Site Manager pour Homarr — conception + +**Date :** 2026-09-09 +**Base :** homarr-labs/homarr `2b1a03849` (v1.77.0) +**Statut :** conception validée, prête pour le plan d'implémentation +**Précède :** `2026-09-08-unifi-os-integration-design.md` (intégration mono-site, locale + cloud optionnel) + +> ⚠️ Aucune clé d'API, adresse MAC réelle, IP publique ou nom de site réel ne +> figure dans ce document. + +--- + +## 1. Objectif + +Ajouter une intégration **UniFi Site Manager**, distincte de `unifiOs`, lisant +**uniquement** l'API cloud `https://api.ui.com`, et un widget affichant une vue +d'ensemble **multi-sites**. + +C'est ce que `unifiOs` ne peut structurellement pas faire : son URL désigne une +console, donc un site. Le Site Manager voit tous les sites d'un compte. + +## 2. Décisions cadrées + +| # | Décision | Justification | +|---|---|---| +| 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. | +| 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 + +Relevées le 2026-09-09 contre `https://api.ui.com` avec une clé réelle. + +### 3.1 Il n'existe pas d'API cachée + +Une trentaine de chemins plausibles ont été sondés : + +- **`/ea/*` est un alias complet de `/v1/*`** — mêmes payloads, mêmes tailles à + l'octet près. C'est le namespace « early access », pas une surface parallèle. +- `/v1/organizations`, `/orgs`, `/users`, `/me`, `/subscriptions`, `/licenses`, + `/backups`, `/tasks`, `/events`, `/topology`, `/clients`, `/networks`, `/wifi`, + `/health`, `/status`, `/version` : **tous 404**. +- `/v1/isp-metrics/{type}` n'accepte que `5m` et `1h`. `1d` et `24h` → 400. + +**La surface documentée est toute la surface.** Aucune requête cachée à exploiter. + +### 3.2 Ce que la v1 consomme + +| Endpoint | Champs retenus | +|---|---| +| `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` | + +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 +clé de jointure est explicite. + +### 3.3 Ce que la v1 ne consomme pas, et pourquoi + +`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 +`latencyAvgMs`/`latencyMaxMs`, soit `notReported` avec un `count`. L'index se +convertit en horodatage par `index × 300` secondes — vérifié. + +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. + +É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. + +## 4. Contrat de données + +```ts +export interface UnifiSiteManagerSite { + siteId: string; + /** `meta.desc` s'il existe, sinon `meta.name`. */ + name: string; + /** `null` quand le cloud ne rapporte pas d'uptime pour ce site. */ + wanUptimePct: number | null; + ispName: string | null; + /** Une entrée par WAN configuré ; vide si aucune IP publique n'est rapportée. */ + externalIps: string[]; + deviceCount: number; + offlineDeviceCount: number; + /** Firmwares d'ÉQUIPEMENTS en attente — distinct de la mise à jour de console. */ + pendingUpdateCount: number; + /** Mise à jour de la CONSOLE. `null` si aucun host ne correspond au site. */ + consoleUpdateAvailable: boolean | null; +} + +export interface UnifiSiteManagerOverview { + sites: UnifiSiteManagerSite[]; +} +``` + +**`consoleUpdateAvailable` est nullable et ce n'est pas un détail.** `null` +signifie « aucun host ne correspond à ce site », ce qui n'est pas la même chose +que `false` (« la console est à jour »). Le widget doit les distinguer, comme il +distingue partout ailleurs l'inconnu du connu. + +**`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. + +## 5. Architecture + +### 5.1 Nouveaux fichiers + +``` +packages/integrations/src/unifi-site-manager/ +├── unifi-site-manager-integration.ts # classe Integration : orchestre et mappe +├── unifi-site-manager-api.ts # client HTTP cloud +├── unifi-site-manager-types.ts # schémas zod + mappers purs +├── unifi-site-manager-parse.ts # parsing, dépend de @homarr/common/server +└── test/ +``` + +**La séparation `types` / `parse` est imposée par un défaut rencontré sur +l'intégration précédente**, pas par goût. `packages/integrations/src/types.ts` +est importé par des composants **client** (la spotlight, entre autres). Tout +module qu'il réexporte doit donc être client-safe. `unifi-os-types.ts` importait +`ParseError` depuis `@homarr/common/server`, ce qui a traîné `ioredis` — et donc +`node:dns` — dans le bundle navigateur et **empêché l'application de démarrer**. +Ni le typecheck, ni 703 tests, ni cinq revues ne l'avaient vu : seul le +lancement de l'application l'a révélé. + +Le module de types de cette intégration ne contiendra donc que zod et des +fonctions pures, et un test de garde vérifiera l'absence de cet import. + +### 5.2 Points d'attache + +Seize entrées, tous identifiés. L'entrée 14 en couvre quatre fichiers à elle +seule, soit dix-neuf fichiers touchés au total. + +| # | Fichier | Ajout | +|---|---|---| +| 1 | `definitions/src/integration.ts` | catégorie `unifiSiteManager` ; kind avec `secretKinds: [["siteManagerApiKey"]]`, `defaultUrl: "https://api.ui.com"`, `documentationUrl: null` | +| 2 | `definitions/src/widget.ts` | `WidgetKind` `unifiSiteManager` | +| 3 | `definitions/src/widget-integration-map.ts` | `unifiSiteManager: ["unifiSiteManager"]` | +| 4 | `definitions/src/docs/integration-doc-slugs.ts` | `unifiSiteManager: null` | +| 5 | `definitions/src/docs/widget-doc-slugs.ts` | `unifiSiteManager: null` | +| 6 | `integrations/src/base/creator.ts` | enregistrement de la classe | +| 7 | `integrations/src/index.ts` | export de la classe | +| 8 | `integrations/src/types.ts` | export des types **client-safe uniquement** | +| 9 | `integrations/src/interfaces/unifi-site-manager/` | interface consommée par le handler | +| 10 | `request-handler/src/unifi-site-manager.ts` | handler de cache | +| 11 | `api/src/router/widgets/unifi-site-manager.ts` | routeur tRPC | +| 12 | `api/src/router/widgets/index.ts` | enregistrement du routeur | +| 13 | `widgets/src/unifi-site-manager/` | définition + composant | +| 14 | `widgets/src/{manifest.ts,index.tsx,catalog.ts,refetch-intervals.ts}` | quatre enregistrements ; l'icône du catalogue doit être **la même référence** que celle de la définition, `manifest.spec.ts` compare par identité | +| 15 | `apps/nextjs/.../integration-grid-shared.ts` | `unifiSiteManager: "integration.category.unifiSiteManager"` — table typée `Record`, donc **non vérifiée par le compilateur** : une omission passe le typecheck et affiche la clé brute | +| 16 | `translation/src/lang/en.json` | libellés du widget + `integration.category.unifiSiteManager` | + +**Un point d'attache en moins qu'`unifiOs` :** le secret `siteManagerApiKey` +existe déjà, ainsi que son icône et ses libellés. On le réutilise tel quel. + +**URL fixe.** `defaultUrl: "https://api.ui.com"`, sur le modèle de l'intégration +`umami` (`https://api.umami.is/v1`). Le champ `url` est `notNull` en base, d'où +le recours à une valeur par défaut plutôt qu'à son absence. + +## 6. Séquence d'un rafraîchissement + +``` +const [sites, hosts] = await Promise.allSettled([ + GET /v1/sites, // source obligatoire + GET /v1/hosts, // enrichissement +]); + +if (sites.status === "rejected") throw sites.reason; // pas de tableau sans sites +// hosts rejeté → consoleUpdateAvailable vaut null partout, le tableau s'affiche +``` + +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. + +`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. + +## 7. Cache et gestion d'erreurs + +| Couche | Valeur | Motif | +|---|---|---| +| `createIntegrationRequestHandler` | 5 min | Les compteurs cloud n'évoluent pas plus vite ; interroger davantage expose à une limitation de débit sans rien gagner | +| Rafraîchissement du widget | 5 min | Aligné : un intervalle plus court relirait la même entrée de cache | +| `fallbackToStaleOnError` | activé, fenêtre 30 min | Sert la dernière valeur connue plutôt qu'une tuile d'erreur pour une coupure passagère | + +**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 +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 +état vide explicite, pas une erreur. + +## 8. Widget + +`HomarrDataTable`, une ligne 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 » | + +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. + +Badge de fraîcheur en tête, comme sur le widget `unifiOs`, alimenté par +`updatedAt` et `useIntegrationConnected`. + +## 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, 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 | + +Fixtures **anonymisées** : identifiants, noms de sites, IP publiques et adresses +MAC remplacés. Un dépôt ne porte pas la cartographie d'un réseau privé. + +## 10. Phasage + +| Phase | Contenu | Critère de validation | +|---|---|---| +| 1 | types + mappers + tests unitaires | `pnpm test` vert, sans réseau | +| 2 | client cloud + tests | idem | +| 3 | classe d'intégration + enregistrements (points 1 à 8) | `pnpm turbo typecheck` propre | +| 4 | request-handler + routeur tRPC (9 à 12) | typecheck propre | +| 5 | widget + enregistrements + traductions (13 à 16) | `manifest.spec.ts` vert, typecheck **global** propre | +| 6 | vérification contre le compte réel | Les valeurs correspondent à l'interface Site Manager | + +**La phase 6 n'est pas optionnelle.** Sur l'intégration précédente, elle a +révélé deux défauts que le typecheck, 703 tests et cinq revues avaient laissés +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`. +- **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). +- **É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. +- **Mutualisation de la clé cloud avec `unifiOs`** — écartée par D1. + +## 12. Risques + +| Risque | Portée | Traitement | +|---|---|---| +| Un module de types non client-safe casse le build | Élevée — déjà survenu | Séparation `types` / `parse` dès le départ, plus un test de garde | +| Une omission dans `categoryTranslationKeys` passe le typecheck | Moyenne — déjà survenu | Point d'attache 15 explicite ; vérification visuelle en phase 6 | +| 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 |