From 9b0dd6d6cebda4a541c8859506d89e9e9f782fa4 Mon Sep 17 00:00:00 2001 From: Manuel <30572287+manuel-rw@users.noreply.github.com> Date: Fri, 2 Jan 2026 18:54:47 +0000 Subject: [PATCH] feat: unraid integration (#4439) Co-authored-by: Meier Lukas --- .../src/router/widgets/health-monitoring.ts | 4 +- packages/definitions/src/integration.ts | 7 + packages/integrations/src/base/creator.ts | 2 + packages/integrations/src/index.ts | 1 + .../src/unraid/unraid-integration.ts | 189 ++++++++++++++++++ .../integrations/src/unraid/unraid-types.ts | 73 +++++++ .../system-resources/chart/memory-chart.tsx | 2 +- .../src/system-resources/component.tsx | 1 + .../widgets/src/system-resources/index.ts | 2 +- 9 files changed, 277 insertions(+), 4 deletions(-) create mode 100644 packages/integrations/src/unraid/unraid-integration.ts create mode 100644 packages/integrations/src/unraid/unraid-types.ts diff --git a/packages/api/src/router/widgets/health-monitoring.ts b/packages/api/src/router/widgets/health-monitoring.ts index c16a600ed..14ab160f7 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", "truenas", "mock")) + .concat(createManyIntegrationMiddleware("query", "openmediavault", "dashDot", "truenas", "unraid", "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", "truenas", "mock")) + .concat(createManyIntegrationMiddleware("query", "openmediavault", "dashDot", "truenas", "unraid", "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 1309796d5..e8518b016 100644 --- a/packages/definitions/src/integration.ts +++ b/packages/definitions/src/integration.ts @@ -298,6 +298,13 @@ export const integrationDefs = { category: ["healthMonitoring"], documentationUrl: createDocumentationLink("/docs/integrations/truenas"), }, + unraid: { + name: "Unraid", + secretKinds: [["apiKey"]], + iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/unraid.svg", + category: ["healthMonitoring"], + documentationUrl: createDocumentationLink("/docs/integrations/unraid"), + }, // 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 0e8c9ea7c..adca7fcb1 100644 --- a/packages/integrations/src/base/creator.ts +++ b/packages/integrations/src/base/creator.ts @@ -38,6 +38,7 @@ 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 { UnraidIntegration } from "../unraid/unraid-integration"; import type { Integration, IntegrationInput } from "./integration"; export const createIntegrationAsync = async ( @@ -101,6 +102,7 @@ export const integrationCreators = { ntfy: NTFYIntegration, mock: MockIntegration, truenas: TrueNasIntegration, + unraid: UnraidIntegration, } satisfies Record Promise]>; type IntegrationInstanceOfKind = { diff --git a/packages/integrations/src/index.ts b/packages/integrations/src/index.ts index c23aa00b7..45a37e476 100644 --- a/packages/integrations/src/index.ts +++ b/packages/integrations/src/index.ts @@ -22,6 +22,7 @@ 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 { UnraidIntegration } from "./unraid/unraid-integration"; export { OPNsenseIntegration } from "./opnsense/opnsense-integration"; export { ICalIntegration } from "./ical/ical-integration"; diff --git a/packages/integrations/src/unraid/unraid-integration.ts b/packages/integrations/src/unraid/unraid-integration.ts new file mode 100644 index 000000000..4ae42cf7b --- /dev/null +++ b/packages/integrations/src/unraid/unraid-integration.ts @@ -0,0 +1,189 @@ +import dayjs from "dayjs"; +import type { fetch as undiciFetch } from "undici/types/fetch"; + +import { humanFileSize } from "@homarr/common"; +import { ResponseError } from "@homarr/common/server"; +import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http"; +import { createLogger } from "@homarr/core/infrastructure/logs"; + +import { HandleIntegrationErrors } from "../base/errors/decorator"; +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"; +import type { UnraidSystemInfo } from "./unraid-types"; +import { unraidSystemInfoSchema } from "./unraid-types"; + +const logger = createLogger({ module: "UnraidIntegration" }); + +@HandleIntegrationErrors([]) +export class UnraidIntegration extends Integration implements ISystemHealthMonitoringIntegration { + protected async testingAsync(input: IntegrationTestingInput): Promise { + await this.queryGraphQLAsync<{ info: UnraidSystemInfo }>( + ` + query { + info { + os { platform } + } + } + `, + input.fetchAsync, + ); + + return { success: true }; + } + + public async getSystemInfoAsync(): Promise { + const systemInfo = await this.getSystemInformationAsync(); + + const cpuUtilization = systemInfo.metrics.cpu.cpus.reduce((acc, val) => acc + val.percentTotal, 0); + const cpuCount = systemInfo.info.cpu.cores; + + // We use "info" object instead of the stats since this is the exact amount the kernel sees, which is what Unraid displays. + const totalMemory = systemInfo.info.memory.layout.reduce((acc, layout) => layout.size + acc, 0); + const usedMemory = totalMemory * (systemInfo.metrics.memory.percentTotal / 100); + const uptime = dayjs(systemInfo.info.os.uptime); + + return { + version: systemInfo.info.os.release, + cpuModelName: systemInfo.info.cpu.brand, + cpuUtilization: cpuUtilization / cpuCount, + memUsedInBytes: usedMemory, + memAvailableInBytes: totalMemory - usedMemory, + uptime: dayjs().diff(uptime, "seconds"), + network: null, // Not implemented, see https://github.com/unraid/api/issues/1602 + loadAverage: null, + rebootRequired: false, + availablePkgUpdates: 0, + cpuTemp: undefined, // Not implemented, see https://github.com/unraid/api/issues/1597 + fileSystem: systemInfo.array.disks.map((disk) => ({ + deviceName: disk.name, + used: humanFileSize(disk.fsUsed * 1024), // API is in KiB (kibibytes), covert to bytes + available: `${disk.size * 1024}`, // API is in KiB (kibibytes), covert to bytes + percentage: (disk.fsUsed / disk.size) * 100, // The units are the same, therefore the actual unit is irrelevant + })), + smart: systemInfo.array.disks.map((disk) => ({ + deviceName: disk.name, + temperature: disk.temp, + overallStatus: disk.status, + })), + }; + } + + private async getSystemInformationAsync(): Promise { + logger.debug("Retrieving system information", { + url: this.url("/graphql"), + }); + + const query = ` + query { + metrics { + cpu { + percentTotal + cpus { + percentTotal + } + }, + memory { + available + used + free + total + swapFree + swapTotal + swapUsed + percentTotal + } + } + array { + state + capacity { + disks { + free + total + used + } + } + disks { + name + size + fsFree + fsUsed + status + temp + } + } + info { + devices { + network { + speed + dhcp + model + model + } + } + os { + platform, + distro, + release, + uptime + }, + cpu { + manufacturer, + brand, + cores, + threads + }, + memory { + layout { + size + } + } + } + } + `; + + const response = await this.queryGraphQLAsync(query); + const result = await unraidSystemInfoSchema.parseAsync(response); + + logger.debug("Retrieved system information", { + url: this.url("/graphql"), + }); + + return result; + } + + private async queryGraphQLAsync( + query: string, + fetchAsync: typeof undiciFetch = fetchWithTrustedCertificatesAsync, + ): Promise { + const url = this.url("/graphql"); + const apiKey = this.getSecretValue("apiKey"); + + logger.debug("Sending GraphQL query", { + url: url.toString(), + }); + + const response = await fetchAsync(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + "x-api-key": apiKey, + }, + body: JSON.stringify({ query }), + }); + + if (!response.ok) { + throw new ResponseError(response); + } + + const json = (await response.json()) as { data: T; errors?: { message: string }[] }; + + if (json.errors) { + throw new Error(`GraphQL errors: ${json.errors.map((error) => error.message).join(", ")}`); + } + + return json.data; + } +} diff --git a/packages/integrations/src/unraid/unraid-types.ts b/packages/integrations/src/unraid/unraid-types.ts new file mode 100644 index 000000000..84f258f38 --- /dev/null +++ b/packages/integrations/src/unraid/unraid-types.ts @@ -0,0 +1,73 @@ +import z from "zod"; + +export const unraidSystemInfoSchema = z.object({ + metrics: z.object({ + cpu: z.object({ + percentTotal: z.number(), + cpus: z.array( + z.object({ + percentTotal: z.number(), + }), + ), + }), + memory: z.object({ + available: z.number(), + used: z.number(), + free: z.number(), + total: z.number().min(0), + percentTotal: z.number().min(0).max(100), + }), + }), + array: z.object({ + state: z.string(), + capacity: z.object({ + disks: z.object({ + free: z.coerce.number(), + total: z.coerce.number(), + used: z.coerce.number(), + }), + }), + disks: z.array( + z.object({ + name: z.string(), + size: z.number(), + fsFree: z.number(), + fsUsed: z.number(), + status: z.string(), + temp: z.number(), + }), + ), + }), + info: z.object({ + devices: z.object({ + network: z.array( + z.object({ + speed: z.number(), + dhcp: z.boolean(), + model: z.string(), + }), + ), + }), + os: z.object({ + platform: z.string(), + distro: z.string(), + release: z.string(), + uptime: z.coerce.date(), + }), + cpu: z.object({ + manufacturer: z.string(), + brand: z.string(), + cores: z.number(), + threads: z.number(), + }), + memory: z.object({ + layout: z.array( + z.object({ + size: z.number(), + }), + ), + }), + }), +}); + +export type UnraidSystemInfo = z.infer; diff --git a/packages/widgets/src/system-resources/chart/memory-chart.tsx b/packages/widgets/src/system-resources/chart/memory-chart.tsx index b0c6d14c1..ac265db8a 100644 --- a/packages/widgets/src/system-resources/chart/memory-chart.tsx +++ b/packages/widgets/src/system-resources/chart/memory-chart.tsx @@ -47,7 +47,7 @@ export const SystemResourceMemoryChart = ({ return ( - {humanFileSize(value)} / {humanFileSize(totalCapacityInBytes)} ( + {humanFileSize(Math.round(value))} / {humanFileSize(totalCapacityInBytes)} ( {Math.round((value / totalCapacityInBytes) * 100)}%) diff --git a/packages/widgets/src/system-resources/component.tsx b/packages/widgets/src/system-resources/component.tsx index 623ed8aa2..46237a27c 100644 --- a/packages/widgets/src/system-resources/component.tsx +++ b/packages/widgets/src/system-resources/component.tsx @@ -22,6 +22,7 @@ export default function SystemResources({ integrationIds, options }: WidgetCompo }); const memoryCapacityInBytes = (data[0]?.healthInfo.memAvailableInBytes ?? 0) + (data[0]?.healthInfo.memUsedInBytes ?? 0); + const [items, setItems] = useState<{ cpu: number; memory: number; network: { up: number; down: number } | null }[]>( data.map((item) => ({ cpu: item.healthInfo.cpuUtilization, diff --git a/packages/widgets/src/system-resources/index.ts b/packages/widgets/src/system-resources/index.ts index 4e0fe27fa..d89d6952f 100644 --- a/packages/widgets/src/system-resources/index.ts +++ b/packages/widgets/src/system-resources/index.ts @@ -14,7 +14,7 @@ const labelDisplayModeOptions = { export const { definition, componentLoader } = createWidgetDefinition("systemResources", { icon: IconGraphFilled, - supportedIntegrations: ["dashDot", "openmediavault", "truenas"], + supportedIntegrations: ["dashDot", "openmediavault", "truenas", "unraid"], createOptions() { return optionsBuilder.from((factory) => ({ hasShadow: factory.switch({ defaultValue: true }),