From 3ce3eefa5ef9a8a142d0222cfe737c4a04b2ce86 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Zachary=20Gu=C3=A9not?= Date: Tue, 8 Sep 2026 14:53:46 +0200 Subject: [PATCH] feat(integrations): add optional UniFi Site Manager client with 5min cache Task 4 of the UniFi OS integration: an optional cloud client for Ubiquiti's Site Manager API that enriches the local console with ISP latency data. Instantiated only when a cloud API key is configured (Task 5). Every failure path (bad key, non-ok status, parse error, unmatched gateway MAC) returns null rather than throwing, since the cloud is a pure enrichment. Metrics are cached 5 minutes, keyed by gateway MAC, to respect the ~27 min publication lag and avoid rate limiting; the query window is 3 hours because a 15-minute window returns an empty array on the real API. Co-Authored-By: Claude Opus 5 --- .../src/unifi-os/test/unifi-sm-api.spec.ts | 138 ++++++++++++++++++ .../integrations/src/unifi-os/unifi-sm-api.ts | 136 +++++++++++++++++ 2 files changed, 274 insertions(+) create mode 100644 packages/integrations/src/unifi-os/test/unifi-sm-api.spec.ts create mode 100644 packages/integrations/src/unifi-os/unifi-sm-api.ts diff --git a/packages/integrations/src/unifi-os/test/unifi-sm-api.spec.ts b/packages/integrations/src/unifi-os/test/unifi-sm-api.spec.ts new file mode 100644 index 000000000..aff34e8de --- /dev/null +++ b/packages/integrations/src/unifi-os/test/unifi-sm-api.spec.ts @@ -0,0 +1,138 @@ +// @vitest-environment node +import { Response } from "undici"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; + +vi.hoisted(() => { + process.env.SKIP_ENV_VALIDATION = "true"; + process.env.SECRET_ENCRYPTION_KEY = "ff3f4f7ce30e870c9630de9e5d244ffa81101a24ed0dfe5f064beb53a7e684f1"; +}); + +import { UnifiSmApi } from "../unifi-sm-api"; + +const GATEWAY_MAC = "aa:bb:cc:11:22:33"; +const OTHER_MAC = "dd:ee:ff:44:55:66"; + +const jsonResponse = (body: unknown, status = 200) => + new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } }) as never; + +const sitesBody = { + data: [ + { + siteId: "mine", + meta: { desc: "Mine", gatewayMac: GATEWAY_MAC }, + statistics: { wans: { WAN: { externalIp: "203.0.113.10" } } }, + }, + { siteId: "theirs", meta: { desc: "Theirs", gatewayMac: OTHER_MAC } }, + ], +}; + +const metricsBody = (metricTime: string) => ({ + data: { + metrics: [ + { + periods: [ + { + metricTime: "2026-09-08T08:00:00Z", + data: { wan: { avgLatency: 99, maxLatency: 99, packetLoss: 5, uptime: 90, ispName: "Old" } }, + }, + { + metricTime, + data: { wan: { avgLatency: 11, maxLatency: 12, packetLoss: 0, uptime: 100, ispName: "Example ISP" } }, + }, + ], + }, + ], + }, +}); + +const createApi = () => { + const fetchAsync = vi.fn(async (url: string) => + 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(); + }); +}); diff --git a/packages/integrations/src/unifi-os/unifi-sm-api.ts b/packages/integrations/src/unifi-os/unifi-sm-api.ts new file mode 100644 index 000000000..807c24126 --- /dev/null +++ b/packages/integrations/src/unifi-os/unifi-sm-api.ts @@ -0,0 +1,136 @@ +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), + }; + } +}