diff --git a/docs/widgets/info/truenas.md b/docs/widgets/info/truenas.md
new file mode 100644
index 00000000..eb08118f
--- /dev/null
+++ b/docs/widgets/info/truenas.md
@@ -0,0 +1,19 @@
+---
+title: TrueNAS
+description: TrueNAS Information Widget Configuration
+---
+
+_(Find the TrueNAS service widget [here](../services/truenas.md))_
+
+The TrueNAS widget allows you to monitor the resources (CPU/memory) of your TrueNAS hosts, and is designed to match the `kubernetes` info widget. You can have multiple instances by adding another configuration block.
+
+```yaml
+- truenas:
+ url: http://host.or.ip:port
+ username: user # not required if using api key
+ password: pass # not required if using api key
+ key: yourtruenasapikey # not required if using username / password
+ label: My TrueNAS # optional
+ icon: si-truenas # optional, defaults to si-truenas
+ refresh: 5000 # optional, in ms
+```
diff --git a/src/components/widgets/truenas/truenas.jsx b/src/components/widgets/truenas/truenas.jsx
new file mode 100644
index 00000000..d934f001
--- /dev/null
+++ b/src/components/widgets/truenas/truenas.jsx
@@ -0,0 +1,38 @@
+import useSWR from "swr";
+import { useTranslation } from "next-i18next";
+
+import Error from "../widget/error";
+import ServiceResource from "../resources/serviceResource";
+
+import ResolvedIcon from "components/resolvedicon";
+
+export default function Widget({ options }) {
+ const { i18n } = useTranslation();
+ const { icon: iconVal, label, refresh } = options;
+
+ const { data, error } = useSWR(
+ `/api/widgets/truenas?${new URLSearchParams({ lang: i18n.language, ...options }).toString()}`,
+ {
+ refreshInterval: refresh ?? 5000,
+ },
+ );
+
+ const icon = ;
+
+ if (error || data?.error) {
+ return ;
+ }
+
+ const memUsagePercent = Math.round(((data?.memory?.used ?? 0) / (data?.memory?.total ?? 1)) * 100);
+
+ return (
+
+ );
+}
diff --git a/src/components/widgets/widget.jsx b/src/components/widgets/widget.jsx
index b4fdb143..4eceba43 100644
--- a/src/components/widgets/widget.jsx
+++ b/src/components/widgets/widget.jsx
@@ -15,6 +15,7 @@ const widgetMappings = {
openmeteo: dynamic(() => import("components/widgets/openmeteo/openmeteo")),
longhorn: dynamic(() => import("components/widgets/longhorn/longhorn")),
kubernetes: dynamic(() => import("components/widgets/kubernetes/kubernetes")),
+ truenas: dynamic(() => import("components/widgets/truenas/truenas")),
};
export default function Widget({ widget, style }) {
diff --git a/src/pages/api/widgets/truenas.js b/src/pages/api/widgets/truenas.js
new file mode 100644
index 00000000..83b2d1d3
--- /dev/null
+++ b/src/pages/api/widgets/truenas.js
@@ -0,0 +1,90 @@
+import { httpProxy } from "utils/proxy/http";
+import createLogger from "utils/logger";
+import { getPrivateWidgetOptions } from "utils/config/widget-helpers";
+
+const logger = createLogger("truenas");
+
+async function retrieveFromTruenasAPI(privateWidgetOptions, endpoint, method, body) {
+ let errorMessage;
+ const url = privateWidgetOptions?.url;
+ if (!url) {
+ errorMessage = "Missing Truenas URL";
+ logger.error(errorMessage);
+ throw new Error(errorMessage);
+ }
+
+ const apiUrl = `${url}/api/v2.0/${endpoint}`;
+ const headers = {
+ "Accept-Encoding": "application/json",
+ };
+ if (privateWidgetOptions.username && privateWidgetOptions.password) {
+ headers.Authorization = `Basic ${Buffer.from(
+ `${privateWidgetOptions.username}:${privateWidgetOptions.password}`,
+ ).toString("base64")}`;
+ } else if (privateWidgetOptions.key) {
+ headers.Authorization = `Bearer ${privateWidgetOptions.key}`;
+ } else {
+ errorMessage = "Missing TrueNAS credentials";
+ logger.error(errorMessage);
+ throw new Error(errorMessage);
+ }
+ const params = { method, headers, body };
+
+ const [status, , data] = await httpProxy(apiUrl, params);
+
+ if (status === 401) {
+ errorMessage = `Authorization failure getting data from TrueNAS API. Data: ${data.toString()}`;
+ logger.error(errorMessage);
+ throw new Error(errorMessage);
+ }
+
+ if (status !== 200) {
+ errorMessage = `HTTP ${status} getting data from TrueNAS API. Data: ${data.toString()}`;
+ logger.error(errorMessage);
+ throw new Error(errorMessage);
+ }
+
+ return JSON.parse(Buffer.from(data).toString());
+}
+
+export default async function handler(req, res) {
+ const { index } = req.query;
+
+ const privateWidgetOptions = await getPrivateWidgetOptions("truenas", index);
+
+ try {
+ const systemInfo = await retrieveFromTruenasAPI(privateWidgetOptions, "system/info");
+ const memoryInfo = await retrieveFromTruenasAPI(
+ privateWidgetOptions,
+ "reporting/get_data",
+ "POST",
+ JSON.stringify({
+ graphs: [{ name: "memory" }],
+ reporting_query: {
+ start: Math.round((new Date() - 30000) / 1000), // 30 seconds ago
+ end: "NOW",
+ aggregate: true,
+ },
+ }),
+ );
+
+ const [used, free, cached, buffered] = memoryInfo?.[0]?.aggregations?.mean || [];
+
+ const data = {
+ cpu: {
+ load: systemInfo?.loadavg?.[0],
+ },
+ memory: {
+ used,
+ free,
+ cached,
+ buffered,
+ total: used + free, // Using used + free instead of total memory to match TrueNAS dashboard
+ },
+ };
+
+ return res.status(200).send(data);
+ } catch (e) {
+ return res.status(400).json({ error: e.message });
+ }
+}