Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions packages/api/src/router/widgets/health-monitoring.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand All @@ -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)[] = [];
Expand Down
7 changes: 7 additions & 0 deletions packages/definitions/src/integration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 2 additions & 0 deletions packages/integrations/src/base/creator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <TKind extends keyof typeof integrationCreators>(
Expand Down Expand Up @@ -101,6 +102,7 @@ export const integrationCreators = {
ntfy: NTFYIntegration,
mock: MockIntegration,
truenas: TrueNasIntegration,
unraid: UnraidIntegration,
} satisfies Record<IntegrationKind, IntegrationInstance | [(input: IntegrationInput) => Promise<Integration>]>;

type IntegrationInstanceOfKind<TKind extends keyof typeof integrationCreators> = {
Expand Down
1 change: 1 addition & 0 deletions packages/integrations/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down
187 changes: 187 additions & 0 deletions packages/integrations/src/unraid/unraid-integration.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
import dayjs from "dayjs";
import type { fetch as undiciFetch } from "undici/types/fetch";

import { fetchWithTrustedCertificatesAsync } from "@homarr/certificates/server";
import { humanFileSize } from "@homarr/common";
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<TestingResult> {
await this.queryGraphQLAsync<{ info: UnraidSystemInfo }>(
`
query {
info {
os { platform }
}
}
`,
input.fetchAsync,
);

return { success: true };
}

public async getSystemInfoAsync(): Promise<SystemHealthMonitoring> {
const systemInfo = await this.getSystemInformationAsync();

const cpuUtilization = systemInfo.metrics.cpu.cpus.reduce((acc, val) => acc + val.percentTotal, 0);
const cpuCount = systemInfo.info.cpu.cores;

const totalMemory = systemInfo.metrics.memory.total;
const uptime = dayjs(systemInfo.info.os.uptime);

return {
version: systemInfo.info.os.release,
cpuModelName: systemInfo.info.cpu.brand,
cpuUtilization: cpuUtilization / cpuCount,
memUsedInBytes: totalMemory * (systemInfo.metrics.memory.percentTotal / 100),
memAvailableInBytes: totalMemory,
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),
available: `${disk.size}`,
percentage: disk.size > 0 ? (disk.fsUsed / disk.size) * 100 : 0,
})),
smart: systemInfo.array.disks.map((disk) => ({
deviceName: disk.name,
temperature: disk.temp,
overallStatus: disk.status,
})),
};
}

private async getSystemInformationAsync(): Promise<UnraidSystemInfo> {
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<UnraidSystemInfo>(query);
console.log("response from Unraid:", JSON.stringify(response));
const result = await unraidSystemInfoSchema.parseAsync(response);

logger.debug("Retrieved system information", {
url: this.url("/graphql"),
});

return result;
}

private async queryGraphQLAsync<T>(
query: string,
fetchAsync: typeof undiciFetch = fetchWithTrustedCertificatesAsync,
): Promise<T> {
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 Error(`GraphQL request failed: ${response.status} ${response.statusText}`);
}

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;
}
}
73 changes: 73 additions & 0 deletions packages/integrations/src/unraid/unraid-types.ts
Original file line number Diff line number Diff line change
@@ -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<typeof unraidSystemInfoSchema>;
2 changes: 1 addition & 1 deletion packages/widgets/src/system-resources/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }),
Expand Down
Loading