feat(integrations): add UniFi Site Manager cloud client
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
87056f998d
commit
49d4c3ae28
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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user