From b5e410974042ae705e8c40ef3f640f0445173489 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Zachary=20Gu=C3=A9not?= Date: Thu, 10 Sep 2026 09:17:22 +0200 Subject: [PATCH] =?UTF-8?q?docs:=20plan=20=E2=80=94=20add=20per-site=20SD-?= =?UTF-8?q?WAN=20tunnel=20status?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit L'état des tunnels entre dans quatre des sept tâches : schémas et index par site (tâche 2), deux méthodes de client bornées (tâche 3), orchestration en enrichissement non fatal (tâche 4), colonne et traductions (tâche 6), et une étape de vérification dédiée (tâche 7). Deux points de justesse portés par des tests : - `ping: 0` devient `pingMs: null`. Relevé sur un tunnel connecté entre deux sites distants, où une latence nulle est physiquement impossible : le champ veut dire « non mesuré ». - le nombre de configurations interrogées est borné à cinq. Le statut étant indexé par configuration, un compte à dix produirait onze requêtes par rafraîchissement. La topologie reste hors périmètre : une liste de quinze réseaux n'a pas sa place dans une ligne de tableau. Co-Authored-By: Claude Opus 5 --- ...26-09-09-unifi-site-manager-integration.md | 1955 +++++++++++++++++ 1 file changed, 1955 insertions(+) create mode 100644 docs/superpowers/plans/2026-09-09-unifi-site-manager-integration.md 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.