diff --git a/docs/widgets/services/index.md b/docs/widgets/services/index.md
index 894a31f6..9e2019d3 100644
--- a/docs/widgets/services/index.md
+++ b/docs/widgets/services/index.md
@@ -108,6 +108,7 @@ You can also find a list of all available service widgets in the sidebar navigat
- [PyLoad](pyload.md)
- [qBittorrent](qbittorrent.md)
- [QNAP](qnap.md)
+- [RackNerd](racknerd.md)
- [Radarr](radarr.md)
- [Readarr](readarr.md)
- [ROMM](romm.md)
diff --git a/docs/widgets/services/racknerd.md b/docs/widgets/services/racknerd.md
new file mode 100644
index 00000000..772887c2
--- /dev/null
+++ b/docs/widgets/services/racknerd.md
@@ -0,0 +1,22 @@
+---
+title: RackNerd
+description: RackNerd Widget Configuration
+---
+
+Learn more about [RackNerd](https://racknerd.com).
+
+Use key & hash. Information about the key & hash can be found under the [VPS](https://nerdvm.racknerd.com) control panel in the API section.
+
+Allowed fields: `["ipAddress", "hddtotal", "bandwidthfree", "bandwidthused"]`.
+
+Note `"memoryusage"` is deprecated as v1 of their API result will be always be 0.
+Note `"status"` is not fully implemented.
+
+Note hard drive free/used/percentage isn't functioning in v1 of their API result.
+```yaml
+widget:
+ type: racknerd
+ url: https://nerdvm.racknerd.com
+ key: token
+ hash: token
+```
diff --git a/mkdocs.yml b/mkdocs.yml
index fa2188ad..55eb0f0b 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -131,6 +131,7 @@ nav:
- widgets/services/pyload.md
- widgets/services/qbittorrent.md
- widgets/services/qnap.md
+ - widgets/services/racknerd.md
- widgets/services/radarr.md
- widgets/services/readarr.md
- widgets/services/romm.md
diff --git a/public/locales/en/common.json b/public/locales/en/common.json
index 484f76b5..6a6a1556 100644
--- a/public/locales/en/common.json
+++ b/public/locales/en/common.json
@@ -716,6 +716,14 @@
"numfiles": "Files",
"numshares": "Shared Items"
},
+ "racknerd": {
+ "ipAddress": "IP Address",
+ "memoryusage": "Memory Usage",
+ "hddtotal": "Total Space",
+ "bandwidthtotal": "Bandwidth Total",
+ "bandwidthused": "Bandwidth Used",
+ "bandwidthfree": "Bandwidth Free"
+ },
"kopia": {
"status": "Status",
"size": "Size",
diff --git a/src/widgets/components.js b/src/widgets/components.js
index 19f41d4a..bc5a8a46 100644
--- a/src/widgets/components.js
+++ b/src/widgets/components.js
@@ -104,6 +104,7 @@ const components = {
pyload: dynamic(() => import("./pyload/component")),
qbittorrent: dynamic(() => import("./qbittorrent/component")),
qnap: dynamic(() => import("./qnap/component")),
+ racknerd: dynamic(() => import("./racknerd/component")),
radarr: dynamic(() => import("./radarr/component")),
readarr: dynamic(() => import("./readarr/component")),
romm: dynamic(() => import("./romm/component")),
diff --git a/src/widgets/racknerd/component.jsx b/src/widgets/racknerd/component.jsx
new file mode 100644
index 00000000..6b945356
--- /dev/null
+++ b/src/widgets/racknerd/component.jsx
@@ -0,0 +1,71 @@
+import { useTranslation } from "next-i18next";
+import { useMemo } from "react";
+
+import Container from "components/services/widget/container";
+import Block from "components/services/widget/block";
+import useWidgetAPI from "utils/proxy/use-widget-api";
+
+export const racknerdDefaultFields = ["ipAddress", "hddtotal", "bandwidthusage"];
+
+export default function Component({ service }) {
+ const { t } = useTranslation();
+
+ const { widget } = service;
+ const params = {
+ key: widget.key,
+ hash: widget.hash,
+ };
+ const { data: racknerdData, error: racknerdError } = useWidgetAPI(widget, "serverinfo", {...params, action: 'info'});
+ // Support for fields (harddriveusage, memoryusage, bandwidthusage)
+ const [showIpAddress, showMemoryUsage, showHardDriveUsage, showBandwidthUsed, showBandwidthFree] = useMemo(() => {
+ // Default values if fields is not set
+ if (!widget.fields) return [true, false, true, true, true];
+
+ const hasIpAddress = widget.fields?.includes("ipAddress") || false;
+ const hasMemoryUsage = widget.fields?.includes("memoryusage") || false;
+ const hasHardDriveUsage = widget.fields?.includes("hddtotal") || false;
+ const hasBandwidthUsed = widget.fields?.includes("bandwidthused") || false;
+ const hasBandwidthFree = widget.fields?.includes("bandwidthfree") || false;
+ return [hasIpAddress, hasMemoryUsage, hasHardDriveUsage, hasBandwidthUsed, hasBandwidthFree];
+ }, [widget.fields]);
+ if (racknerdError) {
+ return ;
+ }
+
+ if (!racknerdData) {
+ return (
+
+ {showIpAddress && }
+ {showMemoryUsage && }
+ {showHardDriveUsage && }
+ {showBandwidthUsed && }
+ {showBandwidthFree && }
+
+ );
+ }
+ const { racknerd: racknerdInfo } = racknerdData;
+ return (
+
+ {showIpAddress && ()}
+ {showMemoryUsage && ()}
+ {showHardDriveUsage && ()}
+ {showBandwidthUsed && ()}
+ {showBandwidthFree && ()}
+
+ );
+}
diff --git a/src/widgets/racknerd/proxy.js b/src/widgets/racknerd/proxy.js
new file mode 100644
index 00000000..a7c3c718
--- /dev/null
+++ b/src/widgets/racknerd/proxy.js
@@ -0,0 +1,100 @@
+import { xml2json } from "xml-js";
+
+import { racknerdDefaultFields } from "./component";
+
+import { httpProxy } from "utils/proxy/http";
+import getServiceWidget from "utils/config/service-helpers";
+import createLogger from "utils/logger";
+
+const logger = createLogger("racknerdProxyHandler");
+
+async function requestEndpoint(apiBaseUrl, action, params) {
+ const request = {
+ method: "POST"
+ };
+ let qs = "";
+ Object.entries(params).forEach(([key, value]) => {
+ qs += `&${key}=${value}`;
+ });
+ const apiUrl = `${apiBaseUrl}?action=${action}${qs}`;
+ const [status, , data] = await httpProxy(apiUrl, request);
+ if (status !== 200) {
+ logger.debug(`HTTP ${status} performing XMLRequest for ${action}`, data);
+ throw new Error(`Failed fetching '${action}'`);
+ }
+ const response = {};
+ try {
+ const jsonData = JSON.parse(xml2json(`${data}`, {compact: true}));
+ const responseElements = jsonData?.root || {};
+ Object.entries(responseElements).forEach(([responseKey, responseValue]) => {
+ /* eslint no-underscore-dangle: ["error", { "allow": ["_text"] }] */
+ response[responseKey] = responseValue?._text || "";
+ });
+ } catch (e) {
+ logger.debug(`Failed parsing ${action} response:`, data);
+ throw new Error(`Failed parsing '${action}' response`);
+ }
+
+ return response;
+}
+
+export default async function racknerdProxyHandler(req, res) {
+ const { group, service, index } = req.query;
+ const serviceWidget = await getServiceWidget(group, service, index);
+
+ if (!serviceWidget) {
+ res.status(500).json({ error: { message: "Service widget not found" } });
+ return;
+ }
+
+ if (!serviceWidget.url) {
+ res.status(500).json({ error: { message: "Service widget url not configured" } });
+ return;
+ }
+
+ const serviceWidgetUrl = new URL(serviceWidget.url);
+ const apiBaseUrl = `${serviceWidgetUrl.protocol}//${serviceWidgetUrl.hostname}/api/client/command.php`;
+
+ if (!serviceWidget.fields?.length > 0) {
+ serviceWidget.fields = racknerdDefaultFields;
+ }
+ const requestStatus = ["status"].some((field) => serviceWidget.fields?.includes(field));
+ const requestInfo = ["bandwidthused", "memoryusage", "hddtotal", "ipAddress"].some((field) => serviceWidget.fields?.includes(field));
+ const params = {
+ bw: serviceWidget.fields?.includes('bandwidthused') || serviceWidget.fields?.includes('bandwidthfree'),
+ hdd: serviceWidget.fields?.includes('hddtotal'),
+ ipAddr: serviceWidget.fields?.includes('ipAddress'),
+ mem: serviceWidget.fields?.includes('memoryusage'),
+ key: serviceWidget.key,
+ hash: serviceWidget.hash,
+ };
+
+ await Promise.all([
+ requestStatus ? requestEndpoint(apiBaseUrl, "status", params) : null,
+ requestInfo ? requestEndpoint(apiBaseUrl, "info", params) : null,
+ ])
+ .then(([statusResponse, infoResponse]) => {
+ const memoryItems = infoResponse.mem?.split(',');
+ const hddItems = infoResponse.hdd?.split(',');
+ const bandwidthItems = infoResponse.bw?.split(',');
+ res.status(200).json({
+ racknerd: {
+ ipAddress: infoResponse.ipaddress || undefined,
+ system: {
+ status: statusResponse ? statusResponse.statusmsg : undefined,
+ memoryused: memoryItems ? parseFloat(memoryItems[1], 10) : undefined,
+ hdd_total: hddItems ? parseFloat(hddItems[0], 10) : undefined,
+ bandwidth_total: bandwidthItems ? parseFloat(bandwidthItems[0], 10) : undefined,
+ bandwidth_used: bandwidthItems ? parseFloat(bandwidthItems[1], 10) : undefined,
+ bandwidth_free: bandwidthItems ? parseFloat(bandwidthItems[2], 10) : undefined,
+ mem_total: memoryItems ? parseFloat(memoryItems[0], 10) : undefined,
+ mem_free: memoryItems ? parseFloat(memoryItems[2], 10) : undefined,
+ mem_percent: memoryItems ? parseFloat(memoryItems[3], 10) : undefined,
+ }
+ }
+ });
+ })
+ .catch((error) => {
+ res.status(500).json({ error: { message: error.message } });
+ });
+}
diff --git a/src/widgets/racknerd/widget.js b/src/widgets/racknerd/widget.js
new file mode 100644
index 00000000..76b5d6e1
--- /dev/null
+++ b/src/widgets/racknerd/widget.js
@@ -0,0 +1,16 @@
+import racknerdProxyHandler from "./proxy";
+
+const widget = {
+ api: "{url}/{endpoint}",
+ proxyHandler: racknerdProxyHandler,
+
+ mappings: {
+ serverinfo: {
+ endpoint: "api/client/command.php",
+ params: ["key", "hash"],
+ optionalParams: ["bw", "mem", "hdd", "ipaddr"],
+ },
+ },
+};
+
+export default widget;
diff --git a/src/widgets/widgets.js b/src/widgets/widgets.js
index 9d4bb935..2744408d 100644
--- a/src/widgets/widgets.js
+++ b/src/widgets/widgets.js
@@ -96,6 +96,7 @@ import pterodactyl from "./pterodactyl/widget";
import pyload from "./pyload/widget";
import qbittorrent from "./qbittorrent/widget";
import qnap from "./qnap/widget";
+import racknerd from "./racknerd/widget";
import radarr from "./radarr/widget";
import readarr from "./readarr/widget";
import rutorrent from "./rutorrent/widget";
@@ -232,6 +233,7 @@ const widgets = {
pyload,
qbittorrent,
qnap,
+ racknerd,
radarr,
readarr,
romm,