From c1cd56304872b5f7b2b49624c6f5018f750d126b Mon Sep 17 00:00:00 2001 From: pitschi Date: Sun, 6 Apr 2025 12:17:51 +0200 Subject: [PATCH] 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 --- .../app/[locale]/boards/(content)/_theme.tsx | 7 +- packages/api/src/router/widgets/index.ts | 2 + .../src/router/widgets/network-controller.ts | 62 +++++ packages/cron-job-runner/src/index.ts | 1 + packages/cron-jobs/src/index.ts | 2 + .../jobs/integrations/network-controller.ts | 14 ++ packages/definitions/src/integration.ts | 9 +- packages/definitions/src/widget.ts | 2 + packages/integrations/src/base/creator.ts | 2 + packages/integrations/src/base/integration.ts | 4 +- .../network-controller-summary-integration.ts | 5 + .../network-controller-summary-types.ts | 27 +++ packages/integrations/src/types.ts | 2 + .../unifi-controller-integration.ts | 228 ++++++++++++++++++ .../unifi-controller-types.ts | 130 ++++++++++ .../request-handler/src/network-controller.ts | 20 ++ packages/translation/src/lang/en.json | 63 +++++ packages/widgets/src/index.tsx | 4 + .../network-status/component.tsx | 84 +++++++ .../network-status/index.ts | 28 +++ .../network-status/variants/stat-row.tsx | 14 ++ .../network-status/variants/wifi-variant.tsx | 24 ++ .../network-status/variants/wired-variant.tsx | 24 ++ .../network-controller/summary/component.tsx | 98 ++++++++ .../src/network-controller/summary/index.ts | 20 ++ 25 files changed, 873 insertions(+), 3 deletions(-) create mode 100644 packages/api/src/router/widgets/network-controller.ts create mode 100644 packages/cron-jobs/src/jobs/integrations/network-controller.ts create mode 100644 packages/integrations/src/interfaces/network-controller-summary/network-controller-summary-integration.ts create mode 100644 packages/integrations/src/interfaces/network-controller-summary/network-controller-summary-types.ts create mode 100644 packages/integrations/src/unifi-controller/unifi-controller-integration.ts create mode 100644 packages/integrations/src/unifi-controller/unifi-controller-types.ts create mode 100644 packages/request-handler/src/network-controller.ts create mode 100644 packages/widgets/src/network-controller/network-status/component.tsx create mode 100644 packages/widgets/src/network-controller/network-status/index.ts create mode 100644 packages/widgets/src/network-controller/network-status/variants/stat-row.tsx create mode 100644 packages/widgets/src/network-controller/network-status/variants/wifi-variant.tsx create mode 100644 packages/widgets/src/network-controller/network-status/variants/wired-variant.tsx create mode 100644 packages/widgets/src/network-controller/summary/component.tsx create mode 100644 packages/widgets/src/network-controller/summary/index.ts diff --git a/apps/nextjs/src/app/[locale]/boards/(content)/_theme.tsx b/apps/nextjs/src/app/[locale]/boards/(content)/_theme.tsx index 76d68a6b3..27f3e9152 100644 --- a/apps/nextjs/src/app/[locale]/boards/(content)/_theme.tsx +++ b/apps/nextjs/src/app/[locale]/boards/(content)/_theme.tsx @@ -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 ( diff --git a/packages/api/src/router/widgets/index.ts b/packages/api/src/router/widgets/index.ts index f3716459c..ddce0b9a0 100644 --- a/packages/api/src/router/widgets/index.ts +++ b/packages/api/src/router/widgets/index.ts @@ -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, }); diff --git a/packages/api/src/router/widgets/network-controller.ts b/packages/api/src/router/widgets/network-controller.ts new file mode 100644 index 000000000..662a8063b --- /dev/null +++ b/packages/api/src/router/widgets/network-controller.ts @@ -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 }>; + 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(); + }); + }; + }); + }), +}); diff --git a/packages/cron-job-runner/src/index.ts b/packages/cron-job-runner/src/index.ts index 9042241c3..08eb3aacd 100644 --- a/packages/cron-job-runner/src/index.ts +++ b/packages/cron-job-runner/src/index.ts @@ -23,6 +23,7 @@ export const cronJobs = { updateChecker: { preventManualExecution: false }, mediaTranscoding: { preventManualExecution: false }, minecraftServerStatus: { preventManualExecution: false }, + networkController: { preventManualExecution: false }, } satisfies Record; /** diff --git a/packages/cron-jobs/src/index.ts b/packages/cron-jobs/src/index.ts index fe8e1b94e..b141c7146 100644 --- a/packages/cron-jobs/src/index.ts +++ b/packages/cron-jobs/src/index.ts @@ -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]; diff --git a/packages/cron-jobs/src/jobs/integrations/network-controller.ts b/packages/cron-jobs/src/jobs/integrations/network-controller.ts new file mode 100644 index 000000000..5d9354588 --- /dev/null +++ b/packages/cron-jobs/src/jobs/integrations/network-controller.ts @@ -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: () => ({}), + }, + }), +); diff --git a/packages/definitions/src/integration.ts b/packages/definitions/src/integration.ts index ab2097f73..8e23e5127 100644 --- a/packages/definitions/src/integration.ts +++ b/packages/definitions/src/integration.ts @@ -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; export const integrationKinds = objectKeys(integrationDefs) as AtLeastOneOf; @@ -209,4 +215,5 @@ export type IntegrationCategory = | "indexerManager" | "healthMonitoring" | "search" - | "mediaTranscoding"; + | "mediaTranscoding" + | "networkController"; diff --git a/packages/definitions/src/widget.ts b/packages/definitions/src/widget.ts index 7f586358c..aff019899 100644 --- a/packages/definitions/src/widget.ts +++ b/packages/definitions/src/widget.ts @@ -17,6 +17,8 @@ export const widgetKinds = [ "mediaRequests-requestStats", "mediaTranscoding", "minecraftServerStatus", + "networkControllerSummary", + "networkControllerStatus", "rssFeed", "bookmarks", "indexerManager", diff --git a/packages/integrations/src/base/creator.ts b/packages/integrations/src/base/creator.ts index 7664e86bc..90467c71c 100644 --- a/packages/integrations/src/base/creator.ts +++ b/packages/integrations/src/base/creator.ts @@ -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 ( @@ -88,6 +89,7 @@ export const integrationCreators = { proxmox: ProxmoxIntegration, emby: EmbyIntegration, nextcloud: NextcloudIntegration, + unifiController: UnifiControllerIntegration, } satisfies Record Promise]>; type IntegrationInstanceOfKind = { diff --git a/packages/integrations/src/base/integration.ts b/packages/integrations/src/base/integration.ts index cfab939f6..b3c1141e6 100644 --- a/packages/integrations/src/base/integration.ts +++ b/packages/integrations/src/base/integration.ts @@ -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: diff --git a/packages/integrations/src/interfaces/network-controller-summary/network-controller-summary-integration.ts b/packages/integrations/src/interfaces/network-controller-summary/network-controller-summary-integration.ts new file mode 100644 index 000000000..3f21e1dff --- /dev/null +++ b/packages/integrations/src/interfaces/network-controller-summary/network-controller-summary-integration.ts @@ -0,0 +1,5 @@ +import type { NetworkControllerSummary } from "./network-controller-summary-types"; + +export interface NetworkControllerSummaryIntegration { + getNetworkSummaryAsync(): Promise; +} diff --git a/packages/integrations/src/interfaces/network-controller-summary/network-controller-summary-types.ts b/packages/integrations/src/interfaces/network-controller-summary/network-controller-summary-types.ts new file mode 100644 index 000000000..587b5470d --- /dev/null +++ b/packages/integrations/src/interfaces/network-controller-summary/network-controller-summary-types.ts @@ -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; + }; +} diff --git a/packages/integrations/src/types.ts b/packages/integrations/src/types.ts index 66a3d0de2..2262164c4 100644 --- a/packages/integrations/src/types.ts +++ b/packages/integrations/src/types.ts @@ -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"; diff --git a/packages/integrations/src/unifi-controller/unifi-controller-integration.ts b/packages/integrations/src/unifi-controller/unifi-controller-integration.ts new file mode 100644 index 000000000..06e45adae --- /dev/null +++ b/packages/integrations/src/unifi-controller/unifi-controller-integration.ts @@ -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 { + if (!this.headers) { + await this.authenticateAndConstructSessionInHeaderAsync(); + } + + const requestUrl = this.url(`/${this.prefix}/api/stat/sites`); + + const requestHeaders: Record = { + "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 { + await this.authenticateAndConstructSessionInHeaderAsync(); + } + + private getStatusValueOverAllSites( + data: z.infer, + subsystem: Subsystem, + selectCallback: (obj: z.infer["data"][number]["health"][number]) => boolean, + ) { + return this.getBooleanValueOverAllSites(data, subsystem, selectCallback) ? "enabled" : "disabled"; + } + + private getNumericValueOverAllSites< + S extends Subsystem, + T extends Extract["data"][number]["health"][number], { subsystem: S }>, + >( + data: z.infer, + 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, + subsystem: Subsystem, + selectCallback: (obj: z.infer["data"][number]["health"][number]) => boolean, + ): boolean { + return data.data.every((site) => selectCallback(this.getSubsystem(site.health, subsystem))); + } + + private getSubsystem( + health: z.infer["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 | undefined = undefined; + private csrfToken: string | undefined; + + private async authenticateAndConstructSessionInHeaderAsync(): Promise { + await this.determineUDMVariantAsync(); + await this.authenticateAndSetCookieAsync(); + } + + private async authenticateAndSetCookieAsync(): Promise { + 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 = { "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 = {}; + const loginToken = UnifiControllerIntegration.extractLoginTokenFromCookies(responseHeaders); + newHeaders.Cookie = `${loginToken};`; + this.headers = newHeaders; + } + + private async determineUDMVariantAsync(): Promise { + 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"); + } +} diff --git a/packages/integrations/src/unifi-controller/unifi-controller-types.ts b/packages/integrations/src/unifi-controller/unifi-controller-types.ts new file mode 100644 index 000000000..cbca72820 --- /dev/null +++ b/packages/integrations/src/unifi-controller/unifi-controller-types.ts @@ -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; + +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; + +export const unifiSummaryResponseSchema = z.object({ + meta: z.object({ + rc: z.enum(["ok"]), + }), + data: z.array(siteSchema), +}); diff --git a/packages/request-handler/src/network-controller.ts b/packages/request-handler/src/network-controller.ts new file mode 100644 index 000000000..f5975acf2 --- /dev/null +++ b/packages/request-handler/src/network-controller.ts @@ -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 +>({ + async requestAsync(integration, _input) { + const integrationInstance = await createIntegrationAsync(integration); + return await integrationInstance.getNetworkSummaryAsync(); + }, + cacheDuration: dayjs.duration(5, "minutes"), + queryKey: "networkControllerSummary", +}); diff --git a/packages/translation/src/lang/en.json b/packages/translation/src/lang/en.json index 443fc76ff..5ea1ce039 100644 --- a/packages/translation/src/lang/en.json +++ b/packages/translation/src/lang/en.json @@ -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" } } }, diff --git a/packages/widgets/src/index.tsx b/packages/widgets/src/index.tsx index dab575965..b9d21e825 100644 --- a/packages/widgets/src/index.tsx +++ b/packages/widgets/src/index.tsx @@ -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, diff --git a/packages/widgets/src/network-controller/network-status/component.tsx b/packages/widgets/src/network-controller/network-status/component.tsx new file mode 100644 index 000000000..f87973252 --- /dev/null +++ b/packages/widgets/src/network-controller/network-status/component.tsx @@ -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 ( + + {options.content === "wifi" ? ( + sum + summary.wifi.guests, 0)} + countUsers={data.reduce((sum, summary) => sum + summary.wifi.users, 0)} + /> + ) : ( + sum + summary.lan.guests, 0)} + countUsers={data.reduce((sum, summary) => sum + summary.lan.users, 0)} + /> + )} + + ); +} diff --git a/packages/widgets/src/network-controller/network-status/index.ts b/packages/widgets/src/network-controller/network-status/index.ts new file mode 100644 index 000000000..4af2fbaf0 --- /dev/null +++ b/packages/widgets/src/network-controller/network-status/index.ts @@ -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")); diff --git a/packages/widgets/src/network-controller/network-status/variants/stat-row.tsx b/packages/widgets/src/network-controller/network-status/variants/stat-row.tsx new file mode 100644 index 000000000..aa6c1998b --- /dev/null +++ b/packages/widgets/src/network-controller/network-status/variants/stat-row.tsx @@ -0,0 +1,14 @@ +import { Stack, Text } from "@mantine/core"; + +export const StatRow = ({ label, value }: { label: string; value: string | number }) => { + return ( + + + {value} + + + {label} + + + ); +}; diff --git a/packages/widgets/src/network-controller/network-status/variants/wifi-variant.tsx b/packages/widgets/src/network-controller/network-status/variants/wifi-variant.tsx new file mode 100644 index 000000000..85b7e2355 --- /dev/null +++ b/packages/widgets/src/network-controller/network-status/variants/wifi-variant.tsx @@ -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 ( + <> + + + + {t("variants.wifi.name")} + + + + + + + + ); +}; diff --git a/packages/widgets/src/network-controller/network-status/variants/wired-variant.tsx b/packages/widgets/src/network-controller/network-status/variants/wired-variant.tsx new file mode 100644 index 000000000..f635dd897 --- /dev/null +++ b/packages/widgets/src/network-controller/network-status/variants/wired-variant.tsx @@ -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 ( + <> + + + + {t("variants.wired.name")} + + + + + + + + ); +}; diff --git a/packages/widgets/src/network-controller/summary/component.tsx b/packages/widgets/src/network-controller/summary/component.tsx new file mode 100644 index 000000000..bbd9775cd --- /dev/null +++ b/packages/widgets/src/network-controller/summary/component.tsx @@ -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 ( + +
+ + }>WAN + }> + + WWW + + {data[0]?.www.latency}ms + + + + }>Wi-Fi + }> + + VPN + + {t("widget.networkControllerSummary.card.vpn.countConnected", { count: `${data[0]?.vpn.users}` })} + + + + +
+
+ ); +} + +const StatusIcon = ({ status }: { status?: "enabled" | "disabled" }) => { + const mantineTheme = useMantineTheme(); + if (status === "enabled") { + return ; + } + return ; +}; diff --git a/packages/widgets/src/network-controller/summary/index.ts b/packages/widgets/src/network-controller/summary/index.ts new file mode 100644 index 000000000..dd541024a --- /dev/null +++ b/packages/widgets/src/network-controller/summary/index.ts @@ -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"));