Skip to content

Commit 2c0efcf

Browse files
committed
feat: unraid integration
1 parent 65f2e82 commit 2c0efcf

File tree

7 files changed

+272
-3
lines changed

7 files changed

+272
-3
lines changed

packages/api/src/router/widgets/health-monitoring.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import { createTRPCRouter, publicProcedure } from "../../trpc";
99

1010
export const healthMonitoringRouter = createTRPCRouter({
1111
getSystemHealthStatus: publicProcedure
12-
.concat(createManyIntegrationMiddleware("query", "openmediavault", "dashDot", "truenas", "mock"))
12+
.concat(createManyIntegrationMiddleware("query", "openmediavault", "dashDot", "truenas", "unraid", "mock"))
1313
.query(async ({ ctx }) => {
1414
return await Promise.all(
1515
ctx.integrations.map(async (integration) => {
@@ -26,7 +26,7 @@ export const healthMonitoringRouter = createTRPCRouter({
2626
);
2727
}),
2828
subscribeSystemHealthStatus: publicProcedure
29-
.concat(createManyIntegrationMiddleware("query", "openmediavault", "dashDot", "truenas", "mock"))
29+
.concat(createManyIntegrationMiddleware("query", "openmediavault", "dashDot", "truenas", "unraid", "mock"))
3030
.subscription(({ ctx }) => {
3131
return observable<{ integrationId: string; healthInfo: SystemHealthMonitoring; timestamp: Date }>((emit) => {
3232
const unsubscribes: (() => void)[] = [];

packages/definitions/src/integration.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -298,6 +298,13 @@ export const integrationDefs = {
298298
category: ["healthMonitoring"],
299299
documentationUrl: createDocumentationLink("/docs/integrations/truenas"),
300300
},
301+
unraid: {
302+
name: "Unraid",
303+
secretKinds: [["apiKey"]],
304+
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/unraid.svg",
305+
category: ["healthMonitoring"],
306+
documentationUrl: createDocumentationLink("/docs/integrations/unraid"),
307+
},
301308
// 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)
302309
mock: {
303310
name: "Mock",

packages/integrations/src/base/creator.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ import { ProxmoxIntegration } from "../proxmox/proxmox-integration";
3838
import { QuayIntegration } from "../quay/quay-integration";
3939
import { TrueNasIntegration } from "../truenas/truenas-integration";
4040
import { UnifiControllerIntegration } from "../unifi-controller/unifi-controller-integration";
41+
import { UnraidIntegration } from "../unraid/unraid-integration";
4142
import type { Integration, IntegrationInput } from "./integration";
4243

4344
export const createIntegrationAsync = async <TKind extends keyof typeof integrationCreators>(
@@ -101,6 +102,7 @@ export const integrationCreators = {
101102
ntfy: NTFYIntegration,
102103
mock: MockIntegration,
103104
truenas: TrueNasIntegration,
105+
unraid: UnraidIntegration,
104106
} satisfies Record<IntegrationKind, IntegrationInstance | [(input: IntegrationInput) => Promise<Integration>]>;
105107

106108
type IntegrationInstanceOfKind<TKind extends keyof typeof integrationCreators> = {

packages/integrations/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ export { PiHoleIntegrationV6 } from "./pi-hole/v6/pi-hole-integration-v6";
2222
export { PlexIntegration } from "./plex/plex-integration";
2323
export { ProwlarrIntegration } from "./prowlarr/prowlarr-integration";
2424
export { TrueNasIntegration } from "./truenas/truenas-integration";
25+
export { UnraidIntegration } from "./unraid/unraid-integration";
2526
export { OPNsenseIntegration } from "./opnsense/opnsense-integration";
2627
export { ICalIntegration } from "./ical/ical-integration";
2728

Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
import dayjs from "dayjs";
2+
import type { fetch as undiciFetch } from "undici/types/fetch";
3+
4+
import { fetchWithTrustedCertificatesAsync } from "@homarr/certificates/server";
5+
import { humanFileSize } from "@homarr/common";
6+
import { logger } from "@homarr/log";
7+
8+
import { HandleIntegrationErrors } from "../base/errors/decorator";
9+
import type { IntegrationTestingInput } from "../base/integration";
10+
import { Integration } from "../base/integration";
11+
import type { TestingResult } from "../base/test-connection/test-connection-service";
12+
import type { ISystemHealthMonitoringIntegration } from "../interfaces/health-monitoring/health-monitoring-integration";
13+
import type { SystemHealthMonitoring } from "../interfaces/health-monitoring/health-monitoring-types";
14+
import type { UnraidSystemInfo } from "./unraid-types";
15+
import { unraidSystemInfoSchema } from "./unraid-types";
16+
17+
const localLogger = logger.child({ module: "UnraidIntegration" });
18+
19+
@HandleIntegrationErrors([])
20+
export class UnraidIntegration extends Integration implements ISystemHealthMonitoringIntegration {
21+
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
22+
await this.queryGraphQLAsync<{ info: UnraidSystemInfo }>(
23+
`
24+
query {
25+
info {
26+
os { platform }
27+
}
28+
}
29+
`,
30+
input.fetchAsync,
31+
);
32+
33+
return { success: true };
34+
}
35+
36+
public async getSystemInfoAsync(): Promise<SystemHealthMonitoring> {
37+
const systemInfo = await this.getSystemInformationAsync();
38+
39+
const cpuUtilization = systemInfo.metrics.cpu.cpus.reduce((acc, val) => acc + val.percentTotal, 0);
40+
const cpuCount = systemInfo.info.cpu.cores;
41+
42+
const totalMemory = systemInfo.metrics.memory.total;
43+
const uptime = dayjs(systemInfo.info.os.uptime);
44+
45+
return {
46+
version: systemInfo.info.os.release,
47+
cpuModelName: systemInfo.info.cpu.brand,
48+
cpuUtilization: cpuUtilization / cpuCount,
49+
memUsedInBytes: systemInfo.metrics.memory.used,
50+
memAvailableInBytes: totalMemory,
51+
uptime: dayjs().diff(uptime, 'seconds'),
52+
network: null, // Not implemented, see https://github.com/unraid/api/issues/1602
53+
loadAverage: null,
54+
rebootRequired: false,
55+
availablePkgUpdates: 0,
56+
cpuTemp: undefined, // Not implemented, see https://github.com/unraid/api/issues/1597
57+
fileSystem: systemInfo.array.disks.map((disk) => ({
58+
deviceName: disk.name,
59+
used: humanFileSize(disk.fsUsed),
60+
available: `${disk.fsFree}`,
61+
percentage: disk.size > 0 ? ((disk.size - disk.fsFree) / disk.size) * 100 : 0,
62+
})),
63+
smart: systemInfo.array.disks.map((disk) => ({
64+
deviceName: disk.name,
65+
temperature: disk.temp,
66+
overallStatus: disk.status,
67+
})),
68+
};
69+
}
70+
71+
private async getSystemInformationAsync(): Promise<UnraidSystemInfo> {
72+
localLogger.debug("Retrieving system information", {
73+
url: this.url("/graphql"),
74+
});
75+
76+
const query = `
77+
query {
78+
metrics {
79+
cpu {
80+
percentTotal
81+
cpus {
82+
percentTotal
83+
}
84+
},
85+
memory {
86+
available
87+
used
88+
free
89+
total
90+
swapFree
91+
swapTotal
92+
swapUsed
93+
percentTotal
94+
}
95+
}
96+
array {
97+
state
98+
capacity {
99+
disks {
100+
free
101+
total
102+
used
103+
}
104+
}
105+
disks {
106+
name
107+
size
108+
fsFree
109+
fsUsed
110+
status
111+
temp
112+
}
113+
}
114+
info {
115+
devices {
116+
network {
117+
speed
118+
dhcp
119+
model
120+
model
121+
}
122+
}
123+
os {
124+
platform,
125+
distro,
126+
release,
127+
uptime
128+
},
129+
cpu {
130+
manufacturer,
131+
brand,
132+
cores,
133+
threads
134+
},
135+
memory {
136+
layout {
137+
size
138+
}
139+
}
140+
}
141+
}
142+
`;
143+
144+
const response = await this.queryGraphQLAsync<UnraidSystemInfo>(query);
145+
console.log("response from unraid:", response);
146+
const result = await unraidSystemInfoSchema.parseAsync(response);
147+
148+
localLogger.debug("Retrieved system information", {
149+
url: this.url("/graphql"),
150+
});
151+
152+
return result;
153+
}
154+
155+
private async queryGraphQLAsync<T>(
156+
query: string,
157+
fetchAsync: typeof undiciFetch = fetchWithTrustedCertificatesAsync,
158+
): Promise<T> {
159+
const url = this.url("/graphql");
160+
const apiKey = this.getSecretValue("apiKey");
161+
162+
localLogger.debug("Sending GraphQL query", {
163+
url: url.toString(),
164+
});
165+
166+
const response = await fetchAsync(url, {
167+
method: "POST",
168+
headers: {
169+
"Content-Type": "application/json",
170+
"x-api-key": apiKey,
171+
},
172+
body: JSON.stringify({ query }),
173+
});
174+
175+
if (!response.ok) {
176+
throw new Error(`GraphQL request failed: ${response.status} ${response.statusText}`);
177+
}
178+
179+
const json = (await response.json()) as { data: T; errors?: { message: string }[] };
180+
181+
if (json.errors) {
182+
throw new Error(`GraphQL errors: ${json.errors.map((error) => error.message).join(", ")}`);
183+
}
184+
185+
return json.data;
186+
}
187+
}
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import z from "zod";
2+
3+
export const unraidSystemInfoSchema = z.object({
4+
metrics: z.object({
5+
cpu: z.object({
6+
percentTotal: z.number(),
7+
cpus: z.array(
8+
z.object({
9+
percentTotal: z.number(),
10+
}),
11+
),
12+
}),
13+
memory: z.object({
14+
available: z.number(),
15+
used: z.number(),
16+
free: z.number(),
17+
total: z.number().min(0),
18+
}),
19+
}),
20+
array: z.object({
21+
state: z.string(),
22+
capacity: z.object({
23+
disks: z.object({
24+
free: z.coerce.number(),
25+
total: z.coerce.number(),
26+
used: z.coerce.number(),
27+
}),
28+
}),
29+
disks: z.array(
30+
z.object({
31+
name: z.string(),
32+
size: z.number(),
33+
fsFree: z.number(),
34+
fsUsed: z.number(),
35+
status: z.string(),
36+
temp: z.number(),
37+
}),
38+
),
39+
}),
40+
info: z.object({
41+
devices: z.object({
42+
network: z.array(
43+
z.object({
44+
speed: z.number(),
45+
dhcp: z.boolean(),
46+
model: z.string(),
47+
}),
48+
),
49+
}),
50+
os: z.object({
51+
platform: z.string(),
52+
distro: z.string(),
53+
release: z.string(),
54+
uptime: z.coerce.date(),
55+
}),
56+
cpu: z.object({
57+
manufacturer: z.string(),
58+
brand: z.string(),
59+
cores: z.number(),
60+
threads: z.number(),
61+
}),
62+
memory: z.object({
63+
layout: z.array(
64+
z.object({
65+
size: z.number(),
66+
}),
67+
),
68+
}),
69+
}),
70+
});
71+
72+
export type UnraidSystemInfo = z.infer<typeof unraidSystemInfoSchema>;

packages/widgets/src/system-resources/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ const labelDisplayModeOptions = {
1414

1515
export const { definition, componentLoader } = createWidgetDefinition("systemResources", {
1616
icon: IconGraphFilled,
17-
supportedIntegrations: ["dashDot", "openmediavault", "truenas"],
17+
supportedIntegrations: ["dashDot", "openmediavault", "truenas", "unraid"],
1818
createOptions() {
1919
return optionsBuilder.from((factory) => ({
2020
hasShadow: factory.switch({ defaultValue: true }),

0 commit comments

Comments
 (0)