feat(integrations): add UnifiSiteManagerIntegration
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
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,
|
||||
|
||||
@@ -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";
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
import type { UnifiSiteManagerOverview } from "../../unifi-site-manager/unifi-site-manager-types";
|
||||
|
||||
export interface UnifiSiteManagerIntegration {
|
||||
getSiteOverviewAsync(): Promise<UnifiSiteManagerOverview>;
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
+142
@@ -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) };
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user