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:
zacharyandClaude Opus 5 committed 2026-09-13 22:21:43 +02:00
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,
+2
View File
@@ -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>;
}
+2
View File
@@ -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);
}
}