feat(integrations): add UniFi Site Manager cloud client

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 3ef0b68112
commit 92b949c47f
2 files changed
+265

No files matched your search

@@ -0,0 +1,146 @@
// @vitest-environment node
import { Response, type RequestInit } from "undici";
import { describe, expect, test, vi } from "vitest";
vi.hoisted(() => {
process.env.SKIP_ENV_VALIDATION = "true";
process.env.SECRET_ENCRYPTION_KEY = "ff3f4f7ce30e870c9630de9e5d244ffa81101a24ed0dfe5f064beb53a7e684f1";
});
import { ResponseError } from "@homarr/common/server";
import { MAX_SD_WAN_CONFIGS, UnifiSiteManagerApi } from "../unifi-site-manager-api";
const API_KEY = "cloud-key";
const NOW = new Date("2026-09-09T20:00:00Z");
const jsonResponse = (body: unknown, status = 200) =>
new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } }) as never;
const createApi = (handler: (url: string, init?: RequestInit) => Promise<Response>) => {
const fetchAsync = vi.fn(async (url: string, init?: RequestInit) => await handler(url, init));
return { api: new UnifiSiteManagerApi(API_KEY, fetchAsync as never), fetchAsync };
};
describe("UnifiSiteManagerApi request shape", () => {
test("calls the cloud host and sends the API key", async () => {
const { api, fetchAsync } = createApi(async () => jsonResponse({ data: [] }));
await api.getSitesAsync();
expect(fetchAsync).toHaveBeenCalledWith(
"https://api.ui.com/v1/sites",
expect.objectContaining({ headers: expect.objectContaining({ "X-API-KEY": API_KEY }) }),
);
});
test("throws ResponseError on a non-ok status instead of parsing the body", async () => {
const { api } = createApi(async () => jsonResponse({ message: "unauthorized" }, 401));
await expect(api.getSitesAsync()).rejects.toBeInstanceOf(ResponseError);
});
});
describe("UnifiSiteManagerApi getSdWanStatusesAsync", () => {
const emptyStatus = { data: { status: { connections: [], sites: [] } } };
test("fetches the status of every configuration it lists", async () => {
const requested: string[] = [];
const { api } = createApi(async (url) => {
requested.push(url);
if (url.endsWith("/v1/sd-wan-configs")) {
return jsonResponse({
data: [
{ id: "c1", name: "First" },
{ id: "c2", name: "Second" },
],
});
}
return jsonResponse(emptyStatus);
});
const statuses = await api.getSdWanStatusesAsync();
expect(statuses.map((entry) => entry.configName)).toEqual(["First", "Second"]);
expect(requested).toContain("https://api.ui.com/v1/sd-wan-configs/c1/status");
expect(requested).toContain("https://api.ui.com/v1/sd-wan-configs/c2/status");
});
/** Sans borne, un compte à dix configurations produirait onze requêtes par cycle. */
test("stops after MAX_SD_WAN_CONFIGS configurations", async () => {
const { api, fetchAsync } = createApi(async (url) =>
url.endsWith("/v1/sd-wan-configs")
? jsonResponse({
data: Array.from({ length: 9 }, (_, index) => ({ id: `c${index}`, name: `Config ${index}` })),
})
: jsonResponse(emptyStatus),
);
const statuses = await api.getSdWanStatusesAsync();
expect(statuses).toHaveLength(MAX_SD_WAN_CONFIGS);
expect(fetchAsync).toHaveBeenCalledTimes(MAX_SD_WAN_CONFIGS + 1);
});
test("skips a configuration whose status call fails rather than losing the others", async () => {
const { api } = createApi(async (url) => {
if (url.endsWith("/v1/sd-wan-configs")) {
return jsonResponse({
data: [
{ id: "c1", name: "First" },
{ id: "c2", name: "Second" },
],
});
}
return url.includes("/c1/") ? jsonResponse({}, 500) : jsonResponse(emptyStatus);
});
const statuses = await api.getSdWanStatusesAsync();
expect(statuses.map((entry) => entry.configName)).toEqual(["Second"]);
});
test("returns an empty list when the account has no configuration", async () => {
const { api } = createApi(async () => jsonResponse({ data: [] }));
expect(await api.getSdWanStatusesAsync()).toEqual([]);
});
});
describe("UnifiSiteManagerApi getIspMetricsAsync", () => {
test("asks for every site in a single bounded query", async () => {
let body: { sites: { siteId: string; beginTimestamp: string; endTimestamp: string }[] } | null = null;
const { api } = createApi(async (_url, init) => {
body = JSON.parse(String(init?.body)) as typeof body;
return jsonResponse({ data: { metrics: [] } });
});
await api.getIspMetricsAsync(
[
{ siteId: "a", hostId: "h1" },
{ siteId: "b", hostId: "h2" },
],
NOW,
);
expect(body!.sites).toHaveLength(2);
const [first] = body!.sites;
const spanHours = (new Date(first!.endTimestamp).getTime() - new Date(first!.beginTimestamp).getTime()) / 3_600_000;
expect(spanHours).toBeGreaterThanOrEqual(24);
});
test("returns an empty list rather than calling the cloud when there is no site", async () => {
const { api, fetchAsync } = createApi(async () => jsonResponse({ data: { metrics: [] } }));
expect(await api.getIspMetricsAsync([], NOW)).toEqual([]);
expect(fetchAsync).not.toHaveBeenCalled();
});
test("uses the hourly granularity, not the five-minute one", async () => {
const { api, fetchAsync } = createApi(async () => jsonResponse({ data: { metrics: [] } }));
await api.getIspMetricsAsync([{ siteId: "a" }], NOW);
expect(String(fetchAsync.mock.calls[0]?.[0])).toBe("https://api.ui.com/v1/isp-metrics/1h/query");
});
});
@@ -0,0 +1,119 @@
import { ResponseError } from "@homarr/common/server";
import type { z } from "zod/v4";
import { parseSiteManagerAsync } from "./unifi-site-manager-parse";
import type { IspMetricsEntry, SdWanStatus, SiteManagerHost, SiteManagerSite } from "./unifi-site-manager-types";
import {
HEALTH_WINDOW_HOURS,
ispMetricsResponseSchema,
sdWanConfigListSchema,
sdWanStatusSchema,
siteManagerHostListSchema,
siteManagerSiteListSchema,
} from "./unifi-site-manager-types";
/** L'API cloud d'Ubiquiti. Jamais configurable : ce n'est pas une console locale. */
const BASE_URL = "https://api.ui.com";
const REQUEST_TIMEOUT_MS = 15_000;
/**
* Le statut SD-WAN est indexé par configuration : il faut d'abord les lister,
* puis interroger chacune. Sans borne, un compte à dix configurations
* produirait onze requêtes par rafraîchissement.
*/
export const MAX_SD_WAN_CONFIGS = 5;
export type SiteManagerFetch = (url: string, init?: RequestInit & { timeout?: number }) => Promise<Response>;
export class UnifiSiteManagerApi {
constructor(
private readonly apiKey: string,
private readonly fetchAsync: SiteManagerFetch,
) {}
public async getSitesAsync(): Promise<SiteManagerSite[]> {
const parsed = await this.requestAsync(
"/v1/sites",
siteManagerSiteListSchema,
"Invalid Site Manager sites response",
);
return parsed.data;
}
public async getHostsAsync(): Promise<SiteManagerHost[]> {
const parsed = await this.requestAsync(
"/v1/hosts",
siteManagerHostListSchema,
"Invalid Site Manager hosts response",
);
return parsed.data;
}
/**
* Granularité horaire sur la fenêtre de la bande de santé, tous les sites dans
* une seule requête bornée. La variante non bornée pèse 216 Ko (mesuré).
*/
public async getIspMetricsAsync(
sites: { siteId: string; hostId?: string }[],
endingAt: Date,
): Promise<IspMetricsEntry[]> {
if (sites.length === 0) return [];
const beginTimestamp = new Date(endingAt.getTime() - HEALTH_WINDOW_HOURS * 3_600_000).toISOString();
const endTimestamp = endingAt.toISOString();
const response = await this.fetchAsync(`${BASE_URL}/v1/isp-metrics/1h/query`, {
method: "POST",
headers: { "X-API-KEY": this.apiKey, Accept: "application/json", "Content-Type": "application/json" },
timeout: REQUEST_TIMEOUT_MS,
body: JSON.stringify({
sites: sites.map((site) => ({ hostId: site.hostId, siteId: site.siteId, beginTimestamp, endTimestamp })),
}),
});
if (!response.ok) throw new ResponseError(response);
const parsed = await parseSiteManagerAsync(
await response.json(),
ispMetricsResponseSchema,
"Invalid Site Manager ISP metrics response",
);
return parsed.data.metrics;
}
public async getSdWanStatusesAsync(): Promise<{ configName: string; status: SdWanStatus["data"]["status"] }[]> {
const configs = await this.requestAsync(
"/v1/sd-wan-configs",
sdWanConfigListSchema,
"Invalid Site Manager SD-WAN configuration list",
);
const statuses: { configName: string; status: SdWanStatus["data"]["status"] }[] = [];
for (const config of configs.data.slice(0, MAX_SD_WAN_CONFIGS)) {
try {
const parsed = await this.requestAsync(
`/v1/sd-wan-configs/${encodeURIComponent(config.id)}/status`,
sdWanStatusSchema,
"Invalid Site Manager SD-WAN status",
);
statuses.push({ configName: config.name ?? config.id, status: parsed.data.status });
} catch {
// Une configuration illisible ne doit pas emporter les autres.
}
}
return statuses;
}
private async requestAsync<TOutput>(path: string, schema: z.ZodType<TOutput>, message: string): Promise<TOutput> {
const response = await this.fetchAsync(`${BASE_URL}${path}`, {
headers: { "X-API-KEY": this.apiKey, Accept: "application/json" },
timeout: REQUEST_TIMEOUT_MS,
});
if (!response.ok) throw new ResponseError(response);
return await parseSiteManagerAsync(await response.json(), schema, message);
}
}