diff --git a/packages/api/src/router/widgets/health-monitoring.ts b/packages/api/src/router/widgets/health-monitoring.ts index 49ebd294b..c16a600ed 100644 --- a/packages/api/src/router/widgets/health-monitoring.ts +++ b/packages/api/src/router/widgets/health-monitoring.ts @@ -9,7 +9,7 @@ import { createTRPCRouter, publicProcedure } from "../../trpc"; export const healthMonitoringRouter = createTRPCRouter({ getSystemHealthStatus: publicProcedure - .concat(createManyIntegrationMiddleware("query", "openmediavault", "dashDot", "mock")) + .concat(createManyIntegrationMiddleware("query", "openmediavault", "dashDot", "truenas", "mock")) .query(async ({ ctx }) => { return await Promise.all( ctx.integrations.map(async (integration) => { @@ -26,7 +26,7 @@ export const healthMonitoringRouter = createTRPCRouter({ ); }), subscribeSystemHealthStatus: publicProcedure - .concat(createManyIntegrationMiddleware("query", "openmediavault", "dashDot", "mock")) + .concat(createManyIntegrationMiddleware("query", "openmediavault", "dashDot", "truenas", "mock")) .subscription(({ ctx }) => { return observable<{ integrationId: string; healthInfo: SystemHealthMonitoring; timestamp: Date }>((emit) => { const unsubscribes: (() => void)[] = []; diff --git a/packages/definitions/src/integration.ts b/packages/definitions/src/integration.ts index 510f3ed11..54dba1d49 100644 --- a/packages/definitions/src/integration.ts +++ b/packages/definitions/src/integration.ts @@ -283,6 +283,13 @@ export const integrationDefs = { category: ["notifications"], documentationUrl: createDocumentationLink("/docs/integrations/ntfy"), }, + truenas: { + name: "TrueNAS", + secretKinds: [["username", "password"]], + iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/truenas.svg", + category: ["healthMonitoring"], + documentationUrl: createDocumentationLink("/docs/integrations/truenas"), + }, // This integration only returns mock data, it is used during development (but can also be used in production by directly going to the create page) mock: { name: "Mock", diff --git a/packages/integrations/src/base/creator.ts b/packages/integrations/src/base/creator.ts index a5a0f5d82..1d34528b0 100644 --- a/packages/integrations/src/base/creator.ts +++ b/packages/integrations/src/base/creator.ts @@ -38,6 +38,7 @@ import { PlexIntegration } from "../plex/plex-integration"; import { ProwlarrIntegration } from "../prowlarr/prowlarr-integration"; import { ProxmoxIntegration } from "../proxmox/proxmox-integration"; import { QuayIntegration } from "../quay/quay-integration"; +import { TrueNasIntegration } from "../truenas/truenas-integration"; import { UnifiControllerIntegration } from "../unifi-controller/unifi-controller-integration"; import type { Integration, IntegrationInput } from "./integration"; @@ -114,6 +115,7 @@ export const integrationCreators = { quay: QuayIntegration, ntfy: NTFYIntegration, mock: MockIntegration, + truenas: TrueNasIntegration, } satisfies Record Promise]>; type IntegrationInstanceOfKind = { diff --git a/packages/integrations/src/index.ts b/packages/integrations/src/index.ts index 713777607..ac21e3080 100644 --- a/packages/integrations/src/index.ts +++ b/packages/integrations/src/index.ts @@ -21,6 +21,7 @@ export { PiHoleIntegrationV5 } from "./pi-hole/v5/pi-hole-integration-v5"; export { PiHoleIntegrationV6 } from "./pi-hole/v6/pi-hole-integration-v6"; export { PlexIntegration } from "./plex/plex-integration"; export { ProwlarrIntegration } from "./prowlarr/prowlarr-integration"; +export { TrueNasIntegration } from "./truenas/truenas-integration"; export { OPNsenseIntegration } from "./opnsense/opnsense-integration"; // Types diff --git a/packages/integrations/src/interfaces/health-monitoring/health-monitoring-types.ts b/packages/integrations/src/interfaces/health-monitoring/health-monitoring-types.ts index b427baab9..49d659fe1 100644 --- a/packages/integrations/src/interfaces/health-monitoring/health-monitoring-types.ts +++ b/packages/integrations/src/interfaces/health-monitoring/health-monitoring-types.ts @@ -15,7 +15,7 @@ export interface SystemHealthMonitoring { "1min": number; "5min": number; "15min": number; - }; + } | null; rebootRequired: boolean; availablePkgUpdates: number; cpuTemp: number | undefined; diff --git a/packages/integrations/src/truenas/truenas-integration.ts b/packages/integrations/src/truenas/truenas-integration.ts new file mode 100644 index 000000000..c31b873ba --- /dev/null +++ b/packages/integrations/src/truenas/truenas-integration.ts @@ -0,0 +1,375 @@ +import dayjs from "dayjs"; +import z from "zod"; + +import { createId } from "@homarr/common"; +import { RequestError, ResponseError } from "@homarr/common/server"; +import { logger } from "@homarr/log"; + +import type { IntegrationTestingInput } from "../base/integration"; +import { Integration } from "../base/integration"; +import type { TestingResult } from "../base/test-connection/test-connection-service"; +import type { ISystemHealthMonitoringIntegration } from "../interfaces/health-monitoring/health-monitoring-integration"; +import type { SystemHealthMonitoring } from "../interfaces/health-monitoring/health-monitoring-types"; + +const localLogger = logger.child({ module: "TrueNasIntegration" }); + +const NETWORK_MULTIPLIER = 100; + +export class TrueNasIntegration extends Integration implements ISystemHealthMonitoringIntegration { + private static webSocketMap = new Map(); + + private wsUrl() { + const url = super.url("/websocket"); + url.protocol = url.protocol.replace("http", "ws"); + return url; + } + + private get webSocket() { + return TrueNasIntegration.webSocketMap.get(this.integration.id) ?? null; + } + + protected async testingAsync(_input: IntegrationTestingInput): Promise { + const webSocket = await this.connectWebSocketAsync(); + await this.registerSessionAsync(webSocket); + await this.authenticateWebSocketAsync(webSocket); + + // Remove current socket connection so we can authenticate with updated credentials + TrueNasIntegration.webSocketMap.delete(this.integration.id); + + return { success: true }; + } + + /** + * TrueNAS API uses WebSocket. This function connects to the socket + * and resolves the promise if the connection was successful. + * @see https://www.truenas.com/docs/api/scale_websocket_api.html + */ + private async connectWebSocketAsync(): Promise { + localLogger.debug("Connecting to websocket server", { + url: this.wsUrl(), + }); + const webSocket = new WebSocket(this.wsUrl()); + + return new Promise((resolve, reject) => { + webSocket.onopen = () => { + localLogger.debug("Connected to websocket server", { + url: this.wsUrl(), + }); + resolve(webSocket); + }; + + webSocket.onerror = () => { + reject(new Error("Failed to connect")); + }; + }); + } + + /** + * Before authentication, a session must be obtained from the server using the "connect" event. + * @see https://www.truenas.com/docs/api/scale_websocket_api.html#websocket_protocol + */ + private async registerSessionAsync(webSocket: WebSocket): Promise { + return new Promise((resolve, reject) => { + const subscribe = (event: MessageEvent) => { + const data = JSON.parse(event.data) as { msg: string }; + if (data.msg === "connected") { + webSocket.removeEventListener("message", subscribe); + resolve(); + } else if (data.msg === "failed") { + webSocket.removeEventListener("message", subscribe); + reject(new Error("Unable to establish connection")); + } + }; + + webSocket.addEventListener("message", subscribe); + webSocket.send( + JSON.stringify({ + msg: "connect", + version: "1", // this must be number, not string + support: ["1"], // this must be number, not string + }), + ); + }); + } + + /** + * After a session was obtained, the session can be authenticated. + * @see https://www.truenas.com/docs/api/scale_websocket_api.html#websocket_protocol + */ + private async authenticateWebSocketAsync(webSocket?: WebSocket): Promise { + localLogger.debug("Authenticating with username and password", { + url: this.wsUrl(), + }); + const response = await this.requestAsync( + "auth.login", + [this.getSecretValue("username"), this.getSecretValue("password")], + webSocket, + ); + const result = await z.boolean().parseAsync(response); + if (!result) throw new ResponseError({ status: 401 }); + localLogger.debug("Authenticated successfully with username and password", { + url: this.wsUrl(), + }); + } + + /** + * Retrieves data using the reporting method + * @see https://www.truenas.com/docs/api/scale_websocket_api.html#reporting + */ + private async getReportingAsync(): Promise { + localLogger.debug("Retrieving reporting data", { + url: this.wsUrl(), + }); + + const response = await this.requestAsync("reporting.get_data", [ + [ + { + name: "cpu", + }, + { + name: "memory", + }, + { + name: "cputemp", + }, + ], + { + aggregate: true, + start: dayjs().add(-5, "minutes").unix(), + end: dayjs().unix(), + }, + ]); + const result = await z.array(reportingItemSchema).parseAsync(response); + + localLogger.debug("Retrieved reporting data", { + url: this.wsUrl(), + count: result.length, + }); + return result; + } + + /** + * Retrieves a list of all available network interfaces + * @see https://www.truenas.com/docs/core/13.0/api/core_websocket_api.html#interface + */ + private async getNetworkInterfacesAsync(): Promise> { + localLogger.debug("Retrieving available network-interfaces", { + url: this.wsUrl(), + }); + + const response = await this.requestAsync("interface.query", [ + [], // no filters + {}, + ]); + const result = await networkInterfaceSchema.parseAsync(response); + + localLogger.debug("Retrieved available network-interfaces", { + url: this.wsUrl(), + count: result.length, + }); + return result; + } + + /** + * Retrieves reporting network data of the last 5 minutes + * @see https://www.truenas.com/docs/api/scale_websocket_api.html#reporting + */ + private async getReportingNetdataAsync(): Promise> { + const networkInterfaces = await this.getNetworkInterfacesAsync(); + + localLogger.debug("Retrieving reporting network data", { + url: this.wsUrl(), + }); + + const response = await this.requestAsync("reporting.netdata_get_data", [ + networkInterfaces.map((networkInterface) => ({ + name: "interface", + identifier: networkInterface.id, + })), + { + start: dayjs().add(-5, "minutes").unix(), + end: dayjs().unix(), + }, + ]); + const result = await reportingNetDataSchema.parseAsync(response); + + localLogger.debug("Retrieved reporting-network-data", { + url: this.wsUrl(), + count: result.length, + }); + return result; + } + + /** + * Retrieves information about the system + * @see https://www.truenas.com/docs/api/scale_websocket_api.html#system + */ + private async getSystemInformationAsync(): Promise> { + localLogger.debug("Retrieving system-information", { + url: this.wsUrl(), + }); + + const response = await this.requestAsync("system.info"); + const result = await systemInfoSchema.parseAsync(response); + + localLogger.debug("Retrieved system-information", { + url: this.wsUrl(), + }); + return result; + } + + public async getSystemInfoAsync(): Promise { + const systemInformation = await this.getSystemInformationAsync(); + const reporting = await this.getReportingAsync(); + + const cpuData = this.extractLatestReportingData(reporting, "cpu"); + const cpuTempData = this.extractLatestReportingData(reporting, "cputemp"); + const memoryData = this.extractLatestReportingData(reporting, "memory"); + + const netdata = await this.getReportingNetdataAsync(); + + const upload = this.extractNetworkTrafficData(netdata, 2); // Index 2 is "sent" + const download = this.extractNetworkTrafficData(netdata, 1); // Index 1 is "received" + + return { + cpuUtilization: cpuData.reduce((acc, item) => acc + (item > 100 ? 0 : item), 0) / cpuData.length, + cpuTemp: Math.max(...cpuTempData.filter((_item, i) => i > 0)), + memAvailableInBytes: systemInformation.physmem, + memUsedInBytes: memoryData[1] ?? 0, // Index 0 is UNIX timestamp, Index 1 is free space in bytes + fileSystem: [], + availablePkgUpdates: 0, + network: { + up: upload * NETWORK_MULTIPLIER, + down: download * NETWORK_MULTIPLIER, + }, + loadAverage: null, + smart: [], + uptime: systemInformation.uptime_seconds, + version: systemInformation.version, + cpuModelName: systemInformation.model, + rebootRequired: false, + }; + } + + /** + * Send a request through websocket and return response + * Times out after 5 seconds when no response was received. + * @param method json-rpc method to call + * @param params array of parameters + * @param webSocketOverride override of webSocket, helpful for not storing the connection + * @returns result of json-rpc call + */ + private async requestAsync(method: string, params: unknown[] = [], webSocketOverride?: WebSocket) { + let webSocket = webSocketOverride ?? this.webSocket; + if (!webSocket || webSocket.readyState !== WebSocket.OPEN) { + localLogger.debug("Connecting to websocket", { + url: this.wsUrl(), + }); + // We can only land here with static webSocket + webSocket = await this.connectWebSocketAsync(); + await this.registerSessionAsync(webSocket); + + TrueNasIntegration.webSocketMap.set(this.integration.id, webSocket); + await this.authenticateWebSocketAsync(); + } + + return await new Promise((resolve, reject) => { + const id = createId(); + const handler = (event: MessageEvent) => { + const data = JSON.parse(event.data) as Record; + if (data.msg !== "result") return; + if (data.id !== id) return; + + clearTimeout(timeoutId); + webSocket.removeEventListener("message", handler); + localLogger.debug("Received method response", { + id, + method, + url: this.wsUrl(), + }); + resolve(data.result); + }; + const timeoutId = setTimeout(() => { + webSocket.removeEventListener("message", handler); + reject( + new RequestError( + { + type: "timeout", + reason: "aborted", + code: "ECONNABORTED", + }, + { cause: new Error("Canceled request after 5 seconds") }, + ), + ); + }, 5000); + + webSocket.addEventListener("message", handler); + + localLogger.debug("Sending method request", { + id, + method, + url: this.wsUrl(), + }); + + webSocket.send( + JSON.stringify({ + id, + msg: "method", + method, + params, + }), + ); + }); + } + + private extractNetworkTrafficData = (data: z.infer, index = 1 | 2) => { + return data.reduce((acc, current) => acc + (current.data.at(-1)?.at(index) ?? 0), 0); + }; + + private extractLatestReportingData(data: ReportingItem[], key: ReportingItem["identifier"]) { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const dataObject = data.find((item) => item.identifier === key)!; + // TODO: check why the below sorting is done, because right now it compares number[] with number[]? + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + return dataObject.data.sort((item1, item2) => (item1 > item2 ? -1 : 1))[0]!; + } +} + +const reportingItemSchema = z.object({ + name: z.enum(["cpu", "memory", "cputemp"]), + identifier: z.enum(["cpu", "memory", "cputemp"]), + aggregations: z.object({ + min: z.record(z.string(), z.unknown()), + mean: z.record(z.string(), z.unknown()), + max: z.record(z.string(), z.unknown()), + }), + start: z.number().min(0), + end: z.number().min(0), + legend: z.array(z.string()), + data: z.array(z.array(z.number())), +}); + +type ReportingItem = z.infer; + +const reportingNetDataSchema = z.array( + z.object({ + name: z.string(), + identifier: z.string(), + data: z.array(z.array(z.number())), + }), +); + +const systemInfoSchema = z.object({ + version: z.string(), + hostname: z.string(), + physmem: z.number().min(0), // pysical memory + model: z.string(), // cpu model + uptime_seconds: z.number().min(0), +}); + +const networkInterfaceSchema = z.array( + z.object({ + id: z.string(), + name: z.string(), + }), +); diff --git a/packages/widgets/src/health-monitoring/system-health.tsx b/packages/widgets/src/health-monitoring/system-health.tsx index 2c4b5fe0b..7a5d4e9d6 100644 --- a/packages/widgets/src/health-monitoring/system-health.tsx +++ b/packages/widgets/src/health-monitoring/system-health.tsx @@ -151,21 +151,26 @@ export const SystemHealthMonitoring = ({ }> {formatUptime(healthInfo.uptime, t)} - }> - {t("widget.healthMonitoring.popover.loadAverage")} - - }> - - {t("widget.healthMonitoring.popover.minute")} {healthInfo.loadAverage["1min"]}% - - - {t("widget.healthMonitoring.popover.minutes", { count: "5" })} {healthInfo.loadAverage["5min"]}% - - - {t("widget.healthMonitoring.popover.minutes", { count: "15" })}{" "} - {healthInfo.loadAverage["15min"]}% - - + {healthInfo.loadAverage && ( + <> + }> + {t("widget.healthMonitoring.popover.loadAverage")} + + }> + + {t("widget.healthMonitoring.popover.minute")} {healthInfo.loadAverage["1min"]}% + + + {t("widget.healthMonitoring.popover.minutes", { count: "5" })}{" "} + {healthInfo.loadAverage["5min"]}% + + + {t("widget.healthMonitoring.popover.minutes", { count: "15" })}{" "} + {healthInfo.loadAverage["15min"]}% + + + + )} diff --git a/packages/widgets/src/system-resources/index.ts b/packages/widgets/src/system-resources/index.ts index 664bc0247..e808fd0c1 100644 --- a/packages/widgets/src/system-resources/index.ts +++ b/packages/widgets/src/system-resources/index.ts @@ -5,7 +5,7 @@ import { optionsBuilder } from "../options"; export const { definition, componentLoader } = createWidgetDefinition("systemResources", { icon: IconGraphFilled, - supportedIntegrations: ["dashDot", "openmediavault"], + supportedIntegrations: ["dashDot", "openmediavault", "truenas"], createOptions() { return optionsBuilder.from(() => ({})); },