diff --git a/packages/definitions/src/widget.ts b/packages/definitions/src/widget.ts index ec0d2ff66..55719026c 100644 --- a/packages/definitions/src/widget.ts +++ b/packages/definitions/src/widget.ts @@ -29,5 +29,6 @@ export const widgetKinds = [ "firewall", "notifications", "systemResources", + "systemDisks", ] as const; export type WidgetKind = (typeof widgetKinds)[number]; diff --git a/packages/integrations/src/dashdot/dashdot-integration.ts b/packages/integrations/src/dashdot/dashdot-integration.ts index 2efb47c16..fb5d9070e 100644 --- a/packages/integrations/src/dashdot/dashdot-integration.ts +++ b/packages/integrations/src/dashdot/dashdot-integration.ts @@ -55,7 +55,7 @@ export class DashDotIntegration extends Integration implements ISystemHealthMoni cpuTemp: cpuLoad.averageTemperature, availablePkgUpdates: 0, rebootRequired: false, - smart: [], + smart: [], // API endpoint does not provide S.M.A.R.T data. uptime: info.uptime, version: `${info.operatingSystemVersion}`, loadAverage: { diff --git a/packages/integrations/src/interfaces/health-monitoring/health-monitoring-types.ts b/packages/integrations/src/interfaces/health-monitoring/health-monitoring-types.ts index 49d659fe1..08b5b4100 100644 --- a/packages/integrations/src/interfaces/health-monitoring/health-monitoring-types.ts +++ b/packages/integrations/src/interfaces/health-monitoring/health-monitoring-types.ts @@ -29,6 +29,7 @@ export interface SystemHealthMonitoring { deviceName: string; temperature: number | null; overallStatus: string; + healthy: boolean; }[]; } diff --git a/packages/integrations/src/mock/data/system-health-monitoring.ts b/packages/integrations/src/mock/data/system-health-monitoring.ts index ebc2ca010..bafccd7c5 100644 --- a/packages/integrations/src/mock/data/system-health-monitoring.ts +++ b/packages/integrations/src/mock/data/system-health-monitoring.ts @@ -33,6 +33,7 @@ export class SystemHealthMonitoringMockService implements ISystemHealthMonitorin deviceName: "Mock Device", temperature: Math.floor(Math.random() * 100), // Random temperature between 0 and 99 overallStatus: "OK", + healthy: true, }, ], }); diff --git a/packages/integrations/src/openmediavault/openmediavault-integration.ts b/packages/integrations/src/openmediavault/openmediavault-integration.ts index 128e68a43..6449ee48c 100644 --- a/packages/integrations/src/openmediavault/openmediavault-integration.ts +++ b/packages/integrations/src/openmediavault/openmediavault-integration.ts @@ -62,6 +62,8 @@ export class OpenMediaVaultIntegration extends Integration implements ISystemHea const smart = smartResult.data.response.map((smart) => ({ deviceName: smart.devicename, temperature: smart.temperature, + // https://github.com/openmediavault/openmediavault/blob/2eb871d05cec6ff49603134d3acead561da01e4d/deb/openmediavault/usr/share/php/openmediavault/system/storage/smartinformation.inc#L592-L605 + healthy: smart.overallstatus === "GOOD", overallStatus: smart.overallstatus, })); diff --git a/packages/integrations/src/truenas/truenas-integration.ts b/packages/integrations/src/truenas/truenas-integration.ts index 8b0a79b89..4ff38d819 100644 --- a/packages/integrations/src/truenas/truenas-integration.ts +++ b/packages/integrations/src/truenas/truenas-integration.ts @@ -112,6 +112,27 @@ export class TrueNasIntegration extends Integration implements ISystemHealthMoni }); } + private async getPoolsAsync() { + logger.debug("Retrieving pools", { + url: this.wsUrl(), + }); + + const response = await this.requestAsync("pool.query", [ + [], + { + extra: { + is_upgraded: true, + }, + }, + ]); + const result = await poolSchema.parseAsync(response); + logger.debug("Retrieved pools", { + url: this.wsUrl(), + count: result.length, + }); + return result; + } + /** * Retrieves data using the reporting method * @see https://www.truenas.com/docs/api/scale_websocket_api.html#reporting @@ -225,6 +246,7 @@ export class TrueNasIntegration extends Integration implements ISystemHealthMoni const cpuData = this.extractLatestReportingData(reporting, "cpu"); const cpuTempData = this.extractLatestReportingData(reporting, "cputemp"); const memoryData = this.extractLatestReportingData(reporting, "memory"); + const datasets = await this.getPoolsAsync(); const netdata = await this.getReportingNetdataAsync(); @@ -236,14 +258,24 @@ export class TrueNasIntegration extends Integration implements ISystemHealthMoni cpuTemp: Math.max(...cpuTempData.filter((_item, i) => i > 0)), memAvailableInBytes: systemInformation.physmem, memUsedInBytes: memoryData[1] ?? 0, // Index 0 is UNIX timestamp, Index 1 is free space in bytes - fileSystem: [], + fileSystem: datasets.map((dataset) => ({ + deviceName: dataset.name, + available: `${dataset.size}`, // TODO: can we use number instead of string here? + used: `${dataset.allocated}`, + percentage: (dataset.allocated / dataset.size) * 100, + })), availablePkgUpdates: 0, network: { up: upload * NETWORK_MULTIPLIER, down: download * NETWORK_MULTIPLIER, }, loadAverage: null, - smart: [], + smart: datasets.map((dataset) => ({ + deviceName: dataset.name, + healthy: dataset.healthy, + overallStatus: dataset.status, + temperature: null, + })), uptime: systemInformation.uptime_seconds, version: systemInformation.version, cpuModelName: systemInformation.model, @@ -351,6 +383,17 @@ const reportingItemSchema = z.object({ type ReportingItem = z.infer; +const poolSchema = z.array( + z.object({ + name: z.string(), + status: z.string(), + healthy: z.boolean(), + free: z.number().min(0), + size: z.number(), + allocated: z.number(), + }), +); + const reportingNetDataSchema = z.array( z.object({ name: z.string(), diff --git a/packages/integrations/src/unraid/unraid-integration.ts b/packages/integrations/src/unraid/unraid-integration.ts index db4025ee2..577d34a4a 100644 --- a/packages/integrations/src/unraid/unraid-integration.ts +++ b/packages/integrations/src/unraid/unraid-integration.ts @@ -67,6 +67,8 @@ export class UnraidIntegration extends Integration implements ISystemHealthMonit deviceName: disk.name, temperature: disk.temp ?? null, overallStatus: disk.status, + // See ArrayDiskStatus from https://studio.apollographql.com/public/Unraid-API/variant/current/explorer + healthy: disk.status === "DISK_OK", })), }; } diff --git a/packages/translation/src/lang/en.json b/packages/translation/src/lang/en.json index 2d9d69546..92f8ebc70 100644 --- a/packages/translation/src/lang/en.json +++ b/packages/translation/src/lang/en.json @@ -2608,6 +2608,35 @@ "up": "UP", "down": "DOWN" } + }, + "systemDisks": { + "name": "System disks", + "description": "Disk usage of your system", + "option": { + "showTemperatureIfAvailable": { + "label": "Show temperature if available" + }, + "displayMode": { + "label": "Display mode", + "option": { + "percentage": { + "label": "Percentage (e.g., 76%)" + }, + "absolute": { + "label": "Absolute values (e.g., 800GB / 1TB)" + }, + "free": { + "label": "Free space percentage (e.g., 24% free)" + } + } + }, + "showBackgroundBar": { + "label": "Show background usage bar" + } + }, + "status": { + "unhealthy": "Unhealthy" + } } }, "widgetPreview": { diff --git a/packages/widgets/src/index.tsx b/packages/widgets/src/index.tsx index 677b801f2..d13888c6a 100644 --- a/packages/widgets/src/index.tsx +++ b/packages/widgets/src/index.tsx @@ -37,6 +37,7 @@ import * as rssFeed from "./rssFeed"; import * as smartHomeEntityState from "./smart-home/entity-state"; import * as smartHomeExecuteAutomation from "./smart-home/execute-automation"; import * as stockPrice from "./stocks"; +import * as systemDisks from "./system-disks"; import * as systemResources from "./system-resources"; import * as video from "./video"; import * as weather from "./weather"; @@ -75,6 +76,7 @@ export const widgetImports = { notifications, mediaReleases, systemResources, + systemDisks, } satisfies WidgetImportRecord; export type WidgetImports = typeof widgetImports; diff --git a/packages/widgets/src/system-disks/component.tsx b/packages/widgets/src/system-disks/component.tsx new file mode 100644 index 000000000..eab43f768 --- /dev/null +++ b/packages/widgets/src/system-disks/component.tsx @@ -0,0 +1,100 @@ +"use client"; + +import { Box, Card, Group, Stack, useMantineColorScheme } from "@mantine/core"; + +import { clientApi } from "@homarr/api/client"; +import { useRequiredBoard } from "@homarr/boards/context"; +import { useI18n } from "@homarr/translation/client"; + +import type { WidgetComponentProps } from "../definition"; +import { NoIntegrationDataError } from "../errors/no-data-integration"; + +type DisplayMode = WidgetComponentProps<"systemDisks">["options"]["displayMode"]; + +const getDisplayText = (item: { used: string; available: string; percentage: number }, displayMode: DisplayMode) => { + switch (displayMode) { + case "percentage": + return `${Math.round(item.percentage)}%`; + case "absolute": + return `${item.used} / ${item.available}`; + case "free": + return `${Math.round(100 - item.percentage)}% free`; + default: + return `${Math.round(item.percentage)}%`; + } +}; + +export default function SystemResources({ integrationIds, options }: WidgetComponentProps<"systemDisks">) { + const queryInput = { integrationIds }; + const [data] = clientApi.widget.healthMonitoring.getSystemHealthStatus.useSuspenseQuery(queryInput); + const utils = clientApi.useUtils(); + + const board = useRequiredBoard(); + const scheme = useMantineColorScheme(); + const t = useI18n(); + + const lastItem = data.at(-1); + + if (!lastItem) return null; + const { fileSystem, smart } = lastItem.healthInfo; + + clientApi.widget.healthMonitoring.subscribeSystemHealthStatus.useSubscription(queryInput, { + onData(data) { + utils.widget.healthMonitoring.getSystemHealthStatus.setData(queryInput, (oldData) => + oldData?.map((item) => (item.integrationId === data.integrationId ? { ...item, ...data } : item)), + ); + }, + }); + + if (fileSystem.length === 0) { + throw new NoIntegrationDataError(); + } + + return ( + + {fileSystem.map((item) => { + const smartItem = smart.find((smart) => smart.deviceName === item.deviceName); + const healthy = smartItem?.healthy ?? true; // fall back to healthy if no information is available + + return ( + + +
+

