feat(widgets): add disks widget (#4445)

Co-authored-by: ajnart <49837342+ajnart@users.noreply.github.com>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com>
Co-authored-by: Meier Lukas <meierschlumpf@gmail.com>
This commit is contained in:
authored and GitHub committed 2026-01-23 16:23:42 +01:00
1 parent 15119b66e9
commit 62caa71a48
11 files changed
+206 -3

No files matched your search

+1
View File
@@ -29,5 +29,6 @@ export const widgetKinds = [
"firewall",
"notifications",
"systemResources",
"systemDisks",
] as const;
export type WidgetKind = (typeof widgetKinds)[number];
@@ -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: {
@@ -29,6 +29,7 @@ export interface SystemHealthMonitoring {
deviceName: string;
temperature: number | null;
overallStatus: string;
healthy: boolean;
}[];
}
@@ -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,
},
],
});
@@ -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,
}));
@@ -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<typeof reportingItemSchema>;
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(),
@@ -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",
})),
};
}
+29
View File
@@ -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": {
+2
View File
@@ -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;
@@ -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 (
<Stack gap="xs" p="xs" h="100%">
{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 (
<Card
radius={board.itemRadius}
py={"xs"}
bg={scheme.colorScheme === "dark" ? "dark.7" : "gray.1"}
key={`disk-${item.deviceName}`}
style={{ overflow: "hidden", position: "relative" }}
>
<Group justify="space-between" style={{ zIndex: 1 }}>
<div>
<p style={{ margin: 0 }}>
<b>{item.deviceName}</b>
</p>
<p style={{ margin: 0 }}>
<span>{getDisplayText(item, options.displayMode)}</span>
{!healthy && <span style={{ marginLeft: 5 }}>{t("widget.systemDisks.status.unhealthy")}</span>}
</p>
</div>
<div>
{smartItem?.temperature && options.showTemperatureIfAvailable ? (
<p style={{ margin: 0 }}>{smartItem.temperature}°C</p>
) : null}
</div>
</Group>
<Box
bg={healthy ? "green" : "red"}
style={{
position: "absolute",
top: 0,
left: 0,
width: `${item.percentage}%`,
height: "100%",
zIndex: 0,
display: options.showBackgroundBar ? "block" : "none",
}}
></Box>
</Card>
);
})}
</Stack>
);
}
@@ -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"));