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,