+ {item.deviceName} +

+

+ {getDisplayText(item, options.displayMode)} + {!healthy && {t("widget.systemDisks.status.unhealthy")}} +

+
+
+ {smartItem?.temperature && options.showTemperatureIfAvailable ? ( +

{smartItem.temperature}°C

+ ) : null} +
+
+ +
+ ); + })} +
+ ); +} diff --git a/packages/widgets/src/system-disks/index.ts b/packages/widgets/src/system-disks/index.ts new file mode 100644 index 000000000..99ea9a70a --- /dev/null +++ b/packages/widgets/src/system-disks/index.ts @@ -0,0 +1,22 @@ +import { IconServer2 } from "@tabler/icons-react"; + +import { createWidgetDefinition } from "../definition"; +import { optionsBuilder } from "../options"; + +export const { definition, componentLoader } = createWidgetDefinition("systemDisks", { + icon: IconServer2, + supportedIntegrations: ["dashDot", "openmediavault", "truenas", "unraid"], + createOptions() { + return optionsBuilder.from((factory) => ({ + showTemperatureIfAvailable: factory.switch({ defaultValue: true }), + displayMode: factory.select({ + options: (["percentage", "absolute", "free"] as const).map((value) => ({ + value, + label: (t) => t(`widget.systemDisks.option.displayMode.option.${value}.label`), + })), + defaultValue: "percentage", + }), + showBackgroundBar: factory.switch({ defaultValue: true }), + })); + }, +}).withDynamicImport(() => import("./component"));