Files
homarr/packages/integrations/src/unifi-controller/unifi-controller-integration.ts
2025-04-25 18:58:55 +00:00

104 lines
4.8 KiB
TypeScript

import type { SiteStats } from "node-unifi";
import { Controller } from "node-unifi";
import { Integration } from "../base/integration";
import type { NetworkControllerSummaryIntegration } from "../interfaces/network-controller-summary/network-controller-summary-integration";
import type { NetworkControllerSummary } from "../interfaces/network-controller-summary/network-controller-summary-types";
import type { HealthSubsystem } from "./unifi-controller-types";
export class UnifiControllerIntegration extends Integration implements NetworkControllerSummaryIntegration {
public async getNetworkSummaryAsync(): Promise<NetworkControllerSummary> {
const client = await this.createControllerClientAsync();
const stats = await client.getSitesStats();
return {
wanStatus: this.getStatusValueOverAllSites(stats, "wan", (site) => site.status === "ok"),
www: {
status: this.getStatusValueOverAllSites(stats, "wan", (site) => site.status === "ok"),
latency: this.getNumericValueOverAllSites(stats, "www", (site) => site.latency, "max"),
ping: this.getNumericValueOverAllSites(stats, "www", (site) => site.speedtest_ping, "max"),
uptime: this.getNumericValueOverAllSites(stats, "www", (site) => site.uptime, "max"),
},
wifi: {
status: this.getStatusValueOverAllSites(stats, "wlan", (site) => site.status === "ok"),
users: this.getNumericValueOverAllSites(stats, "wlan", (site) => site.num_user, "sum"),
guests: this.getNumericValueOverAllSites(stats, "wlan", (site) => site.num_guest, "sum"),
},
lan: {
status: this.getStatusValueOverAllSites(stats, "lan", (site) => site.status === "ok"),
users: this.getNumericValueOverAllSites(stats, "lan", (site) => site.num_user, "sum"),
guests: this.getNumericValueOverAllSites(stats, "lan", (site) => site.num_guest, "sum"),
},
vpn: {
status: this.getStatusValueOverAllSites(stats, "vpn", (site) => site.status === "ok"),
users: this.getNumericValueOverAllSites(stats, "vpn", (site) => site.remote_user_num_active, "sum"),
},
} satisfies NetworkControllerSummary;
}
public async testConnectionAsync(): Promise<void> {
const client = await this.createControllerClientAsync();
await client.getSitesStats();
}
private async createControllerClientAsync() {
const portString = new URL(this.integration.url).port;
const port = Number.isInteger(portString) ? Number(portString) : undefined;
const hostname = new URL(this.integration.url).hostname;
const client = new Controller({
host: hostname,
// @ts-expect-error the URL construction is incorrect and does not append the required / at the end: https://github.com/jens-maus/node-unifi/blob/05665e8f82a900a15a9ea8b1071750b29825b3bc/unifi.js#L56, https://github.com/jens-maus/node-unifi/blob/05665e8f82a900a15a9ea8b1071750b29825b3bc/unifi.js#L95
port: port === undefined ? "/" : `${port}/`,
sslverify: false, // TODO: implement a "ignore certificate toggle", see https://github.com/homarr-labs/homarr/issues/2553
username: this.getSecretValue("username"),
password: this.getSecretValue("password"),
});
// Object.defineProperty(client, '_baseurl', { value: url });
await client.login(this.getSecretValue("username"), this.getSecretValue("password"), null);
return client;
}
private getStatusValueOverAllSites<S extends HealthSubsystem>(
data: SiteStats[],
subsystem: S,
selectCallback: (obj: SiteStats["health"][number]) => boolean,
) {
return this.getBooleanValueOverAllSites(data, subsystem, selectCallback) ? "enabled" : "disabled";
}
private getNumericValueOverAllSites<
S extends HealthSubsystem,
T extends Extract<SiteStats["health"][number], { subsystem: S }>,
>(data: SiteStats[], subsystem: S, selectCallback: (obj: T) => number, strategy: "average" | "sum" | "max"): number {
const values = data.map((site) => selectCallback(this.getSubsystem(site.health, subsystem) as T));
if (strategy === "sum") {
return values.reduce((first, second) => first + second, 0);
}
if (strategy === "average") {
return values.reduce((first, second, _, array) => first + second / array.length, 0);
}
return Math.max(...values);
}
private getBooleanValueOverAllSites(
data: SiteStats[],
subsystem: HealthSubsystem,
selectCallback: (obj: SiteStats["health"][number]) => boolean,
): boolean {
return data.every((site) => selectCallback(this.getSubsystem(site.health, subsystem)));
}
private getSubsystem(health: SiteStats["health"], subsystem: HealthSubsystem) {
const value = health.find((health) => health.subsystem === subsystem);
if (!value) {
throw new Error(`Subsystem ${subsystem} not found!`);
}
return value;
}
}