feat: unraid integration (#4439)
Co-authored-by: Meier Lukas <meierschlumpf@gmail.com>
This commit is contained in:
@@ -9,7 +9,7 @@ import { createTRPCRouter, publicProcedure } from "../../trpc";
|
|||||||
|
|
||||||
export const healthMonitoringRouter = createTRPCRouter({
|
export const healthMonitoringRouter = createTRPCRouter({
|
||||||
getSystemHealthStatus: publicProcedure
|
getSystemHealthStatus: publicProcedure
|
||||||
.concat(createManyIntegrationMiddleware("query", "openmediavault", "dashDot", "truenas", "mock"))
|
.concat(createManyIntegrationMiddleware("query", "openmediavault", "dashDot", "truenas", "unraid", "mock"))
|
||||||
.query(async ({ ctx }) => {
|
.query(async ({ ctx }) => {
|
||||||
return await Promise.all(
|
return await Promise.all(
|
||||||
ctx.integrations.map(async (integration) => {
|
ctx.integrations.map(async (integration) => {
|
||||||
@@ -26,7 +26,7 @@ export const healthMonitoringRouter = createTRPCRouter({
|
|||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
subscribeSystemHealthStatus: publicProcedure
|
subscribeSystemHealthStatus: publicProcedure
|
||||||
.concat(createManyIntegrationMiddleware("query", "openmediavault", "dashDot", "truenas", "mock"))
|
.concat(createManyIntegrationMiddleware("query", "openmediavault", "dashDot", "truenas", "unraid", "mock"))
|
||||||
.subscription(({ ctx }) => {
|
.subscription(({ ctx }) => {
|
||||||
return observable<{ integrationId: string; healthInfo: SystemHealthMonitoring; timestamp: Date }>((emit) => {
|
return observable<{ integrationId: string; healthInfo: SystemHealthMonitoring; timestamp: Date }>((emit) => {
|
||||||
const unsubscribes: (() => void)[] = [];
|
const unsubscribes: (() => void)[] = [];
|
||||||
|
|||||||
@@ -298,6 +298,13 @@ export const integrationDefs = {
|
|||||||
category: ["healthMonitoring"],
|
category: ["healthMonitoring"],
|
||||||
documentationUrl: createDocumentationLink("/docs/integrations/truenas"),
|
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)
|
// 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: {
|
mock: {
|
||||||
name: "Mock",
|
name: "Mock",
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ import { ProxmoxIntegration } from "../proxmox/proxmox-integration";
|
|||||||
import { QuayIntegration } from "../quay/quay-integration";
|
import { QuayIntegration } from "../quay/quay-integration";
|
||||||
import { TrueNasIntegration } from "../truenas/truenas-integration";
|
import { TrueNasIntegration } from "../truenas/truenas-integration";
|
||||||
import { UnifiControllerIntegration } from "../unifi-controller/unifi-controller-integration";
|
import { UnifiControllerIntegration } from "../unifi-controller/unifi-controller-integration";
|
||||||
|
import { UnraidIntegration } from "../unraid/unraid-integration";
|
||||||
import type { Integration, IntegrationInput } from "./integration";
|
import type { Integration, IntegrationInput } from "./integration";
|
||||||
|
|
||||||
export const createIntegrationAsync = async <TKind extends keyof typeof integrationCreators>(
|
export const createIntegrationAsync = async <TKind extends keyof typeof integrationCreators>(
|
||||||
@@ -101,6 +102,7 @@ export const integrationCreators = {
|
|||||||
ntfy: NTFYIntegration,
|
ntfy: NTFYIntegration,
|
||||||
mock: MockIntegration,
|
mock: MockIntegration,
|
||||||
truenas: TrueNasIntegration,
|
truenas: TrueNasIntegration,
|
||||||
|
unraid: UnraidIntegration,
|
||||||
} satisfies Record<IntegrationKind, IntegrationInstance | [(input: IntegrationInput) => Promise<Integration>]>;
|
} satisfies Record<IntegrationKind, IntegrationInstance | [(input: IntegrationInput) => Promise<Integration>]>;
|
||||||
|
|
||||||
type IntegrationInstanceOfKind<TKind extends keyof typeof integrationCreators> = {
|
type IntegrationInstanceOfKind<TKind extends keyof typeof integrationCreators> = {
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ export { PiHoleIntegrationV6 } from "./pi-hole/v6/pi-hole-integration-v6";
|
|||||||
export { PlexIntegration } from "./plex/plex-integration";
|
export { PlexIntegration } from "./plex/plex-integration";
|
||||||
export { ProwlarrIntegration } from "./prowlarr/prowlarr-integration";
|
export { ProwlarrIntegration } from "./prowlarr/prowlarr-integration";
|
||||||
export { TrueNasIntegration } from "./truenas/truenas-integration";
|
export { TrueNasIntegration } from "./truenas/truenas-integration";
|
||||||
|
export { UnraidIntegration } from "./unraid/unraid-integration";
|
||||||
export { OPNsenseIntegration } from "./opnsense/opnsense-integration";
|
export { OPNsenseIntegration } from "./opnsense/opnsense-integration";
|
||||||
export { ICalIntegration } from "./ical/ical-integration";
|
export { ICalIntegration } from "./ical/ical-integration";
|
||||||
|
|
||||||
|
|||||||
189
packages/integrations/src/unraid/unraid-integration.ts
Normal file
189
packages/integrations/src/unraid/unraid-integration.ts
Normal file
@@ -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<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;
|
||||||
|
|
||||||
|
// 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<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);
|
||||||
|
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 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
73
packages/integrations/src/unraid/unraid-types.ts
Normal file
73
packages/integrations/src/unraid/unraid-types.ts
Normal file
@@ -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>;
|
||||||
@@ -47,7 +47,7 @@ export const SystemResourceMemoryChart = ({
|
|||||||
return (
|
return (
|
||||||
<Paper px={3} py={2} withBorder shadow="md" radius="md">
|
<Paper px={3} py={2} withBorder shadow="md" radius="md">
|
||||||
<Text c="dimmed" size="xs">
|
<Text c="dimmed" size="xs">
|
||||||
{humanFileSize(value)} / {humanFileSize(totalCapacityInBytes)} (
|
{humanFileSize(Math.round(value))} / {humanFileSize(totalCapacityInBytes)} (
|
||||||
{Math.round((value / totalCapacityInBytes) * 100)}%)
|
{Math.round((value / totalCapacityInBytes) * 100)}%)
|
||||||
</Text>
|
</Text>
|
||||||
</Paper>
|
</Paper>
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ export default function SystemResources({ integrationIds, options }: WidgetCompo
|
|||||||
});
|
});
|
||||||
const memoryCapacityInBytes =
|
const memoryCapacityInBytes =
|
||||||
(data[0]?.healthInfo.memAvailableInBytes ?? 0) + (data[0]?.healthInfo.memUsedInBytes ?? 0);
|
(data[0]?.healthInfo.memAvailableInBytes ?? 0) + (data[0]?.healthInfo.memUsedInBytes ?? 0);
|
||||||
|
|
||||||
const [items, setItems] = useState<{ cpu: number; memory: number; network: { up: number; down: number } | null }[]>(
|
const [items, setItems] = useState<{ cpu: number; memory: number; network: { up: number; down: number } | null }[]>(
|
||||||
data.map((item) => ({
|
data.map((item) => ({
|
||||||
cpu: item.healthInfo.cpuUtilization,
|
cpu: item.healthInfo.cpuUtilization,
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ const labelDisplayModeOptions = {
|
|||||||
|
|
||||||
export const { definition, componentLoader } = createWidgetDefinition("systemResources", {
|
export const { definition, componentLoader } = createWidgetDefinition("systemResources", {
|
||||||
icon: IconGraphFilled,
|
icon: IconGraphFilled,
|
||||||
supportedIntegrations: ["dashDot", "openmediavault", "truenas"],
|
supportedIntegrations: ["dashDot", "openmediavault", "truenas", "unraid"],
|
||||||
createOptions() {
|
createOptions() {
|
||||||
return optionsBuilder.from((factory) => ({
|
return optionsBuilder.from((factory) => ({
|
||||||
hasShadow: factory.switch({ defaultValue: true }),
|
hasShadow: factory.switch({ defaultValue: true }),
|
||||||
|
|||||||
Reference in New Issue
Block a user