docs: design for UniFi Site Manager integration
Nouvelle intégration cloud-only, distincte de unifiOs, remontant une vue multi-sites. Premier des trois projets identifiés à partir des captures du Site Manager ; il construit aussi le socle des deux suivants. Constat notable : il n'existe pas d'API cachée. /ea/* est un alias complet de /v1/*, et une trentaine d'autres chemins plausibles renvoient 404. La surface documentée est toute la surface. La séparation types / parse est imposée dès la conception par le défaut rencontré sur l'intégration précédente, où un import serveur dans un module réexporté côté client empêchait l'application de démarrer. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
950a925fd6
commit
92c878befd
1 file changed
+275
@@ -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<string, string>`, 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 |
|
||||
Reference in new issue
Block a user