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:
pitschi
2025-04-06 12:17:51 +02:00
committed by GitHub
parent 7caad6fc47
commit c1cd563048
25 changed files with 873 additions and 3 deletions

View File

@@ -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 (

View File

@@ -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,
});

View 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();
});
};
});
}),
});

View File

@@ -23,6 +23,7 @@ export const cronJobs = {
updateChecker: { preventManualExecution: false },
mediaTranscoding: { preventManualExecution: false },
minecraftServerStatus: { preventManualExecution: false },
networkController: { preventManualExecution: false },
} satisfies Record<JobGroupKeys, { preventManualExecution?: boolean }>;
/**

View File

@@ -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];

View File

@@ -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: () => ({}),
},
}),
);

View File

@@ -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";

View File

@@ -17,6 +17,8 @@ export const widgetKinds = [
"mediaRequests-requestStats",
"mediaTranscoding",
"minecraftServerStatus",
"networkControllerSummary",
"networkControllerStatus",
"rssFeed",
"bookmarks",
"indexerManager",

View File

@@ -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> = {

View File

@@ -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:

View File

@@ -0,0 +1,5 @@
import type { NetworkControllerSummary } from "./network-controller-summary-types";
export interface NetworkControllerSummaryIntegration {
getNetworkSummaryAsync(): Promise<NetworkControllerSummary>;
}

View File

@@ -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;
};
}

View File

@@ -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";

View File

@@ -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");
}
}

View File

@@ -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),
});

View 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",
});

View File

@@ -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"
}
}
},

View File

@@ -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,

View File

@@ -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>
);
}

View File

@@ -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"));

View File

@@ -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>
);
};

View File

@@ -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>
</>
);
};

View File

@@ -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>
</>
);
};

View File

@@ -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]} />;
};

View 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"));