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:
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),
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user