diff --git a/docs/superpowers/plans/2026-09-08-unifi-os-integration.md b/docs/superpowers/plans/2026-09-08-unifi-os-integration.md new file mode 100644 index 000000000..f8688c7c8 --- /dev/null +++ b/docs/superpowers/plans/2026-09-08-unifi-os-integration.md @@ -0,0 +1,2075 @@ +# Plan d'implémentation — Intégration UniFi OS + +> **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 à Homarr une intégration `unifiOs` lisant l'API d'intégration officielle d'UniFi OS (clé `X-API-KEY`), enrichie optionnellement par l'API cloud Site Manager, et un widget dédié pour l'afficher. + +**Architecture :** Quatre modules aux responsabilités disjointes dans `packages/integrations/src/unifi-os/` — un client HTTP local, un client HTTP cloud optionnel à cache interne, un module de schémas et de mappers purs, et une classe `Integration` qui orchestre. Le tout branché sur les cinq points d'attache habituels du monorepo (definitions, request-handler, api, widgets, translation). + +**Tech Stack :** TypeScript, zod v4 (`zod/v4`), vitest, undici `fetch`, Mantine v9, tRPC, Turbo/pnpm. + +**Spec :** `docs/superpowers/specs/2026-09-08-unifi-os-integration-design.md` + +## Global Constraints + +- **Branche de travail :** `feat/unifi-os-integration`. Ne jamais committer sur `dev` ni `main`. +- **Lint / format :** oxlint et oxfmt, **pas** ESLint ni Prettier. `pnpm lint` et `pnpm format`. +- **UI :** Mantine v9, **pas** Tailwind. Icônes `@tabler/icons-react`. +- **zod :** importer `{ z } from "zod/v4"` (convention de l'intégration `patchmon`, la plus récente). Objets tolérants aux champs inconnus via `z.looseObject`. +- **Tests :** vitest. Fichiers `*.spec.ts` sous `packages/integrations/src/unifi-os/test/`. En-tête `// @vitest-environment node` pour les tests d'intégration. Lancement : `pnpm test`. +- **Erreurs :** `ParseError` et `ResponseError` depuis `@homarr/common/server` ; `TestConnectionError` depuis `../base/test-connection/test-connection-error`. +- **Base d'URL locale :** l'utilisateur saisit l'origine de la console. Le client préfixe `/proxy/network/integration/v1`. Ce préfixe n'est jamais exposé à la configuration. +- **Base d'URL cloud :** `https://api.ui.com`, en dur, jamais configurable. +- **`limit` de l'API locale est plafonné à 200.** Mesuré. Toute pagination doit en tenir compte. +- **Syntaxe de filtrage locale : préfixe.** `and(type.eq('WIRED'),access.type.eq('DEFAULT'))`. Les formes infixes renvoient `400 api.request.invalid-filter`. +- **Aucun secret, MAC réel, IP publique ou nom de client réel dans le dépôt.** Fixtures anonymisées. +- **`NetworkControllerSummary` ne doit pas être modifié.** Aucun fichier de `unifi-controller/` ni d'`opnsense/` ne doit apparaître dans un diff. +- **Nommer la catégorie `unifiOs`, jamais `networkController`** (voir §5.3 de la spec : la catégorie est un mécanisme de câblage, pas une étiquette). + +--- + +## Structure des fichiers + +**Créés :** + +| Fichier | Responsabilité | +|---|---| +| `packages/integrations/src/unifi-os/unifi-os-types.ts` | Schémas zod et fonctions pures de mapping. Aucun accès réseau. | +| `packages/integrations/src/unifi-os/unifi-os-api.ts` | Client HTTP de la console locale. Ne connaît pas Homarr. | +| `packages/integrations/src/unifi-os/unifi-sm-api.ts` | Client HTTP du cloud Site Manager. Cache interne. Optionnel. | +| `packages/integrations/src/unifi-os/unifi-os-integration.ts` | Classe `Integration`. Orchestration et dégradation. | +| `packages/integrations/src/interfaces/unifi-os-summary/unifi-os-summary-integration.ts` | Interface consommée par le request-handler. | +| `packages/integrations/src/unifi-os/test/unifi-os-types.spec.ts` | Tests des mappers, sans réseau. | +| `packages/integrations/src/unifi-os/test/unifi-os-api.spec.ts` | Tests du client local, `fetch` injecté. | +| `packages/integrations/src/unifi-os/test/unifi-sm-api.spec.ts` | Tests du client cloud et du cache. | +| `packages/integrations/src/unifi-os/test/unifi-os-integration.spec.ts` | Tests d'orchestration et de dégradation. | +| `packages/request-handler/src/unifi-os.ts` | Handler de cache. | +| `packages/api/src/router/widgets/unifi-os.ts` | Routeur tRPC. | +| `packages/widgets/src/unifi-os/index.ts` | Définition du widget. | +| `packages/widgets/src/unifi-os/component.tsx` | Composant Mantine. | + +**Modifiés :** + +| Fichier | Modification | +|---|---| +| `packages/definitions/src/integration.ts` | secret `siteManagerApiKey`, catégorie `unifiOs`, kind `unifiOs` | +| `packages/definitions/src/widget.ts` | `WidgetKind` `unifiOs` | +| `packages/definitions/src/widget-integration-map.ts` | `unifiOs: ["unifiOs"]` | +| `packages/integrations/src/base/creator.ts` | import + entrée `unifiOs` | +| `packages/integrations/src/index.ts` | export de la classe | +| `packages/integrations/src/types.ts` | export des types | +| `packages/api/src/router/widgets/index.ts` | entrée `unifiOs` | +| `packages/widgets/src/manifest.ts` | deux entrées (module + composant) | +| `packages/widgets/src/index.tsx` | import + entrée | +| `packages/widgets/src/catalog.ts` | icône — **vérifié par `manifest.spec.ts`** | +| `packages/widgets/src/refetch-intervals.ts` | intervalle 60 s | +| `packages/translation/src/lang/en.json` | libellés + `integration.category.unifiOs` | + +--- + +## Task 1 : Déclarations dans `@homarr/definitions` + +Poser le vocabulaire avant tout le reste. Sans ces déclarations, rien d'autre ne compile. + +**Files:** +- Modify: `packages/definitions/src/integration.ts` +- Modify: `packages/definitions/src/widget.ts` +- Modify: `packages/definitions/src/widget-integration-map.ts` +- Modify: `packages/translation/src/lang/en.json` + +**Interfaces:** +- Consumes: rien. +- Produces: le kind d'intégration `"unifiOs"`, la catégorie `"unifiOs"`, le secret `"siteManagerApiKey"`, le `WidgetKind` `"unifiOs"`. Toutes les tâches suivantes en dépendent. + +- [ ] **Step 1 : Ajouter le secret** + +Dans `packages/definitions/src/integration.ts`, dans `integrationSecretKindObject`, juste après la ligne `apiKey`, ajouter : + +```ts + siteManagerApiKey: { isPublic: false, multiline: false }, +``` + +- [ ] **Step 2 : Ajouter la catégorie** + +Dans le même fichier, dans le tableau `integrationCategories`, avant la ligne `] as const;`, ajouter : + +```ts + "unifiOs", +``` + +- [ ] **Step 3 : Ajouter le kind d'intégration** + +Toujours dans le même fichier, dans `integrationDefs`, **immédiatement après** le bloc `unifiController` (qui se termine par `defaultPort: 8443,` puis `},`), ajouter : + +```ts + unifiOs: { + name: "UniFi OS", + secretKinds: [["apiKey"], ["apiKey", "siteManagerApiKey"]], + iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/png/unifi.png", + category: ["unifiOs"], + documentationUrl: null, + defaultPort: 443, + }, +``` + +`documentationUrl: null` est volontaire : la décision D1 de la spec exclut la documentation Docusaurus. Le champ accepte `string | null`. + +`secretKinds` déclare deux jeux alternatifs. Le premier — `["apiKey"]` — rend la clé cloud facultative. Motif identique à `ntfy`. + +- [ ] **Step 4 : Ajouter le WidgetKind** + +Dans `packages/definitions/src/widget.ts`, dans le tableau `widgetKinds`, après `"networkControllerStatus",` ajouter : + +```ts + "unifiOs", +``` + +- [ ] **Step 5 : Câbler la carte widget → intégrations** + +Dans `packages/definitions/src/widget-integration-map.ts`, dans `widgetIntegrationSupport`, après la ligne `networkControllerStatus: getIntegrationKindsByCategory("networkController"),` ajouter : + +```ts + unifiOs: ["unifiOs"], +``` + +Liste littérale et non `getIntegrationKindsByCategory("unifiOs")` : le widget ne supporte qu'une intégration, et l'écrire en clair rend la relation évidente à la lecture. C'est ce que fait déjà `coolify: ["coolify"]`. + +- [ ] **Step 6 : Ajouter la traduction de catégorie** + +Dans `packages/translation/src/lang/en.json`, dans l'objet `integration.category`, à côté de `"networkController": "Network"`, ajouter : + +```json +"unifiOs": "UniFi OS", +``` + +- [ ] **Step 7 : Vérifier que le typage tient** + +Run: `pnpm turbo typecheck --filter=@homarr/definitions` +Expected: PASS. + +Si `@homarr/widgets` est aussi typé à ce stade, il **échouera** — c'est attendu : le `WidgetKind` `unifiOs` n'a pas encore d'entrée dans le manifeste. La Task 7 le corrige. Ne pas contourner en retirant le kind. + +- [ ] **Step 8 : Committer** + +```bash +git add packages/definitions/src/integration.ts packages/definitions/src/widget.ts packages/definitions/src/widget-integration-map.ts packages/translation/src/lang/en.json +git commit -m "feat(definitions): declare unifiOs integration kind, category and widget kind" +``` + +--- + +## Task 2 : Schémas et mappers purs + +Le cœur testable de l'intégration. Aucun réseau, donc aucun mock : ces tests restent valables même si Ubiquiti change ses endpoints. + +**Files:** +- Create: `packages/integrations/src/unifi-os/unifi-os-types.ts` +- Test: `packages/integrations/src/unifi-os/test/unifi-os-types.spec.ts` + +**Interfaces:** +- Consumes: rien. +- Produces : + - `UnifiOsSummary`, `UnifiOsIspMetrics`, `UnifiOsClientCounts`, `UnifiOsDeviceCounts`, `UnifiOsGateway` + - `unifiOsInfoSchema`, `unifiOsSiteSchema`, `unifiOsClientSchema`, `unifiOsDeviceSchema`, `unifiOsDeviceStatisticsSchema`, `unifiOsVpnServerSchema`, `createUnifiOsPageSchema` + - `unifiSmSiteSchema`, `unifiSmIspMetricsResponseSchema` + - `parseUnifiOsAsync(value: unknown, schema: z.ZodType, message: string): Promise` + - `normalizeMacAddress(mac: string): string` + - `countClients(clients: UnifiOsClient[]): UnifiOsClientCounts` + - `summarizeDevices(devices: UnifiOsDevice[]): UnifiOsDeviceCounts` + - `findGateway(devices: UnifiOsDevice[]): UnifiOsDevice | null` + - `matchCloudSite(gatewayMac: string | null, sites: T[]): T | null` + +- [ ] **Step 1 : Écrire les tests qui échouent** + +Créer `packages/integrations/src/unifi-os/test/unifi-os-types.spec.ts` : + +```ts +import { describe, expect, test } from "vitest"; + +import type { UnifiOsClient, UnifiOsDevice } from "../unifi-os-types"; +import { + countClients, + findGateway, + matchCloudSite, + normalizeMacAddress, + summarizeDevices, + unifiOsClientSchema, +} from "../unifi-os-types"; + +const client = (type: string, accessType = "DEFAULT"): UnifiOsClient => ({ + id: `client-${type}-${accessType}-${Math.random()}`, + name: "client", + type, + access: { type: accessType }, +}); + +const device = (overrides: Partial = {}): UnifiOsDevice => ({ + id: "device-1", + name: "device", + model: "USW Lite 8", + macAddress: "aa:bb:cc:11:22:33", + state: "ONLINE", + firmwareUpdatable: false, + features: ["switching"], + ...overrides, +}); + +describe("normalizeMacAddress", () => { + test("lowercases and strips separators so both APIs compare equal", () => { + expect(normalizeMacAddress("AA:BB:CC:11:22:33")).toBe("aabbcc112233"); + expect(normalizeMacAddress("aa-bb-cc-11-22-33")).toBe("aabbcc112233"); + expect(normalizeMacAddress("AABBCC112233")).toBe("aabbcc112233"); + }); +}); + +describe("countClients", () => { + test("counts each connection type separately", () => { + const counts = countClients([ + client("WIRED"), + client("WIRED"), + client("WIRELESS"), + client("VPN"), + client("TELEPORT"), + ]); + + expect(counts).toEqual({ total: 5, wired: 2, wireless: 1, vpn: 2, guests: 0 }); + }); + + test("folds TELEPORT into vpn because both are remote access", () => { + expect(countClients([client("TELEPORT")]).vpn).toBe(1); + }); + + test("counts guests across every connection type", () => { + const counts = countClients([client("WIRELESS", "GUEST"), client("WIRED", "GUEST"), client("WIRED")]); + + expect(counts.guests).toBe(2); + expect(counts.wired).toBe(2); + }); + + test("treats an unknown connection type as counted but unclassified", () => { + const counts = countClients([client("SOMETHING_NEW")]); + + expect(counts.total).toBe(1); + expect(counts.wired + counts.wireless + counts.vpn).toBe(0); + }); + + test("tolerates a client without an access block", () => { + const counts = countClients([{ id: "x", name: "x", type: "WIRED" }]); + + expect(counts).toEqual({ total: 1, wired: 1, wireless: 0, vpn: 0, guests: 0 }); + }); +}); + +describe("summarizeDevices", () => { + test("splits online from everything else and counts pending firmware", () => { + const counts = summarizeDevices([ + device({ id: "a", state: "ONLINE", firmwareUpdatable: true }), + device({ id: "b", state: "OFFLINE" }), + device({ id: "c", state: "UPDATING", firmwareUpdatable: true }), + ]); + + expect(counts).toEqual({ total: 3, online: 1, offline: 2, firmwareUpdatable: 2 }); + }); + + test("returns zeroes for an empty site rather than throwing", () => { + expect(summarizeDevices([])).toEqual({ total: 0, online: 0, offline: 0, firmwareUpdatable: 0 }); + }); +}); + +describe("findGateway", () => { + test("prefers the device advertising the gateway feature", () => { + const gateway = device({ id: "gw", features: ["gateway"], model: "UXG Pro" }); + expect(findGateway([device(), gateway])?.id).toBe("gw"); + }); + + test("falls back to a gateway-looking model when features are empty", () => { + const gateway = device({ id: "gw", features: [], model: "Gateway Pro" }); + expect(findGateway([device(), gateway])?.id).toBe("gw"); + }); + + test("returns null when no gateway is present instead of picking any device", () => { + expect(findGateway([device()])).toBeNull(); + }); +}); + +describe("matchCloudSite", () => { + const siteA = { siteId: "a", meta: { gatewayMac: "AA:BB:CC:11:22:33" } }; + const siteB = { siteId: "b", meta: { gatewayMac: "dd:ee:ff:44:55:66" } }; + + test("matches on the gateway MAC regardless of formatting", () => { + expect(matchCloudSite("aa-bb-cc-11-22-33", [siteB, siteA])?.siteId).toBe("a"); + }); + + test("returns null rather than another account site when nothing matches", () => { + expect(matchCloudSite("11:22:33:44:55:66", [siteA, siteB])).toBeNull(); + }); + + test("returns null when the local gateway is unknown", () => { + expect(matchCloudSite(null, [siteA, siteB])).toBeNull(); + }); +}); + +describe("unifiOsClientSchema", () => { + test("keeps fields the published OpenAPI does not document", () => { + const parsed = unifiOsClientSchema.parse({ + id: "1", + name: "n", + type: "WIRED", + access: { type: "DEFAULT" }, + macAddress: "aa:bb:cc:11:22:33", + uplinkDeviceId: "u1", + }); + + expect(parsed).toMatchObject({ macAddress: "aa:bb:cc:11:22:33", uplinkDeviceId: "u1" }); + }); +}); +``` + +- [ ] **Step 2 : Lancer les tests pour vérifier qu'ils échouent** + +Run: `pnpm vitest run packages/integrations/src/unifi-os/test/unifi-os-types.spec.ts` +Expected: FAIL — `Cannot find module '../unifi-os-types'`. + +- [ ] **Step 3 : Écrire l'implémentation** + +Créer `packages/integrations/src/unifi-os/unifi-os-types.ts` : + +```ts +import { ParseError } from "@homarr/common/server"; +import { z } from "zod/v4"; + +/** + * Ubiquiti ne publie l'OpenAPI que pour la 10.4.57 alors que les consoles sont + * en 10.6.x et renvoient déjà des champs absents du spec. Tous les objets sont + * donc « loose » : un champ inattendu ne doit jamais faire échouer un widget. + */ + +export const unifiOsInfoSchema = z.looseObject({ + applicationVersion: z.string(), +}); + +export const unifiOsSiteSchema = z.looseObject({ + id: z.string(), + name: z.string(), + internalReference: z.string().optional(), +}); + +export const unifiOsClientSchema = z.looseObject({ + id: z.string(), + name: z.string(), + type: z.string(), + access: z.looseObject({ type: z.string() }).optional(), +}); + +export const unifiOsDeviceSchema = z.looseObject({ + id: z.string(), + name: z.string(), + model: z.string(), + macAddress: z.string(), + state: z.string(), + firmwareUpdatable: z.boolean().optional(), + features: z.array(z.string()).default([]), +}); + +export const unifiOsDeviceStatisticsSchema = z.looseObject({ + uptimeSec: z.number().optional(), + cpuUtilizationPct: z.number().optional(), + memoryUtilizationPct: z.number().optional(), + uplink: z.looseObject({ txRateBps: z.number(), rxRateBps: z.number() }).optional(), +}); + +export const unifiOsVpnServerSchema = z.looseObject({ + id: z.string(), + name: z.string().optional(), + enabled: z.boolean(), +}); + +/** Enveloppe de pagination commune à tous les endpoints de liste. */ +export const createUnifiOsPageSchema = (item: TItem) => + z.looseObject({ + offset: z.number(), + limit: z.number(), + count: z.number(), + totalCount: z.number(), + data: z.array(item), + }); + +export const unifiSmSiteSchema = z.looseObject({ + siteId: z.string(), + hostId: z.string().optional(), + meta: z.looseObject({ + desc: z.string().optional(), + name: z.string().optional(), + gatewayMac: z.string().optional(), + }), + statistics: z + .looseObject({ + ispInfo: z.looseObject({ name: z.string().optional() }).optional(), + percentages: z.looseObject({ wanUptime: z.number().optional() }).optional(), + wans: z.record(z.string(), z.looseObject({ externalIp: z.string().optional() })).optional(), + }) + .optional(), +}); + +const unifiSmWanMetricsSchema = z.looseObject({ + avgLatency: z.number().optional(), + maxLatency: z.number().optional(), + packetLoss: z.number().optional(), + uptime: z.number().optional(), + ispName: z.string().optional(), +}); + +export const unifiSmIspMetricsResponseSchema = z.looseObject({ + data: z.looseObject({ + metrics: z.array( + z.looseObject({ + periods: z.array( + z.looseObject({ + metricTime: z.string(), + data: z.looseObject({ wan: unifiSmWanMetricsSchema.optional() }), + }), + ), + }), + ), + }), +}); + +export type UnifiOsClient = z.infer; +export type UnifiOsDevice = z.infer; +export type UnifiOsSite = z.infer; +export type UnifiOsDeviceStatistics = z.infer; + +export interface UnifiOsClientCounts { + total: number; + wired: number; + wireless: number; + vpn: number; + guests: number; +} + +export interface UnifiOsDeviceCounts { + total: number; + online: number; + offline: number; + firmwareUpdatable: number; +} + +export interface UnifiOsGateway { + name: string; + model: string; + macAddress: string; + uptimeSec: number | null; + cpuPct: number | null; + memoryPct: number | null; +} + +export interface UnifiOsIspMetrics { + name: string; + externalIp: string | null; + latencyMs: number; + maxLatencyMs: number; + packetLossPct: number; + uptimePct: number; + /** Horodatage de la mesure : la donnée cloud accuse ~27 min de retard. */ + measuredAt: Date; +} + +export interface UnifiOsSummary { + site: { + id: string; + name: string; + applicationVersion: string; + }; + wan: { + state: "online" | "offline" | "unknown"; + throughput: { txBps: number; rxBps: number } | null; + isp: UnifiOsIspMetrics | null; + }; + clients: UnifiOsClientCounts; + devices: UnifiOsDeviceCounts; + gateway: UnifiOsGateway | null; +} + +export const parseUnifiOsAsync = 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; +}; + +/** + * L'API locale renvoie `aa:bb:cc:11:22:33`, le cloud peut renvoyer une autre + * casse ou d'autres séparateurs. La comparaison se fait sur une forme canonique. + */ +export const normalizeMacAddress = (mac: string): string => mac.toLowerCase().replace(/[^0-9a-f]/g, ""); + +const VPN_CLIENT_TYPES = new Set(["VPN", "TELEPORT"]); + +export const countClients = (clients: UnifiOsClient[]): UnifiOsClientCounts => { + const counts: UnifiOsClientCounts = { total: 0, wired: 0, wireless: 0, vpn: 0, guests: 0 }; + + for (const client of clients) { + counts.total += 1; + + if (client.type === "WIRED") counts.wired += 1; + else if (client.type === "WIRELESS") counts.wireless += 1; + else if (VPN_CLIENT_TYPES.has(client.type)) counts.vpn += 1; + + if (client.access?.type === "GUEST") counts.guests += 1; + } + + return counts; +}; + +export const summarizeDevices = (devices: UnifiOsDevice[]): UnifiOsDeviceCounts => { + const online = devices.filter((device) => device.state === "ONLINE").length; + + return { + total: devices.length, + online, + // Tout ce qui n'est pas ONLINE est indisponible du point de vue du dashboard, + // qu'il s'agisse d'OFFLINE, d'UPDATING ou d'un état futur inconnu. + offline: devices.length - online, + firmwareUpdatable: devices.filter((device) => device.firmwareUpdatable === true).length, + }; +}; + +const GATEWAY_MODEL_HINT = /gateway|udm|uxg|ucg|usg/i; + +export const findGateway = (devices: UnifiOsDevice[]): UnifiOsDevice | null => + devices.find((device) => device.features.includes("gateway")) ?? + devices.find((device) => GATEWAY_MODEL_HINT.test(device.model)) ?? + null; + +/** + * Une clé Site Manager voit tous les sites du compte, y compris ceux d'autres + * consoles. Sans correspondance stricte sur le MAC du gateway, on afficherait + * la latence d'un autre réseau. Renvoyer null est le seul comportement sûr. + */ +export const matchCloudSite = ( + gatewayMac: string | null, + sites: TSite[], +): TSite | null => { + if (gatewayMac === null) return null; + + const normalized = normalizeMacAddress(gatewayMac); + + return sites.find((site) => site.meta.gatewayMac !== undefined && normalizeMacAddress(site.meta.gatewayMac) === normalized) ?? null; +}; +``` + +- [ ] **Step 4 : Lancer les tests pour vérifier qu'ils passent** + +Run: `pnpm vitest run packages/integrations/src/unifi-os/test/unifi-os-types.spec.ts` +Expected: PASS, 15 tests. + +- [ ] **Step 5 : Formater et committer** + +```bash +pnpm format +git add packages/integrations/src/unifi-os/ +git commit -m "feat(integrations): add UniFi OS schemas and pure mappers" +``` + +--- + +## Task 3 : Client HTTP local + +**Files:** +- Create: `packages/integrations/src/unifi-os/unifi-os-api.ts` +- Test: `packages/integrations/src/unifi-os/test/unifi-os-api.spec.ts` + +**Interfaces:** +- Consumes: tout ce que produit la Task 2. +- Produces : + - `type UnifiOsFetch = (url: string, init?: RequestInit & { timeout?: number }) => Promise` + - `class UnifiOsApi` — constructeur `(origin: string, apiKey: string, fetchAsync: UnifiOsFetch)` + - `getInfoAsync(): Promise<{ applicationVersion: string }>` + - `getFirstSiteAsync(): Promise` + - `getClientCountsAsync(siteId: string): Promise` + - `getDevicesAsync(siteId: string): Promise` + - `getDeviceStatisticsAsync(siteId, deviceId): Promise` + - `getVpnServersAsync(siteId: string): Promise<{ enabled: boolean }[]>` + +Le `fetchAsync` est **injecté** plutôt que mocké au niveau du module : les tests +deviennent lisibles et n'ont pas besoin de `vi.mock`. + +- [ ] **Step 1 : Écrire les tests qui échouent** + +Créer `packages/integrations/src/unifi-os/test/unifi-os-api.spec.ts` : + +```ts +import { Response } from "undici"; +import { describe, expect, test, vi } from "vitest"; + +import { ResponseError } from "@homarr/common/server"; + +import { UnifiOsApi } from "../unifi-os-api"; + +const ORIGIN = "https://console.example.test"; +const API_KEY = "test-key"; + +const jsonResponse = (body: unknown, status = 200) => + new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } }) as never; + +const page = (data: unknown[], totalCount = data.length, limit = 200) => ({ + offset: 0, + limit, + count: data.length, + totalCount, + data, +}); + +const createApi = (handler: (url: string) => Promise) => { + const fetchAsync = vi.fn(async (url: string) => await handler(url)); + return { api: new UnifiOsApi(ORIGIN, API_KEY, fetchAsync as never), fetchAsync }; +}; + +describe("UnifiOsApi request shape", () => { + test("prefixes the integration path and sends the API key header", async () => { + const fetchAsync = vi.fn(async () => jsonResponse({ applicationVersion: "10.6.102" })); + const api = new UnifiOsApi(ORIGIN, API_KEY, fetchAsync as never); + + await api.getInfoAsync(); + + expect(fetchAsync).toHaveBeenCalledWith( + `${ORIGIN}/proxy/network/integration/v1/info`, + expect.objectContaining({ headers: expect.objectContaining({ "X-API-KEY": API_KEY }) }), + ); + }); + + test("does not double a slash when the origin has a trailing one", async () => { + const fetchAsync = vi.fn(async () => jsonResponse({ applicationVersion: "10.6.102" })); + const api = new UnifiOsApi(`${ORIGIN}/`, API_KEY, fetchAsync as never); + + await api.getInfoAsync(); + + expect(fetchAsync.mock.calls[0]?.[0]).toBe(`${ORIGIN}/proxy/network/integration/v1/info`); + }); + + test("throws ResponseError on a non-ok status instead of parsing the body", async () => { + const { api } = createApi(async () => jsonResponse({ error: { code: 401 } }, 401)); + + await expect(api.getInfoAsync()).rejects.toBeInstanceOf(ResponseError); + }); +}); + +describe("UnifiOsApi getClientCountsAsync", () => { + test("counts from a single page when totalCount fits under the API limit", async () => { + const { api, fetchAsync } = createApi(async () => + jsonResponse( + page([ + { id: "1", name: "a", type: "WIRED", access: { type: "DEFAULT" } }, + { id: "2", name: "b", type: "WIRELESS", access: { type: "GUEST" } }, + ]), + ), + ); + + const counts = await api.getClientCountsAsync("site-1"); + + expect(counts).toEqual({ total: 2, wired: 1, wireless: 1, vpn: 0, guests: 1 }); + expect(fetchAsync).toHaveBeenCalledTimes(1); + }); + + test("switches to filtered count queries when the site exceeds the 200 limit", async () => { + const requested: string[] = []; + const { api } = createApi(async (url) => { + requested.push(url); + if (!url.includes("filter=")) return jsonResponse(page([], 512)); + if (url.includes("WIRED")) return jsonResponse(page([], 400, 1)); + if (url.includes("WIRELESS")) return jsonResponse(page([], 100, 1)); + if (url.includes("TELEPORT")) return jsonResponse(page([], 2, 1)); + if (url.includes("VPN")) return jsonResponse(page([], 10, 1)); + return jsonResponse(page([], 5, 1)); + }); + + const counts = await api.getClientCountsAsync("site-1"); + + expect(counts).toEqual({ total: 512, wired: 400, wireless: 100, vpn: 12, guests: 5 }); + expect(requested.some((url) => url.includes("and("))).toBe(false); + expect(requested.filter((url) => url.includes("filter=")).length).toBe(5); + }); + + test("uses the prefix filter syntax the API actually accepts", async () => { + const requested: string[] = []; + const { api } = createApi(async (url) => { + requested.push(url); + return jsonResponse(page([], url.includes("filter=") ? 1 : 512, 1)); + }); + + await api.getClientCountsAsync("site-1"); + + const guestQuery = requested.find((url) => url.includes("GUEST")); + expect(decodeURIComponent(guestQuery ?? "")).toContain("access.type.eq('GUEST')"); + expect(decodeURIComponent(guestQuery ?? "")).not.toContain(" and "); + }); +}); + +describe("UnifiOsApi getFirstSiteAsync", () => { + test("returns null for a console exposing no site rather than throwing", async () => { + const { api } = createApi(async () => jsonResponse(page([]))); + + expect(await api.getFirstSiteAsync()).toBeNull(); + }); +}); +``` + +- [ ] **Step 2 : Lancer les tests pour vérifier qu'ils échouent** + +Run: `pnpm vitest run packages/integrations/src/unifi-os/test/unifi-os-api.spec.ts` +Expected: FAIL — `Cannot find module '../unifi-os-api'`. + +- [ ] **Step 3 : Écrire l'implémentation** + +Créer `packages/integrations/src/unifi-os/unifi-os-api.ts` : + +```ts +import { ResponseError } from "@homarr/common/server"; +import type { z } from "zod/v4"; + +import type { + UnifiOsClientCounts, + UnifiOsDevice, + UnifiOsDeviceStatistics, + UnifiOsSite, +} from "./unifi-os-types"; +import { + countClients, + createUnifiOsPageSchema, + parseUnifiOsAsync, + unifiOsClientSchema, + unifiOsDeviceSchema, + unifiOsDeviceStatisticsSchema, + unifiOsInfoSchema, + unifiOsSiteSchema, + unifiOsVpnServerSchema, +} from "./unifi-os-types"; + +export type UnifiOsFetch = (url: string, init?: RequestInit & { timeout?: number }) => Promise; + +const INTEGRATION_PATH = "/proxy/network/integration/v1"; +const REQUEST_TIMEOUT_MS = 10_000; + +/** Plafond imposé par l'API : demander davantage renvoie quand même 200. */ +const MAX_PAGE_LIMIT = 200; + +const PARSE_ERROR_MESSAGE = "Invalid UniFi OS response"; + +const clientPageSchema = createUnifiOsPageSchema(unifiOsClientSchema); +const devicePageSchema = createUnifiOsPageSchema(unifiOsDeviceSchema); +const sitePageSchema = createUnifiOsPageSchema(unifiOsSiteSchema); +const vpnServerPageSchema = createUnifiOsPageSchema(unifiOsVpnServerSchema); + +/** + * Client de l'API d'intégration locale d'UniFi OS. + * + * Ne connaît ni Homarr ni la notion d'intégration : il ne sait que parler à une + * console. Le `fetch` est injecté pour que les tests n'aient pas à mocker un module. + */ +export class UnifiOsApi { + private readonly baseUrl: string; + + constructor( + origin: string, + private readonly apiKey: string, + private readonly fetchAsync: UnifiOsFetch, + ) { + this.baseUrl = `${origin.replace(/\/+$/, "")}${INTEGRATION_PATH}`; + } + + public async getInfoAsync(): Promise<{ applicationVersion: string }> { + return await this.requestAsync("/info", unifiOsInfoSchema); + } + + public async getFirstSiteAsync(): Promise { + const page = await this.requestAsync("/sites", sitePageSchema); + return page.data[0] ?? null; + } + + public async getDevicesAsync(siteId: string): Promise { + const page = await this.requestAsync(`/sites/${encodeURIComponent(siteId)}/devices?limit=${MAX_PAGE_LIMIT}`, devicePageSchema); + return page.data; + } + + public async getDeviceStatisticsAsync(siteId: string, deviceId: string): Promise { + return await this.requestAsync( + `/sites/${encodeURIComponent(siteId)}/devices/${encodeURIComponent(deviceId)}/statistics/latest`, + unifiOsDeviceStatisticsSchema, + ); + } + + public async getVpnServersAsync(siteId: string): Promise<{ enabled: boolean }[]> { + const page = await this.requestAsync(`/sites/${encodeURIComponent(siteId)}/vpn/servers`, vpnServerPageSchema); + return page.data; + } + + /** + * Sous le plafond de 200 clients, une seule page suffit et le comptage se fait + * en mémoire. Au-delà, rapatrier tous les enregistrements pour n'en tirer que + * des compteurs coûterait plus cher que cinq requêtes bornées. + */ + public async getClientCountsAsync(siteId: string): Promise { + const path = `/sites/${encodeURIComponent(siteId)}/clients`; + const firstPage = await this.requestAsync(`${path}?limit=${MAX_PAGE_LIMIT}`, clientPageSchema); + + if (firstPage.totalCount <= MAX_PAGE_LIMIT) { + return countClients(firstPage.data); + } + + const countByFilterAsync = async (filter: string) => { + const page = await this.requestAsync(`${path}?limit=1&filter=${encodeURIComponent(filter)}`, clientPageSchema); + return page.totalCount; + }; + + const [wired, wireless, vpn, teleport, guests] = await Promise.all([ + countByFilterAsync("type.eq('WIRED')"), + countByFilterAsync("type.eq('WIRELESS')"), + countByFilterAsync("type.eq('VPN')"), + countByFilterAsync("type.eq('TELEPORT')"), + countByFilterAsync("access.type.eq('GUEST')"), + ]); + + return { total: firstPage.totalCount, wired, wireless, vpn: vpn + teleport, guests }; + } + + private async requestAsync(path: string, schema: z.ZodType): Promise { + const response = await this.fetchAsync(`${this.baseUrl}${path}`, { + headers: { "X-API-KEY": this.apiKey, Accept: "application/json" }, + timeout: REQUEST_TIMEOUT_MS, + }); + + if (!response.ok) { + throw new ResponseError(response); + } + + return await parseUnifiOsAsync(await response.json(), schema, PARSE_ERROR_MESSAGE); + } +} +``` + +- [ ] **Step 4 : Lancer les tests pour vérifier qu'ils passent** + +Run: `pnpm vitest run packages/integrations/src/unifi-os/test/unifi-os-api.spec.ts` +Expected: PASS, 7 tests. + +- [ ] **Step 5 : Committer** + +```bash +pnpm format +git add packages/integrations/src/unifi-os/ +git commit -m "feat(integrations): add UniFi OS local API client" +``` + +--- + +## Task 4 : Client HTTP cloud, optionnel et caché + +**Files:** +- Create: `packages/integrations/src/unifi-os/unifi-sm-api.ts` +- Test: `packages/integrations/src/unifi-os/test/unifi-sm-api.spec.ts` + +**Interfaces:** +- Consumes: `unifiSmSiteSchema`, `unifiSmIspMetricsResponseSchema`, `matchCloudSite`, `parseUnifiOsAsync`, `UnifiOsIspMetrics` de la Task 2 ; `UnifiOsFetch` de la Task 3. +- Produces : + - `class UnifiSmApi` — constructeur `(apiKey: string, fetchAsync: UnifiOsFetch)` + - `getIspMetricsAsync(gatewayMac: string | null): Promise` + - `static clearCache(): void` — réservé aux tests + +- [ ] **Step 1 : Écrire les tests qui échouent** + +Créer `packages/integrations/src/unifi-os/test/unifi-sm-api.spec.ts` : + +```ts +import { Response } from "undici"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; + +import { UnifiSmApi } from "../unifi-sm-api"; + +const GATEWAY_MAC = "aa:bb:cc:11:22:33"; +const OTHER_MAC = "dd:ee:ff:44:55:66"; + +const jsonResponse = (body: unknown, status = 200) => + new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } }) as never; + +const sitesBody = { + data: [ + { siteId: "mine", meta: { desc: "Mine", gatewayMac: GATEWAY_MAC }, statistics: { wans: { WAN: { externalIp: "203.0.113.10" } } } }, + { siteId: "theirs", meta: { desc: "Theirs", gatewayMac: OTHER_MAC } }, + ], +}; + +const metricsBody = (metricTime: string) => ({ + data: { + metrics: [ + { + periods: [ + { metricTime: "2026-09-08T08:00:00Z", data: { wan: { avgLatency: 99, maxLatency: 99, packetLoss: 5, uptime: 90, ispName: "Old" } } }, + { metricTime, data: { wan: { avgLatency: 11, maxLatency: 12, packetLoss: 0, uptime: 100, ispName: "Example ISP" } } }, + ], + }, + ], + }, +}); + +const createApi = () => { + const fetchAsync = vi.fn(async (url: string) => + url.includes("isp-metrics") ? jsonResponse(metricsBody("2026-09-08T09:10:00Z")) : jsonResponse(sitesBody), + ); + return { api: new UnifiSmApi("cloud-key", fetchAsync as never), fetchAsync }; +}; + +beforeEach(() => { + UnifiSmApi.clearCache(); + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-09-08T09:37:00Z")); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +describe("UnifiSmApi getIspMetricsAsync", () => { + test("returns the newest period of the site matching the gateway MAC", async () => { + const { api } = createApi(); + + const metrics = await api.getIspMetricsAsync(GATEWAY_MAC); + + expect(metrics).toEqual({ + name: "Example ISP", + externalIp: "203.0.113.10", + latencyMs: 11, + maxLatencyMs: 12, + packetLossPct: 0, + uptimePct: 100, + measuredAt: new Date("2026-09-08T09:10:00Z"), + }); + }); + + test("returns null when no cloud site matches, never another account site", async () => { + const { api } = createApi(); + + expect(await api.getIspMetricsAsync("11:22:33:44:55:66")).toBeNull(); + }); + + test("returns null when the local gateway could not be identified", async () => { + const { api, fetchAsync } = createApi(); + + expect(await api.getIspMetricsAsync(null)).toBeNull(); + expect(fetchAsync).not.toHaveBeenCalled(); + }); + + test("queries a window wide enough for the cloud lag", async () => { + const { api, fetchAsync } = createApi(); + + await api.getIspMetricsAsync(GATEWAY_MAC); + + const body = JSON.parse(String(fetchAsync.mock.calls.find(([url]) => String(url).includes("isp-metrics"))?.[1]?.body)); + const begin = new Date(body.sites[0].beginTimestamp).getTime(); + const end = new Date(body.sites[0].endTimestamp).getTime(); + + // Une fenêtre de 15 min renvoie un tableau vide sur l'API réelle. + expect(end - begin).toBeGreaterThanOrEqual(3 * 60 * 60 * 1000); + }); + + test("serves a second call from cache instead of hitting the cloud again", async () => { + const { api, fetchAsync } = createApi(); + + await api.getIspMetricsAsync(GATEWAY_MAC); + const callsAfterFirst = fetchAsync.mock.calls.length; + await api.getIspMetricsAsync(GATEWAY_MAC); + + expect(fetchAsync.mock.calls.length).toBe(callsAfterFirst); + }); + + test("refetches once the cache entry has expired", async () => { + const { api, fetchAsync } = createApi(); + + await api.getIspMetricsAsync(GATEWAY_MAC); + const callsAfterFirst = fetchAsync.mock.calls.length; + + vi.setSystemTime(new Date("2026-09-08T09:43:00Z")); + await api.getIspMetricsAsync(GATEWAY_MAC); + + expect(fetchAsync.mock.calls.length).toBeGreaterThan(callsAfterFirst); + }); + + test("returns null when the cloud rejects the key rather than propagating", async () => { + const fetchAsync = vi.fn(async () => jsonResponse({ message: "unauthorized" }, 401)); + const api = new UnifiSmApi("bad-key", fetchAsync as never); + + expect(await api.getIspMetricsAsync(GATEWAY_MAC)).toBeNull(); + }); +}); +``` + +- [ ] **Step 2 : Lancer les tests pour vérifier qu'ils échouent** + +Run: `pnpm vitest run packages/integrations/src/unifi-os/test/unifi-sm-api.spec.ts` +Expected: FAIL — `Cannot find module '../unifi-sm-api'`. + +- [ ] **Step 3 : Écrire l'implémentation** + +Créer `packages/integrations/src/unifi-os/unifi-sm-api.ts` : + +```ts +import { createLogger } from "@homarr/core/infrastructure/logs"; + +import type { UnifiOsFetch } from "./unifi-os-api"; +import type { UnifiOsIspMetrics } from "./unifi-os-types"; +import { matchCloudSite, parseUnifiOsAsync, unifiSmIspMetricsResponseSchema, unifiSmSiteSchema } from "./unifi-os-types"; +import { z } from "zod/v4"; + +const logger = createLogger({ module: "unifiSmApi" }); + +const BASE_URL = "https://api.ui.com"; +const REQUEST_TIMEOUT_MS = 15_000; + +/** + * Les métriques cloud sont agrégées par tranches de 5 min et publiées avec un + * retard mesuré d'environ 27 min. Les rafraîchir plus souvent que le cache + * n'apporterait aucune donnée nouvelle et rapprocherait d'une limitation de débit. + */ +const CACHE_TTL_MS = 5 * 60 * 1000; + +/** Une fenêtre de 15 min renvoie un tableau vide sur l'API réelle ; 3 h fonctionne. */ +const QUERY_WINDOW_MS = 3 * 60 * 60 * 1000; + +const siteListSchema = z.looseObject({ data: z.array(unifiSmSiteSchema) }); + +interface CacheEntry { + expiresAt: number; + value: UnifiOsIspMetrics | null; +} + +/** + * Client de l'API cloud Site Manager. Entièrement optionnel : il n'est instancié + * que lorsqu'une clé Site Manager est configurée. + * + * Toute erreur est absorbée et convertie en `null`. Le cloud est un enrichissement ; + * son indisponibilité ne doit jamais faire tomber le widget. + */ +export class UnifiSmApi { + private static readonly cache = new Map(); + + constructor( + private readonly apiKey: string, + private readonly fetchAsync: UnifiOsFetch, + ) {} + + /** Réservé aux tests : le cache est statique et survivrait d'un cas à l'autre. */ + public static clearCache(): void { + UnifiSmApi.cache.clear(); + } + + public async getIspMetricsAsync(gatewayMac: string | null): Promise { + if (gatewayMac === null) return null; + + const cached = UnifiSmApi.cache.get(gatewayMac); + if (cached !== undefined && cached.expiresAt > Date.now()) { + return cached.value; + } + + let value: UnifiOsIspMetrics | null = null; + try { + value = await this.fetchIspMetricsAsync(gatewayMac); + } catch (error) { + logger.warn("Failed to read UniFi Site Manager metrics", { error }); + } + + UnifiSmApi.cache.set(gatewayMac, { expiresAt: Date.now() + CACHE_TTL_MS, value }); + return value; + } + + private async fetchIspMetricsAsync(gatewayMac: string): Promise { + const sitesResponse = await this.fetchAsync(`${BASE_URL}/v1/sites`, { + headers: { "X-API-KEY": this.apiKey, Accept: "application/json" }, + timeout: REQUEST_TIMEOUT_MS, + }); + + if (!sitesResponse.ok) return null; + + const sites = await parseUnifiOsAsync(await sitesResponse.json(), siteListSchema, "Invalid Site Manager sites response"); + const site = matchCloudSite(gatewayMac, sites.data); + if (site === null) return null; + + const end = new Date(); + const begin = new Date(end.getTime() - QUERY_WINDOW_MS); + + const metricsResponse = await this.fetchAsync(`${BASE_URL}/v1/isp-metrics/5m/query`, { + method: "POST", + headers: { "X-API-KEY": this.apiKey, Accept: "application/json", "Content-Type": "application/json" }, + timeout: REQUEST_TIMEOUT_MS, + body: JSON.stringify({ + sites: [ + { + hostId: site.hostId, + siteId: site.siteId, + beginTimestamp: begin.toISOString(), + endTimestamp: end.toISOString(), + }, + ], + }), + }); + + if (!metricsResponse.ok) return null; + + const parsed = await parseUnifiOsAsync( + await metricsResponse.json(), + unifiSmIspMetricsResponseSchema, + "Invalid Site Manager ISP metrics response", + ); + + const periods = parsed.data.metrics[0]?.periods ?? []; + const latest = periods.at(-1); + const wan = latest?.data.wan; + if (latest === undefined || wan === undefined) return null; + + const externalIp = Object.values(site.statistics?.wans ?? {}) + .map((entry) => entry.externalIp) + .find((ip) => ip !== undefined); + + return { + name: wan.ispName ?? site.statistics?.ispInfo?.name ?? "", + externalIp: externalIp ?? null, + latencyMs: wan.avgLatency ?? 0, + maxLatencyMs: wan.maxLatency ?? 0, + packetLossPct: wan.packetLoss ?? 0, + uptimePct: wan.uptime ?? site.statistics?.percentages?.wanUptime ?? 0, + measuredAt: new Date(latest.metricTime), + }; + } +} +``` + +- [ ] **Step 4 : Lancer les tests pour vérifier qu'ils passent** + +Run: `pnpm vitest run packages/integrations/src/unifi-os/test/unifi-sm-api.spec.ts` +Expected: PASS, 7 tests. + +- [ ] **Step 5 : Committer** + +```bash +pnpm format +git add packages/integrations/src/unifi-os/ +git commit -m "feat(integrations): add optional UniFi Site Manager client with 5min cache" +``` + +--- + +## Task 5 : Classe d'intégration et enregistrement + +**Files:** +- Create: `packages/integrations/src/interfaces/unifi-os-summary/unifi-os-summary-integration.ts` +- Create: `packages/integrations/src/unifi-os/unifi-os-integration.ts` +- Test: `packages/integrations/src/unifi-os/test/unifi-os-integration.spec.ts` +- Modify: `packages/integrations/src/base/creator.ts` +- Modify: `packages/integrations/src/index.ts` +- Modify: `packages/integrations/src/types.ts` + +**Interfaces:** +- Consumes: `UnifiOsApi` (Task 3), `UnifiSmApi` (Task 4), les mappers et types (Task 2), le kind `unifiOs` (Task 1). +- Produces : + - `interface UnifiOsSummaryIntegration { getUnifiOsSummaryAsync(): Promise }` + - `class UnifiOsIntegration extends Integration implements UnifiOsSummaryIntegration` + - entrée `unifiOs` dans `integrationCreators` + +- [ ] **Step 1 : Écrire les tests qui échouent** + +Créer `packages/integrations/src/unifi-os/test/unifi-os-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 { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http"; + +import type { IntegrationSecret } from "../../base/types"; +import { UnifiSmApi } from "../unifi-sm-api"; +import { UnifiOsIntegration } from "../unifi-os-integration"; + +vi.mock("@homarr/core/infrastructure/http", () => ({ + fetchWithTrustedCertificatesAsync: vi.fn(), +})); + +const mockFetch = vi.mocked(fetchWithTrustedCertificatesAsync); + +const GATEWAY_MAC = "aa:bb:cc:11:22:33"; + +const jsonResponse = (body: unknown, status = 200) => + new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } }) as never; + +const page = (data: unknown[]) => ({ offset: 0, limit: 200, count: data.length, totalCount: data.length, data }); + +const localRoutes: Record = { + "/info": { applicationVersion: "10.6.102" }, + "/sites": page([{ id: "site-1", name: "Example site", internalReference: "default" }]), + "/sites/site-1/clients": page([ + { id: "c1", name: "a", type: "WIRED", access: { type: "DEFAULT" } }, + { id: "c2", name: "b", type: "WIRELESS", access: { type: "DEFAULT" } }, + ]), + "/sites/site-1/devices": page([ + { id: "gw", name: "Gateway", model: "UXG Pro", macAddress: GATEWAY_MAC, state: "ONLINE", firmwareUpdatable: true, features: ["gateway"] }, + { id: "sw", name: "Switch", model: "USW Lite 8", macAddress: "aa:bb:cc:99:88:77", state: "OFFLINE", firmwareUpdatable: false, features: ["switching"] }, + ]), + "/sites/site-1/devices/gw/statistics/latest": { + uptimeSec: 1009049, + cpuUtilizationPct: 19.1, + memoryUtilizationPct: 63.1, + uplink: { txRateBps: 14140912, rxRateBps: 3346784 }, + }, + "/sites/site-1/vpn/servers": page([{ id: "v1", name: "vpn", enabled: true }]), +}; + +const routeLocal = (rawUrl: string): Response | null => { + const path = new URL(rawUrl).pathname.replace("/proxy/network/integration/v1", ""); + const body = localRoutes[path]; + return body === undefined ? null : jsonResponse(body); +}; + +const createIntegration = (decryptedSecrets: IntegrationSecret[]) => + new UnifiOsIntegration({ + id: "test-unifi-os", + name: "Test UniFi OS", + url: "https://console.example.test", + externalUrl: null, + decryptedSecrets, + }); + +const localOnlySecrets: IntegrationSecret[] = [{ kind: "apiKey", value: "local-key" }]; +const hybridSecrets: IntegrationSecret[] = [ + { kind: "apiKey", value: "local-key" }, + { kind: "siteManagerApiKey", value: "cloud-key" }, +]; + +beforeEach(() => { + mockFetch.mockReset(); + UnifiSmApi.clearCache(); +}); + +describe("UnifiOsIntegration getUnifiOsSummaryAsync", () => { + test("builds the summary from the local API alone", async () => { + mockFetch.mockImplementation(async (url) => routeLocal(String(url)) ?? jsonResponse({}, 404)); + + const summary = await createIntegration(localOnlySecrets).getUnifiOsSummaryAsync(); + + expect(summary.site).toEqual({ id: "site-1", name: "Example site", applicationVersion: "10.6.102" }); + expect(summary.clients).toEqual({ total: 2, wired: 1, wireless: 1, vpn: 0, guests: 0 }); + expect(summary.devices).toEqual({ total: 2, online: 1, offline: 1, firmwareUpdatable: 1 }); + expect(summary.wan.state).toBe("online"); + expect(summary.wan.throughput).toEqual({ txBps: 14140912, rxBps: 3346784 }); + expect(summary.gateway).toMatchObject({ name: "Gateway", uptimeSec: 1009049, cpuPct: 19.1 }); + }); + + test("leaves the ISP block null when no Site Manager key is configured", async () => { + mockFetch.mockImplementation(async (url) => routeLocal(String(url)) ?? jsonResponse({}, 404)); + + const summary = await createIntegration(localOnlySecrets).getUnifiOsSummaryAsync(); + + expect(summary.wan.isp).toBeNull(); + expect(mockFetch.mock.calls.every(([url]) => !String(url).startsWith("https://api.ui.com"))).toBe(true); + }); + + test("fills the ISP block when a Site Manager key is configured", async () => { + mockFetch.mockImplementation(async (url) => { + const raw = String(url); + if (raw.startsWith("https://api.ui.com/v1/sites")) { + return jsonResponse({ data: [{ siteId: "cloud-1", hostId: "host-1", meta: { gatewayMac: GATEWAY_MAC }, statistics: { wans: { WAN: { externalIp: "203.0.113.10" } } } }] }); + } + if (raw.includes("isp-metrics")) { + return jsonResponse({ + data: { metrics: [{ periods: [{ metricTime: "2026-09-08T09:10:00Z", data: { wan: { avgLatency: 11, maxLatency: 12, packetLoss: 0, uptime: 100, ispName: "Example ISP" } } }] }] }, + }); + } + return routeLocal(raw) ?? jsonResponse({}, 404); + }); + + const summary = await createIntegration(hybridSecrets).getUnifiOsSummaryAsync(); + + expect(summary.wan.isp).toMatchObject({ name: "Example ISP", latencyMs: 11, externalIp: "203.0.113.10" }); + expect(summary.wan.isp?.measuredAt).toEqual(new Date("2026-09-08T09:10:00Z")); + }); + + test("degrades only the affected block when one local endpoint fails", async () => { + mockFetch.mockImplementation(async (url) => { + const raw = String(url); + if (raw.includes("/vpn/servers")) return jsonResponse({ error: "boom" }, 500); + return routeLocal(raw) ?? jsonResponse({}, 404); + }); + + const summary = await createIntegration(localOnlySecrets).getUnifiOsSummaryAsync(); + + expect(summary.clients.total).toBe(2); + expect(summary.devices.total).toBe(2); + }); + + test("keeps clients and devices when the gateway cannot be identified", async () => { + mockFetch.mockImplementation(async (url) => { + const raw = String(url); + if (raw.endsWith("/devices?limit=200")) { + return jsonResponse(page([{ id: "sw", name: "Switch", model: "USW Lite 8", macAddress: "aa:bb:cc:99:88:77", state: "ONLINE", firmwareUpdatable: false, features: ["switching"] }])); + } + return routeLocal(raw) ?? jsonResponse({}, 404); + }); + + const summary = await createIntegration(localOnlySecrets).getUnifiOsSummaryAsync(); + + expect(summary.gateway).toBeNull(); + expect(summary.wan.throughput).toBeNull(); + expect(summary.wan.state).toBe("unknown"); + expect(summary.clients.total).toBe(2); + }); +}); +``` + +- [ ] **Step 2 : Lancer les tests pour vérifier qu'ils échouent** + +Run: `pnpm vitest run packages/integrations/src/unifi-os/test/unifi-os-integration.spec.ts` +Expected: FAIL — `Cannot find module '../unifi-os-integration'`. + +- [ ] **Step 3 : Écrire l'interface** + +Créer `packages/integrations/src/interfaces/unifi-os-summary/unifi-os-summary-integration.ts` : + +```ts +import type { UnifiOsSummary } from "../../unifi-os/unifi-os-types"; + +export interface UnifiOsSummaryIntegration { + getUnifiOsSummaryAsync(): Promise; +} +``` + +- [ ] **Step 4 : Écrire la classe d'intégration** + +Créer `packages/integrations/src/unifi-os/unifi-os-integration.ts` : + +```ts +import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http"; + +import type { IntegrationTestingInput } from "../base/integration"; +import { Integration } from "../base/integration"; +import { TestConnectionError } from "../base/test-connection/test-connection-error"; +import type { TestingResult } from "../base/test-connection/test-connection-service"; +import type { UnifiOsSummaryIntegration } from "../interfaces/unifi-os-summary/unifi-os-summary-integration"; +import type { UnifiOsFetch } from "./unifi-os-api"; +import { UnifiOsApi } from "./unifi-os-api"; +import { UnifiSmApi } from "./unifi-sm-api"; +import type { UnifiOsDevice, UnifiOsDeviceStatistics, UnifiOsSummary } from "./unifi-os-types"; +import { findGateway, summarizeDevices } from "./unifi-os-types"; + +const EMPTY_CLIENT_COUNTS = { total: 0, wired: 0, wireless: 0, vpn: 0, guests: 0 } as const; +const EMPTY_DEVICE_COUNTS = { total: 0, online: 0, offline: 0, firmwareUpdatable: 0 } as const; + +/** `Promise.allSettled` renvoie `rejected` sans valeur ; on retombe sur un défaut. */ +const settledValue = (result: PromiseSettledResult, fallback: T): T => + result.status === "fulfilled" ? result.value : fallback; + +export class UnifiOsIntegration extends Integration implements UnifiOsSummaryIntegration { + public async getUnifiOsSummaryAsync(): Promise { + return await this.buildSummaryAsync(fetchWithTrustedCertificatesAsync as UnifiOsFetch); + } + + protected async testingAsync({ fetchAsync }: IntegrationTestingInput): Promise { + const api = this.createLocalApi(fetchAsync as unknown as UnifiOsFetch); + + try { + await api.getInfoAsync(); + } catch (error) { + return TestConnectionError.UnknownResult(error); + } + + const site = await api.getFirstSiteAsync(); + if (site === null) { + // Une clé valide sur une console sans site est configurable mais inutile : + // mieux vaut le dire au moment du test que laisser un widget vide. + return TestConnectionError.UnknownResult(new Error("The UniFi OS console exposes no site")); + } + + return { success: true }; + } + + private createLocalApi(fetchAsync: UnifiOsFetch) { + return new UnifiOsApi(this.integration.url, this.getSecretValue("apiKey"), fetchAsync); + } + + private async buildSummaryAsync(fetchAsync: UnifiOsFetch): Promise { + const api = this.createLocalApi(fetchAsync); + + const [info, site] = await Promise.all([api.getInfoAsync(), api.getFirstSiteAsync()]); + if (site === null) { + throw new Error("The UniFi OS console exposes no site"); + } + + const [clientsResult, devicesResult] = await Promise.allSettled([ + api.getClientCountsAsync(site.id), + api.getDevicesAsync(site.id), + ]); + + const clients = settledValue(clientsResult, { ...EMPTY_CLIENT_COUNTS }); + const devices = settledValue(devicesResult, []); + const gatewayDevice = findGateway(devices); + + // Les statistiques dépendent du gateway : sans gateway identifié, il n'y a + // rien à demander. Un échec ici ne doit vider que le débit et l'uptime. + let statistics: UnifiOsDeviceStatistics | null = null; + if (gatewayDevice !== null) { + try { + statistics = await api.getDeviceStatisticsAsync(site.id, gatewayDevice.id); + } catch { + statistics = null; + } + } + + const isp = await this.readIspMetricsAsync(fetchAsync, gatewayDevice?.macAddress ?? null); + + return { + site: { id: site.id, name: site.name, applicationVersion: info.applicationVersion }, + wan: { + state: gatewayDevice === null ? "unknown" : gatewayDevice.state === "ONLINE" ? "online" : "offline", + throughput: + statistics?.uplink === undefined + ? null + : { txBps: statistics.uplink.txRateBps, rxBps: statistics.uplink.rxRateBps }, + isp, + }, + clients, + devices: devicesResult.status === "fulfilled" ? summarizeDevices(devices) : { ...EMPTY_DEVICE_COUNTS }, + gateway: + gatewayDevice === null + ? null + : { + name: gatewayDevice.name, + model: gatewayDevice.model, + macAddress: gatewayDevice.macAddress, + uptimeSec: statistics?.uptimeSec ?? null, + cpuPct: statistics?.cpuUtilizationPct ?? null, + memoryPct: statistics?.memoryUtilizationPct ?? null, + }, + }; + } + + /** + * Le cloud n'est jamais interrogé sans clé configurée : le module n'est même + * pas instancié, ce qui rend impossible un appel réseau accidentel. + */ + private async readIspMetricsAsync(fetchAsync: UnifiOsFetch, gatewayMac: string | null) { + if (!this.hasSecretValue("siteManagerApiKey")) return null; + + const smApi = new UnifiSmApi(this.getSecretValue("siteManagerApiKey"), fetchAsync); + return await smApi.getIspMetricsAsync(gatewayMac); + } +} +``` + +- [ ] **Step 5 : Enregistrer la classe** + +Dans `packages/integrations/src/base/creator.ts`, ajouter l'import à côté de celui de `UnifiControllerIntegration` : + +```ts +import { UnifiOsIntegration } from "../unifi-os/unifi-os-integration"; +``` + +et, dans l'objet des créateurs, juste après `unifiController: UnifiControllerIntegration,` : + +```ts + unifiOs: UnifiOsIntegration, +``` + +Dans `packages/integrations/src/index.ts`, ajouter : + +```ts +export { UnifiOsIntegration } from "./unifi-os/unifi-os-integration"; +``` + +Dans `packages/integrations/src/types.ts`, à côté de la ligne `export * from "./unifi-controller/unifi-controller-types";` : + +```ts +export * from "./unifi-os/unifi-os-types"; +export * from "./interfaces/unifi-os-summary/unifi-os-summary-integration"; +``` + +- [ ] **Step 6 : Lancer les tests pour vérifier qu'ils passent** + +Run: `pnpm vitest run packages/integrations/src/unifi-os/` +Expected: PASS, l'ensemble des tests des tâches 2 à 5. + +- [ ] **Step 7 : Vérifier le typage** + +Run: `pnpm turbo typecheck --filter=@homarr/integrations` +Expected: PASS. + +- [ ] **Step 8 : Vérifier qu'aucune intégration existante n'a bougé** + +```bash +git status --short packages/integrations/src/unifi-controller packages/integrations/src/opnsense packages/integrations/src/interfaces/network-controller-summary +``` + +Expected: aucune sortie. Une sortie non vide signifie que la contrainte « ne pas toucher aux intégrations existantes » a été violée — annuler ces modifications. + +- [ ] **Step 9 : Committer** + +```bash +pnpm format +git add packages/integrations/ +git commit -m "feat(integrations): add UnifiOsIntegration with optional cloud enrichment" +``` + +--- + +## Task 6 : Request handler et routeur tRPC + +**Files:** +- Create: `packages/request-handler/src/unifi-os.ts` +- Create: `packages/api/src/router/widgets/unifi-os.ts` +- Modify: `packages/api/src/router/widgets/index.ts` + +**Interfaces:** +- Consumes: `UnifiOsIntegration.getUnifiOsSummaryAsync()` et le type `UnifiOsSummary` (Task 5). +- Produces : + - `unifiOsRequestHandler` avec `.handler(integration, {}).getDataAsync()` + - `unifiOsRouter` exposant `clientApi.widget.unifiOs.summary.useQuery({ integrationIds })` + +- [ ] **Step 1 : Écrire le request handler** + +Créer `packages/request-handler/src/unifi-os.ts`, calqué sur `network-controller.ts` : + +```ts +import { createIntegrationAsync } from "@homarr/integrations"; +import type { UnifiOsSummary } from "@homarr/integrations/types"; + +import { createIntegrationRequestHandler } from "./lib/integration-request-handler"; + +export const unifiOsRequestHandler = createIntegrationRequestHandler>({ + async requestAsync(integration, _input) { + const integrationInstance = await createIntegrationAsync(integration); + return await integrationInstance.getUnifiOsSummaryAsync(); + }, + cacheTtlMs: 60 * 1000, + // Une console momentanément injoignable doit afficher la dernière valeur connue + // plutôt qu'une tuile d'erreur ; le widget signale la péremption. + fallbackToStaleOnError: true, + staleIfErrorTtlMs: 10 * 60 * 1000, +}); +``` + +Le paramètre de kind est ici le littéral `"unifiOs"` et non `IntegrationKindByCategory<"unifiOs">` : une seule intégration appartient à cette catégorie, et l'écrire en clair évite un aller-retour de lecture. + +- [ ] **Step 2 : Écrire le routeur tRPC** + +Créer `packages/api/src/router/widgets/unifi-os.ts` : + +```ts +import { unifiOsRequestHandler } from "@homarr/request-handler/unifi-os"; + +import { createManyIntegrationMiddleware } from "../../middlewares/integration"; +import { settleIntegrationQueries } from "../../settle-integrations"; +import { createTRPCRouter, publicProcedure } from "../../trpc"; + +export const unifiOsRouter = createTRPCRouter({ + summary: publicProcedure.concat(createManyIntegrationMiddleware("query", "unifiOs")).query(async ({ ctx }) => { + return await settleIntegrationQueries(ctx.integrations, async (integration) => { + const innerHandler = unifiOsRequestHandler.handler(integration, {}); + const { data, timestamp } = await innerHandler.getDataAsync(); + + return { + integration: { + id: integration.id, + name: integration.name, + kind: integration.kind, + }, + summary: data, + updatedAt: timestamp, + }; + }); + }), +}); +``` + +- [ ] **Step 3 : Enregistrer le routeur** + +Dans `packages/api/src/router/widgets/index.ts`, à côté de la ligne `networkController: lazy(...)`, ajouter : + +```ts + unifiOs: lazy(() => import("./unifi-os").then((mod) => mod.unifiOsRouter)), +``` + +- [ ] **Step 4 : Vérifier l'export du sous-chemin `@homarr/request-handler/unifi-os`** + +Run: `grep -n '"./\*"\|exports' packages/request-handler/package.json` +Expected: le champ `exports` utilise un motif générique (`"./*"`). Si ce n'est pas le cas, ajouter explicitement l'entrée `"./unifi-os"` en copiant la forme de `"./network-controller"`. + +- [ ] **Step 5 : Vérifier le typage** + +Run: `pnpm turbo typecheck --filter=@homarr/api --filter=@homarr/request-handler` +Expected: PASS. + +- [ ] **Step 6 : Committer** + +```bash +pnpm format +git add packages/request-handler/src/unifi-os.ts packages/api/src/router/widgets/ +git commit -m "feat(api): expose unifiOs widget summary query" +``` + +--- + +## Task 7 : Widget + +**Files:** +- Create: `packages/widgets/src/unifi-os/index.ts` +- Create: `packages/widgets/src/unifi-os/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.unifiOs.summary` (Task 6), `UnifiOsSummary` (Task 2), le `WidgetKind` `unifiOs` (Task 1). +- Produces: le widget affichable, disposition « bandeau WAN + lignes » de la spec §8. + +- [ ] **Step 1 : Écrire la définition du widget** + +Créer `packages/widgets/src/unifi-os/index.ts` : + +```ts +import { IconRouter, IconServerOff } from "@tabler/icons-react"; + +import { createWidgetDefinition } from "../definition"; +import { optionsBuilder } from "../options"; + +export const { definition, componentLoader } = createWidgetDefinition("unifiOs", { + icon: IconRouter, + supportedIntegrations: ["unifiOs"], + integrationsRequired: true, + createOptions() { + return optionsBuilder.from((factory) => ({ + showIsp: factory.switch({ defaultValue: true, withDescription: true }), + showDevices: factory.switch({ defaultValue: true, withDescription: true }), + showFirmwareUpdates: factory.switch({ defaultValue: true, withDescription: true }), + })); + }, + errors: { + INTERNAL_SERVER_ERROR: { + icon: IconServerOff, + message: (t) => t("widget.unifiOs.error.internalServerError"), + }, + }, +}).withDynamicImport(() => import("./component")); +``` + +- [ ] **Step 2 : Écrire le composant** + +Créer `packages/widgets/src/unifi-os/component.tsx`. Disposition : bandeau WAN +dominant, puis une ligne par thème. + +```tsx +"use client"; + +import { useMemo } from "react"; +import { Badge, Box, Group, Stack, Text, Tooltip } from "@mantine/core"; +import { IconCircleFilled } from "@tabler/icons-react"; +import dayjs from "dayjs"; +import relativeTime from "dayjs/plugin/relativeTime"; + +import { clientApi } from "@homarr/api/client"; +import { useI18n } from "@homarr/translation/client"; + +import type { WidgetComponentProps } from "../definition"; + +dayjs.extend(relativeTime); + +const formatBitsPerSecond = (bps: number): string => { + const units = ["b/s", "Kb/s", "Mb/s", "Gb/s"]; + let value = bps; + let unitIndex = 0; + + while (value >= 1000 && unitIndex < units.length - 1) { + value /= 1000; + unitIndex += 1; + } + + return `${value.toFixed(value >= 100 || unitIndex === 0 ? 0 : 1)} ${units[unitIndex]}`; +}; + +const formatUptime = (seconds: number): string => { + const days = Math.floor(seconds / 86400); + const hours = Math.floor((seconds % 86400) / 3600); + + return days > 0 ? `${days}d ${hours}h` : `${hours}h`; +}; + +export default function UnifiOsWidget({ integrationIds, options }: WidgetComponentProps<"unifiOs">) { + const t = useI18n(); + const { data: results = [] } = clientApi.widget.unifiOs.summary.useQuery({ integrationIds }); + + const summary = useMemo(() => results[0]?.summary ?? null, [results]); + + if (summary === null) { + return ( + + + {t("widget.unifiOs.error.noData")} + + + ); + } + + const wanColor = summary.wan.state === "online" ? "green" : summary.wan.state === "offline" ? "red" : "gray"; + + return ( + + {/* Le nom du site n'est pas décoratif : une console peut héberger plusieurs + sites et le widget n'en affiche qu'un. L'afficher rend ce choix visible. */} + + + + {summary.site.name} + + + + + + + + + {t("widget.unifiOs.card.internet")} + + + {summary.wan.throughput !== null && ( + + ↑{formatBitsPerSecond(summary.wan.throughput.txBps)} ↓{formatBitsPerSecond(summary.wan.throughput.rxBps)} + + )} + + + {options.showIsp && summary.wan.isp !== null && ( + + + {summary.wan.isp.name} + {summary.wan.isp.externalIp !== null ? ` · ${summary.wan.isp.externalIp}` : ""} + + + + {t("widget.unifiOs.card.latency", { value: `${summary.wan.isp.latencyMs}` })} + + + + {dayjs(summary.wan.isp.measuredAt).fromNow()} + + + + + )} + + + + + + {t("widget.unifiOs.card.clients")} + + + {t("widget.unifiOs.card.clientBreakdown", { + total: `${summary.clients.total}`, + wired: `${summary.clients.wired}`, + wireless: `${summary.clients.wireless}`, + })} + + + + {options.showDevices && ( + + + {t("widget.unifiOs.card.devices")} + + 0 ? "red" : undefined}> + {t("widget.unifiOs.card.devicesOnline", { + online: `${summary.devices.online}`, + total: `${summary.devices.total}`, + })} + + + )} + + {options.showFirmwareUpdates && summary.devices.firmwareUpdatable > 0 && ( + + + {t("widget.unifiOs.card.firmware")} + + + {t("widget.unifiOs.card.firmwarePending", { count: `${summary.devices.firmwareUpdatable}` })} + + + )} + + + + + {t("widget.unifiOs.card.version", { version: summary.site.applicationVersion })} + + {summary.gateway?.uptimeSec != null && ( + + {t("widget.unifiOs.card.gatewayUptime", { uptime: formatUptime(summary.gateway.uptimeSec) })} + + )} + + + ); +} +``` + +- [ ] **Step 3 : Enregistrer dans le manifeste** + +Dans `packages/widgets/src/manifest.ts`, ajouter dans la carte des modules (à côté de `networkControllerSummary`) : + +```ts + unifiOs: () => import("./unifi-os") as Promise, +``` + +et dans la carte des composants : + +```ts + unifiOs: () => import("./unifi-os/component") as Promise, +``` + +- [ ] **Step 4 : Enregistrer dans l'index et le catalogue** + +Dans `packages/widgets/src/index.tsx`, ajouter l'import à côté de `networkControllerSummary` : + +```ts +import * as unifiOs from "./unifi-os"; +``` + +et l'entrée correspondante dans l'objet exporté : + +```ts + unifiOs, +``` + +Dans `packages/widgets/src/catalog.ts`, importer `IconRouter` depuis `@tabler/icons-react` (en respectant l'ordre alphabétique de la liste d'imports existante) et ajouter au `widgetCatalogIcons` : + +```ts + unifiOs: IconRouter, +``` + +**L'icône doit être exactement la même que celle de la définition** — `manifest.spec.ts` compare les deux par identité (`toBe`). + +- [ ] **Step 5 : Déclarer l'intervalle de rafraîchissement** + +Dans `packages/widgets/src/refetch-intervals.ts`, ajouter à `widgetQueryRefetchIntervals` : + +```ts + { queryKey: [["widget", "unifiOs"]], intervalSeconds: 60 }, +``` + +- [ ] **Step 6 : Ajouter les traductions** + +Dans `packages/translation/src/lang/en.json`, dans l'objet `widget`, ajouter : + +```json +"unifiOs": { + "name": "UniFi OS", + "description": "Network overview from a UniFi OS console", + "option": { + "showIsp": { "label": "Show ISP details", "description": "Requires a Site Manager API key" }, + "showDevices": { "label": "Show devices", "description": "Display how many devices are online" }, + "showFirmwareUpdates": { "label": "Show firmware updates", "description": "Display how many devices have a pending firmware update" } + }, + "card": { + "internet": "Internet", + "latency": "{value} ms", + "measuredAt": "Measured at {time}", + "clients": "Clients", + "clientBreakdown": "{total} · {wired} wired · {wireless} Wi-Fi", + "devices": "Devices", + "devicesOnline": "{online} / {total} online", + "firmware": "Firmware", + "firmwarePending": "{count} to update", + "version": "Network {version}", + "gatewayUptime": "Gateway up {uptime}" + }, + "error": { + "internalServerError": "Could not reach the UniFi OS console", + "noData": "No data available" + } +} +``` + +- [ ] **Step 7 : Lancer les tests du paquet widgets** + +Run: `pnpm vitest run packages/widgets/src/manifest.spec.ts` +Expected: PASS. Ce test vérifie que le nouveau `WidgetKind` est présent dans le manifeste, dans le catalogue avec la **même** icône, et dans les intervalles de rafraîchissement. Un échec ici indique un point d'enregistrement oublié. + +- [ ] **Step 8 : Vérifier le typage global** + +Run: `pnpm turbo typecheck` +Expected: PASS sur tous les paquets. + +- [ ] **Step 9 : Lint** + +Run: `pnpm lint` +Expected: PASS. + +- [ ] **Step 10 : Committer** + +```bash +pnpm format +git add packages/widgets/ packages/translation/src/lang/en.json +git commit -m "feat(widgets): add UniFi OS widget" +``` + +--- + +## Task 8 : Vérification contre la console réelle + +Les 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é. + +**Files:** +- Aucun fichier versionné n'est créé. Le script de vérification reste hors dépôt. + +**Interfaces:** +- Consumes: l'intégralité des tâches 1 à 7. +- Produces: la confirmation que les valeurs affichées correspondent à la console. + +- [ ] **Step 1 : Démarrer l'environnement de développement** + +```bash +corepack enable +pnpm install +pnpm docker:dev:up +pnpm dev +``` + +- [ ] **Step 2 : Créer l'intégration dans l'interface** + +Dans Homarr, créer une intégration de type **UniFi OS** : +- URL : l'origine de la console, sans chemin. +- `apiKey` : clé générée dans l'application Network (Paramètres → Control Plane → Integrations). **Pas** une clé du site manager : elle serait rejetée en `401`. +- `siteManagerApiKey` : optionnelle, générée sur `unifi.ui.com`. + +Puis lancer le **test de connexion**. +Expected: succès. Un échec ici concerne la couche cliente, pas l'interface. + +- [ ] **Step 3 : Comparer les valeurs à la console** + +Ajouter le widget UniFi OS sur un board et comparer, champ par champ, avec +l'interface UniFi : + +| Champ | Où vérifier dans UniFi | +|---|---| +| Clients filaires / Wi-Fi | Client Devices, filtré par type de connexion | +| Équipements en ligne | UniFi Devices | +| Firmwares en attente | UniFi Devices, colonne de mise à jour | +| Débit WAN | Tableau de bord, graphe du gateway | +| Latence et fraîcheur | Site Manager cloud — l'écart d'environ 27 min est **attendu** | + +- [ ] **Step 4 : Vérifier la dégradation** + +Retirer la `siteManagerApiKey` de l'intégration et recharger. +Expected: le bloc ISP disparaît, tout le reste continue de s'afficher. + +- [ ] **Step 5 : Consigner l'écart éventuel** + +Si un chiffre diverge, ne pas ajuster le widget : corriger le mapper de la Task 2 +et **ajouter un test** reproduisant le cas. Un chiffre corrigé sans test revient +la fois suivante. + +--- + +## Task 9 : Build et déploiement sur le LXC + +**Files:** +- Aucun fichier versionné. + +**Interfaces:** +- Consumes: une branche `feat/unifi-os-integration` dont toutes les tâches sont validées. +- Produces: l'instance de production servant le widget. + +⚠️ Ne pas démarrer cette tâche tant que la Task 8 n'est pas concluante. + +- [ ] **Step 1 : Vérifier l'alignement des migrations avant toute chose** + +```bash +ls packages/db/migrations/sqlite/ | tail -3 +ssh zachary@10.70.1.13 'ls /opt/homarr/db/migrations/*/ | tail -3' +``` + +Expected: la dernière migration est identique des deux côtés. **Si elles +divergent, arrêter** et traiter la mise à jour de schéma comme un sujet distinct : +le rollback ne serait plus une simple restauration d'archive. + +- [ ] **Step 2 : Construire** + +```bash +pnpm turbo build +``` + +Expected: build complet sans erreur. + +- [ ] **Step 3 : Sauvegarder l'instance** + +```bash +ssh zachary@10.70.1.13 'sudo tar czf /root/homarr-backup-$(date +%F-%H%M).tar.gz /opt/homarr && sudo ls -la /root/ | grep homarr-backup' +``` + +Expected: l'archive existe et n'est pas vide. Sauvegarder également la base +selon le `DB_DRIVER` déclaré dans `/etc/homarr/homarr.env`. + +- [ ] **Step 4 : Arrêter le service** + +```bash +ssh zachary@10.70.1.13 'sudo systemctl stop homarr && systemctl is-active homarr || true' +``` + +Expected: `inactive`. Ne jamais rsync sur un Next.js en cours d'exécution. + +- [ ] **Step 5 : Déployer** + +Reproduire la disposition existante de `/opt/homarr` (`apps/`, `packages/`, +`build/`, `node_modules/`) à partir de la sortie standalone du build. **Ne pas +toucher** à `/etc/homarr/homarr.env` ni à `run.sh`, `nginx.conf`, `redis.conf`, +`entrypoint.sh`, qui appartiennent à l'installation et non au build. + +- [ ] **Step 6 : Redémarrer et vérifier** + +```bash +ssh zachary@10.70.1.13 'sudo systemctl start homarr && sleep 10 && systemctl is-active homarr && sudo journalctl -u homarr -n 40 --no-pager' +``` + +Expected: `active`, aucune erreur de migration dans les journaux. + +- [ ] **Step 7 : Vérifier dans le navigateur** + +Ouvrir l'instance de production, vérifier que le widget affiche les mêmes valeurs +qu'en développement, et que les intégrations **préexistantes** fonctionnent +toujours — c'est le contrôle qui prouve que `SECRET_ENCRYPTION_KEY` est intact. + +- [ ] **Step 8 : En cas de problème, restaurer** + +```bash +ssh zachary@10.70.1.13 'sudo systemctl stop homarr && sudo rm -rf /opt/homarr && sudo tar xzf /root/homarr-backup-.tar.gz -C / && sudo systemctl start homarr' +``` + +--- + +## Points laissés ouverts + +À trancher hors de ce plan, sans bloquer son exécution : + +1. **Où pousser le fork.** Une instance Gitea est disponible. Sans dépôt distant, + la branche n'existe que sur le poste — une panne de disque effacerait le travail. +2. **Stratégie de rebase.** Homarr publie fréquemment. Les modifications sont + concentrées dans un dossier neuf, mais les douze points d'attache du §5.2 de la + spec sont autant de sources de conflit à chaque montée de version. +3. **Révocation des clés d'API** utilisées pendant la conception. diff --git a/docs/superpowers/specs/2026-09-08-unifi-os-integration-design.md b/docs/superpowers/specs/2026-09-08-unifi-os-integration-design.md index b6993f364..d63473288 100644 --- a/docs/superpowers/specs/2026-09-08-unifi-os-integration-design.md +++ b/docs/superpowers/specs/2026-09-08-unifi-os-integration-design.md @@ -382,10 +382,17 @@ endpoints. adresses MAC, IP publique, noms de clients et identifiants de site remplacés. Un dépôt ne doit pas porter la cartographie d'un réseau privé. -**Cas de régression explicite :** l'issue upstream -[#3222](https://github.com/homarr-labs/homarr/issues/3222) signalait des valeurs -nulles en multi-site sur l'ancienne intégration. Un test couvre l'agrégation -multi-site pour ne pas reproduire ce défaut. +**Multi-site.** Une URL locale désigne une console, et l'intégration se limite au +premier site qu'elle expose. Une console peut techniquement en héberger +plusieurs : le widget affiche donc le **nom du site** retenu, pour que ce choix +soit visible plutôt que subi. Deux tests couvrent les bornes — une console sans +aucun site renvoie une erreur explicite au test de connexion, et le nom du site +figure dans le contrat. + +L'ancienne intégration agrégeait tous les sites, ce qui avait produit l'issue +[#3222](https://github.com/homarr-labs/homarr/issues/3222) (valeurs nulles en +multi-site). Ne pas agréger supprime la classe de défaut plutôt que de la +tester. ## 10. Phasage