diff --git a/packages/definitions/src/widget.ts b/packages/definitions/src/widget.ts index ec0d2ff665..f73222c8a4 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 bf58feb40b..a3f20f1548 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 49d659fe14..73ed919fc8 100644 --- a/packages/integrations/src/interfaces/health-monitoring/health-monitoring-types.ts +++ b/packages/integrations/src/interfaces/health-monitoring/health-monitoring-types.ts @@ -28,7 +28,7 @@ export interface SystemHealthMonitoring { smart: { deviceName: string; temperature: number | null; - overallStatus: string; + healthy: boolean; }[]; } diff --git a/packages/integrations/src/truenas/truenas-integration.ts b/packages/integrations/src/truenas/truenas-integration.ts index c31b873ba3..52eb8b7675 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() { + localLogger.debug("Retrieving pools", { + url: this.wsUrl(), + }); + + const response = await this.requestAsync("pool.query", [ + [], + { + extra: { + is_upgraded: true, + }, + }, + ]); + const result = await poolSchema.parseAsync(response); + localLogger.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,23 @@ 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, + temperature: null, + })), uptime: systemInformation.uptime_seconds, version: systemInformation.version, cpuModelName: systemInformation.model, @@ -351,6 +382,14 @@ const reportingItemSchema = z.object({ type ReportingItem = z.infer; +const poolSchema = z.array(z.object({ + name: 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/translation/src/lang/en.json b/packages/translation/src/lang/en.json index deb14ac2d4..705cf823e8 100644 --- a/packages/translation/src/lang/en.json +++ b/packages/translation/src/lang/en.json @@ -2591,6 +2591,15 @@ "up": "UP", "down": "DOWN" } + }, + "systemDisks": { + "name": "System disks", + "description": "Disk usage of your system", + "option": { + "showTemperatureIfAvailable": { + "label": "Show temperature if available" + } + } } }, "widgetPreview": { diff --git a/packages/widgets/src/index.tsx b/packages/widgets/src/index.tsx index a4e7416494..96e1bc3a53 100644 --- a/packages/widgets/src/index.tsx +++ b/packages/widgets/src/index.tsx @@ -38,6 +38,7 @@ import * as smartHomeEntityState from "./smart-home/entity-state"; import * as smartHomeExecuteAutomation from "./smart-home/execute-automation"; import * as stockPrice from "./stocks"; import * as systemResources from "./system-resources"; +import * as systemDisks from "./system-disks"; 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 0000000000..705da39450 --- /dev/null +++ b/packages/widgets/src/system-disks/component.tsx @@ -0,0 +1,89 @@ +"use client"; + +import { useState } from "react"; +import { Box, Card, Center, Group, Stack, useMantineColorScheme } from "@mantine/core"; + +import { clientApi } from "@homarr/api/client"; +import { useRequiredBoard } from "@homarr/boards/context"; + +import type { WidgetComponentProps } from "../definition"; +import { NoIntegrationDataError } from "../errors/no-data-integration"; + +export default function SystemResources({ integrationIds, options }: WidgetComponentProps<"systemDisks">) { + const [data] = clientApi.widget.healthMonitoring.getSystemHealthStatus.useSuspenseQuery({ + integrationIds, + }); + + const board = useRequiredBoard(); + const scheme = useMantineColorScheme(); + + const lastItem = data.at(-1); + + console.log("last item", lastItem); + + if (!lastItem) return null; + + const [disks, setDisks] = useState<{ + fileSystem: { deviceName: string; used: string; available: string; percentage: number }[]; + smart: { deviceName: string; temperature: number | null; healthy: boolean }[]; + }>({ + fileSystem: lastItem.healthInfo.fileSystem, + smart: lastItem.healthInfo.smart, + }); + + clientApi.widget.healthMonitoring.subscribeSystemHealthStatus.useSubscription( + { + integrationIds, + }, + { + onData(data) { + setDisks({ + fileSystem: data.healthInfo.fileSystem, + smart: data.healthInfo.smart, + }); + }, + }, + ); + + if (disks.fileSystem.length === 0) { + throw new NoIntegrationDataError(); + } + + return ( + + {disks.fileSystem.map((item, index) => { + const smart = disks.smart.find((smart) => smart.deviceName === item.deviceName); + const healthy = smart?.healthy ?? true; // fall back to healthy if no information is available + + return ( + + +
+

+ {item.deviceName} +

+

+ {Math.round(item.percentage)}% + {!healthy && Unhealthy} +

+
+
+ {smart?.temperature && options.showTemperatureIfAvailable &&

{smart.temperature}°C

} +
+
+ +
+ ); + })} +
+ ); +} diff --git a/packages/widgets/src/system-disks/index.ts b/packages/widgets/src/system-disks/index.ts new file mode 100644 index 0000000000..f11e6dd46a --- /dev/null +++ b/packages/widgets/src/system-disks/index.ts @@ -0,0 +1,14 @@ +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 }), + })); + }, +}).withDynamicImport(() => import("./component"));