diff --git a/docs/superpowers/plans/2026-09-09-unifi-site-manager-integration.md b/docs/superpowers/plans/2026-09-09-unifi-site-manager-integration.md new file mode 100644 index 000000000..d7b81cdd4 --- /dev/null +++ b/docs/superpowers/plans/2026-09-09-unifi-site-manager-integration.md @@ -0,0 +1,1955 @@ +# Plan d'implémentation — Intégration UniFi Site Manager + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal :** Ajouter une intégration `unifiSiteManager` lisant uniquement l'API cloud `https://api.ui.com`, et un widget affichant une vue d'ensemble multi-sites avec une bande de santé de 24 h par site. + +**Architecture :** Même découpage que l'intégration `unifiOs` — client HTTP, schémas et mappers purs, orchestration, chacun dans son fichier — avec dès le départ la séparation `types` / `parse` que le défaut de bundle client avait imposée là-bas. Trois appels cloud par rafraîchissement, deux niveaux de criticité. Widget bâti sur `HomarrDataTable`. + +**Tech Stack :** TypeScript, zod v4 (`zod/v4`), vitest, undici `fetch`, Mantine v9, `mantine-datatable`, tRPC, Turbo/pnpm. + +**Spec :** `docs/superpowers/specs/2026-09-09-unifi-site-manager-integration-design.md` + +## Global Constraints + +- **Branche :** `feat/unifi-os-integration`. Ce projet dépend d'elle : le secret `siteManagerApiKey` y est introduit. Ne jamais committer sur `dev` ni `main`. +- **Environnement — obligatoire.** Avant TOUTE commande `pnpm`/`node`, dans chaque shell : + `cd /home/zachary/Dev/homarr && source .superpowers/sdd/2026-09-08-unifi-os-integration/env.sh` + Sans ça, pnpm s'arrête sur `ERR_PNPM_UNSUPPORTED_ENGINE` (le Node par défaut du poste est trop ancien). Les dépendances sont installées ; ne pas lancer `pnpm install`. +- **Tests :** `pnpm test `. **Jamais `pnpm vitest run` seul** sur des modules qui atteignent `@homarr/common/server` sans garde d'environnement. +- **Lint / format :** oxlint et oxfmt. **Ne jamais lancer `pnpm format`** — il reformate tout le dépôt et crée de la dérive dans des paquets étrangers. Formater uniquement ses fichiers : `node_modules/.bin/oxfmt `. +- **zod :** `import { z } from "zod/v4"`. Objets d'API en `z.looseObject` — Ubiquiti ajoute des champs sans prévenir. +- **`unifi-site-manager-types.ts` ne doit JAMAIS importer `@homarr/common/server`**, ni rien qui y mène. Il est réexporté par `packages/integrations/src/types.ts`, lui-même importé par des composants client ; un import serveur y traîne `ioredis` puis `node:dns` dans le bundle navigateur et **empêche l'application de démarrer**. Un test de garde le vérifie (tâche 2). +- **URL cloud** `https://api.ui.com` en dur dans le client, jamais configurable. +- **Traductions uniquement dans `packages/translation/src/lang/en.json`.** Ne pas toucher aux ~30 autres fichiers de langue. +- **UI :** Mantine v9 et `@tabler/icons-react`. **Jamais Tailwind.** +- **Aucune donnée réelle** — pas d'adresse MAC, d'IP publique, de nom de site ou de FAI provenant d'un vrai réseau, y compris dans les fixtures et les commentaires. +- **Ne pas modifier** `packages/integrations/src/unifi-os/`, `unifi-controller/`, `opnsense/`, `interfaces/network-controller-summary/`, ni `packages/widgets/src/{network-controller,unifi-os}/`. +- **Couleurs de statut, valeurs fixes :** `healthy` `#0ca30c`, `degraded` `#fab219`, `down` `#d03b3b`. `unknown` prend un gris neutre de surface. Ce sont des couleurs de **statut**, pas une palette catégorielle — ne pas les « améliorer ». +- `git status --short` ne doit montrer que les fichiers voulus avant chaque commit. + +--- + +## Structure des fichiers + +**Créés :** + +| Fichier | Responsabilité | +|---|---| +| `packages/integrations/src/unifi-site-manager/unifi-site-manager-types.ts` | Schémas zod et mappers purs. **Client-safe.** | +| `.../unifi-site-manager-parse.ts` | Validation d'une réponse. Dépend de `@homarr/common/server`. | +| `.../unifi-site-manager-api.ts` | Client HTTP cloud. | +| `.../unifi-site-manager-integration.ts` | Classe `Integration` : orchestre les trois appels et mappe. | +| `packages/integrations/src/interfaces/unifi-site-manager/unifi-site-manager-integration.ts` | Interface consommée par le request-handler. | +| `.../unifi-site-manager/test/*.spec.ts` | Tests. | +| `packages/request-handler/src/unifi-site-manager.ts` | Handler de cache. | +| `packages/api/src/router/widgets/unifi-site-manager.ts` | Routeur tRPC. | +| `packages/widgets/src/unifi-site-manager/index.ts` | Définition du widget. | +| `packages/widgets/src/unifi-site-manager/component.tsx` | Composant tableau. | +| `packages/widgets/src/unifi-site-manager/health-bar.tsx` | Bande de santé + résumé textuel. | + +**Modifiés :** `definitions/src/{integration.ts,widget.ts,widget-integration-map.ts,docs/integration-doc-slugs.ts,docs/widget-doc-slugs.ts}`, `integrations/src/{base/creator.ts,index.ts,types.ts}`, `api/src/router/widgets/index.ts`, `widgets/src/{manifest.ts,index.tsx,catalog.ts,refetch-intervals.ts}`, `apps/nextjs/src/components/integration/integration-grid-shared.ts`, `translation/src/lang/en.json`. + +--- + +## Task 1 : Déclarations + +Poser le vocabulaire. Rien d'autre ne compile sans lui. + +**Files:** +- Modify: `packages/definitions/src/integration.ts` +- Modify: `packages/definitions/src/widget.ts` +- Modify: `packages/definitions/src/widget-integration-map.ts` +- Modify: `packages/definitions/src/docs/integration-doc-slugs.ts` +- Modify: `packages/definitions/src/docs/widget-doc-slugs.ts` +- Modify: `apps/nextjs/src/components/integration/integration-grid-shared.ts` +- Modify: `packages/translation/src/lang/en.json` + +**Interfaces:** +- Consumes: le secret `siteManagerApiKey`, déjà déclaré dans `integrationSecretKindObject`. **Ne pas le redéclarer.** +- Produces: le kind `"unifiSiteManager"`, la catégorie `"unifiSiteManager"`, le `WidgetKind` `"unifiSiteManager"`. + +- [ ] **Step 1 : Ajouter la catégorie** + +Dans `packages/definitions/src/integration.ts`, dans le tableau `integrationCategories`, avant `] as const;` : + +```ts + "unifiSiteManager", +``` + +- [ ] **Step 2 : Ajouter le kind** + +Dans le même fichier, dans `integrationDefs`, **immédiatement après** le bloc `unifiOs` : + +```ts + 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", + }, +``` + +Pas de `defaultPort` : l'URL est complète. `defaultUrl` suit le précédent d'`umami` (`https://api.umami.is/v1`) ; le champ `url` est `notNull` en base, d'où la valeur par défaut plutôt que son absence. + +- [ ] **Step 3 : Ajouter le WidgetKind** + +Dans `packages/definitions/src/widget.ts`, dans `widgetKinds`, après `"unifiOs",` : + +```ts + "unifiSiteManager", +``` + +- [ ] **Step 4 : Câbler widget → intégration** + +Dans `packages/definitions/src/widget-integration-map.ts`, après la ligne `unifiOs: ["unifiOs"],` : + +```ts + unifiSiteManager: ["unifiSiteManager"], +``` + +- [ ] **Step 5 : Renseigner les tables de slugs de documentation** + +Dans `packages/definitions/src/docs/integration-doc-slugs.ts`, après l'entrée `unifiOs` : + +```ts + unifiSiteManager: null, +``` + +Et dans `packages/definitions/src/docs/widget-doc-slugs.ts`, après l'entrée `unifiOs` : + +```ts + unifiSiteManager: null, +``` + +`null` parce que ce fork ne produit pas de documentation Docusaurus. Ces deux tables sont exhaustives et vérifiées par le compilateur : une omission casse le typecheck. + +- [ ] **Step 6 : Enregistrer la clé de traduction de catégorie** + +Dans `apps/nextjs/src/components/integration/integration-grid-shared.ts`, dans `categoryTranslationKeys`, après l'entrée `unifiOs` : + +```ts + unifiSiteManager: "integration.category.unifiSiteManager", +``` + +**Cette table est typée `Record`, pas `Record`.** Le compilateur ne vérifie donc PAS sa complétude : une omission passe le typecheck et fait afficher la clé brute en majuscules dans le catalogue. C'est exactement ce qui est arrivé à l'intégration précédente, et seul le lancement de l'application l'a révélé. + +- [ ] **Step 7 : Ajouter la traduction de catégorie** + +Dans `packages/translation/src/lang/en.json`, dans `integration.category`, à côté de `"unifiOs"` : + +```json +"unifiSiteManager": "UniFi Site Manager", +``` + +- [ ] **Step 8 : Vérifier** + +Run: `pnpm turbo typecheck --filter=@homarr/definitions` +Expected: PASS. + +`@homarr/widgets` échouera tant que la tâche 6 n'a pas ajouté l'entrée de manifeste du nouveau `WidgetKind`. C'est attendu. **Ne pas « corriger » en retirant le kind, ni en ajoutant des entrées de manifeste ici** — elles appartiennent à la tâche 6. + +- [ ] **Step 9 : Committer** + +```bash +git add packages/definitions apps/nextjs/src/components/integration/integration-grid-shared.ts packages/translation/src/lang/en.json +git commit -m "feat(definitions): declare unifiSiteManager integration and widget kinds + +Co-Authored-By: Claude Opus 5 " +``` + +--- + +## Task 2 : Schémas et mappers purs + +Le cœur testable. Aucun réseau, donc aucun mock. + +**Files:** +- Create: `packages/integrations/src/unifi-site-manager/unifi-site-manager-types.ts` +- Create: `packages/integrations/src/unifi-site-manager/unifi-site-manager-parse.ts` +- Test: `packages/integrations/src/unifi-site-manager/test/unifi-site-manager-types.spec.ts` + +**Interfaces:** +- Consumes: rien. +- Produces : + - types `UnifiSiteHealthState`, `UnifiSiteHealthSegment`, `UnifiSiteHealthSummary`, `UnifiSiteManagerSite`, `UnifiSiteManagerOverview` + - schémas `siteManagerSiteSchema`, `siteManagerSiteListSchema`, `siteManagerHostSchema`, `siteManagerHostListSchema`, `ispMetricsResponseSchema` + - `HEALTH_WINDOW_HOURS: 24` + - `deriveHealthState(wan): UnifiSiteHealthState` + - `buildHealthWindow(periods, endingAt): UnifiSiteHealthSegment[]` + - `summariseHealth(segments): UnifiSiteHealthSummary` + - `sdWanConfigListSchema`, `sdWanStatusSchema` + - `buildSdWanIndex(configs: { configName: string; status: SdWanStatus }[]): Map` + - `mapSites(sites, hosts, metrics, sdWanBySite, now): UnifiSiteManagerSite[]` + - depuis `unifi-site-manager-parse.ts` : `parseSiteManagerAsync(value, schema, message): Promise` + +- [ ] **Step 1 : Écrire les tests qui échouent** + +Créer `packages/integrations/src/unifi-site-manager/test/unifi-site-manager-types.spec.ts` : + +```ts +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { describe, expect, test } from "vitest"; + +import { + buildHealthWindow, + buildSdWanIndex, + deriveHealthState, + HEALTH_WINDOW_HOURS, + mapSites, + 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) => ({ + 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); + }); +}); + +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 = {}) => ({ + status: { + connections: [ + { sites: [{ siteId, status: { connected: true, offline: false, ping: 0, errors: [], ...overrides } }] }, + ], + sites: [{ siteId, errors: [], warnings: [] }], + }, + }); + + 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(); + }); + + test("reports a console with no pending update as false, not null", () => { + const [mapped] = mapSites([site], [{ id: "host-1", reportedState: { deviceState: "connected" } }], [], new Map(), NOW); + + expect(mapped!.consoleUpdateAvailable).toBe(false); + }); + + 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); + }); +}); + +/** + * 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", () => { + const source = readFileSync(join(__dirname, "..", "unifi-site-manager-types.ts"), "utf8"); + + expect(source).not.toContain("@homarr/common/server"); + }); +}); +``` + +- [ ] **Step 2 : Lancer les tests pour vérifier qu'ils échouent** + +Run: `pnpm test packages/integrations/src/unifi-site-manager/` +Expected: FAIL — `Cannot find module '../unifi-site-manager-types'`. + +- [ ] **Step 3 : Écrire le module de types** + +Créer `packages/integrations/src/unifi-site-manager/unifi-site-manager-types.ts`. **Aucun import de `@homarr/common/server` ici.** + +```ts +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() }).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; + +export type SiteManagerSite = z.infer; +export type SiteManagerHost = z.infer; +export type IspMetricsEntry = z.infer; +export type IspMetricsPeriod = z.infer; + +export type UnifiSiteHealthState = "healthy" | "degraded" | "down" | "unknown"; + +export interface UnifiSiteHealthSegment { + /** Début de la tranche horaire. */ + at: Date; + state: UnifiSiteHealthState; +} + +export type UnifiSiteHealthSummary = Record; + +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` si aucun host ne correspond. */ + 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` 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` : « 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 = { 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é. + */ +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(); + 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; +}; + +const latestPeriod = (periods: IspMetricsPeriod[]): IspMetricsPeriod | undefined => + periods.reduce((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 => { + const index = new Map(); + + 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; +}; + +export const mapSites = ( + sites: SiteManagerSite[], + hosts: SiteManagerHost[], + metrics: IspMetricsEntry[], + sdWanBySite: Map, + now: 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, now); + 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: host === undefined ? null : host.reportedState?.deviceState === "updateAvailable", + health, + summary: summariseHealth(health), + latencyMs: latest?.avgLatency ?? null, + packetLossPct: latest?.packetLoss ?? null, + sdWan: sdWanBySite.get(site.siteId) ?? null, + }; + }); +``` + +- [ ] **Step 4 : Écrire le module de parsing** + +Créer `packages/integrations/src/unifi-site-manager/unifi-site-manager-parse.ts` : + +```ts +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 ( + value: unknown, + schema: z.ZodType, + message: string, +): Promise => { + const result = await schema.safeParseAsync(value); + if (!result.success) { + throw new ParseError(message, { cause: result.error }); + } + return result.data; +}; +``` + +- [ ] **Step 5 : Lancer les tests** + +Run: `pnpm test packages/integrations/src/unifi-site-manager/` +Expected: PASS, 24 tests. + +- [ ] **Step 6 : Formater et committer** + +```bash +node_modules/.bin/oxfmt packages/integrations/src/unifi-site-manager/ +git add packages/integrations/src/unifi-site-manager/ +git commit -m "feat(integrations): add UniFi Site Manager schemas and pure mappers + +Co-Authored-By: Claude Opus 5 " +``` + +--- + +## Task 3 : Client HTTP cloud + +**Files:** +- Create: `packages/integrations/src/unifi-site-manager/unifi-site-manager-api.ts` +- Test: `packages/integrations/src/unifi-site-manager/test/unifi-site-manager-api.spec.ts` + +**Interfaces:** +- Consumes: les schémas et `parseSiteManagerAsync` de la tâche 2. +- Produces : + - `type SiteManagerFetch = (url: string, init?: RequestInit & { timeout?: number }) => Promise` + - `class UnifiSiteManagerApi` — constructeur `(apiKey: string, fetchAsync: SiteManagerFetch)` + - `getSitesAsync(): Promise` + - `getHostsAsync(): Promise` + - `getIspMetricsAsync(sites: { siteId: string; hostId?: string }[], endingAt: Date): Promise` + - `getSdWanStatusesAsync(): Promise<{ configName: string; status: SdWanStatus["data"]["status"] }[]>` + - `MAX_SD_WAN_CONFIGS: 5` + +- [ ] **Step 1 : Écrire les tests qui échouent** + +Créer `packages/integrations/src/unifi-site-manager/test/unifi-site-manager-api.spec.ts` : + +```ts +// @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 { MAX_SD_WAN_CONFIGS, 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) => { + 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([]); + }); +}); + +describe("UnifiSiteManagerApi getIspMetricsAsync", () => { + test("asks for every site in a single bounded query", async () => { + let body: { sites: { siteId: string; beginTimestamp: string; endTimestamp: string }[] } | null = null; + const { api } = createApi(async (_url, init) => { + body = JSON.parse(String(init?.body)) as typeof body; + return jsonResponse({ data: { metrics: [] } }); + }); + + await api.getIspMetricsAsync([{ siteId: "a", hostId: "h1" }, { siteId: "b", hostId: "h2" }], NOW); + + expect(body!.sites).toHaveLength(2); + const [first] = body!.sites; + const spanHours = + (new Date(first!.endTimestamp).getTime() - new Date(first!.beginTimestamp).getTime()) / 3_600_000; + expect(spanHours).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(); + }); + + test("uses the hourly granularity, not the five-minute one", async () => { + const { api, fetchAsync } = createApi(async () => jsonResponse({ data: { metrics: [] } })); + + await api.getIspMetricsAsync([{ siteId: "a" }], NOW); + + expect(String(fetchAsync.mock.calls[0]?.[0])).toBe("https://api.ui.com/v1/isp-metrics/1h/query"); + }); +}); +``` + +- [ ] **Step 2 : Lancer les tests pour vérifier qu'ils échouent** + +Run: `pnpm test packages/integrations/src/unifi-site-manager/` +Expected: FAIL — `Cannot find module '../unifi-site-manager-api'`. + +- [ ] **Step 3 : Écrire l'implémentation** + +Créer `packages/integrations/src/unifi-site-manager/unifi-site-manager-api.ts` : + +```ts +import { ResponseError } from "@homarr/common/server"; +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; + +export type SiteManagerFetch = (url: string, init?: RequestInit & { timeout?: number }) => Promise; + +export class UnifiSiteManagerApi { + constructor( + private readonly apiKey: string, + private readonly fetchAsync: SiteManagerFetch, + ) {} + + public async getSitesAsync(): Promise { + const parsed = await this.requestAsync("/v1/sites", siteManagerSiteListSchema, "Invalid Site Manager sites response"); + return parsed.data; + } + + public async getHostsAsync(): Promise { + const parsed = await this.requestAsync("/v1/hosts", siteManagerHostListSchema, "Invalid Site Manager hosts response"); + return parsed.data; + } + + /** + * Granularité horaire sur la fenêtre de la bande de santé, tous les sites dans + * une seule requête bornée. La variante non bornée pèse 216 Ko (mesuré). + */ + public async getIspMetricsAsync( + sites: { siteId: string; hostId?: string }[], + endingAt: Date, + ): Promise { + if (sites.length === 0) return []; + + const beginTimestamp = new Date(endingAt.getTime() - HEALTH_WINDOW_HOURS * 3_600_000).toISOString(); + const endTimestamp = endingAt.toISOString(); + + const response = await this.fetchAsync(`${BASE_URL}/v1/isp-metrics/1h/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); + + 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(path: string, schema: z.ZodType, message: string): Promise { + 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); + } +} +``` + +- [ ] **Step 4 : Lancer les tests** + +Run: `pnpm test packages/integrations/src/unifi-site-manager/` +Expected: PASS, 33 tests. + +- [ ] **Step 5 : Formater et committer** + +```bash +node_modules/.bin/oxfmt packages/integrations/src/unifi-site-manager/ +git add packages/integrations/src/unifi-site-manager/ +git commit -m "feat(integrations): add UniFi Site Manager cloud client + +Co-Authored-By: Claude Opus 5 " +``` + +--- + +## Task 4 : Classe d'intégration et enregistrements + +**Files:** +- Create: `packages/integrations/src/interfaces/unifi-site-manager/unifi-site-manager-integration.ts` +- Create: `packages/integrations/src/unifi-site-manager/unifi-site-manager-integration.ts` +- Test: `packages/integrations/src/unifi-site-manager/test/unifi-site-manager-integration.spec.ts` +- Modify: `packages/integrations/src/base/creator.ts` +- Modify: `packages/integrations/src/index.ts` +- Modify: `packages/integrations/src/types.ts` + +**Interfaces:** +- Consumes: `UnifiSiteManagerApi` (tâche 3), `mapSites` et les types (tâche 2), le kind `unifiSiteManager` (tâche 1). +- Produces : + - `interface UnifiSiteManagerIntegration { getSiteOverviewAsync(): Promise }` + - `class UnifiSiteManagerIntegration extends Integration` + - entrée `unifiSiteManager` dans les créateurs + +- [ ] **Step 1 : Écrire les tests qui échouent** + +Créer `packages/integrations/src/unifi-site-manager/test/unifi-site-manager-integration.spec.ts` : + +```ts +// @vitest-environment node +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 * as undici from "undici"; + +import type { IntegrationSecret } from "../../base/types"; +import { UnifiSiteManagerIntegration } from "../unifi-site-manager-integration"; + +vi.mock("undici", async (importOriginal) => ({ + ...(await importOriginal()), + fetch: vi.fn(), +})); + +const mockFetch = vi.mocked(undici.fetch); + +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(); +}); + +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: [] }); + }); +}); +``` + +- [ ] **Step 2 : Lancer les tests pour vérifier qu'ils échouent** + +Run: `pnpm test packages/integrations/src/unifi-site-manager/` +Expected: FAIL — `Cannot find module '../unifi-site-manager-integration'`. + +- [ ] **Step 3 : Écrire l'interface** + +Créer `packages/integrations/src/interfaces/unifi-site-manager/unifi-site-manager-integration.ts` : + +```ts +import type { UnifiSiteManagerOverview } from "../../unifi-site-manager/unifi-site-manager-types"; + +export interface UnifiSiteManagerIntegration { + getSiteOverviewAsync(): Promise; +} +``` + +- [ ] **Step 4 : Écrire la classe** + +Créer `packages/integrations/src/unifi-site-manager/unifi-site-manager-integration.ts` : + +```ts +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 } 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), + } as Parameters[1]); +}; + +const settled = (result: PromiseSettledResult, fallback: T): T => + result.status === "fulfilled" ? result.value : fallback; + +export class UnifiSiteManagerIntegration extends Integration implements UnifiSiteManagerIntegrationInterface { + public async getSiteOverviewAsync(): Promise { + return await this.buildOverviewAsync(fetchCloudAsync); + } + + protected async testingAsync(_input: IntegrationTestingInput): Promise { + 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 { + 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(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(metricsResult, []); + const sdWanBySite: Map = buildSdWanIndex(settled(sdWanResult, [])); + + return { sites: mapSites(sites, hosts, metrics, sdWanBySite, now) }; + } +} +``` + +- [ ] **Step 5 : Enregistrer** + +Dans `packages/integrations/src/base/creator.ts`, ajouter l'import à côté de celui de `UnifiOsIntegration` : + +```ts +import { UnifiSiteManagerIntegration } from "../unifi-site-manager/unifi-site-manager-integration"; +``` + +et, dans l'objet des créateurs, juste après `unifiOs: UnifiOsIntegration,` : + +```ts + unifiSiteManager: UnifiSiteManagerIntegration, +``` + +Dans `packages/integrations/src/index.ts` : + +```ts +export { UnifiSiteManagerIntegration } from "./unifi-site-manager/unifi-site-manager-integration"; +``` + +Dans `packages/integrations/src/types.ts`, à côté de la ligne d'export de `unifi-os` : + +```ts +export * from "./unifi-site-manager/unifi-site-manager-types"; +export * from "./interfaces/unifi-site-manager/unifi-site-manager-integration"; +``` + +**N'exporter QUE le module de types**, jamais `unifi-site-manager-parse.ts` : `types.ts` est atteint par des composants client. + +- [ ] **Step 6 : Vérifier** + +Run: `pnpm test packages/integrations/src/unifi-site-manager/` +Expected: PASS, 39 tests. + +Run: `pnpm turbo typecheck --filter=@homarr/integrations` +Expected: PASS. + +- [ ] **Step 7 : Vérifier qu'aucune intégration existante n'a bougé** + +```bash +git status --short packages/integrations/src/unifi-os packages/integrations/src/unifi-controller packages/integrations/src/opnsense +``` + +Expected: aucune sortie. + +- [ ] **Step 8 : Committer** + +```bash +node_modules/.bin/oxfmt packages/integrations/src/unifi-site-manager/ packages/integrations/src/interfaces/unifi-site-manager/ +git add packages/integrations/ +git commit -m "feat(integrations): add UnifiSiteManagerIntegration + +Co-Authored-By: Claude Opus 5 " +``` + +--- + +## Task 5 : Request handler et routeur tRPC + +**Files:** +- Create: `packages/request-handler/src/unifi-site-manager.ts` +- Create: `packages/api/src/router/widgets/unifi-site-manager.ts` +- Modify: `packages/api/src/router/widgets/index.ts` + +**Interfaces:** +- Consumes: `getSiteOverviewAsync()` et `UnifiSiteManagerOverview` (tâche 4). +- Produces: `clientApi.widget.unifiSiteManager.overview.useQuery({ integrationIds })`, renvoyant `{ integration, overview, updatedAt }[]`. + +- [ ] **Step 1 : Écrire le request handler** + +Créer `packages/request-handler/src/unifi-site-manager.ts` : + +```ts +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 +>({ + 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, +}); +``` + +- [ ] **Step 2 : Écrire le routeur** + +Créer `packages/api/src/router/widgets/unifi-site-manager.ts` : + +```ts +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, + }; + }); + }), +}); +``` + +- [ ] **Step 3 : Enregistrer le routeur** + +Dans `packages/api/src/router/widgets/index.ts`, à côté de l'entrée `unifiOs` : + +```ts + unifiSiteManager: lazy(() => import("./unifi-site-manager").then((mod) => mod.unifiSiteManagerRouter)), +``` + +- [ ] **Step 4 : Vérifier** + +Run: `pnpm turbo typecheck --filter=@homarr/api --filter=@homarr/request-handler` +Expected: PASS, 2/2. + +`packages/request-handler/package.json` déclare `exports: { "./*": "./src/*.ts" }` : le sous-chemin résout sans ajout. Aucune modification n'est attendue dans ce fichier. + +**Ne créer aucun fichier sous `packages/widgets/` dans cette tâche.** Ils appartiennent à la tâche 6. + +- [ ] **Step 5 : Committer** + +```bash +node_modules/.bin/oxfmt packages/request-handler/src/unifi-site-manager.ts packages/api/src/router/widgets/unifi-site-manager.ts packages/api/src/router/widgets/index.ts +git add packages/request-handler packages/api +git commit -m "feat(api): expose unifiSiteManager overview query + +Co-Authored-By: Claude Opus 5 " +``` + +--- + +## Task 6 : Widget + +**Files:** +- Create: `packages/widgets/src/unifi-site-manager/index.ts` +- Create: `packages/widgets/src/unifi-site-manager/health-bar.tsx` +- Create: `packages/widgets/src/unifi-site-manager/component.tsx` +- Modify: `packages/widgets/src/manifest.ts` +- Modify: `packages/widgets/src/index.tsx` +- Modify: `packages/widgets/src/catalog.ts` +- Modify: `packages/widgets/src/refetch-intervals.ts` +- Modify: `packages/translation/src/lang/en.json` + +**Interfaces:** +- Consumes: `clientApi.widget.unifiSiteManager.overview` (tâche 5), les types (tâche 2), le `WidgetKind` (tâche 1). + +- [ ] **Step 1 : Écrire la bande de santé** + +Créer `packages/widgets/src/unifi-site-manager/health-bar.tsx` : + +```tsx +"use client"; + +import { Box, Group, 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 = { + healthy: "#0ca30c", + degraded: "#fab219", + down: "#d03b3b", + unknown: "var(--mantine-color-default-border)", +}; + +const SEGMENT_HEIGHT = 14; + +export const HealthBar = ({ + segments, + summary, + formatHour, + labels, +}: { + segments: UnifiSiteHealthSegment[]; + summary: UnifiSiteHealthSummary; + formatHour: (at: Date) => string; + labels: Record; +}) => ( + + + {segments.map((segment) => ( + + + + ))} + + + {/* Le canal qui porte l'information quand la couleur ne passe pas. */} + + {summary.down > 0 + ? `${summary.down}h ${labels.down}` + : summary.degraded > 0 + ? `${summary.degraded}h ${labels.degraded}` + : `${summary.healthy}h ${labels.healthy}`} + + +); +``` + +- [ ] **Step 2 : Écrire la définition du widget** + +Créer `packages/widgets/src/unifi-site-manager/index.ts` : + +```ts +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")); +``` + +- [ ] **Step 3 : Écrire le composant** + +Créer `packages/widgets/src/unifi-site-manager/component.tsx` : + +```tsx +"use client"; + +import { Box, Text } from "@mantine/core"; +import type { DataTableColumn } 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; + +export default function UnifiSiteManagerWidget({ + integrationIds, + options, + isEditMode, + width, +}: WidgetComponentProps<"unifiSiteManager">) { + const t = useI18n(); + const { data: results = [] } = clientApi.widget.unifiSiteManager.overview.useQuery({ integrationIds }); + + const sites = results[0]?.overview.sites ?? []; + const isNarrow = width < NARROW_WIDTH; + + const labels: Record = { + 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 formatHour = (at: Date) => + at.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" }); + + const columns: DataTableColumn[] = [ + { + 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) => ( + + ), + }, + { + accessor: "latencyMs", + title: t("widget.unifiSiteManager.column.latency"), + sortable: true, + render: (site) => + site.latencyMs === null + ? EMPTY_VALUE + : t("widget.unifiSiteManager.latency", { + value: `${site.latencyMs}`, + loss: `${site.packetLossPct ?? 0}`, + }), + }, + ...(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) => ( + 0 ? "red" : undefined}> + {t("widget.unifiSiteManager.devicesOnline", { + online: `${site.deviceCount - site.offlineDeviceCount}`, + total: `${site.deviceCount}`, + })} + + ), + }, + { + accessor: "pendingUpdateCount", + title: t("widget.unifiSiteManager.column.updates"), + sortable: true, + render: (site) => ( + 0 ? "yellow" : undefined}> + {site.pendingUpdateCount} + + ), + }, + { + accessor: "sdWan", + title: t("widget.unifiSiteManager.column.sdWan"), + render: (site) => { + // `null` = le site n'appartient à aucune configuration SD-WAN. + 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 ( + + {/* 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`} + + ); + }, + }, + { + 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 ( + + + + ); +} +``` + +- [ ] **Step 4 : Enregistrer le widget — quatre points** + +Dans `packages/widgets/src/manifest.ts`, à côté des entrées `unifiOs`, dans la carte des modules : + +```ts + unifiSiteManager: () => import("./unifi-site-manager") as Promise, +``` + +et dans la carte des composants : + +```ts + unifiSiteManager: () => import("./unifi-site-manager/component") as Promise, +``` + +Dans `packages/widgets/src/index.tsx`, l'import et l'entrée : + +```ts +import * as unifiSiteManager from "./unifi-site-manager"; +``` +```ts + unifiSiteManager, +``` + +Dans `packages/widgets/src/catalog.ts`, importer `IconBuildingBroadcastTower` depuis `@tabler/icons-react` en respectant l'ordre alphabétique existant, puis : + +```ts + unifiSiteManager: IconBuildingBroadcastTower, +``` + +**Ce doit être la même référence d'icône que dans la définition** — `manifest.spec.ts` les compare par identité (`toBe`), pas par nom. + +Dans `packages/widgets/src/refetch-intervals.ts` : + +```ts + { queryKey: [["widget", "unifiSiteManager"]], intervalSeconds: 300 }, +``` + +- [ ] **Step 5 : Ajouter les traductions** + +Dans `packages/translation/src/lang/en.json`, dans l'objet `widget` : + +```json +"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)", + "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" + }, + "latency": "{value} ms · {loss} % loss", + "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" } +} +``` + +- [ ] **Step 6 : Vérifier** + +Run: `pnpm test packages/widgets/src/manifest.spec.ts` +Expected: PASS. Ce test vérifie que le nouveau kind a son entrée de manifeste, son composant, son icône de catalogue **identique** à celle de la définition, et son intervalle. Un échec ici signale un point d'enregistrement oublié. + +Run: `pnpm turbo typecheck` +Expected: PASS sur les 37 paquets. C'est le premier moment du plan où c'est vrai — la tâche 1 laissait volontairement `@homarr/widgets` en échec. + +Run: `pnpm test packages/widgets/ packages/integrations/src/unifi-site-manager/` +Expected: PASS. + +Run: `cd packages/widgets && ../../node_modules/.bin/oxlint 2>&1 | grep unifi-site-manager` +Expected: aucune sortie. + +- [ ] **Step 7 : Committer** + +```bash +node_modules/.bin/oxfmt packages/widgets/src/unifi-site-manager/ packages/widgets/src/manifest.ts packages/widgets/src/index.tsx packages/widgets/src/catalog.ts packages/widgets/src/refetch-intervals.ts +git add packages/widgets packages/translation/src/lang/en.json +git commit -m "feat(widgets): add UniFi Site Manager overview widget + +Co-Authored-By: Claude Opus 5 " +``` + +--- + +## Task 7 : Vérification contre le compte réel + +Les six tâches précédentes prouvent que le code fait ce qu'on croit. Celle-ci +prouve que ce qu'on croit correspond à la réalité. **Elle n'est pas optionnelle :** +sur l'intégration précédente, c'est elle — et elle seule — qui a trouvé le défaut +qui empêchait l'application de démarrer, ainsi qu'un point d'enregistrement +manquant. Le typecheck, 703 tests et cinq revues les avaient laissés passer. + +**Files:** aucun fichier versionné. + +- [ ] **Step 1 : Démarrer l'environnement** + +```bash +cd /home/zachary/Dev/homarr && source .superpowers/sdd/2026-09-08-unifi-os-integration/env.sh +docker run -d --rm --name homarr-dev-redis -p 6379:6379 redis:alpine +``` + +Puis le serveur de dev via l'entrée `homarr-dev` de `/home/zachary/Dev/.claude/launch.json`, port 3003. + +- [ ] **Step 2 : Créer l'intégration dans l'interface** + +Type **UniFi Site Manager**. L'URL doit se pré-remplir à `https://api.ui.com`. Seul secret demandé : la clé Site Manager, générée sur `unifi.ui.com`. + +Lancer **« Test connection and save »**. +Expected: succès. + +- [ ] **Step 3 : Vérifier l'affichage du catalogue** + +Dans « New integration », chercher `unifi`. Trois entrées doivent apparaître : Unifi Controller, UniFi OS, UniFi Site Manager. **Vérifier que le badge de catégorie de la nouvelle lit `UNIFI SITE MANAGER` et non `UNIFISITEMANAGER`** — la seconde forme signale l'oubli du point d'enregistrement 6 de la tâche 1, que le typecheck ne détecte pas. + +- [ ] **Step 4 : Comparer aux valeurs réelles** + +Poser le widget sur un board et comparer chaque colonne à l'interface Site Manager d'Ubiquiti : nombre de sites, uptime, FAI, IP publique, équipements, mises à jour en attente, état de console. + +- [ ] **Step 5 : Vérifier la bande de santé** + +Survoler quelques segments : l'infobulle doit donner l'heure et l'état. Comparer la forme générale à la colonne « Internet Health » de l'interface Ubiquiti sur les dernières 24 h. + +**Attendu, et c'est correct :** des segments gris. Le cloud ne rapporte pas toutes les heures — une fenêtre de 24 h n'en a renvoyé que 16 lors de la conception. Le gris dit « non rapporté », pas « en panne ». Si tous les segments sont verts alors que le cloud a des trous, c'est un **bug** : la règle du §4 de la spec n'est pas respectée. + +- [ ] **Step 6 : Vérifier la colonne SD-WAN** + +Comparer à la vue SD-WAN de l'interface Ubiquiti : chaque site membre d'une +configuration doit afficher son état, les autres un tiret. + +**Attendu, et c'est correct :** aucun ping affiché si le cloud rapporte `0`. +C'est le cas observé pendant la conception sur des tunnels connectés — `0` y +signifie « non mesuré », pas « zéro milliseconde ». Un `0 ms` affiché serait un +**bug** : la règle du §4 de la spec n'est pas respectée. + +- [ ] **Step 7 : Vérifier la dégradation** + +Modifier la clé pour une valeur invalide et recharger. +Expected: une tuile d'erreur, **pas** un tableau vide. Un tableau vide se lirait comme « aucun site », c'est-à-dire comme une information alors qu'il n'y en a aucune. + +Restaurer ensuite la clé valide. + +- [ ] **Step 8 : Consigner tout écart** + +Si un chiffre diverge, ne pas ajuster le widget : corriger le mapper de la tâche 2 et **ajouter un test** reproduisant le cas. Un chiffre corrigé sans test revient à la version suivante.