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 <noreply@anthropic.com>
This commit is contained in:
zacharyandClaude Opus 5 committed 2026-09-13 22:21:43 +02:00
1 parent 6f9a30799e
commit 3ce3eefa5e
2 files changed
+274

No files matched your search

@@ -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();
});
});
@@ -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<string, CacheEntry>();
constructor(
private readonly apiKey: string,
private readonly fetchAsync: UnifiOsFetch,
) {}
/** Réservé aux tests : le cache est statique et survivrait d'un cas à l'autre. */
public static clearCache(): void {
UnifiSmApi.cache.clear();
}
public async getIspMetricsAsync(gatewayMac: string | null): Promise<UnifiOsIspMetrics | null> {
if (gatewayMac === null) return null;
const cached = UnifiSmApi.cache.get(gatewayMac);
if (cached !== undefined && cached.expiresAt > Date.now()) {
return cached.value;
}
let value: UnifiOsIspMetrics | null = null;
try {
value = await this.fetchIspMetricsAsync(gatewayMac);
} catch (error) {
logger.warn("Failed to read UniFi Site Manager metrics", { error });
}
UnifiSmApi.cache.set(gatewayMac, { expiresAt: Date.now() + CACHE_TTL_MS, value });
return value;
}
private async fetchIspMetricsAsync(gatewayMac: string): Promise<UnifiOsIspMetrics | null> {
const sitesResponse = await this.fetchAsync(`${BASE_URL}/v1/sites`, {
headers: { "X-API-KEY": this.apiKey, Accept: "application/json" },
timeout: REQUEST_TIMEOUT_MS,
});
if (!sitesResponse.ok) return null;
const sites = await parseUnifiOsAsync(
await sitesResponse.json(),
siteListSchema,
"Invalid Site Manager sites response",
);
const site = matchCloudSite(gatewayMac, sites.data);
if (site === null) return null;
const end = new Date();
const begin = new Date(end.getTime() - QUERY_WINDOW_MS);
const metricsResponse = await this.fetchAsync(`${BASE_URL}/v1/isp-metrics/5m/query`, {
method: "POST",
headers: { "X-API-KEY": this.apiKey, Accept: "application/json", "Content-Type": "application/json" },
timeout: REQUEST_TIMEOUT_MS,
body: JSON.stringify({
sites: [
{
hostId: site.hostId,
siteId: site.siteId,
beginTimestamp: begin.toISOString(),
endTimestamp: end.toISOString(),
},
],
}),
});
if (!metricsResponse.ok) return null;
const parsed = await parseUnifiOsAsync(
await metricsResponse.json(),
unifiSmIspMetricsResponseSchema,
"Invalid Site Manager ISP metrics response",
);
const periods = parsed.data.metrics[0]?.periods ?? [];
const latest = periods.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),
};
}
}