diff --git a/docs/widgets/services/index.md b/docs/widgets/services/index.md
index efac65070..221fe47b2 100644
--- a/docs/widgets/services/index.md
+++ b/docs/widgets/services/index.md
@@ -139,6 +139,7 @@ You can also find a list of all available service widgets in the sidebar navigat
- [TubeArchivist](tubearchivist.md)
- [UniFi Controller](unifi-controller.md)
- [Unmanic](unmanic.md)
+- [Unraid](unraid.md)
- [Uptime Kuma](uptime-kuma.md)
- [UptimeRobot](uptimerobot.md)
- [UrBackup](urbackup.md)
diff --git a/docs/widgets/services/unraid.md b/docs/widgets/services/unraid.md
new file mode 100644
index 000000000..2a061cf25
--- /dev/null
+++ b/docs/widgets/services/unraid.md
@@ -0,0 +1,28 @@
+---
+title: Unraid
+description: Unraid Widget Configuration
+---
+
+Learn more about [Unraid](https://unraid.net/).
+
+The Unraid widget allows you to monitor the resources of an Unraid server.
+
+**Minimum Requirements:**
+
+- Unraid 7.2 -or- Unraid Connect plugin 2025.08.19.1850
+- API key with the **GUEST** (read only) role: [Managing API Keys](https://docs.unraid.net/go/managing-api-keys)
+
+The widget can display metrics for selected Unraid pools. If using one of the "pool" fields, you must also add the pool name to the settings.
+
+**Allowed fields:** `["cpu","memoryPercent","memoryAvailable","memoryUsed","notifications","arrayFreeSpace","arrayUsedSpace","arrayUsedPercent","status","pool1UsedSpace","pool1FreeSpace","pool1UsedPercent","pool2UsedSpace","pool2FreeSpace","pool2UsedPercent","pool3UsedSpace","pool3FreeSpace","pool3UsedPercent","pool4UsedSpace","pool4FreeSpace","pool4UsedPercent"]`
+
+```yaml
+widget:
+ type: unraid
+ url: https://unraid.host.or.ip
+ key: api-key
+ pool1: pool1name # required only if using pool1 fields
+ pool2: pool2name # required only if using pool2 fields
+ pool3: pool3name # required only if using pool3 fields
+ pool4: pool4name # required only if using pool4 fields
+```
diff --git a/mkdocs.yml b/mkdocs.yml
index a0c01a40a..720a7bd49 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -165,6 +165,7 @@ nav:
- widgets/services/tubearchivist.md
- widgets/services/unifi-controller.md
- widgets/services/unmanic.md
+ - widgets/services/unraid.md
- widgets/services/uptime-kuma.md
- widgets/services/uptimerobot.md
- widgets/services/urbackup.md
diff --git a/public/locales/en/common.json b/public/locales/en/common.json
index f37329e46..ae524b758 100644
--- a/public/locales/en/common.json
+++ b/public/locales/en/common.json
@@ -1083,5 +1083,27 @@
"nextMonthlyCost": "Next Month",
"previousMonthlyCost": "Prev. Month",
"nextRenewingSubscription": "Next Payment"
+ },
+ "unraid": {
+ "STARTED": "Started",
+ "STOPPED": "Stopped",
+ "NEW_ARRAY": "New Array",
+ "RECON_DISK": "Reconstructing Disk",
+ "DISABLE_DISK": "Disk Disabled",
+ "SWAP_DSBL": "Swap Disable",
+ "INVALID_EXPANSION": "Invalid Expansion",
+ "PARITY_NOT_BIGGEST": "Parity Not Biggest",
+ "TOO_MANY_MISSING_DISKS": "Too Many Missing Disks",
+ "NEW_DISK_TOO_SMALL": "New Disk Too Small",
+ "NO_DATA_DISKS": "No Data Disks",
+ "notifications": "Notifications",
+ "status": "Status",
+ "cpu": "CPU",
+ "memoryUsed": "Memory Used",
+ "memoryAvailable": "Memory Available",
+ "arrayUsed": "Array Used",
+ "arrayFree": "Array Free",
+ "poolUsed": "{{pool}} Used",
+ "poolFree": "{{pool}} Free"
}
}
diff --git a/src/components/services/widget/container.jsx b/src/components/services/widget/container.jsx
index f59573829..6458e5601 100644
--- a/src/components/services/widget/container.jsx
+++ b/src/components/services/widget/container.jsx
@@ -37,12 +37,12 @@ export default function Container({ error = false, children, service }) {
if (!field.includes(".")) {
fullField = `${type}.${field}`;
}
- let matches = fullField === child?.props?.label;
+ let matches = fullField === (child?.props?.field || child?.props?.label);
// check if the field is an 'alias'
if (matches) {
return true;
} else if (ALIASED_WIDGETS[type]) {
- matches = fullField.replace(type, ALIASED_WIDGETS[type]) === child?.props?.label;
+ matches = fullField.replace(type, ALIASED_WIDGETS[type]) === (child?.props?.field || child?.props?.label);
return matches;
}
diff --git a/src/utils/config/service-helpers.js b/src/utils/config/service-helpers.js
index 18cbe23b6..9cdff1da0 100644
--- a/src/utils/config/service-helpers.js
+++ b/src/utils/config/service-helpers.js
@@ -396,6 +396,12 @@ export function cleanServiceGroups(groups) {
// unifi
site,
+ // unraid
+ pool1,
+ pool2,
+ pool3,
+ pool4,
+
// vikunja
enableTaskList,
@@ -611,6 +617,12 @@ export function cleanServiceGroups(groups) {
if (type === "grafana") {
if (alerts) widget.alerts = alerts;
}
+ if (type === "unraid") {
+ if (pool1) widget.pool1 = pool1;
+ if (pool2) widget.pool2 = pool2;
+ if (pool3) widget.pool3 = pool3;
+ if (pool4) widget.pool4 = pool4;
+ }
return widget;
});
return cleanedService;
diff --git a/src/widgets/components.js b/src/widgets/components.js
index 9cb02c2af..490e0032a 100644
--- a/src/widgets/components.js
+++ b/src/widgets/components.js
@@ -139,6 +139,7 @@ const components = {
truenas: dynamic(() => import("./truenas/component")),
unifi: dynamic(() => import("./unifi/component")),
unmanic: dynamic(() => import("./unmanic/component")),
+ unraid: dynamic(() => import("./unraid/component")),
uptimekuma: dynamic(() => import("./uptimekuma/component")),
uptimerobot: dynamic(() => import("./uptimerobot/component")),
urbackup: dynamic(() => import("./urbackup/component")),
diff --git a/src/widgets/unraid/component.jsx b/src/widgets/unraid/component.jsx
new file mode 100644
index 000000000..f7b8dc5ca
--- /dev/null
+++ b/src/widgets/unraid/component.jsx
@@ -0,0 +1,93 @@
+import Block from "components/services/widget/block";
+import Container from "components/services/widget/container";
+import { useTranslation } from "next-i18next";
+
+import useWidgetAPI from "utils/proxy/use-widget-api";
+
+const UNRAID_DEFAULT_FIELDS = ["status", "cpu", "memoryPercent", "notifications"];
+const MAX_ALLOWED_FIELDS = 4;
+
+const POOLS = ["pool1", "pool2", "pool3", "pool4"];
+const POOL_FIELDS = [
+ { param: "UsedSpace", label: "poolUsed", valueKey: "fsUsed", valueType: "common.bytes" },
+ { param: "FreeSpace", label: "poolFree", valueKey: "fsFree", valueType: "common.bytes" },
+ { param: "UsedPercent", label: "poolUsed", valueKey: "fsUsedPercent", valueType: "common.percent" },
+];
+
+export default function Component({ service }) {
+ const { t } = useTranslation();
+ const { widget } = service;
+
+ const { data, error } = useWidgetAPI(widget);
+
+ if (error) {
+ return ;
+ }
+
+ if (!widget.fields?.length) {
+ widget.fields = UNRAID_DEFAULT_FIELDS;
+ } else if (widget.fields.length > MAX_ALLOWED_FIELDS) {
+ widget.fields = widget.fields.slice(0, MAX_ALLOWED_FIELDS);
+ }
+
+ if (!data) {
+ return (
+
+
+
+
+
+
+
+
+
+
+ {...POOLS.flatMap((pool) =>
+ POOL_FIELDS.map(({ param, label }) => (
+
+ )),
+ )}
+
+ );
+ }
+
+ return (
+
+
+
+
+
+
+
+
+
+
+ {...POOLS.flatMap((pool) =>
+ POOL_FIELDS.map(({ param, label, valueKey, valueType }) => (
+
+ )),
+ )}
+
+ );
+}
diff --git a/src/widgets/unraid/proxy.js b/src/widgets/unraid/proxy.js
new file mode 100644
index 000000000..489bc8d63
--- /dev/null
+++ b/src/widgets/unraid/proxy.js
@@ -0,0 +1,138 @@
+import getServiceWidget from "utils/config/service-helpers";
+import createLogger from "utils/logger";
+import { asJson } from "utils/proxy/api-helpers";
+import { httpProxy } from "utils/proxy/http";
+
+const logger = createLogger("unraidProxyHandler");
+
+const graphqlQuery = `
+{
+ array {
+ state
+ capacity {
+ kilobytes {
+ free
+ total
+ used
+ }
+ }
+ caches {
+ name
+ fsType
+ fsSize
+ fsFree
+ fsUsed
+ }
+ }
+ metrics {
+ memory {
+ active
+ available
+ percentTotal
+ }
+ cpu {
+ percentTotal
+ }
+ }
+ notifications {
+ overview {
+ unread {
+ total
+ }
+ }
+ }
+}
+`;
+
+function processUnraidResponse(data) {
+ const response = {};
+
+ try {
+ data = asJson(data)?.data;
+
+ response["memoryUsedPercent"] = data?.metrics?.memory?.percentTotal ?? null;
+ response["memoryUsed"] = data?.metrics?.memory?.active ?? null;
+ response["memoryAvailable"] = data?.metrics?.memory?.available ?? null;
+ response["cpuPercent"] = data?.metrics?.cpu?.percentTotal ?? null;
+ response["unreadNotifications"] = data?.notifications?.overview?.unread?.total ?? null;
+ response["arrayState"] = data?.array?.state ?? null;
+ response["arrayFree"] = data?.array?.capacity?.kilobytes?.free * 1000 ?? null;
+ response["arrayUsed"] = data?.array?.capacity?.kilobytes?.used * 1000 ?? null;
+ response["arrayUsedPercent"] =
+ (data?.array?.capacity?.kilobytes?.used / data?.array?.capacity?.kilobytes?.total) * 100 ?? null;
+
+ response["caches"] = {};
+ if (data?.array?.caches) {
+ data.array.caches.forEach((cache) => {
+ if (cache.fsType) {
+ response.caches[cache.name] = {
+ fsFree: cache.fsFree * 1000,
+ fsUsed: cache.fsUsed * 1000,
+ fsUsedPercent: (cache.fsUsed / cache.fsSize) * 100 ?? null,
+ };
+ }
+ });
+ }
+ } catch (error) {
+ return { error: error.message };
+ }
+
+ return response;
+}
+
+export default async function unraidProxyHandler(req, res) {
+ const { group, service, index } = req.query;
+
+ if (!group || !service) {
+ logger.debug("Invalid or missing service '%s' or group '%s'", service, group);
+ return res.status(400).json({ error: "Invalid proxy service type" });
+ }
+
+ const widget = await getServiceWidget(group, service, index);
+ if (!widget) {
+ logger.debug("Invalid or missing widget for service '%s' in group '%s'", service, group);
+ return res.status(400).json({ error: "Invalid proxy service type" });
+ }
+
+ const url = new URL(widget.url + "/graphql");
+
+ const headers = {
+ "Content-Type": "application/json",
+ Accept: `application/json`,
+ "X-API-Key": `${widget.key}`,
+ };
+
+ const params = {
+ method: "POST",
+ headers,
+ };
+ params.body = JSON.stringify({
+ query: graphqlQuery,
+ });
+
+ const [status, , data] = await httpProxy(url, params);
+
+ if (status === 204 || status === 304) {
+ return res.status(status).end();
+ }
+
+ if (status !== 200) {
+ logger.error(
+ "Error getting data from Unraid for service '%s' in group '%s': %d. Data: %s",
+ service,
+ group,
+ status,
+ data,
+ );
+ return res.status(status).send({ error: { message: "Error calling Unraid API.", data } });
+ }
+
+ const result = processUnraidResponse(data);
+ if (result.error) {
+ logger.error("Error processing Unraid data: %s", result.error);
+ return res.status(500).json({ error: result.error });
+ }
+
+ res.setHeader("Content-Type", "application/json");
+ return res.status(status).send(result);
+}
diff --git a/src/widgets/unraid/widget.js b/src/widgets/unraid/widget.js
new file mode 100644
index 000000000..cebcbd60b
--- /dev/null
+++ b/src/widgets/unraid/widget.js
@@ -0,0 +1,7 @@
+import unraidProxyHandler from "./proxy";
+
+const widget = {
+ proxyHandler: unraidProxyHandler,
+};
+
+export default widget;
diff --git a/src/widgets/widgets.js b/src/widgets/widgets.js
index cfd28c590..25f2007a5 100644
--- a/src/widgets/widgets.js
+++ b/src/widgets/widgets.js
@@ -130,6 +130,7 @@ import truenas from "./truenas/widget";
import tubearchivist from "./tubearchivist/widget";
import unifi from "./unifi/widget";
import unmanic from "./unmanic/widget";
+import unraid from "./unraid/widget";
import uptimekuma from "./uptimekuma/widget";
import uptimerobot from "./uptimerobot/widget";
import urbackup from "./urbackup/widget";
@@ -278,6 +279,7 @@ const widgets = {
unifi,
unifi_console: unifi,
unmanic,
+ unraid,
uptimekuma,
uptimerobot,
urbackup,