feat(integrations): add UnifiSiteManagerIntegration

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 58e7fe730a
commit 3d31ce8eec
7 files changed
+242 -1

No files matched your search

@@ -54,6 +54,7 @@ import { UmamiIntegration } from "../umami/umami-integration";
import { UptimeKumaIntegration } from "../uptime-kuma/uptime-kuma-integration";
import { UnifiControllerIntegration } from "../unifi-controller/unifi-controller-integration";
import { UnifiOsIntegration } from "../unifi-os/unifi-os-integration";
import { UnifiSiteManagerIntegration } from "../unifi-site-manager/unifi-site-manager-integration";
import { UnraidIntegration } from "../unraid/unraid-integration";
import { WudIntegration } from "../wud/wud-integration";
import type { Integration, IntegrationInput } from "./integration";
@@ -111,6 +112,7 @@ export const integrationCreators = {
nextcloud: NextcloudIntegration,
unifiController: UnifiControllerIntegration,
unifiOs: UnifiOsIntegration,
unifiSiteManager: UnifiSiteManagerIntegration,
opnsense: OPNsenseIntegration,
ical: ICalIntegration,
ntfy: NTFYIntegration,
+1
View File
@@ -100,6 +100,7 @@ export { anchorNoteUpdateInputSchema } from "./anchor/anchor-types";
export { downloadClientItemSchema } from "./interfaces/downloads/download-client-items";
export { UnifiOsIntegration } from "./unifi-os/unifi-os-integration";
export { UnifiSiteManagerIntegration } from "./unifi-site-manager/unifi-site-manager-integration";
// Helpers
export { createIntegrationAsync } from "./base/creator";
@@ -0,0 +1,5 @@
import type { UnifiSiteManagerOverview } from "../../unifi-site-manager/unifi-site-manager-types";
export interface UnifiSiteManagerIntegration {
getSiteOverviewAsync(): Promise<UnifiSiteManagerOverview>;
}
+2
View File
@@ -15,6 +15,8 @@ export * from "./proxmox/proxmox-types";
export * from "./unifi-controller/unifi-controller-types";
export * from "./unifi-os/unifi-os-types";
export * from "./interfaces/unifi-os-summary/unifi-os-summary-integration";
export * from "./unifi-site-manager/unifi-site-manager-types";
export * from "./interfaces/unifi-site-manager/unifi-site-manager-integration";
export * from "./opnsense/opnsense-types";
export * from "./interfaces/media-releases";
export * from "./coolify/coolify-types";
@@ -0,0 +1,142 @@
// @vitest-environment node
import { Response } from "undici";
import { beforeEach, describe, expect, test, vi } from "vitest";
vi.hoisted(() => {
process.env.SKIP_ENV_VALIDATION = "true";
process.env.SECRET_ENCRYPTION_KEY = "ff3f4f7ce30e870c9630de9e5d244ffa81101a24ed0dfe5f064beb53a7e684f1";
});
import * as undici from "undici";
import type { IntegrationSecret } from "../../base/types";
import { UnifiSiteManagerIntegration } from "../unifi-site-manager-integration";
vi.mock("undici", async (importOriginal) => ({
...(await importOriginal<typeof undici>()),
fetch: vi.fn(),
}));
const mockFetch = vi.mocked(undici.fetch);
const jsonResponse = (body: unknown, status = 200) =>
new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } }) as never;
const SITES = {
data: [
{
siteId: "site-1",
hostId: "host-1",
meta: { desc: "Example site" },
statistics: {
percentages: { wanUptime: 99.5 },
ispInfo: { name: "Example ISP" },
wans: { WAN: { externalIp: "203.0.113.10" } },
counts: { totalDevice: 6, offlineDevice: 0, pendingUpdateDevice: 1 },
},
},
],
};
const HOSTS = { data: [{ id: "host-1", reportedState: { deviceState: "updateAvailable" } }] };
const METRICS = { data: { metrics: [{ siteId: "site-1", periods: [] }] } };
const SD_WAN_CONFIGS = { data: [{ id: "config-1", name: "Example mesh" }] };
const SD_WAN_STATUS = {
data: {
status: {
connections: [
{ sites: [{ siteId: "site-1", status: { connected: true, offline: false, ping: 12, errors: [] } }] },
],
sites: [{ siteId: "site-1", errors: [], warnings: [] }],
},
},
};
const route = (url: string) => {
if (url.endsWith("/v1/sites")) return jsonResponse(SITES);
if (url.endsWith("/v1/hosts")) return jsonResponse(HOSTS);
if (url.includes("isp-metrics")) return jsonResponse(METRICS);
if (url.endsWith("/v1/sd-wan-configs")) return jsonResponse(SD_WAN_CONFIGS);
if (url.includes("/sd-wan-configs/")) return jsonResponse(SD_WAN_STATUS);
return jsonResponse({}, 404);
};
const createIntegration = () =>
new UnifiSiteManagerIntegration({
id: "test-site-manager",
name: "Test Site Manager",
url: "https://api.ui.com",
externalUrl: null,
decryptedSecrets: [{ kind: "siteManagerApiKey", value: "cloud-key" }] as IntegrationSecret[],
});
beforeEach(() => {
mockFetch.mockReset();
});
describe("UnifiSiteManagerIntegration getSiteOverviewAsync", () => {
test("builds one entry per site", async () => {
mockFetch.mockImplementation(async (url) => route(String(url)));
const overview = await createIntegration().getSiteOverviewAsync();
expect(overview.sites).toHaveLength(1);
expect(overview.sites[0]).toMatchObject({
siteId: "site-1",
name: "Example site",
ispName: "Example ISP",
consoleUpdateAvailable: true,
});
expect(overview.sites[0]!.sdWan).toMatchObject({ configName: "Example mesh", connected: true, pingMs: 12 });
});
/** /v1/sites porte la liste : sans elle il n'y a rien à afficher, pas « aucun site ». */
test("propagates a failure of the sites call", async () => {
mockFetch.mockImplementation(async (url) =>
String(url).endsWith("/v1/sites") ? jsonResponse({}, 500) : route(String(url)),
);
await expect(createIntegration().getSiteOverviewAsync()).rejects.toThrow();
});
test("keeps the sites when the hosts call fails, with console state unknown", async () => {
mockFetch.mockImplementation(async (url) =>
String(url).endsWith("/v1/hosts") ? jsonResponse({}, 500) : route(String(url)),
);
const overview = await createIntegration().getSiteOverviewAsync();
expect(overview.sites).toHaveLength(1);
expect(overview.sites[0]!.consoleUpdateAvailable).toBeNull();
});
test("keeps the sites when the metrics call fails, with the window unknown", async () => {
mockFetch.mockImplementation(async (url) =>
String(url).includes("isp-metrics") ? jsonResponse({}, 500) : route(String(url)),
);
const overview = await createIntegration().getSiteOverviewAsync();
expect(overview.sites).toHaveLength(1);
expect(overview.sites[0]!.summary.unknown).toBe(24);
});
test("keeps the sites when the SD-WAN calls fail, with sdWan null", async () => {
mockFetch.mockImplementation(async (url) =>
String(url).includes("sd-wan") ? jsonResponse({}, 500) : route(String(url)),
);
const overview = await createIntegration().getSiteOverviewAsync();
expect(overview.sites).toHaveLength(1);
expect(overview.sites[0]!.sdWan).toBeNull();
});
test("returns an empty site list for an account with no sites, without erroring", async () => {
mockFetch.mockImplementation(async (url) =>
String(url).endsWith("/v1/sites") ? jsonResponse({ data: [] }) : route(String(url)),
);
await expect(createIntegration().getSiteOverviewAsync()).resolves.toEqual({ sites: [] });
});
});
@@ -98,7 +98,7 @@ describe("buildSdWanIndex", () => {
connections: [
{ sites: [{ siteId, status: { connected: true, offline: false, ping: 0, errors: [], ...overrides } }] },
],
sites: [{ siteId, errors: [], warnings: [] }],
sites: [{ siteId, errors: [] as unknown[], warnings: [] as string[] }],
},
});
@@ -0,0 +1,89 @@
import { fetch as platformFetch } from "undici";
import type { IntegrationTestingInput } from "../base/integration";
import { Integration } from "../base/integration";
import { TestConnectionError } from "../base/test-connection/test-connection-error";
import type { TestingResult } from "../base/test-connection/test-connection-service";
import type { UnifiSiteManagerIntegration as UnifiSiteManagerIntegrationInterface } from "../interfaces/unifi-site-manager/unifi-site-manager-integration";
import type { SiteManagerFetch } from "./unifi-site-manager-api";
import { UnifiSiteManagerApi } from "./unifi-site-manager-api";
import type {
IspMetricsEntry,
SiteManagerHost,
UnifiSiteManagerOverview,
UnifiSiteSdWanStatus,
} from "./unifi-site-manager-types";
import { buildSdWanIndex, mapSites } from "./unifi-site-manager-types";
/**
* `api.ui.com` présente un certificat public valide. On n'utilise donc PAS le
* fetch à certificats de confiance de Homarr : il ajoute toutes les autorités
* personnalisées téléversées par l'administrateur, ce qui en ferait des
* ancrages valides pour le cloud d'Ubiquiti — et exposerait la clé, qui lit
* tous les sites du compte.
*/
const fetchCloudAsync: SiteManagerFetch = async (url, init) => {
const { timeout, ...rest } = init ?? {};
const response = await platformFetch(url, {
...rest,
signal: timeout === undefined ? undefined : AbortSignal.timeout(timeout),
} as Parameters<typeof platformFetch>[1]);
// `SiteManagerFetch` (task 3) types its return as the ambient DOM `Response`
// (this file's lib includes `dom`); undici's own `Response` class is
// structurally close but not identical (its Headers iterator differs), so a
// type-only bridge is needed here. No behavior change: same object, same call.
return response as unknown as Response;
};
const settled = <T>(result: PromiseSettledResult<T>, fallback: T): T =>
result.status === "fulfilled" ? result.value : fallback;
export class UnifiSiteManagerIntegration extends Integration implements UnifiSiteManagerIntegrationInterface {
public async getSiteOverviewAsync(): Promise<UnifiSiteManagerOverview> {
return await this.buildOverviewAsync(fetchCloudAsync);
}
protected async testingAsync(_input: IntegrationTestingInput): Promise<TestingResult> {
try {
await this.createApi(fetchCloudAsync).getSitesAsync();
} catch (error) {
return TestConnectionError.UnknownResult(error);
}
return { success: true };
}
private createApi(fetchAsync: SiteManagerFetch) {
return new UnifiSiteManagerApi(this.getSecretValue("siteManagerApiKey"), fetchAsync);
}
private async buildOverviewAsync(fetchAsync: SiteManagerFetch): Promise<UnifiSiteManagerOverview> {
const api = this.createApi(fetchAsync);
const now = new Date();
const [sitesResult, hostsResult] = await Promise.allSettled([api.getSitesAsync(), api.getHostsAsync()]);
// La liste des sites porte tout : son échec doit remonter, sinon le widget
// afficherait « aucun site » alors qu'il n'a rien pu lire.
if (sitesResult.status === "rejected") throw sitesResult.reason;
const sites = sitesResult.value;
const hosts = settled<SiteManagerHost[]>(hostsResult, []);
// Deux enrichissements, en parallèle. Leur échec laisse leur part à
// l'inconnu — la fenêtre à « inconnu » plutôt qu'à « sain », le SD-WAN à
// `null` — et n'emporte jamais la liste des sites.
const [metricsResult, sdWanResult] = await Promise.allSettled([
api.getIspMetricsAsync(
sites.map((site) => ({ siteId: site.siteId, hostId: site.hostId })),
now,
),
api.getSdWanStatusesAsync(),
]);
const metrics = settled<IspMetricsEntry[]>(metricsResult, []);
const sdWanBySite: Map<string, UnifiSiteSdWanStatus> = buildSdWanIndex(settled(sdWanResult, []));
return { sites: mapSites(sites, hosts, metrics, sdWanBySite, now) };
}
}