Skip to content

Commit 9573006

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

File tree

5 files changed

+274
-0
lines changed

5 files changed

+274
-0
lines changed

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
@@ -39,6 +39,7 @@ import { QuayIntegration } from "../quay/quay-integration";
3939
import { TrueNasIntegration } from "../truenas/truenas-integration";
4040
import { UnifiControllerIntegration } from "../unifi-controller/unifi-controller-integration";
4141
import type { Integration, IntegrationInput } from "./integration";
42+
import { UnraidIntegration } from "../unraid/unraid-integration";
4243

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

0 commit comments

Comments
 (0)