feat(integrations): add UnifiOsIntegration with optional cloud enrichment
Introduces UnifiOsIntegration, orchestrating UnifiOsApi (local) and UnifiSmApi (optional cloud) to build the UnifiOsSummary. Local endpoint calls degrade independently via Promise.allSettled; the cloud client is never instantiated without a configured siteManagerApiKey secret. Registers the unifiOs kind in the integration creator, index, and types barrels, resolving the previously-failing typecheck. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
67ec3cab3d
commit
67c3cba167
6 files changed
+323
No files matched your search
@@ -53,6 +53,7 @@ import { TrueNasIntegration } from "../truenas/truenas-integration";
|
||||
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 { UnraidIntegration } from "../unraid/unraid-integration";
|
||||
import { WudIntegration } from "../wud/wud-integration";
|
||||
import type { Integration, IntegrationInput } from "./integration";
|
||||
@@ -109,6 +110,7 @@ export const integrationCreators = {
|
||||
emby: EmbyIntegration,
|
||||
nextcloud: NextcloudIntegration,
|
||||
unifiController: UnifiControllerIntegration,
|
||||
unifiOs: UnifiOsIntegration,
|
||||
opnsense: OPNsenseIntegration,
|
||||
ical: ICalIntegration,
|
||||
ntfy: NTFYIntegration,
|
||||
|
||||
@@ -99,5 +99,7 @@ export { anchorNotesListInputSchema } from "./anchor/anchor-types";
|
||||
export { anchorNoteUpdateInputSchema } from "./anchor/anchor-types";
|
||||
export { downloadClientItemSchema } from "./interfaces/downloads/download-client-items";
|
||||
|
||||
export { UnifiOsIntegration } from "./unifi-os/unifi-os-integration";
|
||||
|
||||
// Helpers
|
||||
export { createIntegrationAsync } from "./base/creator";
|
||||
@@ -0,0 +1,5 @@
|
||||
import type { UnifiOsSummary } from "../../unifi-os/unifi-os-types";
|
||||
|
||||
export interface UnifiOsSummaryIntegration {
|
||||
getUnifiOsSummaryAsync(): Promise<UnifiOsSummary>;
|
||||
}
|
||||
@@ -13,6 +13,8 @@ export * from "./base/searchable-integration";
|
||||
export * from "./homeassistant/homeassistant-types";
|
||||
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 "./opnsense/opnsense-types";
|
||||
export * from "./interfaces/media-releases";
|
||||
export * from "./coolify/coolify-types";
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
// @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 { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
|
||||
|
||||
import type { IntegrationSecret } from "../../base/types";
|
||||
import { UnifiSmApi } from "../unifi-sm-api";
|
||||
import { UnifiOsIntegration } from "../unifi-os-integration";
|
||||
|
||||
vi.mock("@homarr/core/infrastructure/http", () => ({
|
||||
fetchWithTrustedCertificatesAsync: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockFetch = vi.mocked(fetchWithTrustedCertificatesAsync);
|
||||
|
||||
const GATEWAY_MAC = "aa:bb:cc:11:22:33";
|
||||
|
||||
const jsonResponse = (body: unknown, status = 200) =>
|
||||
new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } }) as never;
|
||||
|
||||
const page = (data: unknown[]) => ({ offset: 0, limit: 200, count: data.length, totalCount: data.length, data });
|
||||
|
||||
const localRoutes: Record<string, unknown> = {
|
||||
"/info": { applicationVersion: "10.6.102" },
|
||||
"/sites": page([{ id: "site-1", name: "Example site", internalReference: "default" }]),
|
||||
"/sites/site-1/clients": page([
|
||||
{ id: "c1", name: "a", type: "WIRED", access: { type: "DEFAULT" } },
|
||||
{ id: "c2", name: "b", type: "WIRELESS", access: { type: "DEFAULT" } },
|
||||
]),
|
||||
"/sites/site-1/devices": page([
|
||||
{
|
||||
id: "gw",
|
||||
name: "Gateway",
|
||||
model: "UXG Pro",
|
||||
macAddress: GATEWAY_MAC,
|
||||
state: "ONLINE",
|
||||
firmwareUpdatable: true,
|
||||
features: ["gateway"],
|
||||
},
|
||||
{
|
||||
id: "sw",
|
||||
name: "Switch",
|
||||
model: "USW Lite 8",
|
||||
macAddress: "aa:bb:cc:99:88:77",
|
||||
state: "OFFLINE",
|
||||
firmwareUpdatable: false,
|
||||
features: ["switching"],
|
||||
},
|
||||
]),
|
||||
"/sites/site-1/devices/gw/statistics/latest": {
|
||||
uptimeSec: 1009049,
|
||||
cpuUtilizationPct: 19.1,
|
||||
memoryUtilizationPct: 63.1,
|
||||
uplink: { txRateBps: 14140912, rxRateBps: 3346784 },
|
||||
},
|
||||
"/sites/site-1/vpn/servers": page([{ id: "v1", name: "vpn", enabled: true }]),
|
||||
};
|
||||
|
||||
const routeLocal = (rawUrl: string): Response | null => {
|
||||
const path = new URL(rawUrl).pathname.replace("/proxy/network/integration/v1", "");
|
||||
const body = localRoutes[path];
|
||||
return body === undefined ? null : jsonResponse(body);
|
||||
};
|
||||
|
||||
const createIntegration = (decryptedSecrets: IntegrationSecret[]) =>
|
||||
new UnifiOsIntegration({
|
||||
id: "test-unifi-os",
|
||||
name: "Test UniFi OS",
|
||||
url: "https://console.example.test",
|
||||
externalUrl: null,
|
||||
decryptedSecrets,
|
||||
});
|
||||
|
||||
const localOnlySecrets: IntegrationSecret[] = [{ kind: "apiKey", value: "local-key" }];
|
||||
const hybridSecrets: IntegrationSecret[] = [
|
||||
{ kind: "apiKey", value: "local-key" },
|
||||
{ kind: "siteManagerApiKey", value: "cloud-key" },
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
mockFetch.mockReset();
|
||||
UnifiSmApi.clearCache();
|
||||
});
|
||||
|
||||
describe("UnifiOsIntegration getUnifiOsSummaryAsync", () => {
|
||||
test("builds the summary from the local API alone", async () => {
|
||||
mockFetch.mockImplementation(async (url) => routeLocal(String(url)) ?? jsonResponse({}, 404));
|
||||
|
||||
const summary = await createIntegration(localOnlySecrets).getUnifiOsSummaryAsync();
|
||||
|
||||
expect(summary.site).toEqual({ id: "site-1", name: "Example site", applicationVersion: "10.6.102" });
|
||||
expect(summary.clients).toEqual({ total: 2, wired: 1, wireless: 1, vpn: 0, guests: 0 });
|
||||
expect(summary.devices).toEqual({ total: 2, online: 1, offline: 1, firmwareUpdatable: 1 });
|
||||
expect(summary.wan.state).toBe("online");
|
||||
expect(summary.wan.throughput).toEqual({ txBps: 14140912, rxBps: 3346784 });
|
||||
expect(summary.gateway).toMatchObject({ name: "Gateway", uptimeSec: 1009049, cpuPct: 19.1 });
|
||||
});
|
||||
|
||||
test("leaves the ISP block null when no Site Manager key is configured", async () => {
|
||||
mockFetch.mockImplementation(async (url) => routeLocal(String(url)) ?? jsonResponse({}, 404));
|
||||
|
||||
const summary = await createIntegration(localOnlySecrets).getUnifiOsSummaryAsync();
|
||||
|
||||
expect(summary.wan.isp).toBeNull();
|
||||
expect(mockFetch.mock.calls.every(([url]) => !String(url).startsWith("https://api.ui.com"))).toBe(true);
|
||||
});
|
||||
|
||||
test("fills the ISP block when a Site Manager key is configured", async () => {
|
||||
mockFetch.mockImplementation(async (url) => {
|
||||
const raw = String(url);
|
||||
if (raw.startsWith("https://api.ui.com/v1/sites")) {
|
||||
return jsonResponse({
|
||||
data: [
|
||||
{
|
||||
siteId: "cloud-1",
|
||||
hostId: "host-1",
|
||||
meta: { gatewayMac: GATEWAY_MAC },
|
||||
statistics: { wans: { WAN: { externalIp: "203.0.113.10" } } },
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
if (raw.includes("isp-metrics")) {
|
||||
return jsonResponse({
|
||||
data: {
|
||||
metrics: [
|
||||
{
|
||||
periods: [
|
||||
{
|
||||
metricTime: "2026-09-08T09:10:00Z",
|
||||
data: {
|
||||
wan: { avgLatency: 11, maxLatency: 12, packetLoss: 0, uptime: 100, ispName: "Example ISP" },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
}
|
||||
return routeLocal(raw) ?? jsonResponse({}, 404);
|
||||
});
|
||||
|
||||
const summary = await createIntegration(hybridSecrets).getUnifiOsSummaryAsync();
|
||||
|
||||
expect(summary.wan.isp).toMatchObject({ name: "Example ISP", latencyMs: 11, externalIp: "203.0.113.10" });
|
||||
expect(summary.wan.isp?.measuredAt).toEqual(new Date("2026-09-08T09:10:00Z"));
|
||||
});
|
||||
|
||||
test("degrades only the affected block when one local endpoint fails", async () => {
|
||||
mockFetch.mockImplementation(async (url) => {
|
||||
const raw = String(url);
|
||||
if (raw.includes("/vpn/servers")) return jsonResponse({ error: "boom" }, 500);
|
||||
return routeLocal(raw) ?? jsonResponse({}, 404);
|
||||
});
|
||||
|
||||
const summary = await createIntegration(localOnlySecrets).getUnifiOsSummaryAsync();
|
||||
|
||||
expect(summary.clients.total).toBe(2);
|
||||
expect(summary.devices.total).toBe(2);
|
||||
});
|
||||
|
||||
test("keeps clients and devices when the gateway cannot be identified", async () => {
|
||||
mockFetch.mockImplementation(async (url) => {
|
||||
const raw = String(url);
|
||||
if (raw.endsWith("/devices?limit=200")) {
|
||||
return jsonResponse(
|
||||
page([
|
||||
{
|
||||
id: "sw",
|
||||
name: "Switch",
|
||||
model: "USW Lite 8",
|
||||
macAddress: "aa:bb:cc:99:88:77",
|
||||
state: "ONLINE",
|
||||
firmwareUpdatable: false,
|
||||
features: ["switching"],
|
||||
},
|
||||
]),
|
||||
);
|
||||
}
|
||||
return routeLocal(raw) ?? jsonResponse({}, 404);
|
||||
});
|
||||
|
||||
const summary = await createIntegration(localOnlySecrets).getUnifiOsSummaryAsync();
|
||||
|
||||
expect(summary.gateway).toBeNull();
|
||||
expect(summary.wan.throughput).toBeNull();
|
||||
expect(summary.wan.state).toBe("unknown");
|
||||
expect(summary.clients.total).toBe(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,115 @@
|
||||
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
|
||||
|
||||
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 { UnifiOsSummaryIntegration } from "../interfaces/unifi-os-summary/unifi-os-summary-integration";
|
||||
import type { UnifiOsFetch } from "./unifi-os-api";
|
||||
import { UnifiOsApi } from "./unifi-os-api";
|
||||
import { UnifiSmApi } from "./unifi-sm-api";
|
||||
import type { UnifiOsDevice, UnifiOsDeviceStatistics, UnifiOsSummary } from "./unifi-os-types";
|
||||
import { findGateway, summarizeDevices } from "./unifi-os-types";
|
||||
|
||||
const EMPTY_CLIENT_COUNTS = { total: 0, wired: 0, wireless: 0, vpn: 0, guests: 0 } as const;
|
||||
const EMPTY_DEVICE_COUNTS = { total: 0, online: 0, offline: 0, firmwareUpdatable: 0 } as const;
|
||||
|
||||
/** `Promise.allSettled` renvoie `rejected` sans valeur ; on retombe sur un défaut. */
|
||||
const settledValue = <T>(result: PromiseSettledResult<T>, fallback: T): T =>
|
||||
result.status === "fulfilled" ? result.value : fallback;
|
||||
|
||||
export class UnifiOsIntegration extends Integration implements UnifiOsSummaryIntegration {
|
||||
public async getUnifiOsSummaryAsync(): Promise<UnifiOsSummary> {
|
||||
return await this.buildSummaryAsync(fetchWithTrustedCertificatesAsync as unknown as UnifiOsFetch);
|
||||
}
|
||||
|
||||
protected async testingAsync({ fetchAsync }: IntegrationTestingInput): Promise<TestingResult> {
|
||||
const api = this.createLocalApi(fetchAsync as unknown as UnifiOsFetch);
|
||||
|
||||
try {
|
||||
await api.getInfoAsync();
|
||||
} catch (error) {
|
||||
return TestConnectionError.UnknownResult(error);
|
||||
}
|
||||
|
||||
const site = await api.getFirstSiteAsync();
|
||||
if (site === null) {
|
||||
// Une clé valide sur une console sans site est configurable mais inutile :
|
||||
// mieux vaut le dire au moment du test que laisser un widget vide.
|
||||
return TestConnectionError.UnknownResult(new Error("The UniFi OS console exposes no site"));
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
private createLocalApi(fetchAsync: UnifiOsFetch) {
|
||||
return new UnifiOsApi(this.integration.url, this.getSecretValue("apiKey"), fetchAsync);
|
||||
}
|
||||
|
||||
private async buildSummaryAsync(fetchAsync: UnifiOsFetch): Promise<UnifiOsSummary> {
|
||||
const api = this.createLocalApi(fetchAsync);
|
||||
|
||||
const [info, site] = await Promise.all([api.getInfoAsync(), api.getFirstSiteAsync()]);
|
||||
if (site === null) {
|
||||
throw new Error("The UniFi OS console exposes no site");
|
||||
}
|
||||
|
||||
const [clientsResult, devicesResult] = await Promise.allSettled([
|
||||
api.getClientCountsAsync(site.id),
|
||||
api.getDevicesAsync(site.id),
|
||||
]);
|
||||
|
||||
const clients = settledValue(clientsResult, { ...EMPTY_CLIENT_COUNTS });
|
||||
const devices = settledValue<UnifiOsDevice[]>(devicesResult, []);
|
||||
const gatewayDevice = findGateway(devices);
|
||||
|
||||
// Les statistiques dépendent du gateway : sans gateway identifié, il n'y a
|
||||
// rien à demander. Un échec ici ne doit vider que le débit et l'uptime.
|
||||
let statistics: UnifiOsDeviceStatistics | null = null;
|
||||
if (gatewayDevice !== null) {
|
||||
try {
|
||||
statistics = await api.getDeviceStatisticsAsync(site.id, gatewayDevice.id);
|
||||
} catch {
|
||||
statistics = null;
|
||||
}
|
||||
}
|
||||
|
||||
const isp = await this.readIspMetricsAsync(fetchAsync, gatewayDevice?.macAddress ?? null);
|
||||
|
||||
return {
|
||||
site: { id: site.id, name: site.name, applicationVersion: info.applicationVersion },
|
||||
wan: {
|
||||
state: gatewayDevice === null ? "unknown" : gatewayDevice.state === "ONLINE" ? "online" : "offline",
|
||||
throughput:
|
||||
statistics?.uplink === undefined
|
||||
? null
|
||||
: { txBps: statistics.uplink.txRateBps, rxBps: statistics.uplink.rxRateBps },
|
||||
isp,
|
||||
},
|
||||
clients,
|
||||
devices: devicesResult.status === "fulfilled" ? summarizeDevices(devices) : { ...EMPTY_DEVICE_COUNTS },
|
||||
gateway:
|
||||
gatewayDevice === null
|
||||
? null
|
||||
: {
|
||||
name: gatewayDevice.name,
|
||||
model: gatewayDevice.model,
|
||||
macAddress: gatewayDevice.macAddress,
|
||||
uptimeSec: statistics?.uptimeSec ?? null,
|
||||
cpuPct: statistics?.cpuUtilizationPct ?? null,
|
||||
memoryPct: statistics?.memoryUtilizationPct ?? null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Le cloud n'est jamais interrogé sans clé configurée : le module n'est même
|
||||
* pas instancié, ce qui rend impossible un appel réseau accidentel.
|
||||
*/
|
||||
private async readIspMetricsAsync(fetchAsync: UnifiOsFetch, gatewayMac: string | null) {
|
||||
if (!this.hasSecretValue("siteManagerApiKey")) return null;
|
||||
|
||||
const smApi = new UnifiSmApi(this.getSecretValue("siteManagerApiKey"), fetchAsync);
|
||||
return await smApi.getIspMetricsAsync(gatewayMac);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user