feat: unifi controller integration (#2236)
* feat: unifi controller integration * fix: pr feedback * fix: pr feedback * fix: pr feedback * fix: formatting * fix: pr feedback * fix: typecheck --------- Co-authored-by: Manuel <30572287+manuel-rw@users.noreply.github.com> Co-authored-by: Meier Lukas <meierschlumpf@gmail.com>
This commit is contained in:
@@ -2,7 +2,7 @@
|
||||
|
||||
import type { PropsWithChildren } from "react";
|
||||
import type { MantineColorsTuple } from "@mantine/core";
|
||||
import { colorsTuple, createTheme, darken, lighten, MantineProvider } from "@mantine/core";
|
||||
import { colorsTuple, createTheme, darken, lighten, MantineProvider, rem } from "@mantine/core";
|
||||
|
||||
import { useRequiredBoard } from "@homarr/boards/context";
|
||||
import type { ColorScheme } from "@homarr/definitions";
|
||||
@@ -24,6 +24,11 @@ export const BoardMantineProvider = ({
|
||||
},
|
||||
primaryColor: "primaryColor",
|
||||
autoContrast: true,
|
||||
fontSizes: {
|
||||
"2xl": rem(24),
|
||||
"3xl": rem(28),
|
||||
"4xl": rem(36),
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
|
||||
@@ -9,6 +9,7 @@ import { mediaRequestsRouter } from "./media-requests";
|
||||
import { mediaServerRouter } from "./media-server";
|
||||
import { mediaTranscodingRouter } from "./media-transcoding";
|
||||
import { minecraftRouter } from "./minecraft";
|
||||
import { networkControllerRouter } from "./network-controller";
|
||||
import { notebookRouter } from "./notebook";
|
||||
import { optionsRouter } from "./options";
|
||||
import { rssFeedRouter } from "./rssFeed";
|
||||
@@ -33,4 +34,5 @@ export const widgetRouter = createTRPCRouter({
|
||||
mediaTranscoding: mediaTranscodingRouter,
|
||||
minecraft: minecraftRouter,
|
||||
options: optionsRouter,
|
||||
networkController: networkControllerRouter,
|
||||
});
|
||||
|
||||
62
packages/api/src/router/widgets/network-controller.ts
Normal file
62
packages/api/src/router/widgets/network-controller.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { observable } from "@trpc/server/observable";
|
||||
|
||||
import type { Modify } from "@homarr/common/types";
|
||||
import type { Integration } from "@homarr/db/schema";
|
||||
import type { IntegrationKindByCategory } from "@homarr/definitions";
|
||||
import { getIntegrationKindsByCategory } from "@homarr/definitions";
|
||||
import type { NetworkControllerSummary } from "@homarr/integrations/types";
|
||||
import { networkControllerRequestHandler } from "@homarr/request-handler/network-controller";
|
||||
|
||||
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import { createTRPCRouter, publicProcedure } from "../../trpc";
|
||||
|
||||
export const networkControllerRouter = createTRPCRouter({
|
||||
summary: publicProcedure
|
||||
.unstable_concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("networkController")))
|
||||
.query(async ({ ctx }) => {
|
||||
const results = await Promise.all(
|
||||
ctx.integrations.map(async (integration) => {
|
||||
const innerHandler = networkControllerRequestHandler.handler(integration, {});
|
||||
const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
|
||||
|
||||
return {
|
||||
integration: {
|
||||
id: integration.id,
|
||||
name: integration.name,
|
||||
kind: integration.kind,
|
||||
},
|
||||
summary: data,
|
||||
updatedAt: timestamp,
|
||||
};
|
||||
}),
|
||||
);
|
||||
return results;
|
||||
}),
|
||||
|
||||
subscribeToSummary: publicProcedure
|
||||
.unstable_concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("networkController")))
|
||||
.subscription(({ ctx }) => {
|
||||
return observable<{
|
||||
integration: Modify<Integration, { kind: IntegrationKindByCategory<"networkController"> }>;
|
||||
summary: NetworkControllerSummary;
|
||||
}>((emit) => {
|
||||
const unsubscribes: (() => void)[] = [];
|
||||
for (const integrationWithSecrets of ctx.integrations) {
|
||||
const { decryptedSecrets: _, ...integration } = integrationWithSecrets;
|
||||
const innerHandler = networkControllerRequestHandler.handler(integrationWithSecrets, {});
|
||||
const unsubscribe = innerHandler.subscribe((summary) => {
|
||||
emit.next({
|
||||
integration,
|
||||
summary,
|
||||
});
|
||||
});
|
||||
unsubscribes.push(unsubscribe);
|
||||
}
|
||||
return () => {
|
||||
unsubscribes.forEach((unsubscribe) => {
|
||||
unsubscribe();
|
||||
});
|
||||
};
|
||||
});
|
||||
}),
|
||||
});
|
||||
@@ -23,6 +23,7 @@ export const cronJobs = {
|
||||
updateChecker: { preventManualExecution: false },
|
||||
mediaTranscoding: { preventManualExecution: false },
|
||||
minecraftServerStatus: { preventManualExecution: false },
|
||||
networkController: { preventManualExecution: false },
|
||||
} satisfies Record<JobGroupKeys, { preventManualExecution?: boolean }>;
|
||||
|
||||
/**
|
||||
|
||||
@@ -9,6 +9,7 @@ import { mediaOrganizerJob } from "./jobs/integrations/media-organizer";
|
||||
import { mediaRequestListJob, mediaRequestStatsJob } from "./jobs/integrations/media-requests";
|
||||
import { mediaServerJob } from "./jobs/integrations/media-server";
|
||||
import { mediaTranscodingJob } from "./jobs/integrations/media-transcoding";
|
||||
import { networkControllerJob } from "./jobs/integrations/network-controller";
|
||||
import { minecraftServerStatusJob } from "./jobs/minecraft-server-status";
|
||||
import { pingJob } from "./jobs/ping";
|
||||
import { rssFeedsJob } from "./jobs/rss-feeds";
|
||||
@@ -34,6 +35,7 @@ export const jobGroup = createCronJobGroup({
|
||||
updateChecker: updateCheckerJob,
|
||||
mediaTranscoding: mediaTranscodingJob,
|
||||
minecraftServerStatus: minecraftServerStatusJob,
|
||||
networkController: networkControllerJob,
|
||||
});
|
||||
|
||||
export type JobGroupKeys = ReturnType<(typeof jobGroup)["getKeys"]>[number];
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { EVERY_MINUTE } from "@homarr/cron-jobs-core/expressions";
|
||||
import { createRequestIntegrationJobHandler } from "@homarr/request-handler/lib/cached-request-integration-job-handler";
|
||||
import { networkControllerRequestHandler } from "@homarr/request-handler/network-controller";
|
||||
|
||||
import { createCronJob } from "../../lib";
|
||||
|
||||
export const networkControllerJob = createCronJob("networkController", EVERY_MINUTE).withCallback(
|
||||
createRequestIntegrationJobHandler(networkControllerRequestHandler.handler, {
|
||||
widgetKinds: ["networkControllerSummary"],
|
||||
getInput: {
|
||||
networkControllerSummary: () => ({}),
|
||||
},
|
||||
}),
|
||||
);
|
||||
@@ -157,6 +157,12 @@ export const integrationDefs = {
|
||||
category: ["calendar"],
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/nextcloud.svg",
|
||||
},
|
||||
unifiController: {
|
||||
name: "Unifi Controller",
|
||||
secretKinds: [["username", "password"]],
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/png/unifi.png",
|
||||
category: ["networkController"],
|
||||
},
|
||||
} as const satisfies Record<string, integrationDefinition>;
|
||||
|
||||
export const integrationKinds = objectKeys(integrationDefs) as AtLeastOneOf<IntegrationKind>;
|
||||
@@ -209,4 +215,5 @@ export type IntegrationCategory =
|
||||
| "indexerManager"
|
||||
| "healthMonitoring"
|
||||
| "search"
|
||||
| "mediaTranscoding";
|
||||
| "mediaTranscoding"
|
||||
| "networkController";
|
||||
|
||||
@@ -17,6 +17,8 @@ export const widgetKinds = [
|
||||
"mediaRequests-requestStats",
|
||||
"mediaTranscoding",
|
||||
"minecraftServerStatus",
|
||||
"networkControllerSummary",
|
||||
"networkControllerStatus",
|
||||
"rssFeed",
|
||||
"bookmarks",
|
||||
"indexerManager",
|
||||
|
||||
@@ -26,6 +26,7 @@ import { createPiHoleIntegrationAsync } from "../pi-hole/pi-hole-integration-fac
|
||||
import { PlexIntegration } from "../plex/plex-integration";
|
||||
import { ProwlarrIntegration } from "../prowlarr/prowlarr-integration";
|
||||
import { ProxmoxIntegration } from "../proxmox/proxmox-integration";
|
||||
import { UnifiControllerIntegration } from "../unifi-controller/unifi-controller-integration";
|
||||
import type { Integration, IntegrationInput } from "./integration";
|
||||
|
||||
export const createIntegrationAsync = async <TKind extends keyof typeof integrationCreators>(
|
||||
@@ -88,6 +89,7 @@ export const integrationCreators = {
|
||||
proxmox: ProxmoxIntegration,
|
||||
emby: EmbyIntegration,
|
||||
nextcloud: NextcloudIntegration,
|
||||
unifiController: UnifiControllerIntegration,
|
||||
} satisfies Record<IntegrationKind, IntegrationInstance | [(input: IntegrationInput) => Promise<Integration>]>;
|
||||
|
||||
type IntegrationInstanceOfKind<TKind extends keyof typeof integrationCreators> = {
|
||||
|
||||
@@ -114,7 +114,7 @@ export type TestConnectionResult =
|
||||
success: true;
|
||||
};
|
||||
|
||||
const throwErrorByStatusCode = (statusCode: number) => {
|
||||
export const throwErrorByStatusCode = (statusCode: number) => {
|
||||
switch (statusCode) {
|
||||
case 400:
|
||||
throw new IntegrationTestConnectionError("badRequest");
|
||||
@@ -124,6 +124,8 @@ const throwErrorByStatusCode = (statusCode: number) => {
|
||||
throw new IntegrationTestConnectionError("forbidden");
|
||||
case 404:
|
||||
throw new IntegrationTestConnectionError("notFound");
|
||||
case 429:
|
||||
throw new IntegrationTestConnectionError("tooManyRequests");
|
||||
case 500:
|
||||
throw new IntegrationTestConnectionError("internalServerError");
|
||||
case 503:
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import type { NetworkControllerSummary } from "./network-controller-summary-types";
|
||||
|
||||
export interface NetworkControllerSummaryIntegration {
|
||||
getNetworkSummaryAsync(): Promise<NetworkControllerSummary>;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
export interface NetworkControllerSummary {
|
||||
wanStatus: "enabled" | "disabled";
|
||||
|
||||
www: {
|
||||
status: "enabled" | "disabled";
|
||||
latency: number;
|
||||
ping: number;
|
||||
uptime: number;
|
||||
};
|
||||
|
||||
wifi: {
|
||||
status: "enabled" | "disabled";
|
||||
users: number;
|
||||
guests: number;
|
||||
};
|
||||
|
||||
lan: {
|
||||
status: "enabled" | "disabled";
|
||||
users: number;
|
||||
guests: number;
|
||||
};
|
||||
|
||||
vpn: {
|
||||
status: "enabled" | "disabled";
|
||||
users: number;
|
||||
};
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
export * from "./calendar-types";
|
||||
export * from "./interfaces/dns-hole-summary/dns-hole-summary-types";
|
||||
export * from "./interfaces/network-controller-summary/network-controller-summary-types";
|
||||
export * from "./interfaces/health-monitoring/healt-monitoring";
|
||||
export * from "./interfaces/indexer-manager/indexer";
|
||||
export * from "./interfaces/media-requests/media-request";
|
||||
export * from "./base/searchable-integration";
|
||||
export * from "./homeassistant/homeassistant-types";
|
||||
export * from "./proxmox/proxmox-types";
|
||||
export * from "./unifi-controller/unifi-controller-types";
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
import type z from "zod";
|
||||
|
||||
import { fetchWithTrustedCertificatesAsync } from "@homarr/certificates/server";
|
||||
import { logger } from "@homarr/log";
|
||||
|
||||
import { ParseError } from "../base/error";
|
||||
import { Integration, throwErrorByStatusCode } from "../base/integration";
|
||||
import { IntegrationTestConnectionError } from "../base/test-connection-error";
|
||||
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 { unifiSummaryResponseSchema } from "./unifi-controller-types";
|
||||
|
||||
const udmpPrefix = "proxy/network";
|
||||
type Subsystem = "www" | "wan" | "wlan" | "lan" | "vpn";
|
||||
|
||||
export class UnifiControllerIntegration extends Integration implements NetworkControllerSummaryIntegration {
|
||||
private prefix: string | undefined;
|
||||
|
||||
public async getNetworkSummaryAsync(): Promise<NetworkControllerSummary> {
|
||||
if (!this.headers) {
|
||||
await this.authenticateAndConstructSessionInHeaderAsync();
|
||||
}
|
||||
|
||||
const requestUrl = this.url(`/${this.prefix}/api/stat/sites`);
|
||||
|
||||
const requestHeaders: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
...this.headers,
|
||||
};
|
||||
if (this.csrfToken) {
|
||||
requestHeaders["X-CSRF-TOKEN"] = this.csrfToken;
|
||||
}
|
||||
|
||||
const statsResponse = await fetchWithTrustedCertificatesAsync(requestUrl, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
...requestHeaders,
|
||||
},
|
||||
}).catch((err: TypeError) => {
|
||||
const detailMessage = String(err.cause);
|
||||
throw new IntegrationTestConnectionError("invalidUrl", detailMessage);
|
||||
});
|
||||
|
||||
if (!statsResponse.ok) {
|
||||
throwErrorByStatusCode(statsResponse.status);
|
||||
}
|
||||
|
||||
const result = unifiSummaryResponseSchema.safeParse(await statsResponse.json());
|
||||
|
||||
if (!result.success) {
|
||||
throw new ParseError("Unifi controller", result.error);
|
||||
}
|
||||
|
||||
return {
|
||||
wanStatus: this.getStatusValueOverAllSites(result.data, "wan", (site) => site.status === "ok"),
|
||||
www: {
|
||||
status: this.getStatusValueOverAllSites(result.data, "wan", (site) => site.status === "ok"),
|
||||
latency: this.getNumericValueOverAllSites(result.data, "www", (site) => site.latency, "max"),
|
||||
ping: this.getNumericValueOverAllSites(result.data, "www", (site) => site.speedtest_ping, "max"),
|
||||
uptime: this.getNumericValueOverAllSites(result.data, "www", (site) => site.uptime, "max"),
|
||||
},
|
||||
wifi: {
|
||||
status: this.getStatusValueOverAllSites(result.data, "wlan", (site) => site.status === "ok"),
|
||||
users: this.getNumericValueOverAllSites(result.data, "wlan", (site) => site.num_user, "sum"),
|
||||
guests: this.getNumericValueOverAllSites(result.data, "wlan", (site) => site.num_guest, "sum"),
|
||||
},
|
||||
lan: {
|
||||
status: this.getStatusValueOverAllSites(result.data, "lan", (site) => site.status === "ok"),
|
||||
users: this.getNumericValueOverAllSites(result.data, "lan", (site) => site.num_user, "sum"),
|
||||
guests: this.getNumericValueOverAllSites(result.data, "lan", (site) => site.num_guest, "sum"),
|
||||
},
|
||||
vpn: {
|
||||
status: this.getStatusValueOverAllSites(result.data, "vpn", (site) => site.status === "ok"),
|
||||
users: this.getNumericValueOverAllSites(result.data, "vpn", (site) => site.remote_user_num_active, "sum"),
|
||||
},
|
||||
} satisfies NetworkControllerSummary;
|
||||
}
|
||||
|
||||
public async testConnectionAsync(): Promise<void> {
|
||||
await this.authenticateAndConstructSessionInHeaderAsync();
|
||||
}
|
||||
|
||||
private getStatusValueOverAllSites(
|
||||
data: z.infer<typeof unifiSummaryResponseSchema>,
|
||||
subsystem: Subsystem,
|
||||
selectCallback: (obj: z.infer<typeof unifiSummaryResponseSchema>["data"][number]["health"][number]) => boolean,
|
||||
) {
|
||||
return this.getBooleanValueOverAllSites(data, subsystem, selectCallback) ? "enabled" : "disabled";
|
||||
}
|
||||
|
||||
private getNumericValueOverAllSites<
|
||||
S extends Subsystem,
|
||||
T extends Extract<z.infer<typeof unifiSummaryResponseSchema>["data"][number]["health"][number], { subsystem: S }>,
|
||||
>(
|
||||
data: z.infer<typeof unifiSummaryResponseSchema>,
|
||||
subsystem: S,
|
||||
selectCallback: (obj: T) => number,
|
||||
strategy: "average" | "sum" | "max",
|
||||
): number {
|
||||
const values = data.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: z.infer<typeof unifiSummaryResponseSchema>,
|
||||
subsystem: Subsystem,
|
||||
selectCallback: (obj: z.infer<typeof unifiSummaryResponseSchema>["data"][number]["health"][number]) => boolean,
|
||||
): boolean {
|
||||
return data.data.every((site) => selectCallback(this.getSubsystem(site.health, subsystem)));
|
||||
}
|
||||
|
||||
private getSubsystem(
|
||||
health: z.infer<typeof unifiSummaryResponseSchema>["data"][number]["health"],
|
||||
subsystem: Subsystem,
|
||||
) {
|
||||
const value = health.find((health) => health.subsystem === subsystem);
|
||||
if (!value) {
|
||||
throw new Error(`Subsystem ${subsystem} not found!`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
private headers: Record<string, string> | undefined = undefined;
|
||||
private csrfToken: string | undefined;
|
||||
|
||||
private async authenticateAndConstructSessionInHeaderAsync(): Promise<void> {
|
||||
await this.determineUDMVariantAsync();
|
||||
await this.authenticateAndSetCookieAsync();
|
||||
}
|
||||
|
||||
private async authenticateAndSetCookieAsync(): Promise<void> {
|
||||
if (this.headers) {
|
||||
return;
|
||||
}
|
||||
|
||||
const endpoint = this.prefix === udmpPrefix ? "auth/login" : "login";
|
||||
logger.debug("Authenticating at network console: " + endpoint);
|
||||
|
||||
const loginUrl = this.url(`/api/${endpoint}`);
|
||||
|
||||
const loginBody = {
|
||||
username: this.getSecretValue("username"),
|
||||
password: this.getSecretValue("password"),
|
||||
remember: true,
|
||||
};
|
||||
|
||||
const requestHeaders: Record<string, string> = { "Content-Type": "application/json" };
|
||||
if (this.csrfToken) {
|
||||
requestHeaders["X-CSRF-TOKEN"] = this.csrfToken;
|
||||
}
|
||||
|
||||
const loginResponse = await fetchWithTrustedCertificatesAsync(loginUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
...requestHeaders,
|
||||
},
|
||||
body: JSON.stringify(loginBody),
|
||||
}).catch((err: TypeError) => {
|
||||
const detailMessage = String(err.cause);
|
||||
throw new IntegrationTestConnectionError("invalidUrl", detailMessage);
|
||||
});
|
||||
|
||||
if (!loginResponse.ok) {
|
||||
throwErrorByStatusCode(loginResponse.status);
|
||||
}
|
||||
|
||||
const responseHeaders = loginResponse.headers;
|
||||
const newHeaders: Record<string, string> = {};
|
||||
const loginToken = UnifiControllerIntegration.extractLoginTokenFromCookies(responseHeaders);
|
||||
newHeaders.Cookie = `${loginToken};`;
|
||||
this.headers = newHeaders;
|
||||
}
|
||||
|
||||
private async determineUDMVariantAsync(): Promise<void> {
|
||||
if (this.prefix) {
|
||||
return;
|
||||
}
|
||||
|
||||
logger.debug("Prefix for authentication not set; initial connect to determine UDM variant");
|
||||
const url = this.url("/");
|
||||
|
||||
const { status, ok, headers } = await fetchWithTrustedCertificatesAsync(url, { method: "HEAD" })
|
||||
.then((res) => res)
|
||||
.catch((err: TypeError) => {
|
||||
const detailMessage = String(err.cause);
|
||||
throw new IntegrationTestConnectionError("invalidUrl", detailMessage);
|
||||
});
|
||||
|
||||
if (!ok) {
|
||||
throw new IntegrationTestConnectionError("invalidUrl", "status code: " + status);
|
||||
}
|
||||
|
||||
let prefix = "";
|
||||
if (headers.get("x-csrf-token") !== null) {
|
||||
// Unifi OS < 3.2.5 passes & requires csrf-token
|
||||
prefix = udmpPrefix;
|
||||
const headersCSRFToken = headers.get("x-csrf-token");
|
||||
if (headersCSRFToken) {
|
||||
this.csrfToken = headersCSRFToken;
|
||||
}
|
||||
} else if (headers.get("access-control-expose-headers") !== null) {
|
||||
// Unifi OS ≥ 3.2.5 doesnt pass csrf token but still uses different endpoint
|
||||
prefix = udmpPrefix;
|
||||
}
|
||||
this.prefix = prefix;
|
||||
logger.debug("Final prefix: " + this.prefix);
|
||||
}
|
||||
|
||||
private static extractLoginTokenFromCookies(headers: Headers): string {
|
||||
const cookies = headers.get("set-cookie") ?? "";
|
||||
const loginToken = cookies.split(";").find((cookie) => cookie.includes("TOKEN"));
|
||||
|
||||
if (loginToken) {
|
||||
return loginToken;
|
||||
}
|
||||
|
||||
throw new Error("Login token not found in cookies");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const healthSchema = z.discriminatedUnion("subsystem", [
|
||||
z.object({
|
||||
subsystem: z.literal("wlan"),
|
||||
num_user: z.number(),
|
||||
num_guest: z.number(),
|
||||
num_iot: z.number(),
|
||||
"tx_bytes-r": z.number(),
|
||||
"rx_bytes-r": z.number(),
|
||||
status: z.string(),
|
||||
num_ap: z.number(),
|
||||
num_adopted: z.number(),
|
||||
num_disabled: z.number(),
|
||||
num_disconnected: z.number(),
|
||||
num_pending: z.number(),
|
||||
}),
|
||||
z.object({
|
||||
subsystem: z.literal("wan"),
|
||||
num_gw: z.number(),
|
||||
num_adopted: z.number(),
|
||||
num_disconnected: z.number(),
|
||||
num_pending: z.number(),
|
||||
status: z.string(),
|
||||
wan_ip: z.string().ip(),
|
||||
gateways: z.array(z.string().ip()),
|
||||
netmask: z.string().ip(),
|
||||
nameservers: z.array(z.string().ip()).optional(),
|
||||
num_sta: z.number(),
|
||||
"tx_bytes-r": z.number(),
|
||||
"rx_bytes-r": z.number(),
|
||||
gw_mac: z.string(),
|
||||
gw_name: z.string(),
|
||||
"gw_system-stats": z.object({
|
||||
cpu: z.string(),
|
||||
mem: z.string(),
|
||||
uptime: z.string(),
|
||||
}),
|
||||
gw_version: z.string(),
|
||||
isp_name: z.string(),
|
||||
isp_organization: z.string(),
|
||||
uptime_stats: z.object({
|
||||
WAN: z.object({
|
||||
alerting_monitors: z.array(
|
||||
z.object({
|
||||
availability: z.number(),
|
||||
latency_average: z.number(),
|
||||
target: z.string(),
|
||||
type: z.enum(["icmp", "dns"]),
|
||||
}),
|
||||
),
|
||||
availability: z.number(),
|
||||
latency_average: z.number(),
|
||||
monitors: z.array(
|
||||
z.object({
|
||||
availability: z.number(),
|
||||
latency_average: z.number(),
|
||||
target: z.string(),
|
||||
type: z.enum(["icmp", "dns"]),
|
||||
}),
|
||||
),
|
||||
time_period: z.number(),
|
||||
uptime: z.number(),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
z.object({
|
||||
subsystem: z.literal("www"),
|
||||
status: z.string(),
|
||||
"tx_bytes-r": z.number(),
|
||||
"rx_bytes-r": z.number(),
|
||||
latency: z.number(),
|
||||
uptime: z.number(),
|
||||
drops: z.number(),
|
||||
xput_up: z.number(),
|
||||
xput_down: z.number(),
|
||||
speedtest_status: z.string(),
|
||||
speedtest_lastrun: z.number(),
|
||||
speedtest_ping: z.number(),
|
||||
gw_mac: z.string(),
|
||||
}),
|
||||
z.object({
|
||||
subsystem: z.literal("lan"),
|
||||
lan_ip: z.string().ip().nullish(),
|
||||
status: z.string(),
|
||||
num_user: z.number(),
|
||||
num_guest: z.number(),
|
||||
num_iot: z.number(),
|
||||
"tx_bytes-r": z.number(),
|
||||
"rx_bytes-r": z.number(),
|
||||
num_sw: z.number(),
|
||||
num_adopted: z.number(),
|
||||
num_disconnected: z.number(),
|
||||
num_pending: z.number(),
|
||||
}),
|
||||
z.object({
|
||||
subsystem: z.literal("vpn"),
|
||||
status: z.string(),
|
||||
remote_user_enabled: z.boolean(),
|
||||
remote_user_num_active: z.number(),
|
||||
remote_user_num_inactive: z.number(),
|
||||
remote_user_rx_bytes: z.number(),
|
||||
remote_user_tx_bytes: z.number(),
|
||||
remote_user_rx_packets: z.number(),
|
||||
remote_user_tx_packets: z.number(),
|
||||
site_to_site_enabled: z.boolean(),
|
||||
}),
|
||||
]);
|
||||
|
||||
export type Health = z.infer<typeof healthSchema>;
|
||||
|
||||
export const siteSchema = z.object({
|
||||
anonymous_id: z.string().uuid(),
|
||||
name: z.string(),
|
||||
external_id: z.string().uuid(),
|
||||
_id: z.string(),
|
||||
attr_no_delete: z.boolean(),
|
||||
attr_hidden_id: z.string(),
|
||||
desc: z.string(),
|
||||
health: z.array(healthSchema),
|
||||
num_new_alarms: z.number(),
|
||||
});
|
||||
export type Site = z.infer<typeof siteSchema>;
|
||||
|
||||
export const unifiSummaryResponseSchema = z.object({
|
||||
meta: z.object({
|
||||
rc: z.enum(["ok"]),
|
||||
}),
|
||||
data: z.array(siteSchema),
|
||||
});
|
||||
20
packages/request-handler/src/network-controller.ts
Normal file
20
packages/request-handler/src/network-controller.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import dayjs from "dayjs";
|
||||
|
||||
import type { IntegrationKindByCategory } from "@homarr/definitions";
|
||||
import { createIntegrationAsync } from "@homarr/integrations";
|
||||
import type { NetworkControllerSummary } from "@homarr/integrations/types";
|
||||
|
||||
import { createCachedIntegrationRequestHandler } from "./lib/cached-integration-request-handler";
|
||||
|
||||
export const networkControllerRequestHandler = createCachedIntegrationRequestHandler<
|
||||
NetworkControllerSummary,
|
||||
IntegrationKindByCategory<"networkController">,
|
||||
Record<string, never>
|
||||
>({
|
||||
async requestAsync(integration, _input) {
|
||||
const integrationInstance = await createIntegrationAsync(integration);
|
||||
return await integrationInstance.getNetworkSummaryAsync();
|
||||
},
|
||||
cacheDuration: dayjs.duration(5, "minutes"),
|
||||
queryKey: "networkControllerSummary",
|
||||
});
|
||||
@@ -757,6 +757,10 @@
|
||||
"wrongPath": {
|
||||
"title": "Wrong path",
|
||||
"message": "The path is probably not correct"
|
||||
},
|
||||
"tooManyRequests": {
|
||||
"title": "Too many requests in a given time",
|
||||
"message": "There were too many requests. You are likely being rate-limited or rejected by the target system"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -2026,6 +2030,62 @@
|
||||
"label": "Amount posts limit"
|
||||
}
|
||||
}
|
||||
},
|
||||
"networkControllerSummary": {
|
||||
"option": {},
|
||||
"card": {
|
||||
"vpn": {
|
||||
"countConnected": "{count} connected"
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"integrationsDisconnected": "No data available, all integrations disconnected",
|
||||
"unknownContentOption": "Unknown content option for network controller summary widget: "
|
||||
},
|
||||
"name": "Network Controller Summary",
|
||||
"description": "Displays the summary of a Network Controller (like UniFi Controller)"
|
||||
},
|
||||
"networkControllerStatus": {
|
||||
"card": {
|
||||
"variants": {
|
||||
"wired": {
|
||||
"name": "Wired"
|
||||
},
|
||||
"wifi": {
|
||||
"name": "Wi-Fi"
|
||||
}
|
||||
},
|
||||
"users": {
|
||||
"label": "Users"
|
||||
},
|
||||
"guests": {
|
||||
"label": "Guests"
|
||||
}
|
||||
},
|
||||
"option": {
|
||||
"content": {
|
||||
"option": {
|
||||
"wifi": {
|
||||
"label": "Wi-Fi"
|
||||
},
|
||||
"wired": {
|
||||
"label": "Wired"
|
||||
}
|
||||
},
|
||||
"label": "Widget Content"
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"integrationsDisconnected": "No data available, all integrations disconnected",
|
||||
"unknownContentOption": "Unknown content option for network status widget: "
|
||||
},
|
||||
"name": "Network Status",
|
||||
"description": "Display connected devices on a network"
|
||||
},
|
||||
"networkController": {
|
||||
"error": {
|
||||
"internalServerError": "Failed to fetch Network Controller Summary"
|
||||
}
|
||||
}
|
||||
},
|
||||
"widgetPreview": {
|
||||
@@ -2787,6 +2847,9 @@
|
||||
},
|
||||
"mediaTranscoding": {
|
||||
"label": "Media transcoding"
|
||||
},
|
||||
"networkController": {
|
||||
"label": "Network Controller"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -24,6 +24,8 @@ import * as mediaRequestsStats from "./media-requests/stats";
|
||||
import * as mediaServer from "./media-server";
|
||||
import * as mediaTranscoding from "./media-transcoding";
|
||||
import * as minecraftServerStatus from "./minecraft/server-status";
|
||||
import * as networkControllerStatus from "./network-controller/network-status";
|
||||
import * as networkControllerSummary from "./network-controller/summary";
|
||||
import * as notebook from "./notebook";
|
||||
import type { WidgetOptionDefinition } from "./options";
|
||||
import * as rssFeed from "./rssFeed";
|
||||
@@ -53,6 +55,8 @@ export const widgetImports = {
|
||||
downloads,
|
||||
"mediaRequests-requestList": mediaRequestsList,
|
||||
"mediaRequests-requestStats": mediaRequestsStats,
|
||||
networkControllerSummary,
|
||||
networkControllerStatus,
|
||||
rssFeed,
|
||||
bookmarks,
|
||||
indexerManager,
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { Box } from "@mantine/core";
|
||||
import dayjs from "dayjs";
|
||||
import duration from "dayjs/plugin/duration";
|
||||
import objectSupport from "dayjs/plugin/objectSupport";
|
||||
import relativeTime from "dayjs/plugin/relativeTime";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
|
||||
import type { WidgetComponentProps } from "../../definition";
|
||||
import { WifiVariant } from "./variants/wifi-variant";
|
||||
import { WiredVariant } from "./variants/wired-variant";
|
||||
|
||||
dayjs.extend(objectSupport);
|
||||
dayjs.extend(relativeTime);
|
||||
dayjs.extend(duration);
|
||||
|
||||
export default function NetworkControllerNetworkStatusWidget({
|
||||
options,
|
||||
integrationIds,
|
||||
}: WidgetComponentProps<"networkControllerStatus">) {
|
||||
const [summaries] = clientApi.widget.networkController.summary.useSuspenseQuery(
|
||||
{
|
||||
integrationIds,
|
||||
},
|
||||
{
|
||||
refetchOnMount: false,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnReconnect: false,
|
||||
retry: false,
|
||||
},
|
||||
);
|
||||
const utils = clientApi.useUtils();
|
||||
|
||||
clientApi.widget.networkController.subscribeToSummary.useSubscription(
|
||||
{
|
||||
integrationIds,
|
||||
},
|
||||
{
|
||||
onData: (data) => {
|
||||
utils.widget.networkController.summary.setData(
|
||||
{
|
||||
integrationIds,
|
||||
},
|
||||
(prevData) => {
|
||||
if (!prevData) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return prevData.map((item) =>
|
||||
item.integration.id === data.integration.id
|
||||
? {
|
||||
...item,
|
||||
summary: data.summary,
|
||||
updatedAt: new Date(),
|
||||
}
|
||||
: item,
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const data = useMemo(() => summaries.flatMap(({ summary }) => summary), [summaries]);
|
||||
|
||||
return (
|
||||
<Box p={"sm"}>
|
||||
{options.content === "wifi" ? (
|
||||
<WifiVariant
|
||||
countGuests={data.reduce((sum, summary) => sum + summary.wifi.guests, 0)}
|
||||
countUsers={data.reduce((sum, summary) => sum + summary.wifi.users, 0)}
|
||||
/>
|
||||
) : (
|
||||
<WiredVariant
|
||||
countGuests={data.reduce((sum, summary) => sum + summary.lan.guests, 0)}
|
||||
countUsers={data.reduce((sum, summary) => sum + summary.lan.users, 0)}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { IconServerOff, IconTopologyFull } from "@tabler/icons-react";
|
||||
|
||||
import { getIntegrationKindsByCategory } from "@homarr/definitions";
|
||||
|
||||
import { createWidgetDefinition } from "../../definition";
|
||||
import { optionsBuilder } from "../../options";
|
||||
|
||||
export const { definition, componentLoader } = createWidgetDefinition("networkControllerStatus", {
|
||||
icon: IconTopologyFull,
|
||||
createOptions() {
|
||||
return optionsBuilder.from((factory) => ({
|
||||
content: factory.select({
|
||||
options: (["wifi", "wired"] as const).map((value) => ({
|
||||
value,
|
||||
label: (t) => t(`widget.networkControllerStatus.option.content.option.${value}.label`),
|
||||
})),
|
||||
defaultValue: "wifi",
|
||||
}),
|
||||
}));
|
||||
},
|
||||
supportedIntegrations: getIntegrationKindsByCategory("networkController"),
|
||||
errors: {
|
||||
INTERNAL_SERVER_ERROR: {
|
||||
icon: IconServerOff,
|
||||
message: (t) => t("widget.networkController.error.internalServerError"),
|
||||
},
|
||||
},
|
||||
}).withDynamicImport(() => import("./component"));
|
||||
@@ -0,0 +1,14 @@
|
||||
import { Stack, Text } from "@mantine/core";
|
||||
|
||||
export const StatRow = ({ label, value }: { label: string; value: string | number }) => {
|
||||
return (
|
||||
<Stack gap={0}>
|
||||
<Text size={"2xl"} fw={900} lh={1}>
|
||||
{value}
|
||||
</Text>
|
||||
<Text size={"md"} c={"dimmed"}>
|
||||
{label}
|
||||
</Text>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,24 @@
|
||||
import { Group, Stack, Text } from "@mantine/core";
|
||||
import { IconWifi } from "@tabler/icons-react";
|
||||
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { StatRow } from "./stat-row";
|
||||
|
||||
export const WifiVariant = ({ countGuests, countUsers }: { countUsers: number; countGuests: number }) => {
|
||||
const t = useScopedI18n("widget.networkControllerStatus.card");
|
||||
return (
|
||||
<>
|
||||
<Group gap={"xs"} wrap={"nowrap"} mb={"md"}>
|
||||
<IconWifi size={24} />
|
||||
<Text size={"md"} fw={"bold"}>
|
||||
{t("variants.wifi.name")}
|
||||
</Text>
|
||||
</Group>
|
||||
<Stack gap={"lg"}>
|
||||
<StatRow label={t("users.label")} value={countUsers} />
|
||||
<StatRow label={t("guests.label")} value={countGuests} />
|
||||
</Stack>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,24 @@
|
||||
import { Group, Stack, Text } from "@mantine/core";
|
||||
import { IconNetwork } from "@tabler/icons-react";
|
||||
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { StatRow } from "./stat-row";
|
||||
|
||||
export const WiredVariant = ({ countGuests, countUsers }: { countUsers: number; countGuests: number }) => {
|
||||
const t = useScopedI18n("widget.networkControllerStatus.card");
|
||||
return (
|
||||
<>
|
||||
<Group gap={"xs"} wrap={"nowrap"} mb={"md"}>
|
||||
<IconNetwork size={24} />
|
||||
<Text size={"md"} fw={"bold"}>
|
||||
{t("variants.wired.name")}
|
||||
</Text>
|
||||
</Group>
|
||||
<Stack gap={"lg"}>
|
||||
<StatRow label={t("users.label")} value={countUsers} />
|
||||
<StatRow label={t("guests.label")} value={countGuests} />
|
||||
</Stack>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,98 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { Box, Center, List, Text, useMantineTheme } from "@mantine/core";
|
||||
import { IconCircleCheckFilled, IconCircleXFilled } from "@tabler/icons-react";
|
||||
import dayjs from "dayjs";
|
||||
import duration from "dayjs/plugin/duration";
|
||||
import objectSupport from "dayjs/plugin/objectSupport";
|
||||
import relativeTime from "dayjs/plugin/relativeTime";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import type { WidgetComponentProps } from "../../definition";
|
||||
|
||||
dayjs.extend(objectSupport);
|
||||
dayjs.extend(relativeTime);
|
||||
dayjs.extend(duration);
|
||||
|
||||
export default function NetworkControllerSummaryWidget({
|
||||
integrationIds,
|
||||
}: WidgetComponentProps<"networkControllerSummary">) {
|
||||
const [summaries] = clientApi.widget.networkController.summary.useSuspenseQuery(
|
||||
{
|
||||
integrationIds,
|
||||
},
|
||||
{
|
||||
refetchOnMount: false,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnReconnect: false,
|
||||
retry: false,
|
||||
},
|
||||
);
|
||||
const utils = clientApi.useUtils();
|
||||
|
||||
const t = useI18n();
|
||||
|
||||
clientApi.widget.networkController.subscribeToSummary.useSubscription(
|
||||
{
|
||||
integrationIds,
|
||||
},
|
||||
{
|
||||
onData: (data) => {
|
||||
utils.widget.networkController.summary.setData(
|
||||
{
|
||||
integrationIds,
|
||||
},
|
||||
(prevData) => {
|
||||
if (!prevData) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return prevData.map((item) =>
|
||||
item.integration.id === data.integration.id ? { ...item, summary: data.summary } : item,
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const data = useMemo(() => summaries.flatMap(({ summary }) => summary), [summaries]);
|
||||
|
||||
return (
|
||||
<Box h="100%" p="sm">
|
||||
<Center h={"100%"}>
|
||||
<List spacing={"xs"} center>
|
||||
<List.Item icon={<StatusIcon status={data[0]?.wanStatus} />}>WAN</List.Item>
|
||||
<List.Item icon={<StatusIcon status={data[0]?.www.status} />}>
|
||||
<Text>
|
||||
WWW
|
||||
<Text c={"dimmed"} size={"md"} ms={"xs"} span>
|
||||
{data[0]?.www.latency}ms
|
||||
</Text>
|
||||
</Text>
|
||||
</List.Item>
|
||||
<List.Item icon={<StatusIcon status={data[0]?.wifi.status} />}>Wi-Fi</List.Item>
|
||||
<List.Item icon={<StatusIcon status={data[0]?.vpn.status} />}>
|
||||
<Text>
|
||||
VPN
|
||||
<Text c={"dimmed"} size={"md"} ms={"xs"} span>
|
||||
{t("widget.networkControllerSummary.card.vpn.countConnected", { count: `${data[0]?.vpn.users}` })}
|
||||
</Text>
|
||||
</Text>
|
||||
</List.Item>
|
||||
</List>
|
||||
</Center>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
const StatusIcon = ({ status }: { status?: "enabled" | "disabled" }) => {
|
||||
const mantineTheme = useMantineTheme();
|
||||
if (status === "enabled") {
|
||||
return <IconCircleCheckFilled size={20} color={mantineTheme.colors.green[6]} />;
|
||||
}
|
||||
return <IconCircleXFilled size={20} color={mantineTheme.colors.red[6]} />;
|
||||
};
|
||||
20
packages/widgets/src/network-controller/summary/index.ts
Normal file
20
packages/widgets/src/network-controller/summary/index.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { IconServerOff, IconTopologyFull } from "@tabler/icons-react";
|
||||
|
||||
import { getIntegrationKindsByCategory } from "@homarr/definitions";
|
||||
|
||||
import { createWidgetDefinition } from "../../definition";
|
||||
import { optionsBuilder } from "../../options";
|
||||
|
||||
export const { definition, componentLoader } = createWidgetDefinition("networkControllerSummary", {
|
||||
icon: IconTopologyFull,
|
||||
createOptions() {
|
||||
return optionsBuilder.from(() => ({}));
|
||||
},
|
||||
supportedIntegrations: getIntegrationKindsByCategory("networkController"),
|
||||
errors: {
|
||||
INTERNAL_SERVER_ERROR: {
|
||||
icon: IconServerOff,
|
||||
message: (t) => t("widget.networkController.error.internalServerError"),
|
||||
},
|
||||
},
|
||||
}).withDynamicImport(() => import("./component"));
|
||||
Reference in New Issue
Block a user