Compare commits
60
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a029760f66 | ||
|
|
34d45d7448 | ||
|
|
db558990a0 | ||
|
|
8ffae729cc | ||
|
|
fbebe31ef3 | ||
|
|
0a94110a67 | ||
|
|
04069610fa | ||
|
|
f2572bdeef | ||
|
|
d08bc942a2 | ||
|
|
e505e94644 | ||
|
|
e32b956385 | ||
|
|
5e02ad4e78 | ||
|
|
8bdbc30376 | ||
|
|
44e9c22092 | ||
|
|
d2912c5e31 | ||
|
|
e3e077fcb8 | ||
|
|
ca298cd688 | ||
|
|
ce886f7ca1 | ||
|
|
b39e27fd3b | ||
|
|
74ff68993e | ||
|
|
9a7915d230 | ||
|
|
4b7b7c5d1c | ||
|
|
c3633e7834 | ||
|
|
3d31ce8eec | ||
|
|
58e7fe730a | ||
|
|
92b949c47f | ||
|
|
3ef0b68112 | ||
|
|
ba3a060b06 | ||
|
|
974bf83bde | ||
|
|
896c60df55 | ||
|
|
9d1465c4fc | ||
|
|
7b443c9da3 | ||
|
|
64beae817d | ||
|
|
a3b7ca9a8c | ||
|
|
1c602a305b | ||
|
|
ff24a5b009 | ||
|
|
d5ae666c41 | ||
|
|
b29d4f5445 | ||
|
|
4c04f91c17 | ||
|
|
d0865c3b15 | ||
|
|
367eb82db5 | ||
|
|
731d63c19c | ||
|
|
a81894dd8a | ||
|
|
99282742c6 | ||
|
|
31781d25ad | ||
|
|
c9b3611b77 | ||
|
|
d156d70b31 | ||
|
|
67c3cba167 | ||
|
|
67ec3cab3d | ||
|
|
eed8c5fe91 | ||
|
|
3ce3eefa5e | ||
|
|
6f9a30799e | ||
|
|
d33f2155a7 | ||
|
|
957211164f | ||
|
|
84498d4e7b | ||
|
|
a6e4df0460 | ||
|
|
503735bc0d | ||
|
|
c47be12e69 | ||
|
|
7859faaa1d | ||
|
|
bff6ec735e |
No files matched your search
@@ -1,3 +1,5 @@
|
||||
## [1.77.1](https://github.com/homarr-labs/homarr/compare/v1.77.0...v1.77.1) (2026-09-11)
|
||||
|
||||
## [1.77.0](https://github.com/homarr-labs/homarr/compare/v1.76.2...v1.77.0) (2026-09-06)
|
||||
|
||||
## [1.76.2](https://github.com/homarr-labs/homarr/compare/v1.76.1...v1.76.2) (2026-08-27)
|
||||
|
||||
+1
@@ -28,6 +28,7 @@ export const integrationSecretIcons = {
|
||||
opnsenseApiSecret: IconPassword,
|
||||
patchmonApiKey: IconKey,
|
||||
patchmonApiSecret: IconPassword,
|
||||
siteManagerApiKey: IconKey,
|
||||
githubAppId: IconCode,
|
||||
githubInstallationId: IconPlug,
|
||||
privateKey: IconKey,
|
||||
|
||||
@@ -36,6 +36,8 @@ export const categoryTranslationKeys: Record<string, string> = {
|
||||
documents: "integration.category.documents",
|
||||
mediaLibrary: "integration.category.mediaLibrary",
|
||||
uptimeMonitoring: "integration.category.uptimeMonitoring",
|
||||
unifiOs: "integration.category.unifiOs",
|
||||
unifiSiteManager: "integration.category.unifiSiteManager",
|
||||
};
|
||||
|
||||
export interface IntegrationGridItem {
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,474 @@
|
||||
# Intégration UniFi OS pour Homarr — conception
|
||||
|
||||
**Date :** 2026-09-08
|
||||
**Base :** homarr-labs/homarr `2b1a03849` (v1.77.0)
|
||||
**Branche :** `feat/unifi-os-integration`
|
||||
**Statut :** conception validée, prête pour le plan d'implémentation
|
||||
|
||||
> ⚠️ Aucune clé d'API ne figure dans ce document. Les clés utilisées pendant la
|
||||
> conception ont été fournies hors dépôt et doivent être révoquées en fin de projet.
|
||||
|
||||
---
|
||||
|
||||
## 1. Objectif
|
||||
|
||||
Ajouter à Homarr une intégration **UniFi OS**, en complément — et non en
|
||||
remplacement — de l'intégration `unifiController` existante.
|
||||
|
||||
L'intégration existante s'authentifie par identifiant/mot de passe et lit
|
||||
`/api/s/{site}/stat/sites`, l'API interne de l'ancien Network Controller.
|
||||
UniFi OS expose depuis la version 9 une **API d'intégration officielle**,
|
||||
authentifiée par clé d'API, stable et documentée. C'est celle que cible ce
|
||||
projet.
|
||||
|
||||
L'intégration existante n'est pas touchée. Les deux coexistent.
|
||||
|
||||
## 2. Décisions cadrées
|
||||
|
||||
| # | Décision | Justification |
|
||||
|---|---|---|
|
||||
| D1 | **Fork personnel**, pas de PR upstream | Choix de l'auteur. L'issue upstream [#4703](https://github.com/homarr-labs/homarr/issues/4703) demandait cette fonctionnalité et a été fermée par triage automatique, pas par refus technique : la porte reste ouverte pour plus tard. |
|
||||
| D2 | **Hybride local + cloud optionnel** | L'API locale ne fournit ni latence ni perte de paquets. L'API cloud Site Manager les fournit. Le cloud reste facultatif pour que l'intégration fonctionne sans compte UI ni accès Internet. |
|
||||
| D3 | **Widget dédié**, pas de réutilisation des widgets `networkController*` | Le contrat `NetworkControllerSummary` est taillé pour l'ancienne API. S'y conformer obligerait à inventer des valeurs. Un contrat propre évite ça. |
|
||||
| D4 | **Déploiement par rsync du build standalone** | Le LXC exécute déjà un build standalone piloté par systemd. C'est le changement le plus petit possible, donc le plus facile à annuler. |
|
||||
| D5 | **Structure client / mapping / orchestration séparés** | Modèle TrueNAS et Synology déjà présents dans le dépôt. Isole le cloud, optionnel et lent, de la partie locale, obligatoire et rapide. |
|
||||
|
||||
**Conséquence de D3 :** on ne modifie pas `NetworkControllerSummary`. Les champs
|
||||
`latency` / `ping` restent `number` obligatoires pour `unifiController` et
|
||||
`opnsense`. **Aucune régression possible sur les intégrations existantes.**
|
||||
|
||||
## 3. Constatations vérifiées sur l'API
|
||||
|
||||
Toutes les valeurs ci-dessous ont été relevées sur une console réelle
|
||||
(UniFi Network **10.6.102**) le 2026-09-08, et non déduites de la documentation.
|
||||
|
||||
### 3.1 API locale — `{url}/proxy/network/integration/v1`
|
||||
|
||||
Authentification : en-tête `X-API-KEY`, clé générée **dans l'application Network**
|
||||
(Paramètres → Control Plane → Integrations). Une clé du Site Manager cloud est
|
||||
refusée avec un `401` sur cette API — les deux systèmes de clés sont distincts.
|
||||
|
||||
| Endpoint | Renvoie | Relevé |
|
||||
|---|---|---|
|
||||
| `GET /info` | `applicationVersion` | `10.6.102` |
|
||||
| `GET /sites` | `id`, `internalReference`, `name` — **aucune statistique** | 1 site |
|
||||
| `GET /sites/{id}/clients` | `type`, `access.type`, `name`, `ipAddress`, `macAddress`, `uplinkDeviceId`, `connectedAt` | 51 clients |
|
||||
| — | **`limit` est plafonné à 200.** Une demande à 1000 renvoie `limit: 200`. | mesuré |
|
||||
| `GET /sites/{id}/devices` | `state`, `model`, `macAddress`, `firmwareVersion`, `firmwareUpdatable`, `features` | 6 équipements |
|
||||
| `GET /sites/{id}/devices/{id}/statistics/latest` | `uptimeSec`, `cpuUtilizationPct`, `memoryUtilizationPct`, `uplink.{tx,rx}RateBps` | — |
|
||||
| `GET /sites/{id}/vpn/servers` | `type`, `enabled` | 1 serveur WireGuard actif — **non consommé en v1**, voir §12 |
|
||||
| `GET /sites/{id}/wans` | `id`, `name` **uniquement** — inexploitable pour l'état | 2 WAN |
|
||||
|
||||
**Types de clients** (discriminant `type`) : `WIRED`, `WIRELESS`, `VPN`,
|
||||
`TELEPORT`. Sous-objet `access.type` : `DEFAULT`, `GUEST`.
|
||||
|
||||
**Filtrage.** Supporté, avec une syntaxe **préfixe** :
|
||||
|
||||
```
|
||||
✅ and(type.eq('WIRED'),access.type.eq('DEFAULT'))
|
||||
❌ type.eq('WIRED') and access.type.eq('DEFAULT') → 400 invalid-filter
|
||||
❌ type.eq('WIRED') & access.type.eq('DEFAULT') → 400 invalid-filter
|
||||
❌ type.eq('WIRED'),access.type.eq('DEFAULT') → 400 invalid-filter
|
||||
```
|
||||
|
||||
Combiné à `limit=1`, il permet d'obtenir un `totalCount` sans rapatrier les
|
||||
enregistrements. Utilisé uniquement au-delà de 200 clients (voir §7).
|
||||
|
||||
**Écart avec la documentation.** Ubiquiti ne publie l'OpenAPI que pour
|
||||
`v10.4.57`. La console en `10.6.102` renvoie des champs absents de ce spec
|
||||
(`macAddress`, `uplinkDeviceId` sur les clients). **Corollaire : on code contre
|
||||
les réponses réelles, validées par des schémas zod tolérants (`passthrough`),
|
||||
et on ne génère pas de client depuis le spec publié.**
|
||||
|
||||
### 3.2 API cloud Site Manager — `https://api.ui.com`
|
||||
|
||||
Authentification : en-tête `X-API-KEY`, clé générée sur `unifi.ui.com`.
|
||||
|
||||
| Endpoint | Utilité |
|
||||
|---|---|
|
||||
| `GET /v1/sites` | Corrélation (`meta.gatewayMac`), `ispInfo`, IP publique par WAN, `percentages.wanUptime` |
|
||||
| `POST /v1/isp-metrics/5m/query` | `avgLatency`, `maxLatency`, `packetLoss`, `uptime`, débits |
|
||||
|
||||
**Deux contraintes mesurées :**
|
||||
|
||||
1. `GET /v1/isp-metrics/5m` sans filtre renvoie **133 Ko** (288 points sur 24 h).
|
||||
Il faut passer par `POST .../query` borné dans le temps.
|
||||
2. Une fenêtre de 15 min renvoie un tableau **vide**. Une fenêtre de **3 h**
|
||||
fonctionne. Le point le plus récent accuse un retard mesuré de **~27 minutes**.
|
||||
|
||||
Ces deux faits pilotent le design du cache (§7) et l'affichage de la fraîcheur (§8).
|
||||
|
||||
### 3.3 Corrélation locale ↔ cloud
|
||||
|
||||
Une clé Site Manager donne accès à **tous** les sites du compte UI, y compris des
|
||||
consoles dont l'utilisateur n'est pas propriétaire. Le compte de référence en
|
||||
exposait deux. Prendre les métriques cloud sans discriminer afficherait la
|
||||
latence d'un autre réseau.
|
||||
|
||||
**Clé de jointure retenue : le MAC du gateway.**
|
||||
|
||||
```
|
||||
LOCAL devices[] → gateway.macAddress aa:bb:cc:11:22:33
|
||||
CLOUD sites[] → meta.gatewayMac aa:bb:cc:11:22:33 ✅ correspondance
|
||||
CLOUD sites[] → meta.gatewayMac dd:ee:ff:44:55:66 ✗ autre console du compte
|
||||
```
|
||||
|
||||
*(adresses anonymisées ; la correspondance a été vérifiée sur la console réelle)*
|
||||
|
||||
Retenu plutôt que le nom du site : un nom se renomme, un MAC non. **En l'absence
|
||||
de correspondance, `unifi-sm-api.ts` renvoie `null` et n'essaie aucune
|
||||
approximation.** Un manque visible vaut mieux qu'une donnée silencieusement fausse.
|
||||
|
||||
## 4. Contrat de données
|
||||
|
||||
```ts
|
||||
export interface UnifiOsSummary {
|
||||
site: {
|
||||
id: string;
|
||||
name: string;
|
||||
applicationVersion: string;
|
||||
};
|
||||
|
||||
wan: {
|
||||
/** Dérivé de l'état du gateway côté local. */
|
||||
state: "online" | "offline" | "unknown";
|
||||
/** Débit temps réel de l'uplink du gateway, en bits/s. Local. */
|
||||
throughput: { txBps: number; rxBps: number } | null;
|
||||
/** Bloc cloud. `null` si la clé Site Manager n'est pas configurée
|
||||
* ou si aucun site cloud ne correspond au MAC du gateway. */
|
||||
isp: {
|
||||
name: string;
|
||||
externalIp: string | null;
|
||||
/** Nullables : le cloud omet ces mesures pour les périodes où le WAN
|
||||
* était coupé. Un `0` fabriqué s'afficherait comme « 0 ms » à côté d'un
|
||||
* voyant vert, au moment précis où la connexion est tombée. */
|
||||
latencyMs: number | null;
|
||||
maxLatencyMs: number | null;
|
||||
packetLossPct: number | null;
|
||||
uptimePct: number | null;
|
||||
/** Horodatage de la mesure. Rendu obligatoire : toute donnée cloud
|
||||
* voyage avec sa fraîcheur. */
|
||||
measuredAt: Date;
|
||||
} | null;
|
||||
};
|
||||
|
||||
/** `null` si l'appel a échoué. Surtout pas un compteur à zéro : le lecteur
|
||||
* ne pourrait pas distinguer « échec » de « aucun client connecté ». */
|
||||
clients: {
|
||||
total: number;
|
||||
wired: number;
|
||||
wireless: number;
|
||||
vpn: number; // VPN + TELEPORT
|
||||
guests: number; // access.type === "GUEST", tous types confondus
|
||||
} | null;
|
||||
|
||||
/** `null` si l'appel a échoué, pour la même raison. */
|
||||
devices: {
|
||||
total: number;
|
||||
online: number;
|
||||
offline: number;
|
||||
firmwareUpdatable: number;
|
||||
} | null;
|
||||
|
||||
gateway: {
|
||||
name: string;
|
||||
model: string;
|
||||
macAddress: string;
|
||||
uptimeSec: number | null;
|
||||
cpuPct: number | null;
|
||||
memoryPct: number | null;
|
||||
} | null;
|
||||
}
|
||||
```
|
||||
|
||||
**`measuredAt` est obligatoire dans le bloc `isp`.** Le type impose que la
|
||||
fraîcheur accompagne la donnée cloud : il devient impossible d'afficher une
|
||||
latence sans savoir de quand elle date.
|
||||
|
||||
## 5. Architecture
|
||||
|
||||
### 5.1 Nouveaux fichiers
|
||||
|
||||
```
|
||||
packages/integrations/src/unifi-os/
|
||||
├── unifi-os-integration.ts # extends Integration — orchestre, mappe, dégrade
|
||||
├── unifi-os-api.ts # client HTTP local
|
||||
├── unifi-sm-api.ts # client HTTP cloud, optionnel, cache interne
|
||||
├── unifi-os-types.ts # schémas zod + mappers purs
|
||||
└── test/
|
||||
├── unifi-os-types.spec.ts
|
||||
└── unifi-os-integration.spec.ts
|
||||
```
|
||||
|
||||
Chaque unité répond à une question unique :
|
||||
|
||||
- `unifi-os-api.ts` — *comment parler à la console locale ?* Endpoints,
|
||||
pagination, résolution du `siteId`. Ne connaît pas Homarr.
|
||||
- `unifi-sm-api.ts` — *comment parler au cloud ?* Fenêtre glissante de 3 h,
|
||||
corrélation par MAC, cache interne. **Instancié uniquement si la clé cloud
|
||||
est présente.**
|
||||
- `unifi-os-types.ts` — *comment valider et transformer ?* Schémas zod et
|
||||
fonctions pures. Aucun accès réseau, donc testable sans mock.
|
||||
- `unifi-os-integration.ts` — *comment Homarr consomme tout ça ?* La seule
|
||||
unité qui connaît la classe `Integration`.
|
||||
|
||||
### 5.2 Points d'attache dans le monorepo
|
||||
|
||||
| Fichier | Modification |
|
||||
|---|---|
|
||||
| `packages/definitions/src/integration.ts` | Ajouter le secret `siteManagerApiKey` à `integrationSecretKindObject` ; ajouter `"unifiOs"` à `integrationCategories` ; ajouter le kind `unifiOs` avec `secretKinds: [["apiKey"], ["apiKey", "siteManagerApiKey"]]`, `category: ["unifiOs"]`, `defaultPort: 443` |
|
||||
| `packages/definitions/src/widget.ts` | Ajouter le `WidgetKind` `unifiOs` |
|
||||
| `packages/integrations/src/base/creator.ts` | Enregistrer `unifiOs: UnifiOsIntegration` |
|
||||
| `packages/integrations/src/types.ts` | Exporter `./unifi-os/unifi-os-types` |
|
||||
| `packages/integrations/src/interfaces/unifi-os-summary/` | Interface `UnifiOsSummaryIntegration` |
|
||||
| `packages/request-handler/src/unifi-os.ts` | `createIntegrationRequestHandler` |
|
||||
| `packages/api/src/router/widgets/unifi-os.ts` + `index.ts` | Routeur tRPC |
|
||||
| `packages/widgets/src/unifi-os/{index.ts,component.tsx}` | Définition + composant |
|
||||
| `packages/widgets/src/{manifest.ts,index.tsx,refetch-intervals.ts}` | Enregistrement |
|
||||
| `packages/translation/src/lang/en.json` | Libellés du widget + clé `integration.category.unifiOs` |
|
||||
|
||||
### 5.3 Pourquoi une catégorie dédiée, et pas `networkController`
|
||||
|
||||
`integrationCategories` n'est pas une étiquette de présentation, c'est un
|
||||
mécanisme de câblage. `getIntegrationKindsByCategory("networkController")`
|
||||
alimente trois endroits :
|
||||
|
||||
```
|
||||
widget-integration-map.ts → intégrations proposées par les widgets networkController*
|
||||
router/widgets/network-controller.ts → middleware tRPC
|
||||
request-handler/network-controller.ts → typé IntegrationKindByCategory<"networkController">,
|
||||
appelle getNetworkSummaryAsync()
|
||||
```
|
||||
|
||||
Déclarer `unifiOs` dans cette catégorie la rendrait sélectionnable dans les
|
||||
widgets de l'ancienne intégration **et** exigerait qu'elle implémente
|
||||
`getNetworkSummaryAsync()` — l'interface que la décision D3 écarte
|
||||
explicitement. Le typage `IntegrationKindByCategory` transforme cette
|
||||
contradiction en erreur de compilation, pas en défaut silencieux.
|
||||
|
||||
On ajoute donc une catégorie `unifiOs`. Le dépôt a déjà un précédent de
|
||||
catégorie mono-intégration : `beszel`. Une catégorie sans traduction ne casse
|
||||
rien — `beszel` n'en a pas — mais on ajoute quand même
|
||||
`integration.category.unifiOs`.
|
||||
|
||||
**Base d'URL.** L'utilisateur saisit l'origine de la console
|
||||
(`https://console.exemple.tld`). Le client local préfixe systématiquement
|
||||
`/proxy/network/integration/v1`. Ce préfixe appartient au client, pas à la
|
||||
configuration : l'utilisateur n'a pas à le connaître.
|
||||
|
||||
**Précédent de conception validé :** le champ `secretKinds` accepte plusieurs
|
||||
jeux alternatifs, et la classe de base expose `hasSecretValue()`. L'intégration
|
||||
`ntfy` utilise déjà exactement ce motif (`[["topic"], ["topic", "apiKey"]]`).
|
||||
La clé cloud optionnelle ne demande donc aucun mécanisme nouveau.
|
||||
|
||||
**Note TLS.** La classe de base `Integration` applique les certificats de
|
||||
confiance de Homarr à l'URL de l'intégration uniquement. L'appel vers
|
||||
`api.ui.com` sort de ce périmètre : c'est du HTTPS public à certificat valide,
|
||||
un `fetch` standard suffit. Il ne doit **pas** passer par
|
||||
`fetchWithTrustedCertificatesAsync`, pour ne pas laisser croire que les deux
|
||||
appels partagent la même configuration.
|
||||
|
||||
## 6. Séquence d'un rafraîchissement
|
||||
|
||||
```
|
||||
1. GET /info → applicationVersion
|
||||
2. GET /sites → siteId, name
|
||||
3. Promise.allSettled([
|
||||
GET /sites/{id}/clients?limit=200 → comptage par type (voir §7)
|
||||
GET /sites/{id}/devices → états, firmwares, gateway
|
||||
])
|
||||
4. GET /sites/{id}/devices/{gatewayId}/statistics/latest (si gateway trouvé)
|
||||
5. si clé cloud présente :
|
||||
unifi-sm-api.getIspMetricsAsync(gatewayMac) → cache 5 min
|
||||
```
|
||||
|
||||
**`Promise.allSettled`, pas `Promise.all`.** Un endpoint en échec dégrade son
|
||||
bloc à `null` ; il ne fait pas tomber le widget entier. L'étape 4 dépend de
|
||||
l'étape 3 : sans gateway identifié, `gateway` et `wan.throughput` valent `null`
|
||||
et le reste s'affiche normalement.
|
||||
|
||||
## 7. Cache et gestion d'erreurs
|
||||
|
||||
**Deux caches de rythmes différents**, parce que les deux sources n'évoluent pas
|
||||
à la même vitesse :
|
||||
|
||||
| Couche | TTL | Motif |
|
||||
|---|---|---|
|
||||
| `createIntegrationRequestHandler` | 60 s | Les compteurs locaux sont vivants |
|
||||
| Cache interne de `unifi-sm-api.ts`, clé = MAC gateway | 5 min | La donnée cloud ne change que toutes les 5 min et arrive avec ~27 min de retard |
|
||||
|
||||
Sans le second cache, on émettrait ~120 appels/h vers `api.ui.com` pour ~12
|
||||
valeurs distinctes, avec un risque de limitation de débit. Le motif du cache
|
||||
statique par instance est déjà utilisé par `TrueNasClient`.
|
||||
|
||||
**`fallbackToStaleOnError: true`** sur le handler. Une console injoignable
|
||||
pendant quelques secondes affiche la dernière valeur connue au lieu d'une tuile
|
||||
d'erreur — à condition que l'interface signale que la donnée est périmée (§8).
|
||||
|
||||
**Comptage des clients — règle explicite.** L'API plafonne `limit` à 200
|
||||
(mesuré). Le comportement est donc conditionné par `totalCount`, renvoyé dès la
|
||||
première page :
|
||||
|
||||
| Condition | Stratégie | Coût |
|
||||
|---|---|---|
|
||||
| `totalCount ≤ 200` | La première page contient tout. Comptage en mémoire par `type` et `access.type`. | 1 requête |
|
||||
| `totalCount > 200` | Bascule sur des requêtes filtrées `limit=1`, dont on ne lit que `totalCount`. | 5 requêtes de taille fixe |
|
||||
|
||||
La seconde branche utilise la syntaxe préfixe du §3.1, par exemple
|
||||
`and(type.eq('WIRELESS'),access.type.eq('GUEST'))`.
|
||||
|
||||
On ne pagine **pas** en boucle : au-delà de 200 clients, rapatrier tous les
|
||||
enregistrements pour n'en garder que des compteurs coûte plus cher que cinq
|
||||
requêtes bornées, et la charge croîtrait avec la taille du réseau.
|
||||
|
||||
*Écrire les deux branches maintenant est justifié : ce n'est pas de
|
||||
l'optimisation spéculative mais la gestion d'une limite d'API mesurée, qui
|
||||
produirait sinon des compteurs silencieusement faux au-delà de 200 clients.*
|
||||
|
||||
## 8. Widget
|
||||
|
||||
**Disposition retenue : bandeau WAN dominant + lignes** (option B des maquettes).
|
||||
|
||||
```
|
||||
┌────────────────────────────────────────────────┐
|
||||
│ ● Nom du site │
|
||||
│ ┌────────────────────────────────────────────┐ │
|
||||
│ │ ● Internet ↑14,1 ↓3,3 Mb/s │ │
|
||||
│ │ Nom FAI · 203.0.113.10 │ │
|
||||
│ │ 11 ms · 0 % perte [27min]│ │
|
||||
│ └────────────────────────────────────────────┘ │
|
||||
│ Clients 51 · 44 filaire · 7 Wi-Fi │
|
||||
│ Équipements 6 / 6 en ligne │
|
||||
│ ● Firmware 3 à mettre à jour │
|
||||
│ Network 10.6.102 Gateway up 11 j 16 h │
|
||||
└────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
Justification : la hiérarchie visuelle suit l'ordre de consultation réel — on
|
||||
regarde d'abord si Internet fonctionne, ensuite le détail.
|
||||
|
||||
**Le badge de fraîcheur n'est pas cosmétique.** Il porte deux rôles distincts :
|
||||
signaler le retard structurel des métriques cloud, et signaler une donnée servie
|
||||
depuis le cache périmé après une erreur. Sans lui, le widget juxtaposerait des
|
||||
données de fraîcheurs très différentes sans le dire.
|
||||
|
||||
**Comportement dégradé :**
|
||||
|
||||
| Situation | Rendu |
|
||||
|---|---|
|
||||
| Clé cloud absente | Bloc ISP masqué. Le bandeau garde l'état WAN et le débit. |
|
||||
| Cloud injoignable | Idem, plus un indicateur discret. |
|
||||
| Aucune correspondance de MAC | Bloc ISP masqué. Jamais la latence d'un autre site. |
|
||||
| Gateway introuvable | `throughput`, `uptime`, CPU/RAM masqués ; clients et équipements affichés. |
|
||||
| Console injoignable | Dernière valeur connue + badge périmé, puis tuile d'erreur au-delà du TTL. |
|
||||
|
||||
**Taille.** `WidgetDefinition` n'expose aucune contrainte de taille minimale :
|
||||
l'adaptation est une affaire de CSS dans le composant. Le bandeau passe en deux
|
||||
lignes en tuile étroite, et les lignes de détail se réduisent à leurs chiffres.
|
||||
|
||||
**Rafraîchissement.** Entrée dans `widgetQueryRefetchIntervals` avec
|
||||
`intervalSeconds: 60`. Les widgets `networkController` sont à `null` — pas de
|
||||
rafraîchissement automatique — mais les compteurs de clients ont une valeur
|
||||
d'usage qui justifie l'écart.
|
||||
|
||||
## 9. Tests
|
||||
|
||||
| Niveau | Fichier | Couvre | Réseau |
|
||||
|---|---|---|---|
|
||||
| Unitaire | `unifi-os-types.spec.ts` | Comptage par type et par `access.type`, agrégation d'états, conversion bps, corrélation par MAC **y compris le cas sans correspondance**, tolérance aux champs inconnus | Non |
|
||||
| Intégration | `unifi-os-integration.spec.ts` | Séquence complète sur fixtures capturées, dégradation par bloc, absence de clé cloud, endpoint en échec | Mocké |
|
||||
| Manuel | script hors CI | Vérification contre une console réelle | Oui |
|
||||
|
||||
Le niveau unitaire est le plus important : c'est là que vivent les erreurs de
|
||||
comptage, et c'est le seul qui reste valable si Ubiquiti fait évoluer ses
|
||||
endpoints.
|
||||
|
||||
**Fixtures.** Capturées depuis la console réelle, puis **anonymisées** —
|
||||
adresses MAC, IP publique, noms de clients et identifiants de site remplacés.
|
||||
Un dépôt ne doit pas porter la cartographie d'un réseau privé.
|
||||
|
||||
**Multi-site.** Une URL locale désigne une console, et l'intégration se limite au
|
||||
premier site qu'elle expose. Une console peut techniquement en héberger
|
||||
plusieurs : le widget affiche donc le **nom du site** retenu, pour que ce choix
|
||||
soit visible plutôt que subi. Deux tests couvrent les bornes — une console sans
|
||||
aucun site renvoie une erreur explicite au test de connexion, et le nom du site
|
||||
figure dans le contrat.
|
||||
|
||||
L'ancienne intégration agrégeait tous les sites, ce qui avait produit l'issue
|
||||
[#3222](https://github.com/homarr-labs/homarr/issues/3222) (valeurs nulles en
|
||||
multi-site). Ne pas agréger supprime la classe de défaut plutôt que de la
|
||||
tester.
|
||||
|
||||
## 10. Phasage
|
||||
|
||||
Chaque phase se vérifie seule. C'est ce qui permet de savoir de quel côté
|
||||
chercher quand quelque chose casse.
|
||||
|
||||
| Phase | Contenu | Critère de validation |
|
||||
|---|---|---|
|
||||
| 0 | Fork, `corepack enable`, `pnpm docker:dev:up`, `pnpm dev` | Homarr démarre en local |
|
||||
| 1 | `unifi-os-api.ts`, `unifi-sm-api.ts`, types et mappers, tests unitaires | `pnpm test` vert, sans réseau |
|
||||
| 2 | `UnifiOsIntegration` + enregistrement | Le test de connexion Homarr passe dans l'interface |
|
||||
| 3 | request-handler + routeur tRPC | La query renvoie les vraies valeurs |
|
||||
| 4 | Widget + traductions `en` | Le widget s'affiche sur un board local |
|
||||
| 5 | Build standalone + rsync vers le LXC | Le widget s'affiche en production |
|
||||
|
||||
À l'issue de la phase 2, toute la plomberie est prouvée. Une panne en phase 4
|
||||
est donc nécessairement côté interface.
|
||||
|
||||
## 11. Déploiement
|
||||
|
||||
Cible : LXC Ubuntu 24.04, `10.70.1.13`, 2 vCPU / 2 Go. Homarr y tourne en build
|
||||
standalone sous systemd (`/opt/homarr`, `homarr.service`,
|
||||
`EnvironmentFile=/etc/homarr/homarr.env`), avec nginx et Redis embarqués par
|
||||
`run.sh`.
|
||||
|
||||
**Le build se fait sur le poste de développement** (16 cœurs, 60 Go). Le LXC ne
|
||||
peut pas compiler ce monorepo Next.js.
|
||||
|
||||
Procédure :
|
||||
|
||||
1. `tar` de `/opt/homarr` **et** dump de la base.
|
||||
2. `systemctl stop homarr`.
|
||||
3. `rsync` du build standalone.
|
||||
4. `systemctl start homarr`, puis vérification des logs.
|
||||
|
||||
Rollback : restauration de l'archive et redémarrage.
|
||||
|
||||
**Contrôle préalable obligatoire :** comparer les migrations de
|
||||
`packages/db/migrations/` avec `/opt/homarr/db/migrations/`. Elles sont
|
||||
alignées à `0041_add_right_click_preference.sql` au 2026-09-08. Une divergence
|
||||
rendrait le rollback nettement plus complexe qu'une restauration d'archive.
|
||||
|
||||
**`SECRET_ENCRYPTION_KEY`** dans `/etc/homarr/homarr.env` chiffre les secrets
|
||||
d'intégration en base. Le rsync ne touche pas ce fichier, mais toute
|
||||
modification rendrait les intégrations existantes illisibles.
|
||||
|
||||
## 12. Hors périmètre
|
||||
|
||||
Volontairement exclus de cette spec :
|
||||
|
||||
- **Widget cloud multi-site** (SD-WAN, fabric, vue consolidée de plusieurs
|
||||
consoles). Idée retenue pour un projet ultérieur, qui réutilisera
|
||||
`unifi-sm-api.ts`. Sa mention ici sert à ne pas le construire maintenant.
|
||||
- **État des serveurs VPN.** L'endpoint existe et fonctionne, mais le contrat §4
|
||||
n'expose pas de champ pour lui et la disposition retenue au §8 ne l'affiche pas.
|
||||
Le collecter sans l'afficher serait du code mort.
|
||||
- **Actions d'écriture** (adoption d'équipement, redémarrage de port, gestion de
|
||||
vouchers). L'API les expose ; l'intégration reste en lecture seule.
|
||||
- **Traduction au-delà de l'anglais.** Conséquence de D1.
|
||||
- **Documentation Docusaurus.** Conséquence de D1.
|
||||
- **Pull request upstream.** Conséquence de D1 ; la structure du code ne
|
||||
l'interdit pas plus tard.
|
||||
- **Modification de l'intégration `unifiController`.** Elle reste en l'état.
|
||||
|
||||
## 13. Risques
|
||||
|
||||
| Risque | Portée | Traitement |
|
||||
|---|---|---|
|
||||
| L'OpenAPI publié (`10.4.57`) est en retard sur les consoles | Élevée — déjà constaté | Schémas zod tolérants, code écrit contre les réponses réelles |
|
||||
| La clé Site Manager donne accès à tout le compte UI | Sécurité | Cloud optionnel ; corrélation stricte par MAC ; clé de conception à révoquer |
|
||||
| Retard de ~27 min sur les métriques cloud | Interface | `measuredAt` obligatoire dans le contrat, badge de fraîcheur dans le widget |
|
||||
| Un fork diverge à chaque version de Homarr | Maintenance | Modifications concentrées dans un dossier neuf ; seuls les points d'attache du §5.2 touchent l'existant |
|
||||
| Limitation de débit sur `api.ui.com` | Fiabilité | Cache interne 5 min |
|
||||
| Le déploiement casse l'instance de production | Exploitation | Sauvegarde avant rsync, contrôle des migrations, rollback par restauration |
|
||||
@@ -0,0 +1,399 @@
|
||||
# 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, 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
|
||||
|
||||
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` |
|
||||
| `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
|
||||
clé de jointure est explicite.
|
||||
|
||||
### 3.3 Deux sources pour la santé, une seule utilisable
|
||||
|
||||
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 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.
|
||||
|
||||
**`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.
|
||||
|
||||
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
|
||||
|
||||
```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;
|
||||
/** 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;
|
||||
/** `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";
|
||||
|
||||
export interface UnifiSiteHealthSegment {
|
||||
/** Début de la tranche horaire. */
|
||||
at: Date;
|
||||
state: UnifiSiteHealthState;
|
||||
}
|
||||
|
||||
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.
|
||||
|
||||
**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.
|
||||
|
||||
**`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
|
||||
L'interface Ubiquiti les sépare ; 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 liste sans sites
|
||||
|
||||
// Séquentiel : la requête bornée exige les hostId/siteId de l'étape précédente.
|
||||
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.
|
||||
```
|
||||
|
||||
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`, `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
|
||||
rapatrier les 216 Ko de la variante non bornée.
|
||||
|
||||
## 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 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
|
||||
état vide explicite, pas une erreur.
|
||||
|
||||
## 8. Widget
|
||||
|
||||
`HomarrDataTable`, une ligne par site.
|
||||
|
||||
| Colonne | Source | Rendu |
|
||||
|---|---|---|
|
||||
| Site | `name` | toujours visible, jamais masquée en responsive |
|
||||
| Santé | `health` | bande de 24 segments d'une heure, du plus ancien au plus récent |
|
||||
| Latence | `latencyMs`, `packetLossPct` | `11 ms · 0 %` ; `—` si aucune mesure |
|
||||
| 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 ; `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
|
||||
essentielles disparaissent en premier, `Site` et `Santé` jamais.
|
||||
|
||||
**La bande de santé.** Vingt-quatre segments jointifs séparés par un filet, pour
|
||||
qu'on distingue deux tranches consécutives de même couleur. Vert sain, orange
|
||||
dégradé, rouge coupé, gris inconnu. Dans une colonne de tableau elle sera étroite :
|
||||
c'est le prix de la forme tableau, et c'est ce qui rend l'infobulle nécessaire
|
||||
plutôt que confortable.
|
||||
|
||||
**Couleurs : palette de statut, pas palette catégorielle.** Distinction établie
|
||||
en cours de conception, après avoir échoué à faire passer un jeu vert/orange/rouge
|
||||
au validateur — lequel ne couvre que les palettes **catégorielles**. Sain,
|
||||
dégradé et coupé sont des **statuts**, qui relèvent d'une palette réservée et
|
||||
fixe, non thémée :
|
||||
|
||||
| État | Couleur | Origine |
|
||||
|---|---|---|
|
||||
| `healthy` | `#0ca30c` | statut *good* |
|
||||
| `degraded` | `#fab219` | statut *warning* |
|
||||
| `down` | `#d03b3b` | statut *critical* |
|
||||
| `unknown` | gris neutre de la surface | ce n'est pas un statut, c'est une absence |
|
||||
|
||||
Mesuré au passage : un vert/orange/rouge classique donne un ΔE de **2,9** entre
|
||||
l'orange et le vert en vision protanope — indistinguables. La palette de statut
|
||||
ne prétend pas résoudre cela, elle impose une contrepartie.
|
||||
|
||||
**La contrepartie est obligatoire, et plus forte qu'une infobulle.** La règle
|
||||
attachée aux couleurs de statut est qu'elles ne voyagent jamais seules : icône
|
||||
et libellé les accompagnent. Une icône par segment étant impossible sur
|
||||
vingt-quatre segments, la bande est **doublée d'un résumé textuel** — par exemple
|
||||
`22 h saines · 2 h dégradées` — placé à côté d'elle dans la même colonne. Ce
|
||||
résumé n'est pas décoratif : c'est le canal qui porte l'information quand la
|
||||
couleur ne passe pas.
|
||||
|
||||
**Infobulle par segment** : heure de la tranche, latence moyenne, perte. Elle
|
||||
complète le résumé pour qui veut le détail d'une heure précise.
|
||||
|
||||
**Badge de fraîcheur** en tête de widget, alimenté par `updatedAt`, comme sur le
|
||||
widget `unifiOs`.
|
||||
|
||||
## 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, 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é |
|
||||
| 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
|
||||
|
||||
- **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** — 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.
|
||||
- **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 |
|
||||
| `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 |
|
||||
| Une tranche sans mesure affichée comme saine | Justesse | État `"unknown"` explicite au contrat, testé ; la fenêtre porte toujours 24 entrées |
|
||||
+140
@@ -0,0 +1,140 @@
|
||||
# Fork personnel — exploitation
|
||||
|
||||
Ce dossier n'existe pas en amont. Il ne créera donc jamais de conflit lors d'un
|
||||
rebase, ce qui est exactement pourquoi les fichiers d'exploitation vivent ici et
|
||||
pas dans `scripts/`.
|
||||
|
||||
## Déployer
|
||||
|
||||
```bash
|
||||
ops/deploy-lxc.sh --dry-run # montre ce qui serait écrit, sans rien toucher
|
||||
ops/deploy-lxc.sh
|
||||
```
|
||||
|
||||
Le script exige un arbre propre, construit, compare les migrations et l'ABI Node
|
||||
avant de toucher quoi que ce soit, sauvegarde `/opt/homarr` **et** la base, arrête
|
||||
le service, synchronise, redémarre, puis affiche les journaux. La commande de
|
||||
retour arrière est imprimée à la fin, horodatage compris.
|
||||
|
||||
Ce qu'il ne remplace jamais : `/etc/homarr/homarr.env`, `run.sh`, `entrypoint.sh`,
|
||||
`nginx.conf`, `redis.conf` — ils appartiennent à l'installation du LXC, pas au
|
||||
build — ni `/var/lib/homarr/db.sqlite`, qui est sauvegardée mais laissée en place.
|
||||
|
||||
Après déploiement, le contrôle qui compte n'est pas que le nouveau widget
|
||||
s'affiche : c'est que les intégrations **préexistantes** fonctionnent encore.
|
||||
C'est la seule preuve que `SECRET_ENCRYPTION_KEY` n'a pas bougé.
|
||||
|
||||
## Déplacer l'état hors de /appdata
|
||||
|
||||
`/appdata` vient de l'image Docker, où c'est un point de montage. Sur une
|
||||
machine, il ne suit aucune convention : l'état d'un service se range sous
|
||||
`/var/lib`. La correction se fait sur l'**instance installée**, jamais dans le
|
||||
dépôt — ni le code ni le Dockerfile ne sont touchés, sans quoi chaque rebase sur
|
||||
l'amont rouvrirait le sujet.
|
||||
|
||||
```bash
|
||||
sudo ops/use-var-lib-homarr.sh /opt/homarr # ou /opt/homarr-dev
|
||||
```
|
||||
|
||||
Le script réécrit `/appdata` en `/var/lib/homarr` dans le `run.sh` et le
|
||||
`redis.conf` de l'instance, sauvegarde chaque fichier avant de le toucher, et
|
||||
ajoute `LOCAL_CERTIFICATE_PATH` au démarrage. Cette dernière ligne n'est pas
|
||||
cosmétique : le chemin des certificats n'est pas lu par `run.sh` mais par
|
||||
l'application, qui retombe sur le dossier de l'image dès qu'il manque — elle
|
||||
recréerait donc `/appdata` au premier certificat chargé.
|
||||
|
||||
Le script ne déplace **pas** les données ; il imprime les commandes à passer une
|
||||
fois, service arrêté. Sur cette installation, `/appdata` ne contient qu'un
|
||||
fichier : le certificat racine qui valide la console UniFi locale. Le contrôle
|
||||
d'après-déplacement est donc tout trouvé — si le widget UniFi OS répond encore,
|
||||
le certificat a suivi.
|
||||
|
||||
À relancer après chaque assemblage : `ops/assemble-standalone.sh` recopie le
|
||||
`run.sh` du dépôt quand l'instance n'en a pas, et celui-là parle de `/appdata`.
|
||||
|
||||
## Suivre les mises à jour d'Homarr
|
||||
|
||||
Aujourd'hui `origin` pointe sur le dépôt d'Homarr lui-même et **aucune copie
|
||||
distante de ce travail n'existe** : une panne de disque effacerait la branche.
|
||||
Premier geste, avant tout le reste :
|
||||
|
||||
```bash
|
||||
git remote rename origin upstream
|
||||
git remote add origin <url-de-ton-Gitea>/homarr.git
|
||||
git push -u origin feat/unifi-os-integration
|
||||
```
|
||||
|
||||
### Le cycle de montée de version
|
||||
|
||||
Homarr publie souvent. Se caler sur une **étiquette de version** plutôt que sur
|
||||
`dev` évite de rebaser sur du code en cours :
|
||||
|
||||
```bash
|
||||
git fetch upstream --tags
|
||||
git log --oneline dev..upstream/dev | head # ce qui est arrivé
|
||||
git rebase v1.78.0 feat/unifi-os-integration # remplace par la version visée
|
||||
```
|
||||
|
||||
Puis, dans cet ordre — chaque étape a déjà attrapé quelque chose :
|
||||
|
||||
```bash
|
||||
pnpm install # le lockfile bouge presque à chaque version
|
||||
pnpm turbo typecheck # les 37 paquets
|
||||
pnpm test packages/integrations/ packages/widgets/
|
||||
pnpm test packages/widgets/src/manifest.spec.ts # les points d'enregistrement
|
||||
ops/deploy-lxc.sh --dry-run
|
||||
```
|
||||
|
||||
`manifest.spec.ts` mérite sa ligne à part : il compare l'icône du catalogue à
|
||||
celle de la définition **par identité**, et c'est le seul test qui détecte un
|
||||
point d'enregistrement perdu dans un conflit.
|
||||
|
||||
### Où les conflits vont tomber
|
||||
|
||||
Le gros du travail vit dans des dossiers neufs — `packages/integrations/src/unifi-os/`,
|
||||
`unifi-site-manager/`, `packages/widgets/src/unifi-*/` — qui ne peuvent pas
|
||||
entrer en conflit. Les points de friction sont les fichiers partagés où ce fork
|
||||
ajoute une ligne dans une liste que l'amont fait vivre aussi :
|
||||
|
||||
| Fichier | Ce qu'on y ajoute |
|
||||
|---|---|
|
||||
| `packages/definitions/src/integration.ts` | les deux genres d'intégration |
|
||||
| `packages/definitions/src/widget.ts` | les deux genres de widget |
|
||||
| `packages/definitions/src/widget-integration-map.ts` | l'association widget ↔ intégration |
|
||||
| `packages/definitions/src/docs/integration-doc-slugs.ts` | les slugs de documentation |
|
||||
| `packages/definitions/src/docs/widget-doc-slugs.ts` | idem, côté widgets |
|
||||
| `packages/integrations/src/base/creator.ts` | la construction des classes |
|
||||
| `packages/integrations/src/index.ts` | les exports |
|
||||
| `packages/integrations/src/types.ts` | les types réexportés **côté client** |
|
||||
| `packages/api/src/router/widgets/index.ts` | les routeurs tRPC |
|
||||
| `packages/widgets/src/manifest.ts` | deux cartes distinctes, module et composant |
|
||||
| `packages/widgets/src/index.tsx` | l'import et l'entrée |
|
||||
| `packages/widgets/src/catalog.ts` | l'icône |
|
||||
| `packages/widgets/src/refetch-intervals.ts` | l'intervalle de rafraîchissement |
|
||||
| `apps/nextjs/src/components/integration/integration-grid-shared.ts` | le libellé de catégorie |
|
||||
| `apps/nextjs/src/app/[locale]/manage/integrations/_components/secrets/integration-secret-icons.ts` | l'icône du secret |
|
||||
| `packages/translation/src/lang/en.json` | tous les libellés |
|
||||
|
||||
Deux de ces lignes ne sont vérifiées par **aucun** compilateur, et les perdre
|
||||
dans un conflit passerait tous les tests :
|
||||
|
||||
- `integration-grid-shared.ts` est typé `Record<string, string>` : une entrée
|
||||
manquante affiche `UNIFISITEMANAGER` en majuscules collées au lieu du nom.
|
||||
- `en.json` n'est pas typé du tout : une clé perdue affiche son chemin brut.
|
||||
|
||||
Les deux se voient d'un coup d'œil sur la page « New integration » — c'est le
|
||||
contrôle visuel à faire après chaque montée de version.
|
||||
|
||||
### Rejouer les résolutions de conflit
|
||||
|
||||
Les mêmes conflits reviendront à chaque version, au même endroit. Git sait les
|
||||
rejouer tout seul :
|
||||
|
||||
```bash
|
||||
git config rerere.enabled true
|
||||
```
|
||||
|
||||
À activer **avant** le premier rebase : Git n'enregistre que ce qu'il voit
|
||||
résoudre. Une fois qu'une résolution est connue, les suivantes sont
|
||||
automatiques — à relire quand même, l'amont peut avoir changé la forme de la
|
||||
liste autour.
|
||||
Executable
+66
@@ -0,0 +1,66 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Assemble l'arborescence d'exécution à partir d'un dépôt déjà construit.
|
||||
#
|
||||
# Le dépôt et l'instance qui tourne n'ont PAS la même forme : le clone contient
|
||||
# des sources et un `apps/nextjs/.next/standalone` ; l'instance attend
|
||||
# `apps/nextjs/server.js`, `db/migrations/`, `build/better_sqlite3.node` à sa
|
||||
# racine. Ce script fait la conversion — les mêmes COPY que le stage `runner`
|
||||
# du Dockerfile officiel, d'où vient la disposition de /opt/homarr.
|
||||
#
|
||||
# Usage : ops/assemble-standalone.sh <destination>
|
||||
#
|
||||
# Construire d'abord :
|
||||
# pnpm turbo build --filter=@homarr/nextjs... --filter=@homarr/cli
|
||||
#
|
||||
set -euo pipefail
|
||||
|
||||
DEST="${1:?usage: ops/assemble-standalone.sh <destination>}"
|
||||
REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
|
||||
cd "$REPO"
|
||||
|
||||
[ -d apps/nextjs/.next/standalone ] || {
|
||||
echo "apps/nextjs/.next/standalone absent — construis d'abord :" >&2
|
||||
echo " pnpm turbo build --filter=@homarr/nextjs... --filter=@homarr/cli" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
mkdir -p "$DEST"
|
||||
|
||||
# L'ordre suit le Dockerfile : le standalone d'abord, le reste par-dessus.
|
||||
cp -a apps/nextjs/.next/standalone/. "$DEST/"
|
||||
mkdir -p "$DEST/apps/nextjs/.next"
|
||||
cp -a apps/nextjs/.next/static "$DEST/apps/nextjs/.next/static"
|
||||
cp -a apps/nextjs/public "$DEST/apps/nextjs/public"
|
||||
cp -a apps/nextjs/next.config.ts apps/nextjs/package.json pnpm-workspace.yaml "$DEST/"
|
||||
|
||||
mkdir -p "$DEST/apps/cli"
|
||||
cp -a packages/cli/cli.cjs "$DEST/apps/cli/cli.cjs"
|
||||
|
||||
# Binaire natif : lié à l'architecture et à l'ABI Node de la machine qui a
|
||||
# construit. Assembler et exécuter sur la même machine rend la question sans
|
||||
# objet ; c'est le déploiement à distance qui doit la poser.
|
||||
mkdir -p "$DEST/build"
|
||||
cp -a node_modules/better-sqlite3/build/Release/better_sqlite3.node "$DEST/build/"
|
||||
|
||||
mkdir -p "$DEST/db"
|
||||
rm -rf "$DEST/db/migrations"
|
||||
cp -a packages/db/migrations "$DEST/db/migrations"
|
||||
|
||||
# run.sh n'est copié que s'il manque, jamais écrasé. Sur le LXC, l'installateur
|
||||
# a adapté le sien — il annonce le port 80 là où celui du dépôt annonce 7575 —
|
||||
# et l'écraser ferait écouter nginx sur deux ports différents selon la famille
|
||||
# d'adresses. Même raison pour entrypoint.sh, nginx.conf et redis.conf, qui
|
||||
# appartiennent à l'installation et ne sont pas copiés du tout.
|
||||
if [ -f "$DEST/run.sh" ]; then
|
||||
echo "run.sh déjà présent — laissé tel quel (il peut être adapté à cette installation)."
|
||||
if ! diff -q scripts/run.sh "$DEST/run.sh" > /dev/null; then
|
||||
echo " il diffère de scripts/run.sh : \`diff scripts/run.sh $DEST/run.sh\` pour voir quoi."
|
||||
fi
|
||||
else
|
||||
cp -a scripts/run.sh "$DEST/run.sh"
|
||||
chmod +x "$DEST/run.sh"
|
||||
fi
|
||||
|
||||
echo "Assemblé dans $DEST ($(du -sh "$DEST" | cut -f1))"
|
||||
Executable
+163
@@ -0,0 +1,163 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Déploie le build standalone de ce dépôt sur le LXC Homarr.
|
||||
#
|
||||
# Reproduit la disposition que le Dockerfile officiel assemble (ses COPY du
|
||||
# stage `runner`), parce que l'installation du LXC en vient : /opt/homarr a la
|
||||
# même forme qu'une image, sans le conteneur autour.
|
||||
#
|
||||
# Ce que le script NE touche PAS, volontairement :
|
||||
# - /etc/homarr/homarr.env : secrets et configuration de l'instance
|
||||
# - run.sh, entrypoint.sh, nginx.conf, redis.conf : modifiés par l'installateur
|
||||
# du LXC, ils appartiennent à l'installation et non au build
|
||||
# - /var/lib/homarr/db.sqlite : la base, sauvegardée mais jamais remplacée
|
||||
#
|
||||
# Usage : ops/deploy-lxc.sh [--dry-run]
|
||||
#
|
||||
set -euo pipefail
|
||||
|
||||
HOST="${HOMARR_HOST:-zachary@10.70.1.13}"
|
||||
REMOTE_DIR="${HOMARR_REMOTE_DIR:-/opt/homarr}"
|
||||
REMOTE_DB="${HOMARR_REMOTE_DB:-/var/lib/homarr/db.sqlite}"
|
||||
SERVICE="${HOMARR_SERVICE:-homarr}"
|
||||
|
||||
REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
DRY_RUN=""
|
||||
[ "${1:-}" = "--dry-run" ] && DRY_RUN="--dry-run"
|
||||
|
||||
say() { printf '\n\033[1m==> %s\033[0m\n' "$1"; }
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
say "Contrôles préalables"
|
||||
|
||||
cd "$REPO"
|
||||
[ -z "$(git status --porcelain)" ] || {
|
||||
echo "Arbre de travail sale. Committe ou remise avant de déployer." >&2
|
||||
exit 1
|
||||
}
|
||||
echo "Commit déployé : $(git log --oneline -1)"
|
||||
|
||||
# Le dépôt exige un Node récent (champ engines.node). Le Node par défaut du poste
|
||||
# est plus ancien que ça : sans ce contrôle, l'échec surviendrait au milieu du
|
||||
# build, avec un message de pnpm qui ne dit pas quoi faire.
|
||||
required_node="$(grep -oP '^node = "\K[^"]+' "$REPO/mise.toml" 2>/dev/null || echo '24.18.0')"
|
||||
if ! node -e "process.exit(process.versions.node.localeCompare('$required_node', undefined, { numeric: true }) < 0 ? 1 : 0)"; then
|
||||
echo "Node $(node -v) est trop ancien — il en faut au moins v$required_node." >&2
|
||||
echo "Par exemple : export PATH=\"\$HOME/.local/share/nvm/v24.20.0/bin:\$PATH\"" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Une migration présente ici et absente en prod n'est pas bloquante — Homarr les
|
||||
# applique au démarrage — mais elle rend le rollback non trivial : restaurer
|
||||
# l'archive ne défait pas un schéma déjà migré. On veut le savoir AVANT.
|
||||
#
|
||||
# Comparaison par dialecte : la prod embarque les migrations de tous les moteurs,
|
||||
# et prendre le maximum toutes dialectes confondues masquerait une divergence
|
||||
# propre à celui qui tourne réellement.
|
||||
DIALECT="${HOMARR_DB_DIALECT:-sqlite}"
|
||||
local_migration="$(find "packages/db/migrations/$DIALECT" -name '*.sql' -printf '%f\n' | sort | tail -1)"
|
||||
remote_migration="$(ssh "$HOST" "find $REMOTE_DIR/db/migrations/$DIALECT -name '*.sql' -printf '%f\n' | sort | tail -1")"
|
||||
echo "Dernière migration — locale : $local_migration | prod : $remote_migration"
|
||||
if [ "$local_migration" != "$remote_migration" ]; then
|
||||
echo
|
||||
echo "!! Les migrations divergent. Le rollback ne sera PAS une simple restauration."
|
||||
echo " Sauvegarde la base avant de continuer, puis relance en connaissance de cause."
|
||||
read -r -p " Continuer quand même ? [oui/NON] " answer
|
||||
[ "$answer" = "oui" ] || exit 1
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
say "Build"
|
||||
|
||||
pnpm turbo build --filter=@homarr/nextjs... --filter=@homarr/cli
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
say "Assemblage de l'arborescence à envoyer"
|
||||
|
||||
STAGE="$(mktemp -d)"
|
||||
trap 'rm -rf "$STAGE"' EXIT
|
||||
|
||||
# Même assemblage que pour une installation construite sur place : un seul
|
||||
# endroit décrit la disposition d'exécution, et les deux chemins ne peuvent pas
|
||||
# diverger. `run.sh` en fait partie, mais il est exclu du rsync plus bas —
|
||||
# celui de l'installation est adapté à son port.
|
||||
"$REPO/ops/assemble-standalone.sh" "$STAGE"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
say "Contrôle de compatibilité du binaire natif"
|
||||
|
||||
local_abi="$(node -p 'process.versions.modules')"
|
||||
remote_abi="$(ssh "$HOST" 'node -p "process.versions.modules"')"
|
||||
local_arch="$(uname -m)"
|
||||
remote_arch="$(ssh "$HOST" 'uname -m')"
|
||||
echo "ABI Node — local : $local_abi | prod : $remote_abi"
|
||||
echo "Arch — local : $local_arch | prod : $remote_arch"
|
||||
if [ "$local_abi" != "$remote_abi" ] || [ "$local_arch" != "$remote_arch" ]; then
|
||||
echo "!! better_sqlite3.node ne se chargera pas en prod. Build sur le LXC, ou aligne Node." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -n "$DRY_RUN" ]; then
|
||||
say "Mode --dry-run : arrêt avant toute modification de la prod"
|
||||
# -i sans quoi rsync ne dit rien du tout en mode simulation.
|
||||
rsync -ain --delete --rsync-path="sudo rsync" \
|
||||
--exclude 'run.sh' --exclude 'entrypoint.sh' \
|
||||
--exclude 'nginx.conf' --exclude 'redis.conf' \
|
||||
"$STAGE/" "$HOST:$REMOTE_DIR/" > /tmp/homarr-deploy-plan.txt
|
||||
# Écrit d'abord, tronqué ensuite : un `| head` ferme le tuyau et rsync s'arrête
|
||||
# sur SIGPIPE au bout de trente lignes, ce qui donnerait un plan mensonger.
|
||||
head -30 /tmp/homarr-deploy-plan.txt
|
||||
echo
|
||||
echo "$(grep -c '^\*deleting' /tmp/homarr-deploy-plan.txt) suppressions, \
|
||||
$(grep -vc '^\*deleting' /tmp/homarr-deploy-plan.txt) écritures. Plan complet : /tmp/homarr-deploy-plan.txt"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
say "Sauvegarde"
|
||||
|
||||
stamp="$(date +%F-%H%M)"
|
||||
ssh -t "$HOST" "sudo tar czf /root/homarr-backup-$stamp.tar.gz $REMOTE_DIR \
|
||||
&& sudo cp $REMOTE_DB /root/homarr-db-$stamp.sqlite \
|
||||
&& sudo ls -lh /root/homarr-backup-$stamp.tar.gz /root/homarr-db-$stamp.sqlite"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
say "Arrêt du service"
|
||||
|
||||
# Jamais de rsync sur un Next.js en cours d'exécution : il lit ses chunks à la
|
||||
# demande et servirait des morceaux de deux builds différents.
|
||||
ssh -t "$HOST" "sudo systemctl stop $SERVICE && ! systemctl is-active --quiet $SERVICE && echo arrêté"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
say "Envoi"
|
||||
|
||||
# --delete retire ce que le build ne produit plus ; les quatre fichiers exclus
|
||||
# sont protégés de cette suppression par leur exclusion même.
|
||||
rsync -a --delete --rsync-path="sudo rsync" \
|
||||
--exclude 'run.sh' --exclude 'entrypoint.sh' \
|
||||
--exclude 'nginx.conf' --exclude 'redis.conf' \
|
||||
"$STAGE/" "$HOST:$REMOTE_DIR/"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
say "Redémarrage"
|
||||
|
||||
ssh -t "$HOST" "sudo systemctl start $SERVICE"
|
||||
sleep 10
|
||||
ssh "$HOST" "systemctl is-active $SERVICE; curl -s -o /dev/null -w 'HTTP %{http_code}\n' http://127.0.0.1/"
|
||||
|
||||
say "Journaux (40 dernières lignes)"
|
||||
ssh -t "$HOST" "sudo journalctl -u $SERVICE -n 40 --no-pager"
|
||||
|
||||
cat <<EOF
|
||||
|
||||
Déployé. Vérifie dans le navigateur que les intégrations PRÉEXISTANTES
|
||||
fonctionnent encore — c'est ce qui prouve que SECRET_ENCRYPTION_KEY est intact.
|
||||
|
||||
Retour arrière :
|
||||
ssh $HOST 'sudo systemctl stop $SERVICE \\
|
||||
&& sudo rm -rf $REMOTE_DIR \\
|
||||
&& sudo tar xzf /root/homarr-backup-$stamp.tar.gz -C / \\
|
||||
&& sudo systemctl start $SERVICE'
|
||||
|
||||
La base n'a pas été remplacée ; sa copie est dans /root/homarr-db-$stamp.sqlite.
|
||||
EOF
|
||||
Executable
+87
@@ -0,0 +1,87 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Fait pointer une instance Homarr installée sur /var/lib/homarr au lieu de
|
||||
# /appdata.
|
||||
#
|
||||
# `/appdata` vient de l'image Docker, où c'est un point de montage. Sur une
|
||||
# machine, il ne suit aucune convention : l'état d'un service se range sous
|
||||
# /var/lib. Ce script agit sur l'INSTANCE INSTALLÉE — son `run.sh`, son
|
||||
# `redis.conf` — et jamais sur le dépôt : ni le code source ni le Dockerfile ne
|
||||
# sont touchés, pour que le fork reste rebasable sur l'amont.
|
||||
#
|
||||
# Idempotent : le relancer ne fait rien de plus.
|
||||
#
|
||||
# Usage : ops/use-var-lib-homarr.sh <répertoire de l'instance>
|
||||
# ex. : sudo ops/use-var-lib-homarr.sh /opt/homarr
|
||||
# sudo ops/use-var-lib-homarr.sh /opt/homarr-dev
|
||||
#
|
||||
set -euo pipefail
|
||||
|
||||
DIR="${1:?usage: ops/use-var-lib-homarr.sh <dossier-instance>}"
|
||||
STATE="${HOMARR_STATE_DIR:-/var/lib/homarr}"
|
||||
stamp="$(date +%F-%H%M%S)"
|
||||
|
||||
[ -d "$DIR" ] || { echo "$DIR n'existe pas." >&2; exit 1; }
|
||||
[ -f "$DIR/run.sh" ] || { echo "$DIR/run.sh introuvable — est-ce bien une instance assemblée ?" >&2; exit 1; }
|
||||
|
||||
changed=0
|
||||
|
||||
for file in run.sh redis.conf; do
|
||||
target="$DIR/$file"
|
||||
[ -f "$target" ] || continue
|
||||
grep -q '/appdata' "$target" || continue
|
||||
|
||||
cp -a "$target" "$target.bak-$stamp"
|
||||
sed -i "s#/appdata#$STATE#g" "$target"
|
||||
echo "== $file"
|
||||
diff -u "$target.bak-$stamp" "$target" | sed -n '4,$p' || true
|
||||
changed=1
|
||||
done
|
||||
|
||||
# Le chemin des certificats n'est pas lu par run.sh mais par l'application, qui
|
||||
# retombe sur /appdata/trusted-certificates quand la variable est absente.
|
||||
# Réécrire les `mkdir` ne suffit donc pas : sans cette ligne, Homarr créerait à
|
||||
# nouveau /appdata au premier chargement de certificat.
|
||||
if ! grep -q 'LOCAL_CERTIFICATE_PATH' "$DIR/run.sh"; then
|
||||
[ -f "$DIR/run.sh.bak-$stamp" ] || cp -a "$DIR/run.sh" "$DIR/run.sh.bak-$stamp"
|
||||
awk -v value="$STATE/trusted-certificates" '
|
||||
NR == 1 {
|
||||
print
|
||||
print ""
|
||||
print "# Chemin des certificats de confiance. Sans cette ligne, l'\''application"
|
||||
print "# retombe sur le dossier de l'\''image et le recrée au premier certificat lu."
|
||||
print "export LOCAL_CERTIFICATE_PATH=\"${LOCAL_CERTIFICATE_PATH:-" value "}\""
|
||||
next
|
||||
}
|
||||
{ print }
|
||||
' "$DIR/run.sh" > "$DIR/run.sh.new"
|
||||
cat "$DIR/run.sh.new" > "$DIR/run.sh" # préserve les droits et le propriétaire
|
||||
rm -f "$DIR/run.sh.new"
|
||||
echo "== run.sh : LOCAL_CERTIFICATE_PATH ajouté ($STATE/trusted-certificates)"
|
||||
changed=1
|
||||
fi
|
||||
|
||||
if [ "$changed" -eq 0 ]; then
|
||||
echo "Rien à faire : $DIR ne référence déjà plus /appdata."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
mkdir -p "$STATE/trusted-certificates"
|
||||
|
||||
cat <<EOF
|
||||
|
||||
Instance $DIR réglée sur $STATE. Sauvegardes : *.bak-$stamp
|
||||
|
||||
Il reste les DONNÉES, que ce script ne déplace pas — c'est à faire une fois,
|
||||
service arrêté, et c'est irréversible sans sauvegarde :
|
||||
|
||||
sudo systemctl stop homarr
|
||||
sudo mv /appdata/trusted-certificates/*.pem $STATE/trusted-certificates/ 2>/dev/null || true
|
||||
sudo find /appdata -type f # doit ne RIEN afficher avant la suite
|
||||
sudo rm -rf /appdata
|
||||
sudo systemctl start homarr
|
||||
|
||||
Le contrôle qui compte ensuite : l'intégration UniFi OS doit toujours répondre.
|
||||
C'est elle qui valide son certificat avec le CA déplacé — si elle fonctionne,
|
||||
le déplacement a réussi.
|
||||
EOF
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "homarr",
|
||||
"version": "1.77.0",
|
||||
"version": "1.77.1",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "cross-env CI=true turbo build",
|
||||
|
||||
@@ -31,6 +31,8 @@ export const widgetRouter = createTRPCRouter({
|
||||
options: lazy(() => import("./options").then((mod) => mod.optionsRouter)),
|
||||
releases: lazy(() => import("./releases").then((mod) => mod.releasesRouter)),
|
||||
networkController: lazy(() => import("./network-controller").then((mod) => mod.networkControllerRouter)),
|
||||
unifiOs: lazy(() => import("./unifi-os").then((mod) => mod.unifiOsRouter)),
|
||||
unifiSiteManager: lazy(() => import("./unifi-site-manager").then((mod) => mod.unifiSiteManagerRouter)),
|
||||
firewall: lazy(() => import("./firewall").then((mod) => mod.firewallRouter)),
|
||||
notifications: lazy(() => import("./notifications").then((mod) => mod.notificationsRouter)),
|
||||
timetable: lazy(() => import("./timetable").then((mod) => mod.timetableRouter)),
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { unifiOsRequestHandler } from "@homarr/request-handler/unifi-os";
|
||||
|
||||
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import { settleIntegrationQueries } from "../../settle-integrations";
|
||||
import { createTRPCRouter, publicProcedure } from "../../trpc";
|
||||
|
||||
export const unifiOsRouter = createTRPCRouter({
|
||||
summary: publicProcedure.concat(createManyIntegrationMiddleware("query", "unifiOs")).query(async ({ ctx }) => {
|
||||
return await settleIntegrationQueries(ctx.integrations, async (integration) => {
|
||||
const innerHandler = unifiOsRequestHandler.handler(integration, {});
|
||||
const { data, timestamp } = await innerHandler.getDataAsync();
|
||||
|
||||
return {
|
||||
integration: {
|
||||
id: integration.id,
|
||||
name: integration.name,
|
||||
kind: integration.kind,
|
||||
},
|
||||
summary: data,
|
||||
updatedAt: timestamp,
|
||||
};
|
||||
});
|
||||
}),
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
import { unifiSiteManagerRequestHandler } from "@homarr/request-handler/unifi-site-manager";
|
||||
|
||||
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import { settleIntegrationQueries } from "../../settle-integrations";
|
||||
import { createTRPCRouter, publicProcedure } from "../../trpc";
|
||||
|
||||
export const unifiSiteManagerRouter = createTRPCRouter({
|
||||
overview: publicProcedure
|
||||
.concat(createManyIntegrationMiddleware("query", "unifiSiteManager"))
|
||||
.query(async ({ ctx }) => {
|
||||
return await settleIntegrationQueries(ctx.integrations, async (integration) => {
|
||||
const innerHandler = unifiSiteManagerRequestHandler.handler(integration, {});
|
||||
const { data, timestamp } = await innerHandler.getDataAsync();
|
||||
|
||||
return {
|
||||
integration: { id: integration.id, name: integration.name, kind: integration.kind },
|
||||
overview: data,
|
||||
updatedAt: timestamp,
|
||||
};
|
||||
});
|
||||
}),
|
||||
});
|
||||
@@ -31,6 +31,8 @@ export const integrationDocSlugs: Record<IntegrationKind, string | null> = {
|
||||
proxmox: "proxmox",
|
||||
nextcloud: "nextcloud",
|
||||
unifiController: "unifi-controller",
|
||||
unifiOs: null,
|
||||
unifiSiteManager: null,
|
||||
opnsense: "opnsense",
|
||||
ntfy: "ntfy",
|
||||
gotify: "gotify",
|
||||
|
||||
@@ -23,6 +23,8 @@ export const widgetDocSlugs: Record<WidgetKind, string | null> = {
|
||||
minecraftServerStatus: "minecraft-server-status",
|
||||
networkControllerSummary: "network-controller-summary",
|
||||
networkControllerStatus: "network-controller-status",
|
||||
unifiOs: null,
|
||||
unifiSiteManager: null,
|
||||
rssFeed: "rss-feed",
|
||||
bookmarks: "bookmarks",
|
||||
indexerManager: "indexer-manager",
|
||||
|
||||
@@ -5,6 +5,7 @@ import { createDocumentationLink } from "./docs";
|
||||
|
||||
export const integrationSecretKindObject = {
|
||||
apiKey: { isPublic: false, multiline: false },
|
||||
siteManagerApiKey: { isPublic: false, multiline: false },
|
||||
username: { isPublic: true, multiline: false },
|
||||
password: { isPublic: false, multiline: false },
|
||||
tokenId: { isPublic: true, multiline: false },
|
||||
@@ -286,6 +287,22 @@ export const integrationDefs = {
|
||||
documentationUrl: createDocumentationLink("/docs/integrations/unifi-controller"),
|
||||
defaultPort: 8443,
|
||||
},
|
||||
unifiOs: {
|
||||
name: "UniFi OS",
|
||||
secretKinds: [["apiKey"], ["apiKey", "siteManagerApiKey"]],
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/png/unifi.png",
|
||||
category: ["unifiOs"],
|
||||
documentationUrl: null,
|
||||
defaultPort: 443,
|
||||
},
|
||||
unifiSiteManager: {
|
||||
name: "UniFi Site Manager",
|
||||
secretKinds: [["siteManagerApiKey"]],
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/png/unifi.png",
|
||||
category: ["unifiSiteManager"],
|
||||
documentationUrl: null,
|
||||
defaultUrl: "https://api.ui.com",
|
||||
},
|
||||
opnsense: {
|
||||
name: "OPNsense",
|
||||
secretKinds: [["opnsenseApiKey", "opnsenseApiSecret"]],
|
||||
@@ -593,6 +610,8 @@ export const integrationCategories = [
|
||||
"uptimeMonitoring",
|
||||
"subtitleManager",
|
||||
"reverseProxy",
|
||||
"unifiOs",
|
||||
"unifiSiteManager",
|
||||
] as const;
|
||||
|
||||
export type IntegrationCategory = (typeof integrationCategories)[number];
|
||||
@@ -23,6 +23,8 @@ export const widgetIntegrationSupport: Partial<Record<WidgetKind, readonly Integ
|
||||
mediaMissing: getIntegrationKindsByCategory("mediaOrganizer"),
|
||||
networkControllerSummary: getIntegrationKindsByCategory("networkController"),
|
||||
networkControllerStatus: getIntegrationKindsByCategory("networkController"),
|
||||
unifiOs: ["unifiOs"],
|
||||
unifiSiteManager: ["unifiSiteManager"],
|
||||
indexerManager: getIntegrationKindsByCategory("indexerManager"),
|
||||
healthMonitoring: getIntegrationKindsByCategory("healthMonitoring").filter(
|
||||
(kind) => kind !== "patchmon" && kind !== "wud",
|
||||
|
||||
@@ -21,6 +21,8 @@ export const widgetKinds = [
|
||||
"minecraftServerStatus",
|
||||
"networkControllerSummary",
|
||||
"networkControllerStatus",
|
||||
"unifiOs",
|
||||
"unifiSiteManager",
|
||||
"rssFeed",
|
||||
"bookmarks",
|
||||
"indexerManager",
|
||||
|
||||
@@ -53,6 +53,8 @@ import { TrueNasIntegration } from "../truenas/truenas-integration";
|
||||
import { UmamiIntegration } from "../umami/umami-integration";
|
||||
import { UptimeKumaIntegration } from "../uptime-kuma/uptime-kuma-integration";
|
||||
import { UnifiControllerIntegration } from "../unifi-controller/unifi-controller-integration";
|
||||
import { UnifiOsIntegration } from "../unifi-os/unifi-os-integration";
|
||||
import { UnifiSiteManagerIntegration } from "../unifi-site-manager/unifi-site-manager-integration";
|
||||
import { UnraidIntegration } from "../unraid/unraid-integration";
|
||||
import { WudIntegration } from "../wud/wud-integration";
|
||||
import type { Integration, IntegrationInput } from "./integration";
|
||||
@@ -109,6 +111,8 @@ export const integrationCreators = {
|
||||
emby: EmbyIntegration,
|
||||
nextcloud: NextcloudIntegration,
|
||||
unifiController: UnifiControllerIntegration,
|
||||
unifiOs: UnifiOsIntegration,
|
||||
unifiSiteManager: UnifiSiteManagerIntegration,
|
||||
opnsense: OPNsenseIntegration,
|
||||
ical: ICalIntegration,
|
||||
ntfy: NTFYIntegration,
|
||||
|
||||
@@ -99,5 +99,8 @@ export { anchorNotesListInputSchema } from "./anchor/anchor-types";
|
||||
export { anchorNoteUpdateInputSchema } from "./anchor/anchor-types";
|
||||
export { downloadClientItemSchema } from "./interfaces/downloads/download-client-items";
|
||||
|
||||
export { UnifiOsIntegration } from "./unifi-os/unifi-os-integration";
|
||||
export { UnifiSiteManagerIntegration } from "./unifi-site-manager/unifi-site-manager-integration";
|
||||
|
||||
// Helpers
|
||||
export { createIntegrationAsync } from "./base/creator";
|
||||
@@ -0,0 +1,5 @@
|
||||
import type { UnifiOsSummary } from "../../unifi-os/unifi-os-types";
|
||||
|
||||
export interface UnifiOsSummaryIntegration {
|
||||
getUnifiOsSummaryAsync(): Promise<UnifiOsSummary>;
|
||||
}
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
import type { UnifiSiteManagerOverview } from "../../unifi-site-manager/unifi-site-manager-types";
|
||||
|
||||
export interface UnifiSiteManagerIntegration {
|
||||
getSiteOverviewAsync(): Promise<UnifiSiteManagerOverview>;
|
||||
}
|
||||
@@ -13,6 +13,10 @@ export * from "./base/searchable-integration";
|
||||
export * from "./homeassistant/homeassistant-types";
|
||||
export * from "./proxmox/proxmox-types";
|
||||
export * from "./unifi-controller/unifi-controller-types";
|
||||
export * from "./unifi-os/unifi-os-types";
|
||||
export * from "./interfaces/unifi-os-summary/unifi-os-summary-integration";
|
||||
export * from "./unifi-site-manager/unifi-site-manager-types";
|
||||
export * from "./interfaces/unifi-site-manager/unifi-site-manager-integration";
|
||||
export * from "./opnsense/opnsense-types";
|
||||
export * from "./interfaces/media-releases";
|
||||
export * from "./coolify/coolify-types";
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
// @vitest-environment node
|
||||
import { Response, type RequestInit } from "undici";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
|
||||
vi.hoisted(() => {
|
||||
process.env.SKIP_ENV_VALIDATION = "true";
|
||||
process.env.SECRET_ENCRYPTION_KEY = "ff3f4f7ce30e870c9630de9e5d244ffa81101a24ed0dfe5f064beb53a7e684f1";
|
||||
});
|
||||
|
||||
import { ResponseError } from "@homarr/common/server";
|
||||
|
||||
import { UnifiOsApi } from "../unifi-os-api";
|
||||
|
||||
const ORIGIN = "https://console.example.test";
|
||||
const API_KEY = "test-key";
|
||||
|
||||
const jsonResponse = (body: unknown, status = 200) =>
|
||||
new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } }) as never;
|
||||
|
||||
const page = (data: unknown[], totalCount = data.length, limit = 200) => ({
|
||||
offset: 0,
|
||||
limit,
|
||||
count: data.length,
|
||||
totalCount,
|
||||
data,
|
||||
});
|
||||
|
||||
const createApi = (handler: (url: string) => Promise<Response>) => {
|
||||
const fetchAsync = vi.fn(async (url: string) => await handler(url));
|
||||
return { api: new UnifiOsApi(ORIGIN, API_KEY, fetchAsync as never), fetchAsync };
|
||||
};
|
||||
|
||||
describe("UnifiOsApi request shape", () => {
|
||||
test("prefixes the integration path and sends the API key header", async () => {
|
||||
const fetchAsync = vi.fn(async (_url: string, _init?: RequestInit) =>
|
||||
jsonResponse({ applicationVersion: "10.6.102" }),
|
||||
);
|
||||
const api = new UnifiOsApi(ORIGIN, API_KEY, fetchAsync as never);
|
||||
|
||||
await api.getInfoAsync();
|
||||
|
||||
expect(fetchAsync).toHaveBeenCalledWith(
|
||||
`${ORIGIN}/proxy/network/integration/v1/info`,
|
||||
expect.objectContaining({ headers: expect.objectContaining({ "X-API-KEY": API_KEY }) }),
|
||||
);
|
||||
});
|
||||
|
||||
test("does not double a slash when the origin has a trailing one", async () => {
|
||||
const fetchAsync = vi.fn(async (_url: string, _init?: RequestInit) =>
|
||||
jsonResponse({ applicationVersion: "10.6.102" }),
|
||||
);
|
||||
const api = new UnifiOsApi(`${ORIGIN}/`, API_KEY, fetchAsync as never);
|
||||
|
||||
await api.getInfoAsync();
|
||||
|
||||
expect(fetchAsync.mock.calls[0]?.[0]).toBe(`${ORIGIN}/proxy/network/integration/v1/info`);
|
||||
});
|
||||
|
||||
test("throws ResponseError on a non-ok status instead of parsing the body", async () => {
|
||||
const { api } = createApi(async () => jsonResponse({ error: { code: 401 } }, 401));
|
||||
|
||||
await expect(api.getInfoAsync()).rejects.toBeInstanceOf(ResponseError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("UnifiOsApi getClientCountsAsync", () => {
|
||||
test("counts from a single page when totalCount fits under the API limit", async () => {
|
||||
const { api, fetchAsync } = createApi(async () =>
|
||||
jsonResponse(
|
||||
page([
|
||||
{ id: "1", name: "a", type: "WIRED", access: { type: "DEFAULT" } },
|
||||
{ id: "2", name: "b", type: "WIRELESS", access: { type: "GUEST" } },
|
||||
]),
|
||||
),
|
||||
);
|
||||
|
||||
const counts = await api.getClientCountsAsync("site-1");
|
||||
|
||||
expect(counts).toEqual({ total: 2, wired: 1, wireless: 1, vpn: 0, guests: 1 });
|
||||
expect(fetchAsync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("switches to filtered count queries when the site exceeds the 200 limit", async () => {
|
||||
const requested: string[] = [];
|
||||
const { api } = createApi(async (url) => {
|
||||
requested.push(url);
|
||||
if (!url.includes("filter=")) return jsonResponse(page([], 512));
|
||||
if (url.includes("WIRED")) return jsonResponse(page([], 400, 1));
|
||||
if (url.includes("WIRELESS")) return jsonResponse(page([], 100, 1));
|
||||
if (url.includes("TELEPORT")) return jsonResponse(page([], 2, 1));
|
||||
if (url.includes("VPN")) return jsonResponse(page([], 10, 1));
|
||||
return jsonResponse(page([], 5, 1));
|
||||
});
|
||||
|
||||
const counts = await api.getClientCountsAsync("site-1");
|
||||
|
||||
expect(counts).toEqual({ total: 512, wired: 400, wireless: 100, vpn: 12, guests: 5 });
|
||||
expect(requested.some((url) => url.includes("and("))).toBe(false);
|
||||
expect(requested.filter((url) => url.includes("filter=")).length).toBe(5);
|
||||
});
|
||||
|
||||
test("uses the prefix filter syntax the API actually accepts", async () => {
|
||||
const requested: string[] = [];
|
||||
const { api } = createApi(async (url) => {
|
||||
requested.push(url);
|
||||
return jsonResponse(page([], url.includes("filter=") ? 1 : 512, 1));
|
||||
});
|
||||
|
||||
await api.getClientCountsAsync("site-1");
|
||||
|
||||
const guestQuery = requested.find((url) => url.includes("GUEST"));
|
||||
expect(decodeURIComponent(guestQuery ?? "")).toContain("access.type.eq('GUEST')");
|
||||
expect(decodeURIComponent(guestQuery ?? "")).not.toContain(" and ");
|
||||
});
|
||||
});
|
||||
|
||||
describe("UnifiOsApi getFirstSiteAsync", () => {
|
||||
test("returns null for a console exposing no site rather than throwing", async () => {
|
||||
const { api } = createApi(async () => jsonResponse(page([])));
|
||||
|
||||
expect(await api.getFirstSiteAsync()).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,255 @@
|
||||
// @vitest-environment node
|
||||
import { fetch as platformFetch, Response } from "undici";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
vi.hoisted(() => {
|
||||
process.env.SKIP_ENV_VALIDATION = "true";
|
||||
process.env.SECRET_ENCRYPTION_KEY = "ff3f4f7ce30e870c9630de9e5d244ffa81101a24ed0dfe5f064beb53a7e684f1";
|
||||
});
|
||||
|
||||
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
|
||||
|
||||
import type { IntegrationSecret } from "../../base/types";
|
||||
import { UnifiSmApi } from "../unifi-sm-api";
|
||||
import { UnifiOsIntegration } from "../unifi-os-integration";
|
||||
|
||||
vi.mock("@homarr/core/infrastructure/http", () => ({
|
||||
fetchWithTrustedCertificatesAsync: vi.fn(),
|
||||
}));
|
||||
|
||||
// Les deux transports sont mockés séparément parce qu'ils *doivent* rester
|
||||
// séparés : le local passe par les certificats de confiance de Homarr, le cloud
|
||||
// par le `fetch` de la plateforme. Mocker les deux permet d'affirmer qu'aucune
|
||||
// requête vers api.ui.com n'emprunte le transport local — la garantie du §5.3.
|
||||
vi.mock("undici", async (importOriginalAsync) => {
|
||||
const actual = await importOriginalAsync<typeof import("undici")>();
|
||||
return { ...actual, fetch: vi.fn() };
|
||||
});
|
||||
|
||||
/** Transport local : certificats de confiance, console auto-signée. */
|
||||
const mockFetch = vi.mocked(fetchWithTrustedCertificatesAsync);
|
||||
/** Transport cloud : `fetch` de la plateforme, certificat public ordinaire. */
|
||||
const mockCloudFetch = vi.mocked(platformFetch);
|
||||
|
||||
const CLOUD_ORIGIN = "https://api.ui.com";
|
||||
|
||||
const GATEWAY_MAC = "aa:bb:cc:11:22:33";
|
||||
|
||||
const jsonResponse = (body: unknown, status = 200) =>
|
||||
new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } }) as never;
|
||||
|
||||
const page = (data: unknown[]) => ({ offset: 0, limit: 200, count: data.length, totalCount: data.length, data });
|
||||
|
||||
const localRoutes: Record<string, unknown> = {
|
||||
"/info": { applicationVersion: "10.6.102" },
|
||||
"/sites": page([{ id: "site-1", name: "Example site", internalReference: "default" }]),
|
||||
"/sites/site-1/clients": page([
|
||||
{ id: "c1", name: "a", type: "WIRED", access: { type: "DEFAULT" } },
|
||||
{ id: "c2", name: "b", type: "WIRELESS", access: { type: "DEFAULT" } },
|
||||
]),
|
||||
"/sites/site-1/devices": page([
|
||||
{
|
||||
id: "gw",
|
||||
name: "Gateway",
|
||||
model: "UXG Pro",
|
||||
macAddress: GATEWAY_MAC,
|
||||
state: "ONLINE",
|
||||
firmwareUpdatable: true,
|
||||
features: ["gateway"],
|
||||
},
|
||||
{
|
||||
id: "sw",
|
||||
name: "Switch",
|
||||
model: "USW Lite 8",
|
||||
macAddress: "aa:bb:cc:99:88:77",
|
||||
state: "OFFLINE",
|
||||
firmwareUpdatable: false,
|
||||
features: ["switching"],
|
||||
},
|
||||
]),
|
||||
"/sites/site-1/devices/gw/statistics/latest": {
|
||||
uptimeSec: 1009049,
|
||||
cpuUtilizationPct: 19.1,
|
||||
memoryUtilizationPct: 63.1,
|
||||
uplink: { txRateBps: 14140912, rxRateBps: 3346784 },
|
||||
},
|
||||
};
|
||||
|
||||
const routeLocal = (rawUrl: string): Response | null => {
|
||||
const path = new URL(rawUrl).pathname.replace("/proxy/network/integration/v1", "");
|
||||
const body = localRoutes[path];
|
||||
return body === undefined ? null : jsonResponse(body);
|
||||
};
|
||||
|
||||
const createIntegration = (decryptedSecrets: IntegrationSecret[]) =>
|
||||
new UnifiOsIntegration({
|
||||
id: "test-unifi-os",
|
||||
name: "Test UniFi OS",
|
||||
url: "https://console.example.test",
|
||||
externalUrl: null,
|
||||
decryptedSecrets,
|
||||
});
|
||||
|
||||
const localOnlySecrets: IntegrationSecret[] = [{ kind: "apiKey", value: "local-key" }];
|
||||
const hybridSecrets: IntegrationSecret[] = [
|
||||
{ kind: "apiKey", value: "local-key" },
|
||||
{ kind: "siteManagerApiKey", value: "cloud-key" },
|
||||
];
|
||||
|
||||
/** Toute URL demandée au transport local, quel que soit l'appelant. */
|
||||
const localTransportUrls = () => mockFetch.mock.calls.map(([url]) => String(url));
|
||||
/** Toute URL demandée au transport cloud. */
|
||||
const cloudTransportUrls = () => mockCloudFetch.mock.calls.map(([url]) => String(url));
|
||||
|
||||
beforeEach(() => {
|
||||
mockFetch.mockReset();
|
||||
mockCloudFetch.mockReset();
|
||||
UnifiSmApi.clearCache();
|
||||
});
|
||||
|
||||
describe("UnifiOsIntegration getUnifiOsSummaryAsync", () => {
|
||||
test("builds the summary from the local API alone", async () => {
|
||||
mockFetch.mockImplementation(async (url) => routeLocal(String(url)) ?? jsonResponse({}, 404));
|
||||
|
||||
const summary = await createIntegration(localOnlySecrets).getUnifiOsSummaryAsync();
|
||||
|
||||
expect(summary.site).toEqual({ id: "site-1", name: "Example site", applicationVersion: "10.6.102" });
|
||||
expect(summary.clients).toEqual({ total: 2, wired: 1, wireless: 1, vpn: 0, guests: 0 });
|
||||
expect(summary.devices).toEqual({ total: 2, online: 1, offline: 1, firmwareUpdatable: 1 });
|
||||
expect(summary.wan.state).toBe("online");
|
||||
expect(summary.wan.throughput).toEqual({ txBps: 14140912, rxBps: 3346784 });
|
||||
expect(summary.gateway).toMatchObject({ name: "Gateway", uptimeSec: 1009049, cpuPct: 19.1 });
|
||||
});
|
||||
|
||||
test("leaves the ISP block null and touches no transport at all without a Site Manager key", async () => {
|
||||
mockFetch.mockImplementation(async (url) => routeLocal(String(url)) ?? jsonResponse({}, 404));
|
||||
|
||||
const summary = await createIntegration(localOnlySecrets).getUnifiOsSummaryAsync();
|
||||
|
||||
expect(summary.wan.isp).toBeNull();
|
||||
// Le transport cloud est le seul chemin vers api.ui.com : ne pas l'appeler
|
||||
// du tout prouve qu'aucune requête cloud n'a été émise.
|
||||
expect(mockCloudFetch).not.toHaveBeenCalled();
|
||||
expect(localTransportUrls().every((url) => !url.startsWith(CLOUD_ORIGIN))).toBe(true);
|
||||
});
|
||||
|
||||
test("fills the ISP block from the cloud transport, never from the trusted-certificate one", async () => {
|
||||
mockFetch.mockImplementation(async (url) => routeLocal(String(url)) ?? jsonResponse({}, 404));
|
||||
mockCloudFetch.mockImplementation(async (url) => {
|
||||
const raw = String(url);
|
||||
if (raw.startsWith(`${CLOUD_ORIGIN}/v1/sites`)) {
|
||||
return jsonResponse({
|
||||
data: [
|
||||
{
|
||||
siteId: "cloud-1",
|
||||
hostId: "host-1",
|
||||
meta: { gatewayMac: GATEWAY_MAC },
|
||||
statistics: { wans: { WAN: { externalIp: "203.0.113.10" } } },
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
if (raw.includes("isp-metrics")) {
|
||||
return jsonResponse({
|
||||
data: {
|
||||
metrics: [
|
||||
{
|
||||
periods: [
|
||||
{
|
||||
metricTime: "2026-09-08T09:10:00Z",
|
||||
data: {
|
||||
wan: { avgLatency: 11, maxLatency: 12, packetLoss: 0, uptime: 100, ispName: "Example ISP" },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
}
|
||||
return jsonResponse({}, 404);
|
||||
});
|
||||
|
||||
const summary = await createIntegration(hybridSecrets).getUnifiOsSummaryAsync();
|
||||
|
||||
expect(summary.wan.isp).toMatchObject({ name: "Example ISP", latencyMs: 11, externalIp: "203.0.113.10" });
|
||||
expect(summary.wan.isp?.measuredAt).toEqual(new Date("2026-09-08T09:10:00Z"));
|
||||
|
||||
// Le bloc ISP n'a pu être rempli que par des réponses servies par le mock
|
||||
// cloud : les deux appels y sont donc bien passés.
|
||||
expect(cloudTransportUrls()).toEqual([`${CLOUD_ORIGIN}/v1/sites`, `${CLOUD_ORIGIN}/v1/isp-metrics/5m/query`]);
|
||||
// Le cœur du §5.3 : la clé Site Manager ne voyage jamais sur le transport
|
||||
// qui embarque les autorités personnalisées de l'administrateur.
|
||||
expect(localTransportUrls().every((url) => url.startsWith("https://console.example.test"))).toBe(true);
|
||||
});
|
||||
|
||||
test("never sends the Site Manager key through the trusted-certificate transport", async () => {
|
||||
mockFetch.mockImplementation(async (url) => routeLocal(String(url)) ?? jsonResponse({}, 404));
|
||||
mockCloudFetch.mockImplementation(async () => jsonResponse({}, 500));
|
||||
|
||||
await createIntegration(hybridSecrets).getUnifiOsSummaryAsync();
|
||||
|
||||
const localKeys = mockFetch.mock.calls.map(
|
||||
([, init]) => (init?.headers as Record<string, string> | undefined)?.["X-API-KEY"],
|
||||
);
|
||||
|
||||
expect(localKeys.every((key) => key === "local-key")).toBe(true);
|
||||
expect(localKeys).not.toContain("cloud-key");
|
||||
});
|
||||
|
||||
test("degrades only the clients block when the clients endpoint fails", async () => {
|
||||
mockFetch.mockImplementation(async (url) => {
|
||||
const raw = String(url);
|
||||
if (raw.includes("/clients")) return jsonResponse({ error: "boom" }, 500);
|
||||
return routeLocal(raw) ?? jsonResponse({}, 404);
|
||||
});
|
||||
|
||||
const summary = await createIntegration(localOnlySecrets).getUnifiOsSummaryAsync();
|
||||
|
||||
expect(summary.clients).toBeNull();
|
||||
expect(summary.devices).toEqual({ total: 2, online: 1, offline: 1, firmwareUpdatable: 1 });
|
||||
expect(summary.gateway).not.toBeNull();
|
||||
});
|
||||
|
||||
test("degrades only the devices block when the devices endpoint fails", async () => {
|
||||
mockFetch.mockImplementation(async (url) => {
|
||||
const raw = String(url);
|
||||
if (raw.includes("/devices") && !raw.includes("/statistics")) return jsonResponse({ error: "boom" }, 500);
|
||||
return routeLocal(raw) ?? jsonResponse({}, 404);
|
||||
});
|
||||
|
||||
const summary = await createIntegration(localOnlySecrets).getUnifiOsSummaryAsync();
|
||||
|
||||
expect(summary.devices).toBeNull();
|
||||
expect(summary.clients).toEqual({ total: 2, wired: 1, wireless: 1, vpn: 0, guests: 0 });
|
||||
});
|
||||
|
||||
test("keeps clients and devices when the gateway cannot be identified", async () => {
|
||||
mockFetch.mockImplementation(async (url) => {
|
||||
const raw = String(url);
|
||||
if (raw.endsWith("/devices?limit=200")) {
|
||||
return jsonResponse(
|
||||
page([
|
||||
{
|
||||
id: "sw",
|
||||
name: "Switch",
|
||||
model: "USW Lite 8",
|
||||
macAddress: "aa:bb:cc:99:88:77",
|
||||
state: "ONLINE",
|
||||
firmwareUpdatable: false,
|
||||
features: ["switching"],
|
||||
},
|
||||
]),
|
||||
);
|
||||
}
|
||||
return routeLocal(raw) ?? jsonResponse({}, 404);
|
||||
});
|
||||
|
||||
const summary = await createIntegration(localOnlySecrets).getUnifiOsSummaryAsync();
|
||||
|
||||
expect(summary.gateway).toBeNull();
|
||||
expect(summary.wan.throughput).toBeNull();
|
||||
expect(summary.wan.state).toBe("unknown");
|
||||
expect(summary.clients).toEqual({ total: 2, wired: 1, wireless: 1, vpn: 0, guests: 0 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,182 @@
|
||||
// @vitest-environment node
|
||||
import { readFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
|
||||
vi.hoisted(() => {
|
||||
process.env.SKIP_ENV_VALIDATION = "true";
|
||||
process.env.SECRET_ENCRYPTION_KEY = "ff3f4f7ce30e870c9630de9e5d244ffa81101a24ed0dfe5f064beb53a7e684f1";
|
||||
});
|
||||
|
||||
import type { UnifiOsClient, UnifiOsDevice } from "../unifi-os-types";
|
||||
import {
|
||||
countClients,
|
||||
findGateway,
|
||||
matchCloudSite,
|
||||
normalizeMacAddress,
|
||||
summarizeDevices,
|
||||
unifiOsClientSchema,
|
||||
} from "../unifi-os-types";
|
||||
|
||||
const client = (type: string, accessType = "DEFAULT"): UnifiOsClient => ({
|
||||
id: `client-${type}-${accessType}-${Math.random()}`,
|
||||
name: "client",
|
||||
type,
|
||||
access: { type: accessType },
|
||||
});
|
||||
|
||||
const device = (overrides: Partial<UnifiOsDevice> = {}): UnifiOsDevice => ({
|
||||
id: "device-1",
|
||||
name: "device",
|
||||
model: "USW Lite 8",
|
||||
macAddress: "aa:bb:cc:11:22:33",
|
||||
state: "ONLINE",
|
||||
firmwareUpdatable: false,
|
||||
features: ["switching"],
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe("normalizeMacAddress", () => {
|
||||
test("lowercases and strips separators so both APIs compare equal", () => {
|
||||
expect(normalizeMacAddress("AA:BB:CC:11:22:33")).toBe("aabbcc112233");
|
||||
expect(normalizeMacAddress("aa-bb-cc-11-22-33")).toBe("aabbcc112233");
|
||||
expect(normalizeMacAddress("AABBCC112233")).toBe("aabbcc112233");
|
||||
});
|
||||
});
|
||||
|
||||
describe("countClients", () => {
|
||||
test("counts each connection type separately", () => {
|
||||
const counts = countClients([
|
||||
client("WIRED"),
|
||||
client("WIRED"),
|
||||
client("WIRELESS"),
|
||||
client("VPN"),
|
||||
client("TELEPORT"),
|
||||
]);
|
||||
|
||||
expect(counts).toEqual({ total: 5, wired: 2, wireless: 1, vpn: 2, guests: 0 });
|
||||
});
|
||||
|
||||
test("folds TELEPORT into vpn because both are remote access", () => {
|
||||
expect(countClients([client("TELEPORT")]).vpn).toBe(1);
|
||||
});
|
||||
|
||||
test("counts guests across every connection type", () => {
|
||||
const counts = countClients([client("WIRELESS", "GUEST"), client("WIRED", "GUEST"), client("WIRED")]);
|
||||
|
||||
expect(counts.guests).toBe(2);
|
||||
expect(counts.wired).toBe(2);
|
||||
});
|
||||
|
||||
test("treats an unknown connection type as counted but unclassified", () => {
|
||||
const counts = countClients([client("SOMETHING_NEW")]);
|
||||
|
||||
expect(counts.total).toBe(1);
|
||||
expect(counts.wired + counts.wireless + counts.vpn).toBe(0);
|
||||
});
|
||||
|
||||
test("tolerates a client without an access block", () => {
|
||||
const counts = countClients([{ id: "x", name: "x", type: "WIRED" }]);
|
||||
|
||||
expect(counts).toEqual({ total: 1, wired: 1, wireless: 0, vpn: 0, guests: 0 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("summarizeDevices", () => {
|
||||
test("splits online from everything else and counts pending firmware", () => {
|
||||
const counts = summarizeDevices([
|
||||
device({ id: "a", state: "ONLINE", firmwareUpdatable: true }),
|
||||
device({ id: "b", state: "OFFLINE" }),
|
||||
device({ id: "c", state: "UPDATING", firmwareUpdatable: true }),
|
||||
]);
|
||||
|
||||
expect(counts).toEqual({ total: 3, online: 1, offline: 2, firmwareUpdatable: 2 });
|
||||
});
|
||||
|
||||
test("returns zeroes for an empty site rather than throwing", () => {
|
||||
expect(summarizeDevices([])).toEqual({ total: 0, online: 0, offline: 0, firmwareUpdatable: 0 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("findGateway", () => {
|
||||
test("prefers the device advertising the gateway feature", () => {
|
||||
const gateway = device({ id: "gw", features: ["gateway"], model: "UXG Pro" });
|
||||
expect(findGateway([device(), gateway])?.id).toBe("gw");
|
||||
});
|
||||
|
||||
test("falls back to a gateway-looking model when features are empty", () => {
|
||||
const gateway = device({ id: "gw", features: [], model: "Gateway Pro" });
|
||||
expect(findGateway([device(), gateway])?.id).toBe("gw");
|
||||
});
|
||||
|
||||
test("returns null when no gateway is present instead of picking any device", () => {
|
||||
expect(findGateway([device()])).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("matchCloudSite", () => {
|
||||
const siteA = { siteId: "a", meta: { gatewayMac: "AA:BB:CC:11:22:33" } };
|
||||
const siteB = { siteId: "b", meta: { gatewayMac: "dd:ee:ff:44:55:66" } };
|
||||
|
||||
test("matches on the gateway MAC regardless of formatting", () => {
|
||||
expect(matchCloudSite("aa-bb-cc-11-22-33", [siteB, siteA])?.siteId).toBe("a");
|
||||
});
|
||||
|
||||
test("returns null rather than another account site when nothing matches", () => {
|
||||
expect(matchCloudSite("11:22:33:44:55:66", [siteA, siteB])).toBeNull();
|
||||
});
|
||||
|
||||
test("returns null when the local gateway is unknown", () => {
|
||||
expect(matchCloudSite(null, [siteA, siteB])).toBeNull();
|
||||
});
|
||||
|
||||
// `normalizeMacAddress` réduit "", "unknown" et "null" à la même chaîne vide.
|
||||
// Sans garde, ces valeurs se correspondraient entre elles et le widget
|
||||
// afficherait le réseau d'un autre compte comme celui de l'utilisateur.
|
||||
test("returns null when the local MAC normalizes to nothing", () => {
|
||||
const placeholder = { siteId: "placeholder", meta: { gatewayMac: "unknown" } };
|
||||
|
||||
for (const localMac of ["", "unknown", "null"]) {
|
||||
expect(matchCloudSite(localMac, [placeholder, siteA, siteB])).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
test("skips cloud sites whose gateway MAC normalizes to nothing", () => {
|
||||
const placeholder = { siteId: "placeholder", meta: { gatewayMac: "unknown" } };
|
||||
|
||||
expect(matchCloudSite("aa:bb:cc:11:22:33", [placeholder, siteA])?.siteId).toBe("a");
|
||||
expect(matchCloudSite("11:22:33:44:55:66", [placeholder])).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("client-safety", () => {
|
||||
// `unifi-os-types.ts` is re-exported by `packages/integrations/src/types.ts`,
|
||||
// which client components import (e.g. via the spotlight search group). An
|
||||
// import of `@homarr/common/server` here drags `ioredis` — and transitively
|
||||
// Node's `dns` module — into the browser bundle and breaks `pnpm dev`/build.
|
||||
// Only a source-text check catches this: a type-only "does it compile" check
|
||||
// would not, since the breakage is a bundler resolution failure, not a type
|
||||
// error. This regression guard is what would have caught the original bug.
|
||||
test("does not import @homarr/common/server", () => {
|
||||
const path = fileURLToPath(new URL("../unifi-os-types.ts", import.meta.url));
|
||||
const source = readFileSync(path, "utf-8");
|
||||
|
||||
expect(source).not.toContain("@homarr/common/server");
|
||||
});
|
||||
});
|
||||
|
||||
describe("unifiOsClientSchema", () => {
|
||||
test("keeps fields the published OpenAPI does not document", () => {
|
||||
const parsed = unifiOsClientSchema.parse({
|
||||
id: "1",
|
||||
name: "n",
|
||||
type: "WIRED",
|
||||
access: { type: "DEFAULT" },
|
||||
macAddress: "aa:bb:cc:11:22:33",
|
||||
uplinkDeviceId: "u1",
|
||||
});
|
||||
|
||||
expect(parsed).toMatchObject({ macAddress: "aa:bb:cc:11:22:33", uplinkDeviceId: "u1" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,201 @@
|
||||
// @vitest-environment node
|
||||
import { Response, type RequestInit } from "undici";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
vi.hoisted(() => {
|
||||
process.env.SKIP_ENV_VALIDATION = "true";
|
||||
process.env.SECRET_ENCRYPTION_KEY = "ff3f4f7ce30e870c9630de9e5d244ffa81101a24ed0dfe5f064beb53a7e684f1";
|
||||
});
|
||||
|
||||
import { UnifiSmApi } from "../unifi-sm-api";
|
||||
|
||||
const GATEWAY_MAC = "aa:bb:cc:11:22:33";
|
||||
const OTHER_MAC = "dd:ee:ff:44:55:66";
|
||||
|
||||
const jsonResponse = (body: unknown, status = 200) =>
|
||||
new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } }) as never;
|
||||
|
||||
const sitesBody = {
|
||||
data: [
|
||||
{
|
||||
siteId: "mine",
|
||||
meta: { desc: "Mine", gatewayMac: GATEWAY_MAC },
|
||||
statistics: { wans: { WAN: { externalIp: "203.0.113.10" } } },
|
||||
},
|
||||
{ siteId: "theirs", meta: { desc: "Theirs", gatewayMac: OTHER_MAC } },
|
||||
],
|
||||
};
|
||||
|
||||
const metricsBody = (metricTime: string) => ({
|
||||
data: {
|
||||
metrics: [
|
||||
{
|
||||
periods: [
|
||||
{
|
||||
metricTime: "2026-09-08T08:00:00Z",
|
||||
data: { wan: { avgLatency: 99, maxLatency: 99, packetLoss: 5, uptime: 90, ispName: "Old" } },
|
||||
},
|
||||
{
|
||||
metricTime,
|
||||
data: { wan: { avgLatency: 11, maxLatency: 12, packetLoss: 0, uptime: 100, ispName: "Example ISP" } },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const createApi = () => {
|
||||
const fetchAsync = vi.fn(async (url: string, _init?: RequestInit) =>
|
||||
url.includes("isp-metrics") ? jsonResponse(metricsBody("2026-09-08T09:10:00Z")) : jsonResponse(sitesBody),
|
||||
);
|
||||
return { api: new UnifiSmApi("cloud-key", fetchAsync as never), fetchAsync };
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
UnifiSmApi.clearCache();
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-09-08T09:37:00Z"));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("UnifiSmApi getIspMetricsAsync", () => {
|
||||
test("returns the newest period of the site matching the gateway MAC", async () => {
|
||||
const { api } = createApi();
|
||||
|
||||
const metrics = await api.getIspMetricsAsync(GATEWAY_MAC);
|
||||
|
||||
expect(metrics).toEqual({
|
||||
name: "Example ISP",
|
||||
externalIp: "203.0.113.10",
|
||||
latencyMs: 11,
|
||||
maxLatencyMs: 12,
|
||||
packetLossPct: 0,
|
||||
uptimePct: 100,
|
||||
measuredAt: new Date("2026-09-08T09:10:00Z"),
|
||||
});
|
||||
});
|
||||
|
||||
test("returns the newest period by metricTime even when periods arrive out of order", async () => {
|
||||
const fetchAsync = vi.fn(async (url: string, _init?: RequestInit) => {
|
||||
if (!url.includes("isp-metrics")) return jsonResponse(sitesBody);
|
||||
|
||||
return jsonResponse({
|
||||
data: {
|
||||
metrics: [
|
||||
{
|
||||
periods: [
|
||||
{
|
||||
metricTime: "2026-09-08T09:10:00Z",
|
||||
data: {
|
||||
wan: { avgLatency: 11, maxLatency: 12, packetLoss: 0, uptime: 100, ispName: "Example ISP" },
|
||||
},
|
||||
},
|
||||
{
|
||||
metricTime: "2026-09-08T08:00:00Z",
|
||||
data: { wan: { avgLatency: 99, maxLatency: 99, packetLoss: 5, uptime: 90, ispName: "Old" } },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
});
|
||||
const api = new UnifiSmApi("cloud-key", fetchAsync as never);
|
||||
|
||||
const metrics = await api.getIspMetricsAsync(GATEWAY_MAC);
|
||||
|
||||
expect(metrics?.latencyMs).toBe(11);
|
||||
expect(metrics?.measuredAt).toEqual(new Date("2026-09-08T09:10:00Z"));
|
||||
});
|
||||
|
||||
// Ubiquiti omet la latence pour les périodes où le WAN était coupé. Un `0`
|
||||
// fabriqué s'afficherait comme « 0 ms » au pire moment possible.
|
||||
test("passes missing cloud measurements through as null instead of zero", async () => {
|
||||
const fetchAsync = vi.fn(async (url: string, _init?: RequestInit) => {
|
||||
if (!url.includes("isp-metrics")) return jsonResponse(sitesBody);
|
||||
|
||||
return jsonResponse({
|
||||
data: {
|
||||
metrics: [
|
||||
{
|
||||
periods: [{ metricTime: "2026-09-08T09:10:00Z", data: { wan: { ispName: "Example ISP" } } }],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
});
|
||||
const api = new UnifiSmApi("cloud-key", fetchAsync as never);
|
||||
|
||||
const metrics = await api.getIspMetricsAsync(GATEWAY_MAC);
|
||||
|
||||
expect(metrics).toMatchObject({
|
||||
latencyMs: null,
|
||||
maxLatencyMs: null,
|
||||
packetLossPct: null,
|
||||
uptimePct: null,
|
||||
});
|
||||
});
|
||||
|
||||
test("returns null when no cloud site matches, never another account site", async () => {
|
||||
const { api } = createApi();
|
||||
|
||||
expect(await api.getIspMetricsAsync("11:22:33:44:55:66")).toBeNull();
|
||||
});
|
||||
|
||||
test("returns null when the local gateway could not be identified", async () => {
|
||||
const { api, fetchAsync } = createApi();
|
||||
|
||||
expect(await api.getIspMetricsAsync(null)).toBeNull();
|
||||
expect(fetchAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("queries a window wide enough for the cloud lag", async () => {
|
||||
const { api, fetchAsync } = createApi();
|
||||
|
||||
await api.getIspMetricsAsync(GATEWAY_MAC);
|
||||
|
||||
const body = JSON.parse(
|
||||
String(fetchAsync.mock.calls.find(([url]) => String(url).includes("isp-metrics"))?.[1]?.body),
|
||||
);
|
||||
const begin = new Date(body.sites[0].beginTimestamp).getTime();
|
||||
const end = new Date(body.sites[0].endTimestamp).getTime();
|
||||
|
||||
// Une fenêtre de 15 min renvoie un tableau vide sur l'API réelle.
|
||||
expect(end - begin).toBeGreaterThanOrEqual(3 * 60 * 60 * 1000);
|
||||
});
|
||||
|
||||
test("serves a second call from cache instead of hitting the cloud again", async () => {
|
||||
const { api, fetchAsync } = createApi();
|
||||
|
||||
await api.getIspMetricsAsync(GATEWAY_MAC);
|
||||
const callsAfterFirst = fetchAsync.mock.calls.length;
|
||||
await api.getIspMetricsAsync(GATEWAY_MAC);
|
||||
|
||||
expect(fetchAsync.mock.calls.length).toBe(callsAfterFirst);
|
||||
});
|
||||
|
||||
test("refetches once the cache entry has expired", async () => {
|
||||
const { api, fetchAsync } = createApi();
|
||||
|
||||
await api.getIspMetricsAsync(GATEWAY_MAC);
|
||||
const callsAfterFirst = fetchAsync.mock.calls.length;
|
||||
|
||||
vi.setSystemTime(new Date("2026-09-08T09:43:00Z"));
|
||||
await api.getIspMetricsAsync(GATEWAY_MAC);
|
||||
|
||||
expect(fetchAsync.mock.calls.length).toBeGreaterThan(callsAfterFirst);
|
||||
});
|
||||
|
||||
test("returns null when the cloud rejects the key rather than propagating", async () => {
|
||||
const fetchAsync = vi.fn(async (_url: string, _init?: RequestInit) =>
|
||||
jsonResponse({ message: "unauthorized" }, 401),
|
||||
);
|
||||
const api = new UnifiSmApi("bad-key", fetchAsync as never);
|
||||
|
||||
expect(await api.getIspMetricsAsync(GATEWAY_MAC)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,118 @@
|
||||
import { ResponseError } from "@homarr/common/server";
|
||||
import type { RequestInit, Response } from "undici";
|
||||
import type { z } from "zod/v4";
|
||||
|
||||
import { parseUnifiOsAsync } from "./unifi-os-parse";
|
||||
import type { UnifiOsClientCounts, UnifiOsDevice, UnifiOsDeviceStatistics, UnifiOsSite } from "./unifi-os-types";
|
||||
import {
|
||||
countClients,
|
||||
createUnifiOsPageSchema,
|
||||
unifiOsClientSchema,
|
||||
unifiOsDeviceSchema,
|
||||
unifiOsDeviceStatisticsSchema,
|
||||
unifiOsInfoSchema,
|
||||
unifiOsSiteSchema,
|
||||
} from "./unifi-os-types";
|
||||
|
||||
/**
|
||||
* Déclaré sur les types d'undici, pas sur ceux du DOM : le dépôt n'utilise que
|
||||
* `undici` côté serveur. Aligner les deux évite les `as unknown as` qui
|
||||
* désactivaient le contrôle de type à la frontière du transport.
|
||||
*/
|
||||
export type UnifiOsFetch = (url: string, init?: RequestInit & { timeout?: number }) => Promise<Response>;
|
||||
|
||||
const INTEGRATION_PATH = "/proxy/network/integration/v1";
|
||||
const REQUEST_TIMEOUT_MS = 10_000;
|
||||
|
||||
/** Plafond imposé par l'API : demander davantage renvoie quand même 200. */
|
||||
const MAX_PAGE_LIMIT = 200;
|
||||
|
||||
const PARSE_ERROR_MESSAGE = "Invalid UniFi OS response";
|
||||
|
||||
const clientPageSchema = createUnifiOsPageSchema(unifiOsClientSchema);
|
||||
const devicePageSchema = createUnifiOsPageSchema(unifiOsDeviceSchema);
|
||||
const sitePageSchema = createUnifiOsPageSchema(unifiOsSiteSchema);
|
||||
|
||||
/**
|
||||
* Client de l'API d'intégration locale d'UniFi OS.
|
||||
*
|
||||
* Ne connaît ni Homarr ni la notion d'intégration : il ne sait que parler à une
|
||||
* console. Le `fetch` est injecté pour que les tests n'aient pas à mocker un module.
|
||||
*/
|
||||
export class UnifiOsApi {
|
||||
private readonly baseUrl: string;
|
||||
|
||||
constructor(
|
||||
origin: string,
|
||||
private readonly apiKey: string,
|
||||
private readonly fetchAsync: UnifiOsFetch,
|
||||
) {
|
||||
this.baseUrl = `${origin.replace(/\/+$/, "")}${INTEGRATION_PATH}`;
|
||||
}
|
||||
|
||||
public async getInfoAsync(): Promise<{ applicationVersion: string }> {
|
||||
return await this.requestAsync("/info", unifiOsInfoSchema);
|
||||
}
|
||||
|
||||
public async getFirstSiteAsync(): Promise<UnifiOsSite | null> {
|
||||
const page = await this.requestAsync("/sites", sitePageSchema);
|
||||
return page.data[0] ?? null;
|
||||
}
|
||||
|
||||
public async getDevicesAsync(siteId: string): Promise<UnifiOsDevice[]> {
|
||||
const page = await this.requestAsync(
|
||||
`/sites/${encodeURIComponent(siteId)}/devices?limit=${MAX_PAGE_LIMIT}`,
|
||||
devicePageSchema,
|
||||
);
|
||||
return page.data;
|
||||
}
|
||||
|
||||
public async getDeviceStatisticsAsync(siteId: string, deviceId: string): Promise<UnifiOsDeviceStatistics> {
|
||||
return await this.requestAsync(
|
||||
`/sites/${encodeURIComponent(siteId)}/devices/${encodeURIComponent(deviceId)}/statistics/latest`,
|
||||
unifiOsDeviceStatisticsSchema,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sous le plafond de 200 clients, une seule page suffit et le comptage se fait
|
||||
* en mémoire. Au-delà, rapatrier tous les enregistrements pour n'en tirer que
|
||||
* des compteurs coûterait plus cher que cinq requêtes bornées.
|
||||
*/
|
||||
public async getClientCountsAsync(siteId: string): Promise<UnifiOsClientCounts> {
|
||||
const path = `/sites/${encodeURIComponent(siteId)}/clients`;
|
||||
const firstPage = await this.requestAsync(`${path}?limit=${MAX_PAGE_LIMIT}`, clientPageSchema);
|
||||
|
||||
if (firstPage.totalCount <= MAX_PAGE_LIMIT) {
|
||||
return countClients(firstPage.data);
|
||||
}
|
||||
|
||||
const countByFilterAsync = async (filter: string) => {
|
||||
const page = await this.requestAsync(`${path}?limit=1&filter=${encodeURIComponent(filter)}`, clientPageSchema);
|
||||
return page.totalCount;
|
||||
};
|
||||
|
||||
const [wired, wireless, vpn, teleport, guests] = await Promise.all([
|
||||
countByFilterAsync("type.eq('WIRED')"),
|
||||
countByFilterAsync("type.eq('WIRELESS')"),
|
||||
countByFilterAsync("type.eq('VPN')"),
|
||||
countByFilterAsync("type.eq('TELEPORT')"),
|
||||
countByFilterAsync("access.type.eq('GUEST')"),
|
||||
]);
|
||||
|
||||
return { total: firstPage.totalCount, wired, wireless, vpn: vpn + teleport, guests };
|
||||
}
|
||||
|
||||
private async requestAsync<TOutput>(path: string, schema: z.ZodType<TOutput>): Promise<TOutput> {
|
||||
const response = await this.fetchAsync(`${this.baseUrl}${path}`, {
|
||||
headers: { "X-API-KEY": this.apiKey, Accept: "application/json" },
|
||||
timeout: REQUEST_TIMEOUT_MS,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new ResponseError(response);
|
||||
}
|
||||
|
||||
return await parseUnifiOsAsync(await response.json(), schema, PARSE_ERROR_MESSAGE);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import { fetch as platformFetch } from "undici";
|
||||
|
||||
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
|
||||
|
||||
import type { IntegrationTestingInput } from "../base/integration";
|
||||
import { Integration } from "../base/integration";
|
||||
import { TestConnectionError } from "../base/test-connection/test-connection-error";
|
||||
import type { TestingResult } from "../base/test-connection/test-connection-service";
|
||||
import type { UnifiOsSummaryIntegration } from "../interfaces/unifi-os-summary/unifi-os-summary-integration";
|
||||
import type { UnifiOsFetch } from "./unifi-os-api";
|
||||
import { UnifiOsApi } from "./unifi-os-api";
|
||||
import { UnifiSmApi } from "./unifi-sm-api";
|
||||
import type { UnifiOsDeviceStatistics, UnifiOsSummary } from "./unifi-os-types";
|
||||
import { findGateway, summarizeDevices } from "./unifi-os-types";
|
||||
|
||||
/**
|
||||
* Transport du cloud Site Manager (§5.3).
|
||||
*
|
||||
* `fetchWithTrustedCertificatesAsync` ajoute **toutes** les autorités
|
||||
* personnalisées téléversées par l'administrateur Homarr, et installe un
|
||||
* `checkServerIdentity` qui saute la vérification du nom d'hôte pour les
|
||||
* empreintes épinglées. Une autorité ajoutée pour faire accepter le certificat
|
||||
* auto-signé de la console deviendrait alors une ancre de confiance valable
|
||||
* pour `api.ui.com` aussi : son détenteur pourrait intercepter l'appel cloud et
|
||||
* récupérer la clé Site Manager, qui lit tous les sites du compte Ubiquiti.
|
||||
*
|
||||
* `api.ui.com` présente un certificat public ordinaire : le `fetch` de la
|
||||
* plateforme suffit, et c'est le seul qui soit sûr ici. Le `timeout` du client
|
||||
* est traduit en `AbortSignal`, `fetch` ne connaissant pas cette option.
|
||||
*/
|
||||
const fetchCloudAsync: UnifiOsFetch = async (url, init) => {
|
||||
const { timeout, ...requestInit } = init ?? {};
|
||||
|
||||
return await platformFetch(url, {
|
||||
...requestInit,
|
||||
...(timeout === undefined ? {} : { signal: AbortSignal.timeout(timeout) }),
|
||||
});
|
||||
};
|
||||
|
||||
export class UnifiOsIntegration extends Integration implements UnifiOsSummaryIntegration {
|
||||
public async getUnifiOsSummaryAsync(): Promise<UnifiOsSummary> {
|
||||
return await this.buildSummaryAsync(fetchWithTrustedCertificatesAsync);
|
||||
}
|
||||
|
||||
protected async testingAsync({ fetchAsync }: IntegrationTestingInput): Promise<TestingResult> {
|
||||
const api = this.createLocalApi(fetchAsync);
|
||||
|
||||
try {
|
||||
await api.getInfoAsync();
|
||||
} catch (error) {
|
||||
return TestConnectionError.UnknownResult(error);
|
||||
}
|
||||
|
||||
const site = await api.getFirstSiteAsync();
|
||||
if (site === null) {
|
||||
// Une clé valide sur une console sans site est configurable mais inutile :
|
||||
// mieux vaut le dire au moment du test que laisser un widget vide.
|
||||
return TestConnectionError.UnknownResult(new Error("The UniFi OS console exposes no site"));
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
private createLocalApi(fetchAsync: UnifiOsFetch) {
|
||||
return new UnifiOsApi(this.integration.url, this.getSecretValue("apiKey"), fetchAsync);
|
||||
}
|
||||
|
||||
private async buildSummaryAsync(fetchAsync: UnifiOsFetch): Promise<UnifiOsSummary> {
|
||||
const api = this.createLocalApi(fetchAsync);
|
||||
|
||||
const [info, site] = await Promise.all([api.getInfoAsync(), api.getFirstSiteAsync()]);
|
||||
if (site === null) {
|
||||
throw new Error("The UniFi OS console exposes no site");
|
||||
}
|
||||
|
||||
const [clientsResult, devicesResult] = await Promise.allSettled([
|
||||
api.getClientCountsAsync(site.id),
|
||||
api.getDevicesAsync(site.id),
|
||||
]);
|
||||
|
||||
// `null` marks a failed call so the UI never mistakes it for a real zero
|
||||
// count (a site with genuinely no clients or devices).
|
||||
const clients = clientsResult.status === "fulfilled" ? clientsResult.value : null;
|
||||
const devices = devicesResult.status === "fulfilled" ? devicesResult.value : [];
|
||||
const gatewayDevice = findGateway(devices);
|
||||
|
||||
// Les statistiques dépendent du gateway : sans gateway identifié, il n'y a
|
||||
// rien à demander. Un échec ici ne doit vider que le débit et l'uptime.
|
||||
let statistics: UnifiOsDeviceStatistics | null = null;
|
||||
if (gatewayDevice !== null) {
|
||||
try {
|
||||
statistics = await api.getDeviceStatisticsAsync(site.id, gatewayDevice.id);
|
||||
} catch {
|
||||
statistics = null;
|
||||
}
|
||||
}
|
||||
|
||||
const isp = await this.readIspMetricsAsync(gatewayDevice?.macAddress ?? null);
|
||||
|
||||
return {
|
||||
site: { id: site.id, name: site.name, applicationVersion: info.applicationVersion },
|
||||
wan: {
|
||||
state: gatewayDevice === null ? "unknown" : gatewayDevice.state === "ONLINE" ? "online" : "offline",
|
||||
throughput:
|
||||
statistics?.uplink === undefined
|
||||
? null
|
||||
: { txBps: statistics.uplink.txRateBps, rxBps: statistics.uplink.rxRateBps },
|
||||
isp,
|
||||
},
|
||||
clients,
|
||||
devices: devicesResult.status === "fulfilled" ? summarizeDevices(devices) : null,
|
||||
gateway:
|
||||
gatewayDevice === null
|
||||
? null
|
||||
: {
|
||||
name: gatewayDevice.name,
|
||||
model: gatewayDevice.model,
|
||||
macAddress: gatewayDevice.macAddress,
|
||||
uptimeSec: statistics?.uptimeSec ?? null,
|
||||
cpuPct: statistics?.cpuUtilizationPct ?? null,
|
||||
memoryPct: statistics?.memoryUtilizationPct ?? null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Le cloud n'est jamais interrogé sans clé configurée : le module n'est même
|
||||
* pas instancié, ce qui rend impossible un appel réseau accidentel. Il reçoit
|
||||
* `fetchCloudAsync`, jamais le transport à certificats de confiance du local.
|
||||
*/
|
||||
private async readIspMetricsAsync(gatewayMac: string | null) {
|
||||
if (!this.hasSecretValue("siteManagerApiKey")) return null;
|
||||
|
||||
const smApi = new UnifiSmApi(this.getSecretValue("siteManagerApiKey"), fetchCloudAsync);
|
||||
return await smApi.getIspMetricsAsync(gatewayMac);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { ParseError } from "@homarr/common/server";
|
||||
import type { z } from "zod/v4";
|
||||
|
||||
/**
|
||||
* Server-only: pulled out of `unifi-os-types.ts` because `ParseError` comes
|
||||
* from `@homarr/common/server`, which drags in `ioredis` and Node's `dns`.
|
||||
* `unifi-os-types.ts` is re-exported by `packages/integrations/src/types.ts`,
|
||||
* which client components import, so it must stay free of server-only code.
|
||||
*/
|
||||
export const parseUnifiOsAsync = async <TOutput>(
|
||||
value: unknown,
|
||||
schema: z.ZodType<TOutput>,
|
||||
message: string,
|
||||
): Promise<TOutput> => {
|
||||
const result = await schema.safeParseAsync(value);
|
||||
if (!result.success) {
|
||||
throw new ParseError(message, { cause: result.error });
|
||||
}
|
||||
return result.data;
|
||||
};
|
||||
@@ -0,0 +1,228 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
/**
|
||||
* Ubiquiti ne publie l'OpenAPI que pour la 10.4.57 alors que les consoles sont
|
||||
* en 10.6.x et renvoient déjà des champs absents du spec. Tous les objets sont
|
||||
* donc « loose » : un champ inattendu ne doit jamais faire échouer un widget.
|
||||
*/
|
||||
|
||||
export const unifiOsInfoSchema = z.looseObject({
|
||||
applicationVersion: z.string(),
|
||||
});
|
||||
|
||||
export const unifiOsSiteSchema = z.looseObject({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
internalReference: z.string().optional(),
|
||||
});
|
||||
|
||||
export const unifiOsClientSchema = z.looseObject({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
type: z.string(),
|
||||
access: z.looseObject({ type: z.string() }).optional(),
|
||||
});
|
||||
|
||||
export const unifiOsDeviceSchema = z.looseObject({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
model: z.string(),
|
||||
macAddress: z.string(),
|
||||
state: z.string(),
|
||||
firmwareUpdatable: z.boolean().optional(),
|
||||
features: z.array(z.string()).default([]),
|
||||
});
|
||||
|
||||
export const unifiOsDeviceStatisticsSchema = z.looseObject({
|
||||
uptimeSec: z.number().optional(),
|
||||
cpuUtilizationPct: z.number().optional(),
|
||||
memoryUtilizationPct: z.number().optional(),
|
||||
uplink: z.looseObject({ txRateBps: z.number(), rxRateBps: z.number() }).optional(),
|
||||
});
|
||||
|
||||
/** Enveloppe de pagination commune à tous les endpoints de liste. */
|
||||
export const createUnifiOsPageSchema = <TItem extends z.ZodType>(item: TItem) =>
|
||||
z.looseObject({
|
||||
offset: z.number(),
|
||||
limit: z.number(),
|
||||
count: z.number(),
|
||||
totalCount: z.number(),
|
||||
data: z.array(item),
|
||||
});
|
||||
|
||||
export const unifiSmSiteSchema = z.looseObject({
|
||||
siteId: z.string(),
|
||||
hostId: z.string().optional(),
|
||||
meta: z.looseObject({
|
||||
desc: z.string().optional(),
|
||||
name: z.string().optional(),
|
||||
gatewayMac: z.string().optional(),
|
||||
}),
|
||||
statistics: z
|
||||
.looseObject({
|
||||
ispInfo: z.looseObject({ name: z.string().optional() }).optional(),
|
||||
percentages: z.looseObject({ wanUptime: z.number().optional() }).optional(),
|
||||
wans: z.record(z.string(), z.looseObject({ externalIp: z.string().optional() })).optional(),
|
||||
})
|
||||
.optional(),
|
||||
});
|
||||
|
||||
const unifiSmWanMetricsSchema = z.looseObject({
|
||||
avgLatency: z.number().optional(),
|
||||
maxLatency: z.number().optional(),
|
||||
packetLoss: z.number().optional(),
|
||||
uptime: z.number().optional(),
|
||||
ispName: z.string().optional(),
|
||||
});
|
||||
|
||||
export const unifiSmIspMetricsResponseSchema = z.looseObject({
|
||||
data: z.looseObject({
|
||||
metrics: z.array(
|
||||
z.looseObject({
|
||||
periods: z.array(
|
||||
z.looseObject({
|
||||
metricTime: z.string(),
|
||||
data: z.looseObject({ wan: unifiSmWanMetricsSchema.optional() }),
|
||||
}),
|
||||
),
|
||||
}),
|
||||
),
|
||||
}),
|
||||
});
|
||||
|
||||
export type UnifiOsClient = z.infer<typeof unifiOsClientSchema>;
|
||||
export type UnifiOsDevice = z.infer<typeof unifiOsDeviceSchema>;
|
||||
export type UnifiOsSite = z.infer<typeof unifiOsSiteSchema>;
|
||||
export type UnifiOsDeviceStatistics = z.infer<typeof unifiOsDeviceStatisticsSchema>;
|
||||
|
||||
export interface UnifiOsClientCounts {
|
||||
total: number;
|
||||
wired: number;
|
||||
wireless: number;
|
||||
vpn: number;
|
||||
guests: number;
|
||||
}
|
||||
|
||||
export interface UnifiOsDeviceCounts {
|
||||
total: number;
|
||||
online: number;
|
||||
offline: number;
|
||||
firmwareUpdatable: number;
|
||||
}
|
||||
|
||||
export interface UnifiOsGateway {
|
||||
name: string;
|
||||
model: string;
|
||||
macAddress: string;
|
||||
uptimeSec: number | null;
|
||||
cpuPct: number | null;
|
||||
memoryPct: number | null;
|
||||
}
|
||||
|
||||
export interface UnifiOsIspMetrics {
|
||||
name: string;
|
||||
externalIp: string | null;
|
||||
/** Nullables : le cloud omet ces mesures pour les périodes où le WAN
|
||||
* était coupé. Un `0` fabriqué s'afficherait comme « 0 ms » à côté d'un
|
||||
* voyant vert, au moment précis où la connexion est tombée. */
|
||||
latencyMs: number | null;
|
||||
maxLatencyMs: number | null;
|
||||
packetLossPct: number | null;
|
||||
uptimePct: number | null;
|
||||
/** Horodatage de la mesure : la donnée cloud accuse ~27 min de retard. */
|
||||
measuredAt: Date;
|
||||
}
|
||||
|
||||
export interface UnifiOsSummary {
|
||||
site: {
|
||||
id: string;
|
||||
name: string;
|
||||
applicationVersion: string;
|
||||
};
|
||||
wan: {
|
||||
state: "online" | "offline" | "unknown";
|
||||
throughput: { txBps: number; rxBps: number } | null;
|
||||
isp: UnifiOsIspMetrics | null;
|
||||
};
|
||||
/** `null` when the call failed — never a zero count, which the UI could not
|
||||
* tell apart from a site with genuinely no clients. */
|
||||
clients: UnifiOsClientCounts | null;
|
||||
/** `null` when the call failed, for the same reason. */
|
||||
devices: UnifiOsDeviceCounts | null;
|
||||
gateway: UnifiOsGateway | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* L'API locale renvoie `aa:bb:cc:11:22:33`, le cloud peut renvoyer une autre
|
||||
* casse ou d'autres séparateurs. La comparaison se fait sur une forme canonique.
|
||||
*/
|
||||
export const normalizeMacAddress = (mac: string): string => mac.toLowerCase().replace(/[^0-9a-f]/g, "");
|
||||
|
||||
const VPN_CLIENT_TYPES = new Set(["VPN", "TELEPORT"]);
|
||||
|
||||
export const countClients = (clients: UnifiOsClient[]): UnifiOsClientCounts => {
|
||||
const counts: UnifiOsClientCounts = { total: 0, wired: 0, wireless: 0, vpn: 0, guests: 0 };
|
||||
|
||||
for (const client of clients) {
|
||||
counts.total += 1;
|
||||
|
||||
if (client.type === "WIRED") counts.wired += 1;
|
||||
else if (client.type === "WIRELESS") counts.wireless += 1;
|
||||
else if (VPN_CLIENT_TYPES.has(client.type)) counts.vpn += 1;
|
||||
|
||||
if (client.access?.type === "GUEST") counts.guests += 1;
|
||||
}
|
||||
|
||||
return counts;
|
||||
};
|
||||
|
||||
export const summarizeDevices = (devices: UnifiOsDevice[]): UnifiOsDeviceCounts => {
|
||||
const online = devices.filter((device) => device.state === "ONLINE").length;
|
||||
|
||||
return {
|
||||
total: devices.length,
|
||||
online,
|
||||
// Tout ce qui n'est pas ONLINE est indisponible du point de vue du dashboard,
|
||||
// qu'il s'agisse d'OFFLINE, d'UPDATING ou d'un état futur inconnu.
|
||||
offline: devices.length - online,
|
||||
firmwareUpdatable: devices.filter((device) => device.firmwareUpdatable === true).length,
|
||||
};
|
||||
};
|
||||
|
||||
const GATEWAY_MODEL_HINT = /gateway|udm|uxg|ucg|usg/i;
|
||||
|
||||
export const findGateway = (devices: UnifiOsDevice[]): UnifiOsDevice | null =>
|
||||
devices.find((device) => device.features.includes("gateway")) ??
|
||||
devices.find((device) => GATEWAY_MODEL_HINT.test(device.model)) ??
|
||||
null;
|
||||
|
||||
/**
|
||||
* Une clé Site Manager voit tous les sites du compte, y compris ceux d'autres
|
||||
* consoles. Sans correspondance stricte sur le MAC du gateway, on afficherait
|
||||
* la latence d'un autre réseau. Renvoyer null est le seul comportement sûr.
|
||||
*
|
||||
* `normalizeMacAddress` retire tout caractère hors `[0-9a-f]` : `""`,
|
||||
* `"unknown"` et `"null"` se réduisent donc à la même chaîne vide. Sans les deux
|
||||
* gardes ci-dessous, un gateway local sans MAC exploitable correspondrait à
|
||||
* n'importe quel site cloud dont le `gatewayMac` est un marqueur de la même
|
||||
* forme — et le widget afficherait le FAI, l'IP publique et la latence du réseau
|
||||
* d'un voisin de compte comme s'ils étaient ceux de l'utilisateur.
|
||||
*/
|
||||
export const matchCloudSite = <TSite extends { meta: { gatewayMac?: string } }>(
|
||||
gatewayMac: string | null,
|
||||
sites: TSite[],
|
||||
): TSite | null => {
|
||||
if (gatewayMac === null) return null;
|
||||
|
||||
const normalized = normalizeMacAddress(gatewayMac);
|
||||
if (normalized === "") return null;
|
||||
|
||||
return (
|
||||
sites.find((site) => {
|
||||
if (site.meta.gatewayMac === undefined) return false;
|
||||
|
||||
const candidate = normalizeMacAddress(site.meta.gatewayMac);
|
||||
return candidate !== "" && candidate === normalized;
|
||||
}) ?? null
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,139 @@
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
|
||||
import type { UnifiOsFetch } from "./unifi-os-api";
|
||||
import { parseUnifiOsAsync } from "./unifi-os-parse";
|
||||
import type { UnifiOsIspMetrics } from "./unifi-os-types";
|
||||
import { matchCloudSite, unifiSmIspMetricsResponseSchema, unifiSmSiteSchema } from "./unifi-os-types";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
const logger = createLogger({ module: "unifiSmApi" });
|
||||
|
||||
const BASE_URL = "https://api.ui.com";
|
||||
const REQUEST_TIMEOUT_MS = 15_000;
|
||||
|
||||
/**
|
||||
* Les métriques cloud sont agrégées par tranches de 5 min et publiées avec un
|
||||
* retard mesuré d'environ 27 min. Les rafraîchir plus souvent que le cache
|
||||
* n'apporterait aucune donnée nouvelle et rapprocherait d'une limitation de débit.
|
||||
*/
|
||||
const CACHE_TTL_MS = 5 * 60 * 1000;
|
||||
|
||||
/** Une fenêtre de 15 min renvoie un tableau vide sur l'API réelle ; 3 h fonctionne. */
|
||||
const QUERY_WINDOW_MS = 3 * 60 * 60 * 1000;
|
||||
|
||||
const siteListSchema = z.looseObject({ data: z.array(unifiSmSiteSchema) });
|
||||
|
||||
interface CacheEntry {
|
||||
expiresAt: number;
|
||||
value: UnifiOsIspMetrics | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Client de l'API cloud Site Manager. Entièrement optionnel : il n'est instancié
|
||||
* que lorsqu'une clé Site Manager est configurée.
|
||||
*
|
||||
* Toute erreur est absorbée et convertie en `null`. Le cloud est un enrichissement ;
|
||||
* son indisponibilité ne doit jamais faire tomber le widget.
|
||||
*/
|
||||
export class UnifiSmApi {
|
||||
private static readonly cache = new Map<string, CacheEntry>();
|
||||
|
||||
constructor(
|
||||
private readonly apiKey: string,
|
||||
private readonly fetchAsync: UnifiOsFetch,
|
||||
) {}
|
||||
|
||||
/** Réservé aux tests : le cache est statique et survivrait d'un cas à l'autre. */
|
||||
public static clearCache(): void {
|
||||
UnifiSmApi.cache.clear();
|
||||
}
|
||||
|
||||
public async getIspMetricsAsync(gatewayMac: string | null): Promise<UnifiOsIspMetrics | null> {
|
||||
if (gatewayMac === null) return null;
|
||||
|
||||
const cached = UnifiSmApi.cache.get(gatewayMac);
|
||||
if (cached !== undefined && cached.expiresAt > Date.now()) {
|
||||
return cached.value;
|
||||
}
|
||||
|
||||
let value: UnifiOsIspMetrics | null = null;
|
||||
try {
|
||||
value = await this.fetchIspMetricsAsync(gatewayMac);
|
||||
} catch (error) {
|
||||
logger.warn("Failed to read UniFi Site Manager metrics", { error });
|
||||
}
|
||||
|
||||
UnifiSmApi.cache.set(gatewayMac, { expiresAt: Date.now() + CACHE_TTL_MS, value });
|
||||
return value;
|
||||
}
|
||||
|
||||
private async fetchIspMetricsAsync(gatewayMac: string): Promise<UnifiOsIspMetrics | null> {
|
||||
const sitesResponse = await this.fetchAsync(`${BASE_URL}/v1/sites`, {
|
||||
headers: { "X-API-KEY": this.apiKey, Accept: "application/json" },
|
||||
timeout: REQUEST_TIMEOUT_MS,
|
||||
});
|
||||
|
||||
if (!sitesResponse.ok) return null;
|
||||
|
||||
const sites = await parseUnifiOsAsync(
|
||||
await sitesResponse.json(),
|
||||
siteListSchema,
|
||||
"Invalid Site Manager sites response",
|
||||
);
|
||||
const site = matchCloudSite(gatewayMac, sites.data);
|
||||
if (site === null) return null;
|
||||
|
||||
const end = new Date();
|
||||
const begin = new Date(end.getTime() - QUERY_WINDOW_MS);
|
||||
|
||||
const metricsResponse = await this.fetchAsync(`${BASE_URL}/v1/isp-metrics/5m/query`, {
|
||||
method: "POST",
|
||||
headers: { "X-API-KEY": this.apiKey, Accept: "application/json", "Content-Type": "application/json" },
|
||||
timeout: REQUEST_TIMEOUT_MS,
|
||||
body: JSON.stringify({
|
||||
sites: [
|
||||
{
|
||||
hostId: site.hostId,
|
||||
siteId: site.siteId,
|
||||
beginTimestamp: begin.toISOString(),
|
||||
endTimestamp: end.toISOString(),
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
if (!metricsResponse.ok) return null;
|
||||
|
||||
const parsed = await parseUnifiOsAsync(
|
||||
await metricsResponse.json(),
|
||||
unifiSmIspMetricsResponseSchema,
|
||||
"Invalid Site Manager ISP metrics response",
|
||||
);
|
||||
|
||||
const periods = parsed.data.metrics[0]?.periods ?? [];
|
||||
const latest = periods.reduce<(typeof periods)[number] | undefined>((newest, period) => {
|
||||
if (newest === undefined) return period;
|
||||
return new Date(period.metricTime).getTime() > new Date(newest.metricTime).getTime() ? period : newest;
|
||||
}, undefined);
|
||||
const wan = latest?.data.wan;
|
||||
if (latest === undefined || wan === undefined) return null;
|
||||
|
||||
const externalIp = Object.values(site.statistics?.wans ?? {})
|
||||
.map((entry) => entry.externalIp)
|
||||
.find((ip) => ip !== undefined);
|
||||
|
||||
// Ubiquiti omet ces mesures pour les périodes où le WAN était coupé. Les
|
||||
// remplacer par `0` fabriquerait un « 0 ms » rassurant à l'instant précis où
|
||||
// la connexion est tombée : on laisse passer `null`, que le widget affiche
|
||||
// comme une absence.
|
||||
return {
|
||||
name: wan.ispName ?? site.statistics?.ispInfo?.name ?? "",
|
||||
externalIp: externalIp ?? null,
|
||||
latencyMs: wan.avgLatency ?? null,
|
||||
maxLatencyMs: wan.maxLatency ?? null,
|
||||
packetLossPct: wan.packetLoss ?? null,
|
||||
uptimePct: wan.uptime ?? site.statistics?.percentages?.wanUptime ?? null,
|
||||
measuredAt: new Date(latest.metricTime),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
// @vitest-environment node
|
||||
/* eslint-disable @typescript-eslint/no-non-null-assertion -- Style des specs du dépôt : voir packages/integrations/test/nextcloud.spec.ts. */
|
||||
import type { RequestInit } from "undici";
|
||||
import { Response } from "undici";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
|
||||
vi.hoisted(() => {
|
||||
process.env.SKIP_ENV_VALIDATION = "true";
|
||||
process.env.SECRET_ENCRYPTION_KEY = "ff3f4f7ce30e870c9630de9e5d244ffa81101a24ed0dfe5f064beb53a7e684f1";
|
||||
});
|
||||
|
||||
import { ResponseError } from "@homarr/common/server";
|
||||
|
||||
import { MAX_SD_WAN_CONFIGS, RECENT_METRICS_WINDOW_HOURS, UnifiSiteManagerApi } from "../unifi-site-manager-api";
|
||||
|
||||
const API_KEY = "cloud-key";
|
||||
const NOW = new Date("2026-09-09T20:00:00Z");
|
||||
|
||||
const jsonResponse = (body: unknown, status = 200) =>
|
||||
new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } }) as never;
|
||||
|
||||
const createApi = (handler: (url: string, init?: RequestInit) => Promise<Response>) => {
|
||||
const fetchAsync = vi.fn(async (url: string, init?: RequestInit) => await handler(url, init));
|
||||
return { api: new UnifiSiteManagerApi(API_KEY, fetchAsync as never), fetchAsync };
|
||||
};
|
||||
|
||||
describe("UnifiSiteManagerApi request shape", () => {
|
||||
test("calls the cloud host and sends the API key", async () => {
|
||||
const { api, fetchAsync } = createApi(async () => jsonResponse({ data: [] }));
|
||||
|
||||
await api.getSitesAsync();
|
||||
|
||||
expect(fetchAsync).toHaveBeenCalledWith(
|
||||
"https://api.ui.com/v1/sites",
|
||||
expect.objectContaining({ headers: expect.objectContaining({ "X-API-KEY": API_KEY }) }),
|
||||
);
|
||||
});
|
||||
|
||||
test("throws ResponseError on a non-ok status instead of parsing the body", async () => {
|
||||
const { api } = createApi(async () => jsonResponse({ message: "unauthorized" }, 401));
|
||||
|
||||
await expect(api.getSitesAsync()).rejects.toBeInstanceOf(ResponseError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("UnifiSiteManagerApi getSdWanStatusesAsync", () => {
|
||||
const emptyStatus = { data: { status: { connections: [], sites: [] } } };
|
||||
|
||||
test("fetches the status of every configuration it lists", async () => {
|
||||
const requested: string[] = [];
|
||||
const { api } = createApi(async (url) => {
|
||||
requested.push(url);
|
||||
if (url.endsWith("/v1/sd-wan-configs")) {
|
||||
return jsonResponse({
|
||||
data: [
|
||||
{ id: "c1", name: "First" },
|
||||
{ id: "c2", name: "Second" },
|
||||
],
|
||||
});
|
||||
}
|
||||
return jsonResponse(emptyStatus);
|
||||
});
|
||||
|
||||
const statuses = await api.getSdWanStatusesAsync();
|
||||
|
||||
expect(statuses.map((entry) => entry.configName)).toEqual(["First", "Second"]);
|
||||
expect(requested).toContain("https://api.ui.com/v1/sd-wan-configs/c1/status");
|
||||
expect(requested).toContain("https://api.ui.com/v1/sd-wan-configs/c2/status");
|
||||
});
|
||||
|
||||
/** Sans borne, un compte à dix configurations produirait onze requêtes par cycle. */
|
||||
test("stops after MAX_SD_WAN_CONFIGS configurations", async () => {
|
||||
const { api, fetchAsync } = createApi(async (url) =>
|
||||
url.endsWith("/v1/sd-wan-configs")
|
||||
? jsonResponse({
|
||||
data: Array.from({ length: 9 }, (_, index) => ({ id: `c${index}`, name: `Config ${index}` })),
|
||||
})
|
||||
: jsonResponse(emptyStatus),
|
||||
);
|
||||
|
||||
const statuses = await api.getSdWanStatusesAsync();
|
||||
|
||||
expect(statuses).toHaveLength(MAX_SD_WAN_CONFIGS);
|
||||
expect(fetchAsync).toHaveBeenCalledTimes(MAX_SD_WAN_CONFIGS + 1);
|
||||
});
|
||||
|
||||
test("skips a configuration whose status call fails rather than losing the others", async () => {
|
||||
const { api } = createApi(async (url) => {
|
||||
if (url.endsWith("/v1/sd-wan-configs")) {
|
||||
return jsonResponse({
|
||||
data: [
|
||||
{ id: "c1", name: "First" },
|
||||
{ id: "c2", name: "Second" },
|
||||
],
|
||||
});
|
||||
}
|
||||
return url.includes("/c1/") ? jsonResponse({}, 500) : jsonResponse(emptyStatus);
|
||||
});
|
||||
|
||||
const statuses = await api.getSdWanStatusesAsync();
|
||||
|
||||
expect(statuses.map((entry) => entry.configName)).toEqual(["Second"]);
|
||||
});
|
||||
|
||||
test("returns an empty list when the account has no configuration", async () => {
|
||||
const { api } = createApi(async () => jsonResponse({ data: [] }));
|
||||
|
||||
expect(await api.getSdWanStatusesAsync()).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
interface IspMetricsRequestBody {
|
||||
sites: { siteId: string; hostId?: string; beginTimestamp: string; endTimestamp: string }[];
|
||||
}
|
||||
|
||||
/** Les corps envoyés, indexés par granularité — l'URL seule ne dit rien de la fenêtre. */
|
||||
const collectIspMetricsRequests = (handler?: (granularity: string) => Promise<Response>) => {
|
||||
const bodies = new Map<string, IspMetricsRequestBody>();
|
||||
const { api, fetchAsync } = createApi(async (url, init) => {
|
||||
const granularity = url.includes("/5m/") ? "5m" : "1h";
|
||||
bodies.set(granularity, JSON.parse(String(init?.body)) as IspMetricsRequestBody);
|
||||
return handler === undefined ? jsonResponse({ data: { metrics: [] } }) : await handler(granularity);
|
||||
});
|
||||
return { api, fetchAsync, bodies };
|
||||
};
|
||||
|
||||
const spanHoursOf = (site: { beginTimestamp: string; endTimestamp: string }) =>
|
||||
(new Date(site.endTimestamp).getTime() - new Date(site.beginTimestamp).getTime()) / 3_600_000;
|
||||
|
||||
const metricsEntry = (siteId: string, metricTimes: string[]) => ({
|
||||
siteId,
|
||||
periods: metricTimes.map((metricTime) => ({ metricTime, data: { wan: { downtime: 0, packetLoss: 0 } } })),
|
||||
});
|
||||
|
||||
describe("UnifiSiteManagerApi getIspMetricsAsync", () => {
|
||||
test("asks for every site in a single bounded query", async () => {
|
||||
const { api, bodies } = collectIspMetricsRequests();
|
||||
|
||||
await api.getIspMetricsAsync(
|
||||
[
|
||||
{ siteId: "a", hostId: "h1" },
|
||||
{ siteId: "b", hostId: "h2" },
|
||||
],
|
||||
NOW,
|
||||
);
|
||||
|
||||
const hourly = bodies.get("1h");
|
||||
expect(hourly!.sites).toHaveLength(2);
|
||||
expect(spanHoursOf(hourly!.sites[0]!)).toBeGreaterThanOrEqual(24);
|
||||
});
|
||||
|
||||
test("returns an empty list rather than calling the cloud when there is no site", async () => {
|
||||
const { api, fetchAsync } = createApi(async () => jsonResponse({ data: { metrics: [] } }));
|
||||
|
||||
expect(await api.getIspMetricsAsync([], NOW)).toEqual([]);
|
||||
expect(fetchAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
/**
|
||||
* La granularité horaire est en retard de près de quatre heures sur le compte
|
||||
* réel : la requête à 5 minutes remplit ces heures-là, et rien qu'elles.
|
||||
* Vérifier les CORPS et pas seulement les chemins — deux fenêtres identiques
|
||||
* passeraient un test qui ne regarde que l'URL.
|
||||
*/
|
||||
test("queries both granularities, the five-minute one only over the recent hours", async () => {
|
||||
const { api, fetchAsync, bodies } = collectIspMetricsRequests();
|
||||
|
||||
await api.getIspMetricsAsync([{ siteId: "a", hostId: "h1" }], NOW);
|
||||
|
||||
const requested = fetchAsync.mock.calls.map((call) => String(call[0]));
|
||||
expect(requested).toContain("https://api.ui.com/v1/isp-metrics/1h/query");
|
||||
expect(requested).toContain("https://api.ui.com/v1/isp-metrics/5m/query");
|
||||
|
||||
const hourly = bodies.get("1h")!.sites[0]!;
|
||||
const recent = bodies.get("5m")!.sites[0]!;
|
||||
expect(spanHoursOf(hourly)).toBe(24);
|
||||
expect(spanHoursOf(recent)).toBe(RECENT_METRICS_WINDOW_HOURS);
|
||||
// Même bord droit : les deux fenêtres s'arrêtent à l'instant demandé.
|
||||
expect(recent.endTimestamp).toBe(hourly.endTimestamp);
|
||||
expect(new Date(recent.beginTimestamp).getTime()).toBeGreaterThan(new Date(hourly.beginTimestamp).getTime());
|
||||
// Le site est demandé à l'identique aux deux granularités.
|
||||
expect(recent.siteId).toBe("a");
|
||||
expect(recent.hostId).toBe("h1");
|
||||
});
|
||||
|
||||
test("issues the two queries concurrently rather than one after the other", async () => {
|
||||
const started: string[] = [];
|
||||
let releaseHourly: (() => void) | undefined;
|
||||
const { api } = collectIspMetricsRequests(async (granularity) => {
|
||||
started.push(granularity);
|
||||
// La requête horaire ne rend la main qu'une fois la seconde partie : en
|
||||
// séquence, ce test se bloquerait au lieu de passer.
|
||||
if (granularity === "1h") {
|
||||
await new Promise<void>((resolve) => {
|
||||
releaseHourly = resolve;
|
||||
});
|
||||
} else {
|
||||
releaseHourly?.();
|
||||
}
|
||||
return jsonResponse({ data: { metrics: [] } });
|
||||
});
|
||||
|
||||
await api.getIspMetricsAsync([{ siteId: "a" }], NOW);
|
||||
|
||||
expect(started).toEqual(["1h", "5m"]);
|
||||
});
|
||||
|
||||
test("merges the periods of both granularities into one entry per site", async () => {
|
||||
const { api } = collectIspMetricsRequests(async (granularity) =>
|
||||
jsonResponse({
|
||||
data: {
|
||||
metrics: [
|
||||
granularity === "1h"
|
||||
? metricsEntry("a", ["2026-09-09T17:00:00Z", "2026-09-09T18:00:00Z"])
|
||||
: metricsEntry("a", ["2026-09-09T19:30:00Z", "2026-09-09T19:35:00Z"]),
|
||||
],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const metrics = await api.getIspMetricsAsync([{ siteId: "a" }], NOW);
|
||||
|
||||
expect(metrics).toHaveLength(1);
|
||||
expect(metrics[0]!.periods.map((period) => period.metricTime)).toEqual([
|
||||
"2026-09-09T17:00:00Z",
|
||||
"2026-09-09T18:00:00Z",
|
||||
"2026-09-09T19:30:00Z",
|
||||
"2026-09-09T19:35:00Z",
|
||||
]);
|
||||
});
|
||||
|
||||
test("keeps a site reported by only one of the two granularities", async () => {
|
||||
const { api } = collectIspMetricsRequests(async (granularity) =>
|
||||
jsonResponse({
|
||||
data: {
|
||||
metrics:
|
||||
granularity === "1h"
|
||||
? [metricsEntry("only-hourly", ["2026-09-09T17:00:00Z"])]
|
||||
: [metricsEntry("only-recent", ["2026-09-09T19:30:00Z"])],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const metrics = await api.getIspMetricsAsync([{ siteId: "only-hourly" }, { siteId: "only-recent" }], NOW);
|
||||
|
||||
expect(metrics.map((metric) => metric.siteId).toSorted()).toEqual(["only-hourly", "only-recent"]);
|
||||
expect(metrics.find((metric) => metric.siteId === "only-recent")!.periods).toHaveLength(1);
|
||||
});
|
||||
|
||||
/** La fraîcheur est un enrichissement : la perdre ne doit pas emporter la bande. */
|
||||
test("still returns the hourly window when the five-minute query fails", async () => {
|
||||
const { api } = collectIspMetricsRequests(async (granularity) =>
|
||||
granularity === "5m"
|
||||
? jsonResponse({ message: "rate limited" }, 429)
|
||||
: jsonResponse({ data: { metrics: [metricsEntry("a", ["2026-09-09T18:00:00Z"])] } }),
|
||||
);
|
||||
|
||||
const metrics = await api.getIspMetricsAsync([{ siteId: "a" }], NOW);
|
||||
|
||||
expect(metrics.map((metric) => metric.siteId)).toEqual(["a"]);
|
||||
expect(metrics[0]!.periods).toHaveLength(1);
|
||||
});
|
||||
|
||||
/** L'inverse n'est pas vrai : sans la fenêtre horaire, il n'y a pas de bande. */
|
||||
test("throws when the hourly query fails, even if the five-minute one succeeded", async () => {
|
||||
const { api } = collectIspMetricsRequests(async (granularity) =>
|
||||
granularity === "1h"
|
||||
? jsonResponse({ message: "unauthorized" }, 401)
|
||||
: jsonResponse({ data: { metrics: [metricsEntry("a", ["2026-09-09T19:30:00Z"])] } }),
|
||||
);
|
||||
|
||||
await expect(api.getIspMetricsAsync([{ siteId: "a" }], NOW)).rejects.toBeInstanceOf(ResponseError);
|
||||
});
|
||||
});
|
||||
+191
@@ -0,0 +1,191 @@
|
||||
// @vitest-environment node
|
||||
/* eslint-disable @typescript-eslint/no-non-null-assertion -- Style des specs du dépôt : voir packages/integrations/test/nextcloud.spec.ts. */
|
||||
import { Response } from "undici";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
vi.hoisted(() => {
|
||||
process.env.SKIP_ENV_VALIDATION = "true";
|
||||
process.env.SECRET_ENCRYPTION_KEY = "ff3f4f7ce30e870c9630de9e5d244ffa81101a24ed0dfe5f064beb53a7e684f1";
|
||||
});
|
||||
|
||||
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
|
||||
import * as undici from "undici";
|
||||
|
||||
import type { IntegrationTestingInput } from "../../base/integration";
|
||||
import type { IntegrationSecret } from "../../base/types";
|
||||
import { UnifiSiteManagerIntegration } from "../unifi-site-manager-integration";
|
||||
|
||||
vi.mock("undici", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof undici>()),
|
||||
fetch: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mocked separately from `undici` on purpose: `fetchWithTrustedCertificatesAsync`
|
||||
// also calls undici's `fetch` underneath, so mocking `undici` alone would
|
||||
// intercept both transports and hide a regression that routes the Site
|
||||
// Manager key through the trusted-certificate (admin CA bundle) transport.
|
||||
vi.mock("@homarr/core/infrastructure/http", () => ({
|
||||
fetchWithTrustedCertificatesAsync: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockFetch = vi.mocked(undici.fetch);
|
||||
const mockTrustedFetch = vi.mocked(fetchWithTrustedCertificatesAsync);
|
||||
|
||||
const jsonResponse = (body: unknown, status = 200) =>
|
||||
new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } }) as never;
|
||||
|
||||
const SITES = {
|
||||
data: [
|
||||
{
|
||||
siteId: "site-1",
|
||||
hostId: "host-1",
|
||||
meta: { desc: "Example site" },
|
||||
statistics: {
|
||||
percentages: { wanUptime: 99.5 },
|
||||
ispInfo: { name: "Example ISP" },
|
||||
wans: { WAN: { externalIp: "203.0.113.10" } },
|
||||
counts: { totalDevice: 6, offlineDevice: 0, pendingUpdateDevice: 1 },
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const HOSTS = { data: [{ id: "host-1", reportedState: { deviceState: "updateAvailable" } }] };
|
||||
const METRICS = { data: { metrics: [{ siteId: "site-1", periods: [] }] } };
|
||||
const SD_WAN_CONFIGS = { data: [{ id: "config-1", name: "Example mesh" }] };
|
||||
const SD_WAN_STATUS = {
|
||||
data: {
|
||||
status: {
|
||||
connections: [
|
||||
{ sites: [{ siteId: "site-1", status: { connected: true, offline: false, ping: 12, errors: [] } }] },
|
||||
],
|
||||
sites: [{ siteId: "site-1", errors: [], warnings: [] }],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const route = (url: string) => {
|
||||
if (url.endsWith("/v1/sites")) return jsonResponse(SITES);
|
||||
if (url.endsWith("/v1/hosts")) return jsonResponse(HOSTS);
|
||||
if (url.includes("isp-metrics")) return jsonResponse(METRICS);
|
||||
if (url.endsWith("/v1/sd-wan-configs")) return jsonResponse(SD_WAN_CONFIGS);
|
||||
if (url.includes("/sd-wan-configs/")) return jsonResponse(SD_WAN_STATUS);
|
||||
return jsonResponse({}, 404);
|
||||
};
|
||||
|
||||
const createIntegration = () =>
|
||||
new UnifiSiteManagerIntegration({
|
||||
id: "test-site-manager",
|
||||
name: "Test Site Manager",
|
||||
url: "https://api.ui.com",
|
||||
externalUrl: null,
|
||||
decryptedSecrets: [{ kind: "siteManagerApiKey", value: "cloud-key" }] as IntegrationSecret[],
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
mockFetch.mockReset();
|
||||
mockTrustedFetch.mockReset();
|
||||
});
|
||||
|
||||
describe("UnifiSiteManagerIntegration getSiteOverviewAsync", () => {
|
||||
test("builds one entry per site", async () => {
|
||||
mockFetch.mockImplementation(async (url) => route(String(url)));
|
||||
|
||||
const overview = await createIntegration().getSiteOverviewAsync();
|
||||
|
||||
expect(overview.sites).toHaveLength(1);
|
||||
expect(overview.sites[0]).toMatchObject({
|
||||
siteId: "site-1",
|
||||
name: "Example site",
|
||||
ispName: "Example ISP",
|
||||
consoleUpdateAvailable: true,
|
||||
});
|
||||
expect(overview.sites[0]!.sdWan).toMatchObject({ configName: "Example mesh", connected: true, pingMs: 12 });
|
||||
});
|
||||
|
||||
/** /v1/sites porte la liste : sans elle il n'y a rien à afficher, pas « aucun site ». */
|
||||
test("propagates a failure of the sites call", async () => {
|
||||
mockFetch.mockImplementation(async (url) =>
|
||||
String(url).endsWith("/v1/sites") ? jsonResponse({}, 500) : route(String(url)),
|
||||
);
|
||||
|
||||
await expect(createIntegration().getSiteOverviewAsync()).rejects.toThrow();
|
||||
});
|
||||
|
||||
test("keeps the sites when the hosts call fails, with console state unknown", async () => {
|
||||
mockFetch.mockImplementation(async (url) =>
|
||||
String(url).endsWith("/v1/hosts") ? jsonResponse({}, 500) : route(String(url)),
|
||||
);
|
||||
|
||||
const overview = await createIntegration().getSiteOverviewAsync();
|
||||
|
||||
expect(overview.sites).toHaveLength(1);
|
||||
expect(overview.sites[0]!.consoleUpdateAvailable).toBeNull();
|
||||
});
|
||||
|
||||
test("keeps the sites when the metrics call fails, with the window unknown", async () => {
|
||||
mockFetch.mockImplementation(async (url) =>
|
||||
String(url).includes("isp-metrics") ? jsonResponse({}, 500) : route(String(url)),
|
||||
);
|
||||
|
||||
const overview = await createIntegration().getSiteOverviewAsync();
|
||||
|
||||
expect(overview.sites).toHaveLength(1);
|
||||
expect(overview.sites[0]!.summary.unknown).toBe(24);
|
||||
});
|
||||
|
||||
test("keeps the sites when the SD-WAN calls fail, with sdWan null", async () => {
|
||||
mockFetch.mockImplementation(async (url) =>
|
||||
String(url).includes("sd-wan") ? jsonResponse({}, 500) : route(String(url)),
|
||||
);
|
||||
|
||||
const overview = await createIntegration().getSiteOverviewAsync();
|
||||
|
||||
expect(overview.sites).toHaveLength(1);
|
||||
expect(overview.sites[0]!.sdWan).toBeNull();
|
||||
});
|
||||
|
||||
test("returns an empty site list for an account with no sites, without erroring", async () => {
|
||||
mockFetch.mockImplementation(async (url) =>
|
||||
String(url).endsWith("/v1/sites") ? jsonResponse({ data: [] }) : route(String(url)),
|
||||
);
|
||||
|
||||
await expect(createIntegration().getSiteOverviewAsync()).resolves.toEqual({ sites: [] });
|
||||
});
|
||||
});
|
||||
|
||||
describe("UnifiSiteManagerIntegration cloud-only transport", () => {
|
||||
type TestingFn = (input: IntegrationTestingInput) => Promise<{ success: boolean }>;
|
||||
|
||||
/**
|
||||
* `testingAsync` is protected; this bypasses that at runtime the same way
|
||||
* the wud/gluetun specs do, so the test can call it directly instead of
|
||||
* going through the DB-backed certificate lookup in `testConnectionAsync`.
|
||||
*/
|
||||
const invokeTesting = (integration: UnifiSiteManagerIntegration, input: IntegrationTestingInput) => {
|
||||
const testing = (integration as unknown as { testingAsync: TestingFn }).testingAsync.bind(integration);
|
||||
return testing(input);
|
||||
};
|
||||
|
||||
/**
|
||||
* Regression guard for the vulnerability this integration exists to avoid:
|
||||
* if a future edit makes `testingAsync` destructure and use `fetchAsync`
|
||||
* from its input (as the sibling `unifi-os` integration legitimately does),
|
||||
* that `fetchAsync` is built from Homarr's trusted-certificate transport —
|
||||
* the one that trusts every admin-uploaded CA — and this test catches it.
|
||||
*/
|
||||
test("never routes the Site Manager key through the trusted-certificate transport", async () => {
|
||||
mockFetch.mockImplementation(async (url) => route(String(url)));
|
||||
|
||||
await createIntegration().getSiteOverviewAsync();
|
||||
|
||||
// Stand-in for the real `input.fetchAsync`, which in production is built
|
||||
// on top of `fetchWithTrustedCertificatesAsync`. If `testingAsync` used
|
||||
// it, this mock would record the call.
|
||||
const input = { fetchAsync: mockTrustedFetch } as unknown as IntegrationTestingInput;
|
||||
const result = await invokeTesting(createIntegration(), input);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(mockTrustedFetch).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,412 @@
|
||||
// @vitest-environment node
|
||||
/* eslint-disable @typescript-eslint/no-non-null-assertion -- Style des specs du dépôt : voir packages/integrations/test/nextcloud.spec.ts. */
|
||||
import { readFileSync } from "node:fs";
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import {
|
||||
buildHealthWindow,
|
||||
buildSdWanIndex,
|
||||
deriveHealthState,
|
||||
HEALTH_WINDOW_HOURS,
|
||||
mapSites,
|
||||
resolveHealthWindowEnd,
|
||||
summariseHealth,
|
||||
} from "../unifi-site-manager-types";
|
||||
|
||||
const hoursBefore = (reference: Date, hours: number) => new Date(reference.getTime() - hours * 3_600_000);
|
||||
|
||||
const NOW = new Date("2026-09-09T20:00:00Z");
|
||||
|
||||
const period = (at: Date, wan: Record<string, number>) => ({
|
||||
metricTime: at.toISOString(),
|
||||
data: { wan },
|
||||
});
|
||||
|
||||
describe("deriveHealthState", () => {
|
||||
test("ranks an outage above packet loss", () => {
|
||||
expect(deriveHealthState({ downtime: 60, packetLoss: 5 })).toBe("down");
|
||||
});
|
||||
|
||||
test("reports packet loss as degraded", () => {
|
||||
expect(deriveHealthState({ downtime: 0, packetLoss: 2 })).toBe("degraded");
|
||||
});
|
||||
|
||||
test("reports a clean period as healthy", () => {
|
||||
expect(deriveHealthState({ downtime: 0, packetLoss: 0 })).toBe("healthy");
|
||||
});
|
||||
|
||||
/** Champs absents : le cloud les omet parfois. Absent n'est pas zéro. */
|
||||
test("treats a period with no measurements as unknown", () => {
|
||||
expect(deriveHealthState({})).toBe("unknown");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildHealthWindow", () => {
|
||||
test("always returns one segment per hour of the window", () => {
|
||||
expect(buildHealthWindow([], NOW)).toHaveLength(HEALTH_WINDOW_HOURS);
|
||||
});
|
||||
|
||||
/**
|
||||
* Le cas central. Une fenêtre de 24 h dont le cloud ne rapporte que 16 heures
|
||||
* a été observée sur l'API réelle : les trous sont au milieu, pas seulement au
|
||||
* début. Les peindre en vert affirmerait un bon fonctionnement jamais mesuré.
|
||||
*/
|
||||
test("fills hours the cloud did not report with unknown, never healthy", () => {
|
||||
const window = buildHealthWindow([period(hoursBefore(NOW, 2), { downtime: 0, packetLoss: 0 })], NOW);
|
||||
|
||||
expect(window.filter((segment) => segment.state === "healthy")).toHaveLength(1);
|
||||
expect(window.filter((segment) => segment.state === "unknown")).toHaveLength(HEALTH_WINDOW_HOURS - 1);
|
||||
});
|
||||
|
||||
test("orders segments oldest first", () => {
|
||||
const window = buildHealthWindow([], NOW);
|
||||
|
||||
expect(window.at(0)!.at.getTime()).toBeLessThan(window.at(-1)!.at.getTime());
|
||||
});
|
||||
|
||||
test("ignores periods that fall outside the window", () => {
|
||||
const window = buildHealthWindow([period(hoursBefore(NOW, 100), { downtime: 0, packetLoss: 0 })], NOW);
|
||||
|
||||
expect(window.every((segment) => segment.state === "unknown")).toBe(true);
|
||||
});
|
||||
|
||||
test("keeps the worst state when several readings share an hour", () => {
|
||||
const at = hoursBefore(NOW, 3);
|
||||
const window = buildHealthWindow(
|
||||
[period(at, { downtime: 0, packetLoss: 0 }), period(at, { downtime: 30, packetLoss: 0 })],
|
||||
NOW,
|
||||
);
|
||||
|
||||
expect(window.filter((segment) => segment.state === "down")).toHaveLength(1);
|
||||
});
|
||||
|
||||
/**
|
||||
* Ce qu'affirme la fusion des deux granularités du côté de l'API : les points
|
||||
* de 5 minutes n'ont besoin d'aucun traitement propre. Douze points d'une même
|
||||
* heure se replient sur UNE tranche, et le pire l'emporte — une coupure de
|
||||
* cinq minutes ne se dilue pas dans onze relevés sains.
|
||||
*/
|
||||
test("collapses five-minute points onto the hour they belong to, worst first", () => {
|
||||
const hour = hoursBefore(NOW, 1);
|
||||
const fiveMinutePoints = Array.from({ length: 12 }, (_, index) =>
|
||||
period(
|
||||
new Date(hour.getTime() + index * 300_000),
|
||||
index === 7 ? { downtime: 30 } : { downtime: 0, packetLoss: 0 },
|
||||
),
|
||||
);
|
||||
|
||||
const window = buildHealthWindow(fiveMinutePoints, NOW);
|
||||
|
||||
expect(window.filter((segment) => segment.state !== "unknown")).toHaveLength(1);
|
||||
expect(window.filter((segment) => segment.state === "down")).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Les métriques ISP du cloud arrivent avec 25 à 35 minutes de retard (mesuré sur
|
||||
* le compte réel) : l'heure courante n'est pas encore mesurée. Une fenêtre
|
||||
* finissant sur elle réservait donc sa dernière tranche à du gris permanent
|
||||
* pendant la première moitié de chaque heure.
|
||||
*/
|
||||
describe("resolveHealthWindowEnd", () => {
|
||||
test("ends on the freshest reported period instead of the wall clock", () => {
|
||||
const freshest = hoursBefore(NOW, 1);
|
||||
const end = resolveHealthWindowEnd(
|
||||
[{ siteId: "site-1", periods: [period(hoursBefore(NOW, 3), {}), period(freshest, {})] }],
|
||||
NOW,
|
||||
);
|
||||
|
||||
expect(end.getTime()).toBe(freshest.getTime());
|
||||
});
|
||||
|
||||
/**
|
||||
* Le maximum est GLOBAL. Les lignes du tableau se lisent heure contre heure :
|
||||
* une fin propre à chaque site ferait glisser sa fenêtre par rapport à celles
|
||||
* de ses voisines, et la colonne cesserait de désigner la même heure.
|
||||
*/
|
||||
test("takes the maximum across every site rather than per site", () => {
|
||||
const freshest = hoursBefore(NOW, 1);
|
||||
const end = resolveHealthWindowEnd(
|
||||
[
|
||||
{ siteId: "site-1", periods: [period(hoursBefore(NOW, 6), {})] },
|
||||
{ siteId: "site-2", periods: [period(freshest, {})] },
|
||||
],
|
||||
NOW,
|
||||
);
|
||||
|
||||
expect(end.getTime()).toBe(freshest.getTime());
|
||||
});
|
||||
|
||||
test("falls back to the current time when no site reported any period", () => {
|
||||
expect(resolveHealthWindowEnd([], NOW)).toBe(NOW);
|
||||
expect(resolveHealthWindowEnd([{ siteId: "site-1", periods: [] }], NOW)).toBe(NOW);
|
||||
});
|
||||
});
|
||||
|
||||
describe("summariseHealth", () => {
|
||||
test("counts hours per state so the bar is readable without colour", () => {
|
||||
const summary = summariseHealth([
|
||||
{ at: NOW, state: "healthy" },
|
||||
{ at: NOW, state: "healthy" },
|
||||
{ at: NOW, state: "degraded" },
|
||||
{ at: NOW, state: "unknown" },
|
||||
]);
|
||||
|
||||
expect(summary).toEqual({ healthy: 2, degraded: 1, down: 0, unknown: 1 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildSdWanIndex", () => {
|
||||
const status = (siteId: string, overrides: Record<string, unknown> = {}) => ({
|
||||
status: {
|
||||
connections: [
|
||||
{ sites: [{ siteId, status: { connected: true, offline: false, ping: 0, errors: [], ...overrides } }] },
|
||||
],
|
||||
sites: [{ siteId, errors: [] as unknown[], warnings: [] as string[] }],
|
||||
},
|
||||
});
|
||||
|
||||
test("indexes a site by its identifier", () => {
|
||||
const index = buildSdWanIndex([{ configName: "Mesh", status: status("site-1").status }]);
|
||||
|
||||
expect(index.get("site-1")).toMatchObject({ configName: "Mesh", connected: true, offline: false });
|
||||
});
|
||||
|
||||
/**
|
||||
* Relevé sur un tunnel connecté entre deux sites distants : `ping: 0`. Une
|
||||
* latence nulle y est physiquement impossible — le champ veut dire « non
|
||||
* mesuré », et propager le zéro l'afficherait comme une performance.
|
||||
*/
|
||||
test("reads a ping of zero as unmeasured, not as zero milliseconds", () => {
|
||||
const index = buildSdWanIndex([{ configName: "Mesh", status: status("site-1").status }]);
|
||||
|
||||
expect(index.get("site-1")!.pingMs).toBeNull();
|
||||
});
|
||||
|
||||
test("keeps a measured ping", () => {
|
||||
const index = buildSdWanIndex([{ configName: "Mesh", status: status("site-1", { ping: 12 }).status }]);
|
||||
|
||||
expect(index.get("site-1")!.pingMs).toBe(12);
|
||||
});
|
||||
|
||||
test("counts errors and warnings", () => {
|
||||
const raw = status("site-1", { errors: ["a", "b"] }).status;
|
||||
raw.sites[0]!.warnings = ["w"];
|
||||
|
||||
const index = buildSdWanIndex([{ configName: "Mesh", status: raw }]);
|
||||
|
||||
expect(index.get("site-1")).toMatchObject({ errorCount: 2, warningCount: 1 });
|
||||
});
|
||||
|
||||
test("keeps the first configuration when a site appears in several", () => {
|
||||
const index = buildSdWanIndex([
|
||||
{ configName: "First", status: status("site-1").status },
|
||||
{ configName: "Second", status: status("site-1").status },
|
||||
]);
|
||||
|
||||
expect(index.get("site-1")!.configName).toBe("First");
|
||||
});
|
||||
|
||||
test("leaves a site absent from every configuration out of the index", () => {
|
||||
expect(buildSdWanIndex([]).get("site-1")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("mapSites", () => {
|
||||
const site = {
|
||||
siteId: "site-1",
|
||||
hostId: "host-1",
|
||||
meta: { desc: "Example site", name: "default" },
|
||||
statistics: {
|
||||
percentages: { wanUptime: 99.5 },
|
||||
ispInfo: { name: "Example ISP" },
|
||||
wans: { WAN: { externalIp: "203.0.113.10" }, WAN2: { externalIp: "203.0.113.11" } },
|
||||
counts: { totalDevice: 6, offlineDevice: 1, pendingUpdateDevice: 3 },
|
||||
},
|
||||
};
|
||||
|
||||
const host = { id: "host-1", reportedState: { deviceState: "updateAvailable" } };
|
||||
|
||||
test("maps a complete site", () => {
|
||||
const [mapped] = mapSites([site], [host], [], new Map(), NOW);
|
||||
|
||||
expect(mapped).toMatchObject({
|
||||
siteId: "site-1",
|
||||
name: "Example site",
|
||||
wanUptimePct: 99.5,
|
||||
ispName: "Example ISP",
|
||||
externalIps: ["203.0.113.10", "203.0.113.11"],
|
||||
deviceCount: 6,
|
||||
offlineDeviceCount: 1,
|
||||
pendingUpdateCount: 3,
|
||||
consoleUpdateAvailable: true,
|
||||
});
|
||||
});
|
||||
|
||||
test("falls back to meta.name when there is no description", () => {
|
||||
const [mapped] = mapSites([{ ...site, meta: { name: "default" } }], [], [], new Map(), NOW);
|
||||
|
||||
expect(mapped!.name).toBe("default");
|
||||
});
|
||||
|
||||
/**
|
||||
* `null` veut dire « aucun host ne correspond », ce qui n'est pas « la console
|
||||
* est à jour ». Le widget doit pouvoir les distinguer.
|
||||
*/
|
||||
test("leaves consoleUpdateAvailable null when no host matches", () => {
|
||||
const [mapped] = mapSites([site], [], [], new Map(), NOW);
|
||||
|
||||
expect(mapped!.consoleUpdateAvailable).toBeNull();
|
||||
});
|
||||
|
||||
/**
|
||||
* Ce test affirmait `false`. `deviceState` n'est pas un booléen : le compte
|
||||
* réel renvoie `"setup"`, `"updateAvailable"`, et Ubiquiti en ajoutera
|
||||
* d'autres. Tout ce qui n'est pas la chaîne magique tombait donc sur `false`,
|
||||
* que le widget affiche « à jour » — une affirmation sur le firmware tirée
|
||||
* d'un état qui n'en parle pas.
|
||||
*/
|
||||
test("treats a device state that says nothing about firmware as unknown, not as up to date", () => {
|
||||
const [mapped] = mapSites(
|
||||
[site],
|
||||
[{ id: "host-1", reportedState: { deviceState: "connected" } }],
|
||||
[],
|
||||
new Map(),
|
||||
NOW,
|
||||
);
|
||||
|
||||
expect(mapped!.consoleUpdateAvailable).toBeNull();
|
||||
});
|
||||
|
||||
/** Le cloud se prononce : aucune version disponible. C'est le seul « à jour ». */
|
||||
test("reports a console as up to date when the cloud states no available version", () => {
|
||||
const [mapped] = mapSites(
|
||||
[site],
|
||||
[{ id: "host-1", reportedState: { deviceState: "setup", firmwareUpdate: { latestAvailableVersion: null } } }],
|
||||
[],
|
||||
new Map(),
|
||||
NOW,
|
||||
);
|
||||
|
||||
expect(mapped!.consoleUpdateAvailable).toBe(false);
|
||||
});
|
||||
|
||||
test("reports an available version as an update even without a device state", () => {
|
||||
const [mapped] = mapSites(
|
||||
[site],
|
||||
[{ id: "host-1", reportedState: { firmwareUpdate: { latestAvailableVersion: "9.9.9" } } }],
|
||||
[],
|
||||
new Map(),
|
||||
NOW,
|
||||
);
|
||||
|
||||
expect(mapped!.consoleUpdateAvailable).toBe(true);
|
||||
});
|
||||
|
||||
/**
|
||||
* Le piège d'origine : `undefined === "updateAvailable"` vaut `false`, donc une
|
||||
* absence totale d'information s'affichait comme une console à jour.
|
||||
*/
|
||||
test("leaves consoleUpdateAvailable null when the host reports neither field", () => {
|
||||
const [mapped] = mapSites([site], [{ id: "host-1", reportedState: {} }], [], new Map(), NOW);
|
||||
|
||||
expect(mapped!.consoleUpdateAvailable).toBeNull();
|
||||
});
|
||||
|
||||
test("yields an empty list for an account with no sites", () => {
|
||||
expect(mapSites([], [], [], new Map(), NOW)).toEqual([]);
|
||||
});
|
||||
|
||||
test("attaches the health window of the matching metrics entry", () => {
|
||||
const [mapped] = mapSites(
|
||||
[site],
|
||||
[],
|
||||
[{ siteId: "site-1", periods: [period(hoursBefore(NOW, 1), { downtime: 0, packetLoss: 0 })] }],
|
||||
new Map(),
|
||||
NOW,
|
||||
);
|
||||
|
||||
expect(mapped!.health).toHaveLength(HEALTH_WINDOW_HOURS);
|
||||
expect(mapped!.summary.healthy).toBe(1);
|
||||
expect(mapped!.latencyMs).toBeNull();
|
||||
});
|
||||
|
||||
test("takes latency and loss from the most recent period", () => {
|
||||
const [mapped] = mapSites(
|
||||
[site],
|
||||
[],
|
||||
[
|
||||
{
|
||||
siteId: "site-1",
|
||||
periods: [
|
||||
period(hoursBefore(NOW, 5), { downtime: 0, packetLoss: 9, avgLatency: 99 }),
|
||||
period(hoursBefore(NOW, 1), { downtime: 0, packetLoss: 0, avgLatency: 11 }),
|
||||
],
|
||||
},
|
||||
],
|
||||
new Map(),
|
||||
NOW,
|
||||
);
|
||||
|
||||
expect(mapped!.latencyMs).toBe(11);
|
||||
expect(mapped!.packetLossPct).toBe(0);
|
||||
});
|
||||
|
||||
test("ends the health window on the freshest reported hour, not on the wall clock", () => {
|
||||
const freshest = hoursBefore(NOW, 1);
|
||||
const metrics = [{ siteId: "site-1", periods: [period(freshest, { downtime: 0, packetLoss: 0 })] }];
|
||||
|
||||
const [mapped] = mapSites([site], [], metrics, new Map(), resolveHealthWindowEnd(metrics, NOW));
|
||||
|
||||
expect(mapped!.health.at(-1)!.at.getTime()).toBe(freshest.getTime());
|
||||
expect(mapped!.health.at(-1)!.state).toBe("healthy");
|
||||
});
|
||||
|
||||
/**
|
||||
* Une seule fin pour toutes les lignes : le site à jour remplit sa dernière
|
||||
* tranche, celui qui retarde garde son gris à droite. C'est une information
|
||||
* vraie — il n'a rien rapporté depuis — et non un artefact d'horloge.
|
||||
*/
|
||||
test("keeps the trailing grey of a site lagging the others, on one shared window", () => {
|
||||
const metrics = [
|
||||
{ siteId: "site-1", periods: [period(hoursBefore(NOW, 1), { downtime: 0, packetLoss: 0 })] },
|
||||
{ siteId: "site-2", periods: [period(hoursBefore(NOW, 4), { downtime: 0, packetLoss: 0 })] },
|
||||
];
|
||||
|
||||
const [current, lagging] = mapSites(
|
||||
[site, { ...site, siteId: "site-2" }],
|
||||
[],
|
||||
metrics,
|
||||
new Map(),
|
||||
resolveHealthWindowEnd(metrics, NOW),
|
||||
);
|
||||
|
||||
expect(current!.health.at(-1)!.state).toBe("healthy");
|
||||
expect(lagging!.health.at(-1)!.state).toBe("unknown");
|
||||
expect(lagging!.health.at(-1)!.at.getTime()).toBe(current!.health.at(-1)!.at.getTime());
|
||||
});
|
||||
|
||||
test("keeps a window ending now when the account reported no period at all", () => {
|
||||
const [mapped] = mapSites([site], [], [], new Map(), resolveHealthWindowEnd([], NOW));
|
||||
|
||||
expect(mapped!.summary.unknown).toBe(HEALTH_WINDOW_HOURS);
|
||||
expect(mapped!.health.at(-1)!.at.getTime()).toBe(NOW.getTime());
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Ce module est réexporté par packages/integrations/src/types.ts, lui-même
|
||||
* importé par des composants client. Un import de @homarr/common/server y
|
||||
* traîne ioredis puis node:dns dans le bundle navigateur et empêche
|
||||
* l'application de démarrer — défaut réellement survenu sur l'intégration
|
||||
* précédente, invisible au typecheck comme aux tests unitaires.
|
||||
*/
|
||||
describe("client bundle safety", () => {
|
||||
test("the types module does not import server-only code", () => {
|
||||
// `import.meta.url` plutôt que `__dirname` : ce dernier n'existe pas en ESM,
|
||||
// et aucun autre spec du dépôt ne s'y fie.
|
||||
const source = readFileSync(new URL("../unifi-site-manager-types.ts", import.meta.url), "utf8");
|
||||
|
||||
expect(source).not.toContain("@homarr/common/server");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,197 @@
|
||||
import { ResponseError } from "@homarr/common/server";
|
||||
import type { RequestInit, Response } from "undici";
|
||||
import type { z } from "zod/v4";
|
||||
|
||||
import { parseSiteManagerAsync } from "./unifi-site-manager-parse";
|
||||
import type { IspMetricsEntry, SdWanStatus, SiteManagerHost, SiteManagerSite } from "./unifi-site-manager-types";
|
||||
import {
|
||||
HEALTH_WINDOW_HOURS,
|
||||
ispMetricsResponseSchema,
|
||||
sdWanConfigListSchema,
|
||||
sdWanStatusSchema,
|
||||
siteManagerHostListSchema,
|
||||
siteManagerSiteListSchema,
|
||||
} from "./unifi-site-manager-types";
|
||||
|
||||
/** L'API cloud d'Ubiquiti. Jamais configurable : ce n'est pas une console locale. */
|
||||
const BASE_URL = "https://api.ui.com";
|
||||
const REQUEST_TIMEOUT_MS = 15_000;
|
||||
|
||||
/**
|
||||
* Le statut SD-WAN est indexé par configuration : il faut d'abord les lister,
|
||||
* puis interroger chacune. Sans borne, un compte à dix configurations
|
||||
* produirait onze requêtes par rafraîchissement.
|
||||
*/
|
||||
export const MAX_SD_WAN_CONFIGS = 5;
|
||||
|
||||
/**
|
||||
* Fenêtre de la requête à 5 minutes.
|
||||
*
|
||||
* Mesuré sur un compte réel : la dernière heure que rapporte `/1h/query` a 2 h 43
|
||||
* et 3 h 43 de retard selon le site, là où `/5m/query` rapporte des points de 24
|
||||
* et 34 minutes. Le bout droit de la bande était donc structurellement gris et la
|
||||
* latence affichée vieille de trois heures. Quatre heures couvrent le pire retard
|
||||
* observé sans rapatrier une fenêtre fine de 24 h.
|
||||
*/
|
||||
export const RECENT_METRICS_WINDOW_HOURS = 4;
|
||||
|
||||
/**
|
||||
* Rassemble les deux granularités en une entrée par site.
|
||||
*
|
||||
* Les périodes des deux réponses sont concaténées sans déduplication : en aval,
|
||||
* `buildHealthWindow` plaque chaque période sur son heure et garde le pire état
|
||||
* quand plusieurs y tombent, et `latestPeriod` prend le maximum par
|
||||
* `metricTime`. Les points de 5 minutes se replient donc d'eux-mêmes sur les
|
||||
* tranches horaires, et la latence vient du point le plus frais.
|
||||
*
|
||||
* Un site présent dans une seule des deux réponses doit ressortir : la fusion
|
||||
* part des deux listes, jamais de l'intersection.
|
||||
*/
|
||||
const mergeIspMetrics = (hourly: IspMetricsEntry[], recent: IspMetricsEntry[]): IspMetricsEntry[] => {
|
||||
const bySite = new Map<string, IspMetricsEntry>();
|
||||
|
||||
for (const entry of [...hourly, ...recent]) {
|
||||
const existing = bySite.get(entry.siteId);
|
||||
// Copie des périodes : les tableaux viennent de la réponse analysée, et les
|
||||
// concaténer en place muterait celle-ci sous les autres lecteurs.
|
||||
if (existing === undefined) {
|
||||
bySite.set(entry.siteId, { ...entry, periods: [...entry.periods] });
|
||||
continue;
|
||||
}
|
||||
existing.periods.push(...entry.periods);
|
||||
}
|
||||
|
||||
return Array.from(bySite.values());
|
||||
};
|
||||
|
||||
/**
|
||||
* Déclaré sur les types d'undici, pas sur ceux du DOM : le dépôt n'utilise que
|
||||
* `undici` côté serveur. Aligner les deux évite les `as unknown as` qui
|
||||
* désactivaient le contrôle de type à la frontière du transport.
|
||||
*/
|
||||
export type SiteManagerFetch = (url: string, init?: RequestInit & { timeout?: number }) => Promise<Response>;
|
||||
|
||||
export class UnifiSiteManagerApi {
|
||||
constructor(
|
||||
private readonly apiKey: string,
|
||||
private readonly fetchAsync: SiteManagerFetch,
|
||||
) {}
|
||||
|
||||
public async getSitesAsync(): Promise<SiteManagerSite[]> {
|
||||
const parsed = await this.requestAsync(
|
||||
"/v1/sites",
|
||||
siteManagerSiteListSchema,
|
||||
"Invalid Site Manager sites response",
|
||||
);
|
||||
return parsed.data;
|
||||
}
|
||||
|
||||
public async getHostsAsync(): Promise<SiteManagerHost[]> {
|
||||
const parsed = await this.requestAsync(
|
||||
"/v1/hosts",
|
||||
siteManagerHostListSchema,
|
||||
"Invalid Site Manager hosts response",
|
||||
);
|
||||
return parsed.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deux granularités pour une seule fenêtre.
|
||||
*
|
||||
* La requête horaire couvre les HEALTH_WINDOW_HOURS heures de la bande, tous
|
||||
* les sites en une requête bornée — la variante non bornée pèse 216 Ko
|
||||
* (mesuré). Elle rapporte cependant jusqu'à près de quatre heures de retard,
|
||||
* d'où la seconde requête, à 5 minutes, sur les seules heures récentes.
|
||||
*
|
||||
* Les deux partent en parallèle : enchaînées, elles ajouteraient la latence de
|
||||
* l'une à celle de l'autre à chaque rafraîchissement.
|
||||
*/
|
||||
public async getIspMetricsAsync(
|
||||
sites: { siteId: string; hostId?: string }[],
|
||||
endingAt: Date,
|
||||
): Promise<IspMetricsEntry[]> {
|
||||
if (sites.length === 0) return [];
|
||||
|
||||
const endTimestamp = endingAt.toISOString();
|
||||
const beginTimestampOf = (hours: number) => new Date(endingAt.getTime() - hours * 3_600_000).toISOString();
|
||||
|
||||
const [hourlyResult, recentResult] = await Promise.allSettled([
|
||||
this.queryIspMetricsAsync("1h", sites, beginTimestampOf(HEALTH_WINDOW_HOURS), endTimestamp),
|
||||
this.queryIspMetricsAsync("5m", sites, beginTimestampOf(RECENT_METRICS_WINDOW_HOURS), endTimestamp),
|
||||
]);
|
||||
|
||||
// La fenêtre horaire porte toute la bande : son échec doit remonter.
|
||||
if (hourlyResult.status === "rejected") throw hourlyResult.reason;
|
||||
|
||||
// L'échec de la requête à 5 minutes est avalé À DESSEIN : elle n'apporte que
|
||||
// la fraîcheur des dernières heures. Sans elle, ces heures retombent en gris
|
||||
// — « non rapporté », ce qui est vrai — au lieu de perdre tout le widget
|
||||
// pour un enrichissement. Ne pas propager ce rejet.
|
||||
const recent = recentResult.status === "fulfilled" ? recentResult.value : [];
|
||||
|
||||
return mergeIspMetrics(hourlyResult.value, recent);
|
||||
}
|
||||
|
||||
private async queryIspMetricsAsync(
|
||||
granularity: "1h" | "5m",
|
||||
sites: { siteId: string; hostId?: string }[],
|
||||
beginTimestamp: string,
|
||||
endTimestamp: string,
|
||||
): Promise<IspMetricsEntry[]> {
|
||||
const response = await this.fetchAsync(`${BASE_URL}/v1/isp-metrics/${granularity}/query`, {
|
||||
method: "POST",
|
||||
headers: { "X-API-KEY": this.apiKey, Accept: "application/json", "Content-Type": "application/json" },
|
||||
timeout: REQUEST_TIMEOUT_MS,
|
||||
body: JSON.stringify({
|
||||
sites: sites.map((site) => ({ hostId: site.hostId, siteId: site.siteId, beginTimestamp, endTimestamp })),
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) throw new ResponseError(response);
|
||||
|
||||
// Le schéma est identique aux deux granularités : `data.metrics[].periods[]`
|
||||
// avec `metricTime` et `data.wan` — vérifié sur la réponse réelle à 5 min.
|
||||
const parsed = await parseSiteManagerAsync(
|
||||
await response.json(),
|
||||
ispMetricsResponseSchema,
|
||||
"Invalid Site Manager ISP metrics response",
|
||||
);
|
||||
return parsed.data.metrics;
|
||||
}
|
||||
|
||||
public async getSdWanStatusesAsync(): Promise<{ configName: string; status: SdWanStatus["data"]["status"] }[]> {
|
||||
const configs = await this.requestAsync(
|
||||
"/v1/sd-wan-configs",
|
||||
sdWanConfigListSchema,
|
||||
"Invalid Site Manager SD-WAN configuration list",
|
||||
);
|
||||
|
||||
const statuses: { configName: string; status: SdWanStatus["data"]["status"] }[] = [];
|
||||
|
||||
for (const config of configs.data.slice(0, MAX_SD_WAN_CONFIGS)) {
|
||||
try {
|
||||
const parsed = await this.requestAsync(
|
||||
`/v1/sd-wan-configs/${encodeURIComponent(config.id)}/status`,
|
||||
sdWanStatusSchema,
|
||||
"Invalid Site Manager SD-WAN status",
|
||||
);
|
||||
statuses.push({ configName: config.name ?? config.id, status: parsed.data.status });
|
||||
} catch {
|
||||
// Une configuration illisible ne doit pas emporter les autres.
|
||||
}
|
||||
}
|
||||
|
||||
return statuses;
|
||||
}
|
||||
|
||||
private async requestAsync<TOutput>(path: string, schema: z.ZodType<TOutput>, message: string): Promise<TOutput> {
|
||||
const response = await this.fetchAsync(`${BASE_URL}${path}`, {
|
||||
headers: { "X-API-KEY": this.apiKey, Accept: "application/json" },
|
||||
timeout: REQUEST_TIMEOUT_MS,
|
||||
});
|
||||
|
||||
if (!response.ok) throw new ResponseError(response);
|
||||
|
||||
return await parseSiteManagerAsync(await response.json(), schema, message);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { fetch as platformFetch } from "undici";
|
||||
|
||||
import type { IntegrationTestingInput } from "../base/integration";
|
||||
import { Integration } from "../base/integration";
|
||||
import { TestConnectionError } from "../base/test-connection/test-connection-error";
|
||||
import type { TestingResult } from "../base/test-connection/test-connection-service";
|
||||
import type { UnifiSiteManagerIntegration as UnifiSiteManagerIntegrationInterface } from "../interfaces/unifi-site-manager/unifi-site-manager-integration";
|
||||
import type { SiteManagerFetch } from "./unifi-site-manager-api";
|
||||
import { UnifiSiteManagerApi } from "./unifi-site-manager-api";
|
||||
import type {
|
||||
IspMetricsEntry,
|
||||
SiteManagerHost,
|
||||
UnifiSiteManagerOverview,
|
||||
UnifiSiteSdWanStatus,
|
||||
} from "./unifi-site-manager-types";
|
||||
import { buildSdWanIndex, mapSites, resolveHealthWindowEnd } from "./unifi-site-manager-types";
|
||||
|
||||
/**
|
||||
* `api.ui.com` présente un certificat public valide. On n'utilise donc PAS le
|
||||
* fetch à certificats de confiance de Homarr : il ajoute toutes les autorités
|
||||
* personnalisées téléversées par l'administrateur, ce qui en ferait des
|
||||
* ancrages valides pour le cloud d'Ubiquiti — et exposerait la clé, qui lit
|
||||
* tous les sites du compte.
|
||||
*/
|
||||
const fetchCloudAsync: SiteManagerFetch = async (url, init) => {
|
||||
const { timeout, ...rest } = init ?? {};
|
||||
return await platformFetch(url, {
|
||||
...rest,
|
||||
signal: timeout === undefined ? undefined : AbortSignal.timeout(timeout),
|
||||
});
|
||||
};
|
||||
|
||||
const settled = <T>(result: PromiseSettledResult<T>, fallback: T): T =>
|
||||
result.status === "fulfilled" ? result.value : fallback;
|
||||
|
||||
export class UnifiSiteManagerIntegration extends Integration implements UnifiSiteManagerIntegrationInterface {
|
||||
public async getSiteOverviewAsync(): Promise<UnifiSiteManagerOverview> {
|
||||
return await this.buildOverviewAsync(fetchCloudAsync);
|
||||
}
|
||||
|
||||
// `input.fetchAsync` is ignored on purpose: it carries the administrator's
|
||||
// trusted-certificate bundle, and using it here would let that CA vouch for
|
||||
// `api.ui.com` — see the module-level comment on `fetchCloudAsync`.
|
||||
protected async testingAsync(_input: IntegrationTestingInput): Promise<TestingResult> {
|
||||
try {
|
||||
await this.createApi(fetchCloudAsync).getSitesAsync();
|
||||
} catch (error) {
|
||||
return TestConnectionError.UnknownResult(error);
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
private createApi(fetchAsync: SiteManagerFetch) {
|
||||
return new UnifiSiteManagerApi(this.getSecretValue("siteManagerApiKey"), fetchAsync);
|
||||
}
|
||||
|
||||
private async buildOverviewAsync(fetchAsync: SiteManagerFetch): Promise<UnifiSiteManagerOverview> {
|
||||
const api = this.createApi(fetchAsync);
|
||||
const now = new Date();
|
||||
|
||||
const [sitesResult, hostsResult] = await Promise.allSettled([api.getSitesAsync(), api.getHostsAsync()]);
|
||||
|
||||
// La liste des sites porte tout : son échec doit remonter, sinon le widget
|
||||
// afficherait « aucun site » alors qu'il n'a rien pu lire.
|
||||
if (sitesResult.status === "rejected") throw sitesResult.reason;
|
||||
|
||||
const sites = sitesResult.value;
|
||||
const hosts = settled<SiteManagerHost[]>(hostsResult, []);
|
||||
|
||||
// Deux enrichissements, en parallèle. Leur échec laisse leur part à
|
||||
// l'inconnu — la fenêtre à « inconnu » plutôt qu'à « sain », le SD-WAN à
|
||||
// `null` — et n'emporte jamais la liste des sites.
|
||||
const [metricsResult, sdWanResult] = await Promise.allSettled([
|
||||
api.getIspMetricsAsync(
|
||||
sites.map((site) => ({ siteId: site.siteId, hostId: site.hostId })),
|
||||
now,
|
||||
),
|
||||
api.getSdWanStatusesAsync(),
|
||||
]);
|
||||
|
||||
const metrics = settled<IspMetricsEntry[]>(metricsResult, []);
|
||||
const sdWanBySite: Map<string, UnifiSiteSdWanStatus> = buildSdWanIndex(settled(sdWanResult, []));
|
||||
|
||||
// `now` drives the metrics REQUEST, but not the end of the health window:
|
||||
// the cloud's ISP metrics lag 25 to 35 minutes, so the current hour has no
|
||||
// measurement yet for the first half of every hour and its segment would be
|
||||
// permanently grey. The window therefore ends on the freshest hour any site
|
||||
// actually reported — one end for every row, so the table stays comparable
|
||||
// hour against hour. See `resolveHealthWindowEnd`.
|
||||
return { sites: mapSites(sites, hosts, metrics, sdWanBySite, resolveHealthWindowEnd(metrics, now)) };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { ParseError } from "@homarr/common/server";
|
||||
import type { z } from "zod/v4";
|
||||
|
||||
/**
|
||||
* Séparé de `unifi-site-manager-types.ts` à dessein : ce module importe
|
||||
* `@homarr/common/server`, qui atteint `ioredis` puis `node:dns`. Le module de
|
||||
* types, lui, est réexporté vers des composants client et doit rester pur.
|
||||
*/
|
||||
export const parseSiteManagerAsync = async <TOutput>(
|
||||
value: unknown,
|
||||
schema: z.ZodType<TOutput>,
|
||||
message: string,
|
||||
): Promise<TOutput> => {
|
||||
const result = await schema.safeParseAsync(value);
|
||||
if (!result.success) {
|
||||
throw new ParseError(message, { cause: result.error });
|
||||
}
|
||||
return result.data;
|
||||
};
|
||||
@@ -0,0 +1,371 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
/** Ubiquiti ajoute des champs sans prévenir : tous les objets d'API sont tolérants. */
|
||||
|
||||
const wanMetricsSchema = z.looseObject({
|
||||
avgLatency: z.number().optional(),
|
||||
maxLatency: z.number().optional(),
|
||||
packetLoss: z.number().optional(),
|
||||
downtime: z.number().optional(),
|
||||
uptime: z.number().optional(),
|
||||
ispName: z.string().optional(),
|
||||
});
|
||||
|
||||
export const ispMetricsPeriodSchema = z.looseObject({
|
||||
metricTime: z.string(),
|
||||
data: z.looseObject({ wan: wanMetricsSchema.optional() }),
|
||||
});
|
||||
|
||||
export const ispMetricsEntrySchema = z.looseObject({
|
||||
siteId: z.string(),
|
||||
hostId: z.string().optional(),
|
||||
periods: z.array(ispMetricsPeriodSchema).default([]),
|
||||
});
|
||||
|
||||
export const ispMetricsResponseSchema = z.looseObject({
|
||||
data: z.looseObject({ metrics: z.array(ispMetricsEntrySchema).default([]) }),
|
||||
});
|
||||
|
||||
export const siteManagerSiteSchema = z.looseObject({
|
||||
siteId: z.string(),
|
||||
hostId: z.string().optional(),
|
||||
meta: z.looseObject({ desc: z.string().optional(), name: z.string().optional() }),
|
||||
statistics: z
|
||||
.looseObject({
|
||||
percentages: z.looseObject({ wanUptime: z.number().optional() }).optional(),
|
||||
ispInfo: z.looseObject({ name: z.string().optional() }).optional(),
|
||||
wans: z.record(z.string(), z.looseObject({ externalIp: z.string().optional() })).optional(),
|
||||
counts: z
|
||||
.looseObject({
|
||||
totalDevice: z.number().optional(),
|
||||
offlineDevice: z.number().optional(),
|
||||
pendingUpdateDevice: z.number().optional(),
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export const siteManagerSiteListSchema = z.looseObject({ data: z.array(siteManagerSiteSchema).default([]) });
|
||||
|
||||
export const siteManagerHostSchema = z.looseObject({
|
||||
id: z.string(),
|
||||
reportedState: z
|
||||
.looseObject({
|
||||
deviceState: z.string().optional(),
|
||||
/**
|
||||
* `.nullable().optional()` et non `.nullish()` : « présent et explicitement
|
||||
* `null` » — le cloud affirme qu'aucune version n'est disponible — doit
|
||||
* rester distinguable de « absent », où il ne dit rien.
|
||||
*/
|
||||
firmwareUpdate: z.looseObject({ latestAvailableVersion: z.string().nullable().optional() }).optional(),
|
||||
})
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export const siteManagerHostListSchema = z.looseObject({ data: z.array(siteManagerHostSchema).default([]) });
|
||||
|
||||
export const sdWanConfigSchema = z.looseObject({ id: z.string(), name: z.string().optional() });
|
||||
|
||||
export const sdWanConfigListSchema = z.looseObject({ data: z.array(sdWanConfigSchema).default([]) });
|
||||
|
||||
const sdWanConnectionSiteSchema = z.looseObject({
|
||||
siteId: z.string(),
|
||||
status: z
|
||||
.looseObject({
|
||||
connected: z.boolean().optional(),
|
||||
offline: z.boolean().optional(),
|
||||
ping: z.number().optional(),
|
||||
errors: z.array(z.unknown()).default([]),
|
||||
})
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export const sdWanStatusSchema = z.looseObject({
|
||||
data: z.looseObject({
|
||||
status: z.looseObject({
|
||||
connections: z.array(z.looseObject({ sites: z.array(sdWanConnectionSiteSchema).default([]) })).default([]),
|
||||
sites: z
|
||||
.array(
|
||||
z.looseObject({
|
||||
siteId: z.string(),
|
||||
errors: z.array(z.unknown()).default([]),
|
||||
warnings: z.array(z.unknown()).default([]),
|
||||
}),
|
||||
)
|
||||
.default([]),
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
export type SdWanStatus = z.infer<typeof sdWanStatusSchema>;
|
||||
|
||||
export type SiteManagerSite = z.infer<typeof siteManagerSiteSchema>;
|
||||
export type SiteManagerHost = z.infer<typeof siteManagerHostSchema>;
|
||||
export type IspMetricsEntry = z.infer<typeof ispMetricsEntrySchema>;
|
||||
export type IspMetricsPeriod = z.infer<typeof ispMetricsPeriodSchema>;
|
||||
|
||||
export type UnifiSiteHealthState = "healthy" | "degraded" | "down" | "unknown";
|
||||
|
||||
export interface UnifiSiteHealthSegment {
|
||||
/** Début de la tranche horaire. */
|
||||
at: Date;
|
||||
state: UnifiSiteHealthState;
|
||||
}
|
||||
|
||||
export type UnifiSiteHealthSummary = Record<UnifiSiteHealthState, number>;
|
||||
|
||||
export interface UnifiSiteManagerSite {
|
||||
siteId: string;
|
||||
name: string;
|
||||
wanUptimePct: number | null;
|
||||
ispName: string | null;
|
||||
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` quand aucun host ne correspond ou que le
|
||||
* cloud ne se prononce pas — voir `resolveConsoleUpdate`.
|
||||
*/
|
||||
consoleUpdateAvailable: boolean | null;
|
||||
/** Toujours HEALTH_WINDOW_HOURS entrées, la plus ancienne en premier. */
|
||||
health: UnifiSiteHealthSegment[];
|
||||
summary: UnifiSiteHealthSummary;
|
||||
latencyMs: number | null;
|
||||
packetLossPct: number | null;
|
||||
/**
|
||||
* `null` couvre DEUX cas que ce contrat ne distingue pas : le site n'appartient
|
||||
* à aucune configuration SD-WAN, ou l'appel de statut a échoué. Ne pas en
|
||||
* déduire l'un des deux à l'affichage — le tiret dit « on ne sait pas ».
|
||||
*/
|
||||
sdWan: UnifiSiteSdWanStatus | null;
|
||||
}
|
||||
|
||||
export interface UnifiSiteSdWanStatus {
|
||||
configName: string;
|
||||
connected: boolean;
|
||||
offline: boolean;
|
||||
/** `null` quand le cloud rapporte `0` : « non mesuré », pas « zéro milliseconde ». */
|
||||
pingMs: number | null;
|
||||
errorCount: number;
|
||||
warningCount: number;
|
||||
}
|
||||
|
||||
export interface UnifiSiteManagerOverview {
|
||||
sites: UnifiSiteManagerSite[];
|
||||
}
|
||||
|
||||
export const HEALTH_WINDOW_HOURS = 24;
|
||||
|
||||
const HOUR_MS = 3_600_000;
|
||||
|
||||
/** Gravité décroissante. L'ordre compte : une coupure prime sur une perte. */
|
||||
export const deriveHealthState = (
|
||||
wan:
|
||||
| {
|
||||
downtime?: number;
|
||||
packetLoss?: number;
|
||||
}
|
||||
| undefined,
|
||||
): UnifiSiteHealthState => {
|
||||
if (wan === undefined) return "unknown";
|
||||
if (wan.downtime === undefined && wan.packetLoss === undefined) return "unknown";
|
||||
if ((wan.downtime ?? 0) > 0) return "down";
|
||||
if ((wan.packetLoss ?? 0) > 0) return "degraded";
|
||||
return "healthy";
|
||||
};
|
||||
|
||||
const SEVERITY: Record<UnifiSiteHealthState, number> = { unknown: 0, healthy: 1, degraded: 2, down: 3 };
|
||||
|
||||
const floorToHour = (value: number) => Math.floor(value / HOUR_MS) * HOUR_MS;
|
||||
|
||||
/**
|
||||
* Reconstitue une fenêtre pleine de HEALTH_WINDOW_HOURS tranches.
|
||||
*
|
||||
* Le cloud ne rapporte pas toutes les heures : une fenêtre de 24 h n'en a
|
||||
* renvoyé que 16 sur l'API réelle, avec des trous au milieu. Une tranche non
|
||||
* rapportée vaut `"unknown"` et jamais `"healthy"` — la peindre en vert
|
||||
* affirmerait un bon fonctionnement qui n'a pas été mesuré.
|
||||
*
|
||||
* `endingAt` est la dernière heure MESURÉE, pas maintenant : voir
|
||||
* `resolveHealthWindowEnd`. Les périodes hors fenêtre étant écartées ci-dessous,
|
||||
* reculer cette fin ne peut pas y faire entrer de données plus anciennes.
|
||||
*/
|
||||
export const buildHealthWindow = (periods: IspMetricsPeriod[], endingAt: Date): UnifiSiteHealthSegment[] => {
|
||||
const lastHour = floorToHour(endingAt.getTime());
|
||||
const firstHour = lastHour - (HEALTH_WINDOW_HOURS - 1) * HOUR_MS;
|
||||
|
||||
const byHour = new Map<number, UnifiSiteHealthState>();
|
||||
for (const period of periods) {
|
||||
const at = floorToHour(new Date(period.metricTime).getTime());
|
||||
if (Number.isNaN(at) || at < firstHour || at > lastHour) continue;
|
||||
|
||||
const state = deriveHealthState(period.data.wan);
|
||||
const existing = byHour.get(at);
|
||||
// Plusieurs mesures dans la même heure : on garde la pire.
|
||||
if (existing === undefined || SEVERITY[state] > SEVERITY[existing]) byHour.set(at, state);
|
||||
}
|
||||
|
||||
return Array.from({ length: HEALTH_WINDOW_HOURS }, (_, offset) => {
|
||||
const at = firstHour + offset * HOUR_MS;
|
||||
return { at: new Date(at), state: byHour.get(at) ?? "unknown" };
|
||||
});
|
||||
};
|
||||
|
||||
/** Le canal qui porte l'information quand la couleur ne passe pas. */
|
||||
export const summariseHealth = (segments: UnifiSiteHealthSegment[]): UnifiSiteHealthSummary => {
|
||||
const summary: UnifiSiteHealthSummary = { healthy: 0, degraded: 0, down: 0, unknown: 0 };
|
||||
for (const segment of segments) summary[segment.state] += 1;
|
||||
return summary;
|
||||
};
|
||||
|
||||
/**
|
||||
* Fin de la fenêtre de santé : la dernière heure RÉELLEMENT rapportée, et non
|
||||
* maintenant.
|
||||
*
|
||||
* Les métriques ISP du cloud arrivent avec 25 à 35 minutes de retard (mesuré sur
|
||||
* le compte réel). Terminer la fenêtre sur l'heure courante réservait donc sa
|
||||
* dernière tranche à une heure qu'aucune mesure ne couvre encore : elle restait
|
||||
* grise pendant la première moitié de chaque heure. Honnêtement grise, et
|
||||
* totalement inutile — un vingt-quatrième de la barre ne disait jamais rien.
|
||||
*
|
||||
* Le maximum est GLOBAL, pris sur les périodes de TOUS les sites, et non site par
|
||||
* site : les lignes du tableau se lisent heure contre heure, et une fin propre à
|
||||
* chaque site ferait glisser sa fenêtre par rapport à celles de ses voisines.
|
||||
* Avec une fin unique, un site qui retarde vraiment sur les autres garde une
|
||||
* tranche grise à droite — c'est une information vraie, pas un artefact.
|
||||
*
|
||||
* Sans aucune période (compte vide, ou appel de métriques en échec), on retombe
|
||||
* sur `now` : la fenêtre reste pleine de HEALTH_WINDOW_HOURS tranches inconnues.
|
||||
*/
|
||||
export const resolveHealthWindowEnd = (metrics: IspMetricsEntry[], now: Date): Date => {
|
||||
let newest: number | undefined;
|
||||
|
||||
for (const entry of metrics) {
|
||||
for (const period of entry.periods) {
|
||||
const at = new Date(period.metricTime).getTime();
|
||||
if (Number.isNaN(at)) continue;
|
||||
if (newest === undefined || at > newest) newest = at;
|
||||
}
|
||||
}
|
||||
|
||||
return newest === undefined ? now : new Date(newest);
|
||||
};
|
||||
|
||||
const latestPeriod = (periods: IspMetricsPeriod[]): IspMetricsPeriod | undefined =>
|
||||
periods.reduce<IspMetricsPeriod | undefined>((newest, period) => {
|
||||
if (newest === undefined) return period;
|
||||
return new Date(period.metricTime).getTime() > new Date(newest.metricTime).getTime() ? period : newest;
|
||||
}, undefined);
|
||||
|
||||
/**
|
||||
* Indexe l'état des tunnels par site.
|
||||
*
|
||||
* `ping: 0` a été relevé sur un tunnel `connected` reliant deux sites
|
||||
* géographiquement distants — une latence nulle y est physiquement impossible.
|
||||
* Le champ signifie donc « non mesuré », et il devient `null` plutôt que d'être
|
||||
* propagé comme une performance.
|
||||
*
|
||||
* Un site peut apparaître dans plusieurs configurations : la première l'emporte,
|
||||
* et son nom accompagne l'état pour que la ligne reste interprétable.
|
||||
*/
|
||||
export const buildSdWanIndex = (
|
||||
configs: { configName: string; status: SdWanStatus["data"]["status"] }[],
|
||||
): Map<string, UnifiSiteSdWanStatus> => {
|
||||
const index = new Map<string, UnifiSiteSdWanStatus>();
|
||||
|
||||
for (const { configName, status } of configs) {
|
||||
const warningsBySite = new Map(status.sites.map((site) => [site.siteId, site]));
|
||||
|
||||
for (const connection of status.connections) {
|
||||
for (const site of connection.sites) {
|
||||
if (index.has(site.siteId)) continue;
|
||||
|
||||
const reported = site.status;
|
||||
const ping = reported?.ping ?? 0;
|
||||
|
||||
index.set(site.siteId, {
|
||||
configName,
|
||||
connected: reported?.connected ?? false,
|
||||
offline: reported?.offline ?? false,
|
||||
pingMs: ping > 0 ? ping : null,
|
||||
errorCount: reported?.errors.length ?? 0,
|
||||
warningCount: warningsBySite.get(site.siteId)?.warnings.length ?? 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return index;
|
||||
};
|
||||
|
||||
/**
|
||||
* `false` exige un signal positif du cloud.
|
||||
*
|
||||
* `deviceState` n'est pas un booléen : le compte réel renvoie `"setup"` sur une
|
||||
* console et `"updateAvailable"` sur l'autre, et Ubiquiti peut en ajouter
|
||||
* d'autres. Comparer à la chaîne magique repliait donc tout le reste — `"setup"`,
|
||||
* une console hors ligne, un champ absent — sur `false`, que le widget affiche
|
||||
* « à jour » : dire d'un état qui ne parle pas du firmware qu'il n'y a pas de
|
||||
* mise à jour affirme une mesure qui n'a jamais été faite.
|
||||
*
|
||||
* Seul `firmwareUpdate.latestAvailableVersion` autorise `false` : explicitement
|
||||
* `null` sur la console à jour, porteur d'une version sur celle qui ne l'est pas.
|
||||
* Faute de l'un ou de l'autre, l'état reste inconnu.
|
||||
*/
|
||||
const resolveConsoleUpdate = (host: SiteManagerHost | undefined): boolean | null => {
|
||||
if (host === undefined) return null;
|
||||
|
||||
const reported = host.reportedState;
|
||||
if (reported?.deviceState === "updateAvailable") return true;
|
||||
|
||||
// `undefined` (champ ou objet absent) ne vaut pas `null` : seul `null` est une réponse.
|
||||
const latestAvailableVersion = reported?.firmwareUpdate?.latestAvailableVersion;
|
||||
if (typeof latestAvailableVersion === "string") return true;
|
||||
if (latestAvailableVersion === null) return false;
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export const mapSites = (
|
||||
sites: SiteManagerSite[],
|
||||
hosts: SiteManagerHost[],
|
||||
metrics: IspMetricsEntry[],
|
||||
sdWanBySite: Map<string, UnifiSiteSdWanStatus>,
|
||||
/**
|
||||
* Dernière heure de la fenêtre de santé, commune à toutes les lignes. Ce n'est
|
||||
* PAS `now` : l'appelant la tient de `resolveHealthWindowEnd`, qui explique
|
||||
* pourquoi.
|
||||
*/
|
||||
healthWindowEndsAt: Date,
|
||||
): UnifiSiteManagerSite[] =>
|
||||
sites.map((site) => {
|
||||
const statistics = site.statistics;
|
||||
const counts = statistics?.counts;
|
||||
const host = site.hostId === undefined ? undefined : hosts.find((candidate) => candidate.id === site.hostId);
|
||||
const entry = metrics.find((candidate) => candidate.siteId === site.siteId);
|
||||
const periods = entry?.periods ?? [];
|
||||
const health = buildHealthWindow(periods, healthWindowEndsAt);
|
||||
const latest = latestPeriod(periods)?.data.wan;
|
||||
|
||||
return {
|
||||
siteId: site.siteId,
|
||||
name: site.meta.desc ?? site.meta.name ?? site.siteId,
|
||||
wanUptimePct: statistics?.percentages?.wanUptime ?? null,
|
||||
ispName: statistics?.ispInfo?.name ?? null,
|
||||
externalIps: Object.values(statistics?.wans ?? {})
|
||||
.map((wan) => wan.externalIp)
|
||||
.filter((ip): ip is string => ip !== undefined),
|
||||
deviceCount: counts?.totalDevice ?? 0,
|
||||
offlineDeviceCount: counts?.offlineDevice ?? 0,
|
||||
pendingUpdateCount: counts?.pendingUpdateDevice ?? 0,
|
||||
consoleUpdateAvailable: resolveConsoleUpdate(host),
|
||||
health,
|
||||
summary: summariseHealth(health),
|
||||
latencyMs: latest?.avgLatency ?? null,
|
||||
packetLossPct: latest?.packetLoss ?? null,
|
||||
sdWan: sdWanBySite.get(site.siteId) ?? null,
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
import { createIntegrationAsync } from "@homarr/integrations";
|
||||
import type { UnifiOsSummary } from "@homarr/integrations/types";
|
||||
|
||||
import { createIntegrationRequestHandler } from "./lib/integration-request-handler";
|
||||
|
||||
export const unifiOsRequestHandler = createIntegrationRequestHandler<UnifiOsSummary, "unifiOs", Record<string, never>>({
|
||||
async requestAsync(integration, _input) {
|
||||
const integrationInstance = await createIntegrationAsync(integration);
|
||||
return await integrationInstance.getUnifiOsSummaryAsync();
|
||||
},
|
||||
cacheTtlMs: 5 * 1000,
|
||||
// Aligné sur l'intervalle du widget : un cache plus long qu'elle rendrait le
|
||||
// rafraîchissement rapide inopérant, la requête relisant la même entrée.
|
||||
// La console ne rafraîchit ses statistiques que toutes les 30 s (mesuré), donc
|
||||
// interroger plus vite gagne en réactivité, pas en résolution.
|
||||
// Le cache cloud reste à 5 min dans unifi-sm-api.ts : ses métriques n'évoluent
|
||||
// que par tranches de 5 min et accusent ~30 min de retard.
|
||||
// Une console momentanément injoignable doit afficher la dernière valeur connue
|
||||
// plutôt qu'une tuile d'erreur ; le widget signale la péremption.
|
||||
fallbackToStaleOnError: true,
|
||||
staleIfErrorTtlMs: 10 * 60 * 1000,
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
import { createIntegrationAsync } from "@homarr/integrations";
|
||||
import type { UnifiSiteManagerOverview } from "@homarr/integrations/types";
|
||||
|
||||
import { createIntegrationRequestHandler } from "./lib/integration-request-handler";
|
||||
|
||||
export const unifiSiteManagerRequestHandler = createIntegrationRequestHandler<
|
||||
UnifiSiteManagerOverview,
|
||||
"unifiSiteManager",
|
||||
Record<string, never>
|
||||
>({
|
||||
async requestAsync(integration, _input) {
|
||||
const integrationInstance = await createIntegrationAsync(integration);
|
||||
return await integrationInstance.getSiteOverviewAsync();
|
||||
},
|
||||
// Les compteurs cloud n'évoluent pas plus vite ; interroger davantage expose
|
||||
// à une limitation de débit sans rien gagner. Aligné sur l'intervalle du widget.
|
||||
cacheTtlMs: 5 * 60 * 1000,
|
||||
fallbackToStaleOnError: true,
|
||||
staleIfErrorTtlMs: 30 * 60 * 1000,
|
||||
});
|
||||
@@ -1015,6 +1015,8 @@
|
||||
"search": "Search",
|
||||
"mediaTranscoding": "Transcoding",
|
||||
"networkController": "Network",
|
||||
"unifiOs": "UniFi OS",
|
||||
"unifiSiteManager": "UniFi Site Manager",
|
||||
"releasesProvider": "Releases",
|
||||
"notifications": "Notifications",
|
||||
"firewall": "Firewall",
|
||||
@@ -1297,6 +1299,10 @@
|
||||
"label": "API Secret",
|
||||
"newLabel": "New API Secret"
|
||||
},
|
||||
"siteManagerApiKey": {
|
||||
"label": "Site Manager API Key",
|
||||
"newLabel": "New Site Manager API Key"
|
||||
},
|
||||
"githubAppId": {
|
||||
"label": "App Id",
|
||||
"newLabel": "New App Id"
|
||||
@@ -4217,6 +4223,84 @@
|
||||
"subtitle": "Network traffic of docker containers"
|
||||
}
|
||||
}
|
||||
},
|
||||
"unifiOs": {
|
||||
"name": "UniFi OS",
|
||||
"description": "Network overview from a UniFi OS console",
|
||||
"option": {
|
||||
"showIsp": {
|
||||
"label": "Show ISP details",
|
||||
"description": "Requires a Site Manager API key"
|
||||
},
|
||||
"showDevices": {
|
||||
"label": "Show devices",
|
||||
"description": "Display how many devices are online"
|
||||
},
|
||||
"showFirmwareUpdates": {
|
||||
"label": "Show firmware updates",
|
||||
"description": "Display how many devices have a pending firmware update"
|
||||
}
|
||||
},
|
||||
"card": {
|
||||
"internet": "Internet",
|
||||
"up": "Up",
|
||||
"down": "Down",
|
||||
"latency": "{value} ms",
|
||||
"packetLoss": "{value} % loss",
|
||||
"measuredAt": "Measured at {time}",
|
||||
"stale": "Local data could not be refreshed, showing the last known values",
|
||||
"clients": "Clients",
|
||||
"clientBreakdown": "{total} · {wired} wired · {wireless} Wi-Fi",
|
||||
"devices": "Devices",
|
||||
"devicesOnline": "{online} / {total} online",
|
||||
"firmware": "Firmware",
|
||||
"firmwarePending": "{count} to update",
|
||||
"version": "Network {version}",
|
||||
"gatewayUptime": "Gateway up {uptime}"
|
||||
},
|
||||
"error": {
|
||||
"internalServerError": "Could not reach the UniFi OS console",
|
||||
"noData": "No data available"
|
||||
}
|
||||
},
|
||||
"unifiSiteManager": {
|
||||
"name": "UniFi Site Manager",
|
||||
"description": "Multi-site overview from a UniFi account",
|
||||
"option": {
|
||||
"showIsp": { "label": "Show ISP", "description": "Display the ISP name column" },
|
||||
"showExternalIp": { "label": "Show public IP", "description": "Display the public IP column" }
|
||||
},
|
||||
"column": {
|
||||
"site": "Site",
|
||||
"health": "Health (24h)",
|
||||
"wanUptime": "WAN uptime",
|
||||
"latency": "Latency",
|
||||
"isp": "ISP",
|
||||
"externalIp": "Public IP",
|
||||
"devices": "Devices",
|
||||
"updates": "Updates",
|
||||
"console": "Console",
|
||||
"sdWan": "SD-WAN"
|
||||
},
|
||||
"sdWan": {
|
||||
"connected": "Connected",
|
||||
"disconnected": "Disconnected",
|
||||
"offline": "Offline"
|
||||
},
|
||||
"health": {
|
||||
"healthy": "healthy",
|
||||
"degraded": "degraded",
|
||||
"down": "down",
|
||||
"unknown": "not reported"
|
||||
},
|
||||
"wanUptime": "{value} %",
|
||||
"latency": "{value} ms · {loss} % loss",
|
||||
"latencyOnly": "{value} ms",
|
||||
"devicesOnline": "{online} / {total}",
|
||||
"consoleUpdateAvailable": "Update available",
|
||||
"consoleUpToDate": "Up to date",
|
||||
"noSites": "No sites in this account",
|
||||
"error": { "internalServerError": "Could not reach the UniFi Site Manager API" }
|
||||
}
|
||||
},
|
||||
"widgetPreview": {
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
IconBrandMinecraft,
|
||||
IconBrowser,
|
||||
IconBuildingBank,
|
||||
IconBuildingBroadcastTower,
|
||||
IconBusStop,
|
||||
IconCalendar,
|
||||
IconChartAreaLine,
|
||||
@@ -35,6 +36,7 @@ import {
|
||||
IconReportSearch,
|
||||
IconRocket,
|
||||
IconRoute,
|
||||
IconRouter,
|
||||
IconRss,
|
||||
IconServer2,
|
||||
IconShieldCheck,
|
||||
@@ -75,6 +77,8 @@ export const widgetCatalogIcons: Record<WidgetKind, TablerIcon> = {
|
||||
minecraftServerStatus: IconBrandMinecraft,
|
||||
networkControllerSummary: IconTopologyFull,
|
||||
networkControllerStatus: IconTopologyFull,
|
||||
unifiOs: IconRouter,
|
||||
unifiSiteManager: IconBuildingBroadcastTower,
|
||||
rssFeed: IconRss,
|
||||
bookmarks: IconBookmark,
|
||||
indexerManager: IconReportSearch,
|
||||
|
||||
@@ -55,6 +55,8 @@ import * as timetable from "./timetable";
|
||||
import * as traefik from "./traefik";
|
||||
import * as tracearr from "./tracearr";
|
||||
import * as umami from "./umami";
|
||||
import * as unifiOs from "./unifi-os";
|
||||
import * as unifiSiteManager from "./unifi-site-manager";
|
||||
import * as ups from "./ups";
|
||||
import * as vpn from "./vpn";
|
||||
import * as beszelSystemTable from "./beszel-system-table";
|
||||
@@ -131,6 +133,8 @@ export const widgetImports = {
|
||||
traefik,
|
||||
customApi,
|
||||
wud,
|
||||
unifiOs,
|
||||
unifiSiteManager,
|
||||
} satisfies WidgetImportRecord;
|
||||
|
||||
export type WidgetImports = typeof widgetImports;
|
||||
|
||||
@@ -43,6 +43,8 @@ const moduleLoaders: Record<WidgetKind, () => Promise<WidgetModule>> = {
|
||||
mediaMissing: () => import("./media-missing") as Promise<WidgetModule>,
|
||||
networkControllerSummary: () => import("./network-controller/summary") as Promise<WidgetModule>,
|
||||
networkControllerStatus: () => import("./network-controller/network-status") as Promise<WidgetModule>,
|
||||
unifiOs: () => import("./unifi-os") as Promise<WidgetModule>,
|
||||
unifiSiteManager: () => import("./unifi-site-manager") as Promise<WidgetModule>,
|
||||
rssFeed: () => import("./rssFeed") as Promise<WidgetModule>,
|
||||
bookmarks: () => import("./bookmarks") as Promise<WidgetModule>,
|
||||
bazarr: () => import("./bazarr") as Promise<WidgetModule>,
|
||||
@@ -105,6 +107,8 @@ const componentLoaders: Record<WidgetKind, () => Promise<WidgetComponentModule>>
|
||||
networkControllerSummary: () => import("./network-controller/summary/component") as Promise<WidgetComponentModule>,
|
||||
networkControllerStatus: () =>
|
||||
import("./network-controller/network-status/component") as Promise<WidgetComponentModule>,
|
||||
unifiOs: () => import("./unifi-os/component") as Promise<WidgetComponentModule>,
|
||||
unifiSiteManager: () => import("./unifi-site-manager/component") as Promise<WidgetComponentModule>,
|
||||
rssFeed: () => import("./rssFeed/component") as Promise<WidgetComponentModule>,
|
||||
bookmarks: () => import("./bookmarks/component") as Promise<WidgetComponentModule>,
|
||||
bazarr: () => import("./bazarr/component") as Promise<WidgetComponentModule>,
|
||||
|
||||
@@ -25,6 +25,8 @@ export const widgetQueryRefetchIntervals = [
|
||||
{ queryKey: [["widget", "speedtestTracker"]], intervalSeconds: null },
|
||||
{ queryKey: [["widget", "stockPrice"]], intervalSeconds: null },
|
||||
{ queryKey: [["widget", "umami"]], intervalSeconds: null },
|
||||
{ queryKey: [["widget", "unifiOs"]], intervalSeconds: 5 },
|
||||
{ queryKey: [["widget", "unifiSiteManager"]], intervalSeconds: 300 },
|
||||
{ queryKey: [["widget", "vpn"]], intervalSeconds: null },
|
||||
{ queryKey: [["widget", "wud"]], intervalSeconds: null },
|
||||
] as const;
|
||||
@@ -0,0 +1,268 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Badge, Box, Group, Stack, Text, Tooltip } from "@mantine/core";
|
||||
import { IconCircleFilled } from "@tabler/icons-react";
|
||||
import dayjs from "dayjs";
|
||||
import relativeTime from "dayjs/plugin/relativeTime";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useIntegrationConnected } from "@homarr/common";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import type { WidgetComponentProps } from "../definition";
|
||||
import { ThroughputChart, ThroughputLegend } from "./throughput-chart";
|
||||
import type { ThroughputPoint } from "./throughput-history";
|
||||
import { appendThroughputPoint } from "./throughput-history";
|
||||
|
||||
dayjs.extend(relativeTime);
|
||||
|
||||
const EMPTY_VALUE = "—";
|
||||
|
||||
/**
|
||||
* Le request-handler met les compteurs locaux en cache 5 s et le widget se
|
||||
* rafraîchit toutes les 5 s : en régime normal la donnée a moins de 10 s.
|
||||
* 30 s laissent six cycles de marge avant de crier au loup ; au-delà, la console
|
||||
* n'a pas répondu et `fallbackToStaleOnError` sert la dernière valeur connue —
|
||||
* ce que l'utilisateur doit voir, sans quoi la source périmée paraîtrait plus
|
||||
* fraîche que le bloc cloud, qui affiche son âge.
|
||||
*/
|
||||
const LOCAL_STALE_AFTER_MS = 30_000;
|
||||
|
||||
/** Identité stable : évite de relancer l'effet du hook à chaque rendu. */
|
||||
const NEVER_UPDATED = new Date(0);
|
||||
|
||||
const formatBitsPerSecond = (bps: number): string => {
|
||||
const units = ["b/s", "Kb/s", "Mb/s", "Gb/s"];
|
||||
let value = bps;
|
||||
let unitIndex = 0;
|
||||
|
||||
while (value >= 1000 && unitIndex < units.length - 1) {
|
||||
value /= 1000;
|
||||
unitIndex += 1;
|
||||
}
|
||||
|
||||
return `${value.toFixed(value >= 100 || unitIndex === 0 ? 0 : 1)} ${units[unitIndex]}`;
|
||||
};
|
||||
|
||||
const formatUptime = (seconds: number): string => {
|
||||
const days = Math.floor(seconds / 86400);
|
||||
const hours = Math.floor((seconds % 86400) / 3600);
|
||||
|
||||
return days > 0 ? `${days}d ${hours}h` : `${hours}h`;
|
||||
};
|
||||
|
||||
export default function UnifiOsWidget({ integrationIds, options }: WidgetComponentProps<"unifiOs">) {
|
||||
const t = useI18n();
|
||||
const { data: results = [], dataUpdatedAt } = clientApi.widget.unifiOs.summary.useQuery({ integrationIds });
|
||||
|
||||
const summary = useMemo(() => results[0]?.summary ?? null, [results]);
|
||||
const updatedAt = results[0]?.updatedAt ?? NEVER_UPDATED;
|
||||
const isLocalFresh = useIntegrationConnected(updatedAt, { timeout: LOCAL_STALE_AFTER_MS });
|
||||
|
||||
/* L'API ne fournit que l'instantané et Homarr ne stocke aucun historique :
|
||||
on l'accumule ici, comme le fait le widget systemResources. La série
|
||||
repart donc vide à chaque rechargement de page — c'est assumé. */
|
||||
const [throughputHistory, setThroughputHistory] = useState<ThroughputPoint[]>([]);
|
||||
|
||||
const previousIntegrationIds = useRef(integrationIds);
|
||||
useEffect(() => {
|
||||
if (previousIntegrationIds.current === integrationIds) return;
|
||||
previousIntegrationIds.current = integrationIds;
|
||||
setThroughputHistory([]);
|
||||
}, [integrationIds]);
|
||||
|
||||
const lastRecordedAt = useRef(dataUpdatedAt);
|
||||
useEffect(() => {
|
||||
if (dataUpdatedAt === lastRecordedAt.current) return;
|
||||
lastRecordedAt.current = dataUpdatedAt;
|
||||
|
||||
const throughput = results[0]?.summary.wan.throughput ?? null;
|
||||
setThroughputHistory((previous) => appendThroughputPoint(previous, throughput));
|
||||
}, [dataUpdatedAt, results]);
|
||||
|
||||
if (summary === null) {
|
||||
return (
|
||||
<Box h="100%" p="sm">
|
||||
<Text c="dimmed" size="sm">
|
||||
{t("widget.unifiOs.error.noData")}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
const wanColor = summary.wan.state === "online" ? "green" : summary.wan.state === "offline" ? "red" : "gray";
|
||||
const isp = summary.wan.isp;
|
||||
|
||||
// No `justify="space-between"` on the outer stack: the Internet card flexes to
|
||||
// take the leftover height, so there is no slack left to distribute —
|
||||
// distributing it is exactly what piled empty space between the bottom rows.
|
||||
return (
|
||||
<Stack h="100%" p="sm" gap="xs">
|
||||
{/* The site name is not decorative: a console can host several sites and
|
||||
the widget only shows one. Naming it makes that choice visible. */}
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<IconCircleFilled size={8} color={`var(--mantine-color-${wanColor}-6)`} />
|
||||
<Text size="xs" c="dimmed" tt="uppercase" fw={600} truncate>
|
||||
{summary.site.name}
|
||||
</Text>
|
||||
{/* Second rôle du badge de fraîcheur (§8) : une console injoignable est
|
||||
servie depuis le cache périmé. Sans ce signal, les compteurs locaux
|
||||
paraîtraient plus frais que le bloc cloud, qui affiche son âge.
|
||||
Gris et discret : il ne doit pas concurrencer le bandeau WAN. */}
|
||||
{!isLocalFresh && (
|
||||
<Tooltip label={t("widget.unifiOs.card.stale")}>
|
||||
<Badge size="xs" variant="light" color="gray" style={{ flexShrink: 0 }}>
|
||||
{dayjs(updatedAt).fromNow()}
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
{/* The one block that grows; the header, the stats rows and the footer keep
|
||||
their natural size. No `minHeight: 0` here on purpose: a flex item's
|
||||
automatic minimum is its content, so on a tile too short for everything
|
||||
the card stops shrinking at its own content and the tile scrolls, as it
|
||||
already did, rather than the card being squeezed and spilling the graph
|
||||
over the rows below it. */}
|
||||
<Stack
|
||||
gap={4}
|
||||
p="xs"
|
||||
style={{
|
||||
flex: 1,
|
||||
borderRadius: "var(--mantine-radius-md)",
|
||||
background: "var(--mantine-color-default-hover)",
|
||||
}}
|
||||
>
|
||||
{/* The rates ride the header row, right-aligned opposite the title, so
|
||||
they read as a heading and detach from the area below them. */}
|
||||
<Group justify="space-between" wrap="nowrap" gap="xs">
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<IconCircleFilled size={10} color={`var(--mantine-color-${wanColor}-6)`} />
|
||||
<Text fw={600} size="sm">
|
||||
{t("widget.unifiOs.card.internet")}
|
||||
</Text>
|
||||
</Group>
|
||||
{summary.wan.throughput === null ? (
|
||||
<Text size="sm" ff="monospace">
|
||||
{EMPTY_VALUE}
|
||||
</Text>
|
||||
) : (
|
||||
<ThroughputLegend
|
||||
upLabel={formatBitsPerSecond(summary.wan.throughput.txBps)}
|
||||
downLabel={formatBitsPerSecond(summary.wan.throughput.rxBps)}
|
||||
/>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
{/* Le graphe est rendu dès la première mesure : avec un seul point il
|
||||
reste vide, ce qui évite un saut de mise en page au second — et une
|
||||
aire vide n'affirme rien, contrairement à une ligne plate. */}
|
||||
{summary.wan.throughput !== null && (
|
||||
<ThroughputChart
|
||||
history={throughputHistory}
|
||||
formatRate={formatBitsPerSecond}
|
||||
upName={t("widget.unifiOs.card.up")}
|
||||
downName={t("widget.unifiOs.card.down")}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Bloc ISP masqué quand il n'y a pas de donnée cloud (§8) : sans clé
|
||||
Site Manager, cloud injoignable, ou aucun site correspondant au MAC
|
||||
du gateway. Une absence que l'intégration a positivement établie ne
|
||||
se déguise pas en valeur inconnue. */}
|
||||
{options.showIsp && isp !== null && (
|
||||
<Group justify="space-between" wrap="nowrap" gap="xs">
|
||||
<Text size="xs" c="dimmed" truncate>
|
||||
{isp.name}
|
||||
{isp.externalIp !== null ? ` · ${isp.externalIp}` : ""}
|
||||
</Text>
|
||||
<Group gap={6} wrap="nowrap">
|
||||
{/* Le cloud omet la latence pour les périodes de coupure : afficher
|
||||
un tiret plutôt qu'un « 0 ms » qui rassurerait à tort. */}
|
||||
<Text size="xs">
|
||||
{isp.latencyMs === null ? EMPTY_VALUE : t("widget.unifiOs.card.latency", { value: `${isp.latencyMs}` })}
|
||||
</Text>
|
||||
{isp.packetLossPct !== null && (
|
||||
<Text size="xs" c={isp.packetLossPct > 0 ? "yellow" : undefined}>
|
||||
{t("widget.unifiOs.card.packetLoss", { value: `${isp.packetLossPct}` })}
|
||||
</Text>
|
||||
)}
|
||||
{/* The cloud metrics behind this badge lag ~27 minutes: it must
|
||||
never look live, hence the relative-time badge instead of a
|
||||
bare number. */}
|
||||
<Tooltip
|
||||
label={t("widget.unifiOs.card.measuredAt", {
|
||||
time: dayjs(isp.measuredAt).format("HH:mm"),
|
||||
})}
|
||||
>
|
||||
<Badge size="xs" variant="light" color="yellow">
|
||||
{dayjs(isp.measuredAt).fromNow()}
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
</Group>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
<Stack gap={2}>
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("widget.unifiOs.card.clients")}
|
||||
</Text>
|
||||
<Text size="sm" fw={600}>
|
||||
{summary.clients === null
|
||||
? EMPTY_VALUE
|
||||
: t("widget.unifiOs.card.clientBreakdown", {
|
||||
total: `${summary.clients.total}`,
|
||||
wired: `${summary.clients.wired}`,
|
||||
wireless: `${summary.clients.wireless}`,
|
||||
})}
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
{options.showDevices && (
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("widget.unifiOs.card.devices")}
|
||||
</Text>
|
||||
<Text size="sm" fw={600} c={summary.devices !== null && summary.devices.offline > 0 ? "red" : undefined}>
|
||||
{summary.devices === null
|
||||
? EMPTY_VALUE
|
||||
: t("widget.unifiOs.card.devicesOnline", {
|
||||
online: `${summary.devices.online}`,
|
||||
total: `${summary.devices.total}`,
|
||||
})}
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
|
||||
{options.showFirmwareUpdates && (summary.devices === null || summary.devices.firmwareUpdatable > 0) && (
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("widget.unifiOs.card.firmware")}
|
||||
</Text>
|
||||
<Text size="sm" fw={600} c={summary.devices !== null ? "yellow" : undefined}>
|
||||
{summary.devices === null
|
||||
? EMPTY_VALUE
|
||||
: t("widget.unifiOs.card.firmwarePending", { count: `${summary.devices.firmwareUpdatable}` })}
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Text size="xs" c="dimmed" truncate>
|
||||
{t("widget.unifiOs.card.version", { version: summary.site.applicationVersion })}
|
||||
</Text>
|
||||
{(summary.devices === null || summary.gateway !== null) && (
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("widget.unifiOs.card.gatewayUptime", {
|
||||
uptime: summary.gateway?.uptimeSec != null ? formatUptime(summary.gateway.uptimeSec) : EMPTY_VALUE,
|
||||
})}
|
||||
</Text>
|
||||
)}
|
||||
</Group>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { IconRouter, IconServerOff } from "@tabler/icons-react";
|
||||
|
||||
import { createWidgetDefinition } from "../definition";
|
||||
import { optionsBuilder } from "../options";
|
||||
|
||||
export const { definition, componentLoader } = createWidgetDefinition("unifiOs", {
|
||||
icon: IconRouter,
|
||||
refetchInterval: 5,
|
||||
supportedIntegrations: ["unifiOs"],
|
||||
integrationsRequired: true,
|
||||
createOptions() {
|
||||
return optionsBuilder.from((factory) => ({
|
||||
showIsp: factory.switch({ defaultValue: true, withDescription: true }),
|
||||
showDevices: factory.switch({ defaultValue: true, withDescription: true }),
|
||||
showFirmwareUpdates: factory.switch({ defaultValue: true, withDescription: true }),
|
||||
}));
|
||||
},
|
||||
errors: {
|
||||
INTERNAL_SERVER_ERROR: {
|
||||
icon: IconServerOff,
|
||||
message: (t) => t("widget.unifiOs.error.internalServerError"),
|
||||
},
|
||||
},
|
||||
}).withDynamicImport(() => import("./component"));
|
||||
@@ -0,0 +1,137 @@
|
||||
"use client";
|
||||
|
||||
import { AreaChart } from "@mantine/charts";
|
||||
import { Box, Group, Paper, Stack, Text } from "@mantine/core";
|
||||
|
||||
import type { ThroughputPoint } from "./throughput-history";
|
||||
|
||||
/**
|
||||
* Series colours.
|
||||
*
|
||||
* Not copied from the `systemResources` network chart, which pairs `orange.5`
|
||||
* with `yellow.5`: that pair scores a normal-vision ΔE of 12.1, below the
|
||||
* legibility floor of 15, so even readers with full colour vision struggle to
|
||||
* tell the two lines apart. This pair was validated instead, and passes every
|
||||
* check — lightness band, chroma, colour-vision-deficiency separation,
|
||||
* normal-vision separation and surface contrast — against BOTH the light and
|
||||
* the dark chart surface, so one pair serves both themes.
|
||||
*/
|
||||
const DOWN_COLOR = "blue.7";
|
||||
const UP_COLOR = "orange.8";
|
||||
|
||||
/**
|
||||
* A floor, not a height: the chart now takes the height the card has left over.
|
||||
* `ResponsiveContainer` measures its parent and renders nothing at all when that
|
||||
* parent collapses to zero, so the wrapper keeps a small non-zero minimum — on a
|
||||
* very short tile the area stays legible instead of disappearing.
|
||||
*/
|
||||
const CHART_MIN_HEIGHT = 32;
|
||||
|
||||
/**
|
||||
* A dot carries the series identity so the figure beside it can stay in muted
|
||||
* ink: colour is never the only thing distinguishing up from down — the arrow
|
||||
* glyph and the label do it too.
|
||||
*/
|
||||
const SeriesKey = ({ color, label }: { color: string; label: string }) => (
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<Text c={color} size="xs" lh={1} aria-hidden>
|
||||
●
|
||||
</Text>
|
||||
<Text c="dimmed" size="xs" ff="monospace" lh={1}>
|
||||
{label}
|
||||
</Text>
|
||||
</Group>
|
||||
);
|
||||
|
||||
/**
|
||||
* Defined at module level rather than inline in the `content` prop: recharts
|
||||
* clones this element and injects `payload`, so it keeps a stable identity
|
||||
* across renders. The repo's other chart tooltips pass a closure instead and
|
||||
* each carries a `no-unstable-nested-components` warning for it.
|
||||
*/
|
||||
const ThroughputTooltip = ({
|
||||
payload,
|
||||
formatRate,
|
||||
upName,
|
||||
downName,
|
||||
}: {
|
||||
payload?: { name?: string | number; value?: string | number }[];
|
||||
formatRate: (bitsPerSecond: number) => string;
|
||||
upName: string;
|
||||
downName: string;
|
||||
}) => {
|
||||
if (!payload || payload.length === 0) return null;
|
||||
|
||||
return (
|
||||
<Paper px="xs" py={4} shadow="md" withBorder>
|
||||
<Stack gap={2}>
|
||||
{payload.map((item) => (
|
||||
<Text key={String(item.name)} size="xs" c="dimmed" ff="monospace">
|
||||
{item.name === "up" ? `↑ ${upName}` : `↓ ${downName}`} {formatRate(Number(item.value))}
|
||||
</Text>
|
||||
))}
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
|
||||
export interface ThroughputLegendProps {
|
||||
/** Rendered beside the up marker, already formatted. */
|
||||
upLabel: string;
|
||||
/** Rendered beside the down marker, already formatted. */
|
||||
downLabel: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sits on the card's "Internet" header row rather than above the graph, so the
|
||||
* figures read as a heading and detach from the area they summarise. Exported
|
||||
* from here instead of being rewritten there: the dots take the very constants
|
||||
* the chart's series use, so the two can never drift apart.
|
||||
*/
|
||||
export const ThroughputLegend = ({ upLabel, downLabel }: ThroughputLegendProps) => (
|
||||
<Group gap="sm" wrap="nowrap" justify="flex-end">
|
||||
<SeriesKey color={DOWN_COLOR} label={`↓ ${downLabel}`} />
|
||||
<SeriesKey color={UP_COLOR} label={`↑ ${upLabel}`} />
|
||||
</Group>
|
||||
);
|
||||
|
||||
export interface ThroughputChartProps {
|
||||
history: ThroughputPoint[];
|
||||
/** Formats a raw bits-per-second value for the tooltip. */
|
||||
formatRate: (bitsPerSecond: number) => string;
|
||||
upName: string;
|
||||
downName: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The wrapper is the flex child that claims the card's leftover height, and the
|
||||
* chart fills it with `h="100%"`. Mantine turns that into an inline
|
||||
* `height: 100%` on the chart root, which recharts' `ResponsiveContainer` then
|
||||
* measures; a percentage resolves here because flex layout gives this wrapper a
|
||||
* definite used height, the card above it being a flex item of a `h="100%"`
|
||||
* stack inside a tile of fixed height.
|
||||
*/
|
||||
export const ThroughputChart = ({ history, formatRate, upName, downName }: ThroughputChartProps) => (
|
||||
<Box style={{ flex: 1, minHeight: CHART_MIN_HEIGHT }}>
|
||||
<AreaChart
|
||||
h="100%"
|
||||
data={history}
|
||||
dataKey="index"
|
||||
series={[
|
||||
{ name: "down", label: downName, color: DOWN_COLOR },
|
||||
{ name: "up", label: upName, color: UP_COLOR },
|
||||
]}
|
||||
curveType="monotone"
|
||||
strokeWidth={2}
|
||||
withDots={false}
|
||||
withXAxis={false}
|
||||
withYAxis={false}
|
||||
gridAxis="none"
|
||||
withLegend={false}
|
||||
tooltipAnimationDuration={150}
|
||||
tooltipProps={{
|
||||
content: <ThroughputTooltip formatRate={formatRate} upName={upName} downName={downName} />,
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
@@ -0,0 +1,72 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import type { ThroughputPoint } from "./throughput-history";
|
||||
import { appendThroughputPoint, MAX_THROUGHPUT_POINTS } from "./throughput-history";
|
||||
|
||||
const point = (index: number, up: number, down: number): ThroughputPoint => ({ index, up, down });
|
||||
|
||||
describe("appendThroughputPoint", () => {
|
||||
test("records a reading with an index continuing the series", () => {
|
||||
const history = appendThroughputPoint([point(0, 1, 2)], { txBps: 30, rxBps: 40 });
|
||||
|
||||
expect(history).toEqual([point(0, 1, 2), point(1, 30, 40)]);
|
||||
});
|
||||
|
||||
test("starts the series at index 0 when there is no history yet", () => {
|
||||
expect(appendThroughputPoint([], { txBps: 5, rxBps: 6 })).toEqual([point(0, 5, 6)]);
|
||||
});
|
||||
|
||||
/**
|
||||
* A failed reading is not a measurement of zero. Appending 0 would draw a
|
||||
* plunge to the baseline that never happened — the chart would report an
|
||||
* outage the network did not have.
|
||||
*/
|
||||
test("skips a null reading instead of recording it as zero throughput", () => {
|
||||
const existing = [point(0, 10, 20)];
|
||||
|
||||
expect(appendThroughputPoint(existing, null)).toEqual(existing);
|
||||
});
|
||||
|
||||
/**
|
||||
* The gateway refreshes its statistics every 30 s (measured: `lastHeartbeatAt`
|
||||
* advances in exact 30 s steps). Polling faster returns the same numbers
|
||||
* repeatedly. Recording them would draw a staircase and burn the history
|
||||
* window on duplicates rather than on time.
|
||||
*/
|
||||
test("ignores a reading identical to the last recorded one", () => {
|
||||
const existing = [point(0, 10, 20)];
|
||||
|
||||
expect(appendThroughputPoint(existing, { txBps: 10, rxBps: 20 })).toEqual(existing);
|
||||
});
|
||||
|
||||
test("records a reading that differs in either direction", () => {
|
||||
const existing = [point(0, 10, 20)];
|
||||
|
||||
expect(appendThroughputPoint(existing, { txBps: 11, rxBps: 20 })).toEqual([...existing, point(1, 11, 20)]);
|
||||
expect(appendThroughputPoint(existing, { txBps: 10, rxBps: 21 })).toEqual([...existing, point(1, 10, 21)]);
|
||||
});
|
||||
|
||||
test("compares only against the most recent point, so a value may recur later", () => {
|
||||
const history = appendThroughputPoint([point(0, 10, 20), point(1, 30, 40)], { txBps: 10, rxBps: 20 });
|
||||
|
||||
expect(history).toEqual([point(0, 10, 20), point(1, 30, 40), point(2, 10, 20)]);
|
||||
});
|
||||
|
||||
test("keeps only the most recent points once the cap is reached", () => {
|
||||
const full = Array.from({ length: MAX_THROUGHPUT_POINTS }, (_, index) => point(index, index, index));
|
||||
|
||||
const history = appendThroughputPoint(full, { txBps: 99, rxBps: 98 });
|
||||
|
||||
expect(history).toHaveLength(MAX_THROUGHPUT_POINTS);
|
||||
expect(history.at(0)).toEqual(point(1, 1, 1));
|
||||
expect(history.at(-1)).toMatchObject({ up: 99, down: 98 });
|
||||
});
|
||||
|
||||
test("does not mutate the history it is given", () => {
|
||||
const existing = [point(0, 10, 20)];
|
||||
|
||||
appendThroughputPoint(existing, { txBps: 1, rxBps: 2 });
|
||||
|
||||
expect(existing).toEqual([point(0, 10, 20)]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* Rolling WAN throughput history, kept client-side.
|
||||
*
|
||||
* The UniFi OS integration reports the gateway's uplink rate at the instant it
|
||||
* is polled — there is no history endpoint, and Homarr stores none. So the
|
||||
* widget accumulates points in React state while the page is open, the same way
|
||||
* the `systemResources` widget does. The consequence is deliberate and worth
|
||||
* knowing: the series restarts empty on every page load.
|
||||
*/
|
||||
|
||||
export interface ThroughputPoint {
|
||||
index: number;
|
||||
/** Uplink, bits per second. Measured against a real console: the API's `Bps` fields are bits. */
|
||||
up: number;
|
||||
/** Downlink, bits per second. */
|
||||
down: number;
|
||||
}
|
||||
|
||||
/** At the widget's 60 s refetch interval this holds roughly an hour of history. */
|
||||
export const MAX_THROUGHPUT_POINTS = 60;
|
||||
|
||||
/**
|
||||
* Appends one reading, oldest points falling off the front once the cap is hit.
|
||||
*
|
||||
* Two readings are refused:
|
||||
*
|
||||
* - `null` — the statistics call failed, or no gateway was identified. Skipped
|
||||
* rather than recorded as zero: zero is a measurement, and drawing one would
|
||||
* show a plunge to the baseline that never happened.
|
||||
* - a repeat of the previous point. The gateway refreshes its statistics every
|
||||
* 30 s (measured: `lastHeartbeatAt` advances in exact 30 s steps), while the
|
||||
* widget polls faster than that, so the same numbers come back several times
|
||||
* in a row. Recording them would draw a staircase and spend the history
|
||||
* window on duplicates instead of on elapsed time.
|
||||
*/
|
||||
export const appendThroughputPoint = (
|
||||
history: ThroughputPoint[],
|
||||
throughput: { txBps: number; rxBps: number } | null,
|
||||
): ThroughputPoint[] => {
|
||||
if (throughput === null) return history;
|
||||
|
||||
const last = history.at(-1);
|
||||
if (last !== undefined && last.up === throughput.txBps && last.down === throughput.rxBps) return history;
|
||||
|
||||
const nextIndex = (history.at(-1)?.index ?? -1) + 1;
|
||||
|
||||
return [...history, { index: nextIndex, up: throughput.txBps, down: throughput.rxBps }].slice(-MAX_THROUGHPUT_POINTS);
|
||||
};
|
||||
@@ -0,0 +1,33 @@
|
||||
/*
|
||||
* Les lignes se partagent la hauteur de la tuile au lieu de laisser du vide
|
||||
* sous la dernière. Scopé à ce widget : `homarr-data-table.css` est partagé
|
||||
* avec docker, downloads et les autres, qui ne veulent pas de ce comportement.
|
||||
*/
|
||||
|
||||
/*
|
||||
* Relais indispensable. Entre le viewport de la ScrollArea — seul élément de
|
||||
* la chaîne à avoir une hauteur définie — et le tableau, Mantine insère un div
|
||||
* de contenu de hauteur automatique. Une hauteur en pourcentage se résout en
|
||||
* `auto` sous un parent de hauteur automatique : sans cette règle, le
|
||||
* `height: 100%` ci-dessous n'a strictement aucun effet.
|
||||
*/
|
||||
.unifi-site-manager-table .mantine-datatable-scroll-area > [data-scrollarea-viewport] > div {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/*
|
||||
* Le navigateur répartit alors la hauteur excédentaire entre les lignes. Quand
|
||||
* les lignes débordent, le tableau redevient plus haut que le viewport et le
|
||||
* défilement existant reprend la main : la règle n'écrase jamais le contenu.
|
||||
*/
|
||||
.unifi-site-manager-table .mantine-datatable-table {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/*
|
||||
* Sur une cellule de tableau, `height` vaut minimum et non taille fixe :
|
||||
* l'en-tête garde sa hauteur naturelle au lieu de grandir avec les lignes.
|
||||
*/
|
||||
.unifi-site-manager-table .mantine-datatable-table thead th {
|
||||
height: 0;
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
"use client";
|
||||
|
||||
import "./component.css";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { Box, Text } from "@mantine/core";
|
||||
import type { DataTableColumn, DataTableSortStatus } from "mantine-datatable";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import type { UnifiSiteHealthState, UnifiSiteManagerSite } from "@homarr/integrations/types";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import { HomarrDataTable } from "../common/homarr-data-table";
|
||||
import type { WidgetComponentProps } from "../definition";
|
||||
import { HealthBar } from "./health-bar";
|
||||
|
||||
const EMPTY_VALUE = "—";
|
||||
|
||||
/** En dessous, les colonnes secondaires ne tiennent plus lisiblement. */
|
||||
const NARROW_WIDTH = 420;
|
||||
|
||||
const formatHour = (at: Date) => at.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" });
|
||||
|
||||
/**
|
||||
* Départage deux sites dont au moins un porte une valeur inconnue.
|
||||
*
|
||||
* Renvoie 0 quand les deux le sont — à l'appelant de continuer, s'il a mieux.
|
||||
*/
|
||||
const compareUnknownLast = (first: unknown, second: unknown) => {
|
||||
if (first === null && second === null) return 0;
|
||||
return first === null ? 1 : -1;
|
||||
};
|
||||
|
||||
/**
|
||||
* Compare deux sites sur la colonne triée.
|
||||
*
|
||||
* `multiplier` ne s'applique qu'à la comparaison de deux valeurs CONNUES.
|
||||
* `wanUptimePct`, `latencyMs` et `ispName` sont nullables, et `null` y signifie
|
||||
* « pas mesuré » ou « pas rapporté » — jamais « zéro milliseconde », « 0 % » ni
|
||||
* « chaîne vide ». Un inconnu n'est donc pas une valeur extrême et n'a sa place à
|
||||
* aucun des deux bouts : il part en dernier DANS LES DEUX SENS, hors du
|
||||
* multiplicateur. Le traiter comme une valeur ferait remonter en tête d'un tri
|
||||
* croissant un site dont la latence n'a jamais été relevée, comme s'il était le
|
||||
* plus rapide — et classerait une disponibilité inconnue parmi les pires.
|
||||
*
|
||||
* Les colonnes textuelles passent par `localeCompare` et non par `<` : sinon
|
||||
* les noms accentués se classeraient en ordre de points de code.
|
||||
*/
|
||||
const compareSites = (
|
||||
first: UnifiSiteManagerSite,
|
||||
second: UnifiSiteManagerSite,
|
||||
accessor: DataTableSortStatus<UnifiSiteManagerSite>["columnAccessor"],
|
||||
multiplier: number,
|
||||
) => {
|
||||
if (accessor === "wanUptimePct") {
|
||||
const { wanUptimePct: firstUptime } = first;
|
||||
const { wanUptimePct: secondUptime } = second;
|
||||
if (firstUptime === null || secondUptime === null) return compareUnknownLast(firstUptime, secondUptime);
|
||||
return (firstUptime - secondUptime) * multiplier;
|
||||
}
|
||||
|
||||
if (accessor === "latencyMs") {
|
||||
const { latencyMs: firstLatency } = first;
|
||||
const { latencyMs: secondLatency } = second;
|
||||
if (firstLatency === null || secondLatency === null) return compareUnknownLast(firstLatency, secondLatency);
|
||||
return (firstLatency - secondLatency) * multiplier;
|
||||
}
|
||||
|
||||
if (accessor === "ispName") {
|
||||
const { ispName: firstIsp } = first;
|
||||
const { ispName: secondIsp } = second;
|
||||
if (firstIsp === null || secondIsp === null) return compareUnknownLast(firstIsp, secondIsp);
|
||||
return firstIsp.localeCompare(secondIsp) * multiplier;
|
||||
}
|
||||
|
||||
if (accessor === "deviceCount") return (first.deviceCount - second.deviceCount) * multiplier;
|
||||
if (accessor === "pendingUpdateCount") return (first.pendingUpdateCount - second.pendingUpdateCount) * multiplier;
|
||||
|
||||
// `name` : la seule autre colonne à déclarer `sortable`.
|
||||
return first.name.localeCompare(second.name) * multiplier;
|
||||
};
|
||||
|
||||
export default function UnifiSiteManagerWidget({
|
||||
integrationIds,
|
||||
options,
|
||||
isEditMode,
|
||||
width,
|
||||
}: WidgetComponentProps<"unifiSiteManager">) {
|
||||
const t = useI18n();
|
||||
const { data: results = [], isFetching } = clientApi.widget.unifiSiteManager.overview.useQuery({ integrationIds });
|
||||
|
||||
const sites = useMemo(() => results[0]?.overview.sites ?? [], [results]);
|
||||
const isNarrow = width < NARROW_WIDTH;
|
||||
|
||||
const [sortStatus, setSortStatus] = useState<DataTableSortStatus<UnifiSiteManagerSite>>({
|
||||
columnAccessor: "name",
|
||||
direction: "asc",
|
||||
});
|
||||
|
||||
const sortedSites = useMemo(() => {
|
||||
const multiplier = sortStatus.direction === "desc" ? -1 : 1;
|
||||
// Copie : le tableau appartient au cache de la requête, le trier en place
|
||||
// le muterait sous les autres lecteurs.
|
||||
return [...sites].toSorted((first, second) => compareSites(first, second, sortStatus.columnAccessor, multiplier));
|
||||
}, [sites, sortStatus]);
|
||||
|
||||
const labels: Record<UnifiSiteHealthState, string> = {
|
||||
healthy: t("widget.unifiSiteManager.health.healthy"),
|
||||
degraded: t("widget.unifiSiteManager.health.degraded"),
|
||||
down: t("widget.unifiSiteManager.health.down"),
|
||||
unknown: t("widget.unifiSiteManager.health.unknown"),
|
||||
};
|
||||
|
||||
const columns: DataTableColumn<UnifiSiteManagerSite>[] = [
|
||||
{
|
||||
accessor: "name",
|
||||
title: t("widget.unifiSiteManager.column.site"),
|
||||
sortable: true,
|
||||
ellipsis: true,
|
||||
},
|
||||
{
|
||||
accessor: "health",
|
||||
title: t("widget.unifiSiteManager.column.health"),
|
||||
width: isNarrow ? 140 : 220,
|
||||
render: (site) => (
|
||||
<HealthBar segments={site.health} summary={site.summary} formatHour={formatHour} labels={labels} />
|
||||
),
|
||||
},
|
||||
{
|
||||
accessor: "wanUptimePct",
|
||||
// Intitulé SANS fenêtre temporelle, à dessein : le cloud ne documente pas
|
||||
// la période que couvre `statistics.percentages.wanUptime`. Écrire
|
||||
// « WAN uptime (24 h) » ou « (30 j) » affirmerait ce que nous ne savons
|
||||
// pas. Ne pas « améliorer » cet en-tête en y ajoutant une durée.
|
||||
title: t("widget.unifiSiteManager.column.wanUptime"),
|
||||
sortable: true,
|
||||
// `null` = le cloud n'a rien rapporté. Ni 0 %, ni 100 % : le tiret.
|
||||
render: (site) =>
|
||||
site.wanUptimePct === null
|
||||
? EMPTY_VALUE
|
||||
: t("widget.unifiSiteManager.wanUptime", { value: `${site.wanUptimePct}` }),
|
||||
},
|
||||
{
|
||||
accessor: "latencyMs",
|
||||
title: t("widget.unifiSiteManager.column.latency"),
|
||||
sortable: true,
|
||||
render: (site) => {
|
||||
if (site.latencyMs === null) return EMPTY_VALUE;
|
||||
|
||||
// Le cloud rapporte la latence et la perte de paquets indépendamment :
|
||||
// une période mesurée pour l'une peut ne pas l'être pour l'autre. Un 0
|
||||
// par défaut affirmerait à tort une perte mesurée à zéro, alors qu'elle
|
||||
// n'a simplement pas été relevée sur cette période.
|
||||
if (site.packetLossPct === null) {
|
||||
return t("widget.unifiSiteManager.latencyOnly", { value: `${site.latencyMs}` });
|
||||
}
|
||||
|
||||
return t("widget.unifiSiteManager.latency", {
|
||||
value: `${site.latencyMs}`,
|
||||
loss: `${site.packetLossPct}`,
|
||||
});
|
||||
},
|
||||
},
|
||||
...(options.showIsp && !isNarrow
|
||||
? [
|
||||
{
|
||||
accessor: "ispName",
|
||||
title: t("widget.unifiSiteManager.column.isp"),
|
||||
sortable: true,
|
||||
render: (site: UnifiSiteManagerSite) => site.ispName ?? EMPTY_VALUE,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(options.showExternalIp && !isNarrow
|
||||
? [
|
||||
{
|
||||
accessor: "externalIps",
|
||||
title: t("widget.unifiSiteManager.column.externalIp"),
|
||||
render: (site: UnifiSiteManagerSite) =>
|
||||
site.externalIps.length === 0 ? EMPTY_VALUE : site.externalIps.join(" · "),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
accessor: "deviceCount",
|
||||
title: t("widget.unifiSiteManager.column.devices"),
|
||||
sortable: true,
|
||||
render: (site) => (
|
||||
<Text size="sm" c={site.offlineDeviceCount > 0 ? "red" : undefined}>
|
||||
{t("widget.unifiSiteManager.devicesOnline", {
|
||||
online: `${site.deviceCount - site.offlineDeviceCount}`,
|
||||
total: `${site.deviceCount}`,
|
||||
})}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessor: "pendingUpdateCount",
|
||||
title: t("widget.unifiSiteManager.column.updates"),
|
||||
sortable: true,
|
||||
render: (site) => (
|
||||
<Text size="sm" c={site.pendingUpdateCount > 0 ? "yellow" : undefined}>
|
||||
{site.pendingUpdateCount}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessor: "sdWan",
|
||||
title: t("widget.unifiSiteManager.column.sdWan"),
|
||||
render: (site) => {
|
||||
// `null` couvre DEUX cas que le contrat ne distingue pas : le site
|
||||
// n'appartient à aucune configuration, ou le cloud n'a pas répondu.
|
||||
// Le tiret n'affirme donc ni l'un ni l'autre — il dit « on ne sait pas »,
|
||||
// ce qui est vrai dans les deux cas. Ne pas le remplacer par un libellé
|
||||
// du type « aucun mesh », qui trancherait à tort.
|
||||
if (site.sdWan === null) return EMPTY_VALUE;
|
||||
|
||||
const { offline, connected, pingMs, errorCount } = site.sdWan;
|
||||
const color = offline || !connected ? "red" : errorCount > 0 ? "yellow" : undefined;
|
||||
const label = offline
|
||||
? t("widget.unifiSiteManager.sdWan.offline")
|
||||
: connected
|
||||
? t("widget.unifiSiteManager.sdWan.connected")
|
||||
: t("widget.unifiSiteManager.sdWan.disconnected");
|
||||
|
||||
return (
|
||||
<Text size="sm" c={color}>
|
||||
{/* Le ping n'apparaît que mesuré : le cloud rapporte 0 quand il ne
|
||||
l'a pas mesuré, et l'afficher se lirait comme une latence nulle. */}
|
||||
{pingMs === null ? label : `${label} · ${pingMs} ms`}
|
||||
</Text>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessor: "consoleUpdateAvailable",
|
||||
title: t("widget.unifiSiteManager.column.console"),
|
||||
// `null` = aucun host correspondant. Ce n'est pas « à jour ».
|
||||
render: (site) =>
|
||||
site.consoleUpdateAvailable === null
|
||||
? EMPTY_VALUE
|
||||
: site.consoleUpdateAvailable
|
||||
? t("widget.unifiSiteManager.consoleUpdateAvailable")
|
||||
: t("widget.unifiSiteManager.consoleUpToDate"),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Box h="100%">
|
||||
<HomarrDataTable
|
||||
className="unifi-site-manager-table"
|
||||
isEditMode={isEditMode}
|
||||
cellPadding={isNarrow ? "2px 6px" : "4px 8px"}
|
||||
records={sortedSites}
|
||||
columns={columns}
|
||||
idAccessor="siteId"
|
||||
sortStatus={sortStatus}
|
||||
// En mode édition, le tableau ne doit pas se réordonner sous le curseur.
|
||||
onSortStatusChange={isEditMode ? undefined : setSortStatus}
|
||||
// Sans cela, « aucun site » s'affiche pendant la première requête : une
|
||||
// affirmation sur le compte, fausse tant qu'on n'a rien reçu.
|
||||
fetching={isFetching && sortedSites.length === 0}
|
||||
noRecordsText={t("widget.unifiSiteManager.noSites")}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
"use client";
|
||||
|
||||
import { Box, Group, Stack, Text, Tooltip } from "@mantine/core";
|
||||
|
||||
import type { UnifiSiteHealthSegment, UnifiSiteHealthState, UnifiSiteHealthSummary } from "@homarr/integrations/types";
|
||||
|
||||
/**
|
||||
* Couleurs de STATUT, pas palette catégorielle — elles sont fixes et non thémées.
|
||||
* Un vert/orange/rouge « classique » a été mesuré à ΔE 2,9 entre l'orange et le
|
||||
* vert en vision protanope : indistinguables. La palette de statut ne résout pas
|
||||
* cela, elle impose une contrepartie — voir le résumé textuel ci-dessous, qui
|
||||
* n'est pas décoratif.
|
||||
*/
|
||||
const STATE_COLOR: Record<UnifiSiteHealthState, string> = {
|
||||
healthy: "#0ca30c",
|
||||
degraded: "#fab219",
|
||||
down: "#d03b3b",
|
||||
unknown: "var(--mantine-color-default-border)",
|
||||
};
|
||||
|
||||
const SEGMENT_HEIGHT = 14;
|
||||
|
||||
const NOTABLE_STATES = ["down", "degraded", "unknown"] as const;
|
||||
|
||||
/**
|
||||
* TOUS les états non nuls sont listés, sans plafond sur leur nombre.
|
||||
* N'en retenir qu'un ferait disparaître les heures des autres, et le lecteur
|
||||
* en déduirait un reste sain qui serait faux. `unknown` compte au même titre :
|
||||
* une fenêtre partiellement rapportée n'est pas une fenêtre partiellement
|
||||
* dégradée, et la taire laisse croire que les heures manquantes étaient mauvaises.
|
||||
* Pour qui ne distingue pas les trois couleurs de la barre, cette phrase EST la
|
||||
* donnée — toute omission ici est une omission tout court.
|
||||
*/
|
||||
const composeSummary = (summary: UnifiSiteHealthSummary, labels: Record<UnifiSiteHealthState, string>) => {
|
||||
const notable = NOTABLE_STATES.filter((state) => summary[state] > 0).map(
|
||||
(state) => `${summary[state]}h ${labels[state]}`,
|
||||
);
|
||||
|
||||
return notable.length > 0 ? notable.join(" · ") : `${summary.healthy}h ${labels.healthy}`;
|
||||
};
|
||||
|
||||
export const HealthBar = ({
|
||||
segments,
|
||||
summary,
|
||||
formatHour,
|
||||
labels,
|
||||
}: {
|
||||
segments: UnifiSiteHealthSegment[];
|
||||
summary: UnifiSiteHealthSummary;
|
||||
formatHour: (at: Date) => string;
|
||||
labels: Record<UnifiSiteHealthState, string>;
|
||||
}) => {
|
||||
const summaryText = composeSummary(summary, labels);
|
||||
|
||||
return (
|
||||
<Stack gap={2}>
|
||||
{/* Les segments n'ont que le survol : ni le clavier ni le toucher n'y
|
||||
accèdent. Plutôt qu'un `aria-label` sur un `div` sans rôle — que les
|
||||
lecteurs d'écran ignorent couramment — la barre est masquée et c'est
|
||||
le résumé ci-dessous, visible et composé des mêmes chiffres, qui porte
|
||||
l'information. Le rendre lisible deux fois n'ajouterait rien. */}
|
||||
<Group gap={1} wrap="nowrap" aria-hidden>
|
||||
{segments.map((segment) => (
|
||||
<Tooltip
|
||||
key={segment.at.toISOString()}
|
||||
label={`${formatHour(segment.at)} — ${labels[segment.state]}`}
|
||||
openDelay={150}
|
||||
>
|
||||
<Box
|
||||
style={{
|
||||
flex: 1,
|
||||
minWidth: 2,
|
||||
height: SEGMENT_HEIGHT,
|
||||
borderRadius: 1,
|
||||
background: STATE_COLOR[segment.state],
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
))}
|
||||
</Group>
|
||||
|
||||
{/* Le canal qui porte l'information quand la couleur ne passe pas.
|
||||
Sous la barre et sans `nowrap` : il dispose de toute la largeur de la
|
||||
cellule et passe à la ligne plutôt que d'être tronqué. */}
|
||||
<Text size="xs" c="dimmed" ff="monospace">
|
||||
{summaryText}
|
||||
</Text>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,23 @@
|
||||
import { IconBuildingBroadcastTower, IconServerOff } from "@tabler/icons-react";
|
||||
|
||||
import { createWidgetDefinition } from "../definition";
|
||||
import { optionsBuilder } from "../options";
|
||||
|
||||
export const { definition, componentLoader } = createWidgetDefinition("unifiSiteManager", {
|
||||
icon: IconBuildingBroadcastTower,
|
||||
supportedIntegrations: ["unifiSiteManager"],
|
||||
integrationsRequired: true,
|
||||
refetchInterval: 300,
|
||||
createOptions() {
|
||||
return optionsBuilder.from((factory) => ({
|
||||
showIsp: factory.switch({ defaultValue: true, withDescription: true }),
|
||||
showExternalIp: factory.switch({ defaultValue: true, withDescription: true }),
|
||||
}));
|
||||
},
|
||||
errors: {
|
||||
INTERNAL_SERVER_ERROR: {
|
||||
icon: IconServerOff,
|
||||
message: (t) => t("widget.unifiSiteManager.error.internalServerError"),
|
||||
},
|
||||
},
|
||||
}).withDynamicImport(() => import("./component"));
|
||||
Reference in new issue
Block a user