diff --git a/src/components/widgets/longhorn/longhorn.jsx b/src/components/widgets/longhorn/longhorn.jsx
new file mode 100644
index 00000000..3ba421d0
--- /dev/null
+++ b/src/components/widgets/longhorn/longhorn.jsx
@@ -0,0 +1,57 @@
+import useSWR from "swr";
+import { BiError } from "react-icons/bi";
+import { i18n, useTranslation } from "next-i18next";
+
+import Node from "./node";
+
+export default function Longhorn({ options }) {
+ const { expanded, total, labels, include, nodes } = options;
+ const { t } = useTranslation();
+ const { data, error } = useSWR(`/api/widgets/longhorn`, {
+ refreshInterval: 1500
+ });
+
+ if (error || data?.error) {
+ return (
+
+
+
+ {t("widget.api_error")}
+
+
+ );
+ }
+
+ if (!data) {
+ return (
+
+ );
+ }
+
+ return (
+
+
+ {data.nodes
+ .filter((node) => {
+ if (node.id === 'total' && total) {
+ return true;
+ }
+ if (!nodes) {
+ return false;
+ }
+ if (include && !include.includes(node.id)) {
+ return false;
+ }
+ return true;
+ })
+ .map((node) =>
+
+
+
+ )}
+
+
+ );
+}
diff --git a/src/components/widgets/longhorn/node.jsx b/src/components/widgets/longhorn/node.jsx
new file mode 100644
index 00000000..44b96b1c
--- /dev/null
+++ b/src/components/widgets/longhorn/node.jsx
@@ -0,0 +1,32 @@
+import { FiHardDrive } from "react-icons/fi";
+import { useTranslation } from "next-i18next";
+
+import UsageBar from "../resources/usage-bar";
+
+export default function Node({ data, expanded, labels }) {
+ const { t } = useTranslation();
+
+ return (
+ <>
+
+
+
+
+ {t("common.bytes", { value: data.node.available })}
+ {t("resources.free")}
+
+ {expanded && (
+
+ {t("common.bytes", { value: data.node.maximum })}
+ {t("resources.total")}
+
+ )}
+
+
+
+ {labels && (
+ {data.node.id}
+ )}
+ >
+ );
+}
diff --git a/src/components/widgets/widget.jsx b/src/components/widgets/widget.jsx
index 86f79dfe..29e7a180 100644
--- a/src/components/widgets/widget.jsx
+++ b/src/components/widgets/widget.jsx
@@ -13,6 +13,7 @@ const widgetMappings = {
unifi_console: dynamic(() => import("components/widgets/unifi_console/unifi_console")),
glances: dynamic(() => import("components/widgets/glances/glances")),
openmeteo: dynamic(() => import("components/widgets/openmeteo/openmeteo")),
+ longhorn: dynamic(() => import("components/widgets/longhorn/longhorn")),
};
export default function Widget({ widget }) {
diff --git a/src/pages/api/widgets/kubernetes.js b/src/pages/api/widgets/kubernetes.js
index a740df90..350c93a2 100644
--- a/src/pages/api/widgets/kubernetes.js
+++ b/src/pages/api/widgets/kubernetes.js
@@ -39,19 +39,7 @@ export default async function handler(req, res) {
}
});
}
- // Maybe Storage CSI can provide this information
- // if (type === "disk") {
- // if (!existsSync(target)) {
- // return res.status(404).json({
- // error: "Target not found",
- // });
- // }
- //
- // return res.status(200).json({
- // drive: await drive.info(target || "/"),
- // });
- // }
- //
+
if (type === "memory") {
const SCALE_MB = 1024 * 1024;
const usedMemMb = memUsage / SCALE_MB;
diff --git a/src/pages/api/widgets/longhorn.js b/src/pages/api/widgets/longhorn.js
new file mode 100644
index 00000000..cb9ed24b
--- /dev/null
+++ b/src/pages/api/widgets/longhorn.js
@@ -0,0 +1,82 @@
+import { httpProxy } from "../../../utils/proxy/http";
+import createLogger from "../../../utils/logger";
+import { getSettings } from "../../../utils/config/config";
+
+const logger = createLogger("longhorn");
+
+function parseLonghornData(data) {
+ const json = JSON.parse(data);
+
+ if (!json) {
+ return null;
+ }
+
+ const nodes = json.data.map((node) => {
+ let available = 0;
+ let maximum = 0;
+ let reserved = 0;
+ let scheduled = 0;
+ Object.keys(node.disks).forEach((diskKey) => {
+ const disk = node.disks[diskKey];
+ available += disk.storageAvailable;
+ maximum += disk.storageMaximum;
+ reserved += disk.storageReserved;
+ scheduled += disk.storageScheduled;
+ });
+ return {
+ id: node.id,
+ available,
+ maximum,
+ reserved,
+ scheduled,
+ };
+ });
+ const total = nodes.reduce((summary, node) => ({
+ available: summary.available + node.available,
+ maximum: summary.maximum + node.maximum,
+ reserved: summary.reserved + node.reserved,
+ scheduled: summary.scheduled + node.scheduled,
+ }));
+ total.id = "total";
+ nodes.push(total);
+ return nodes;
+}
+
+export default async function handler(req, res) {
+ const settings = getSettings();
+ const longhornSettings = settings?.providers?.longhorn;
+ const {url, username, password} = longhornSettings;
+
+ if (!url) {
+ const errorMessage = "Missing Longhorn URL";
+ logger.error(errorMessage);
+ return res.status(400).json({ error: errorMessage });
+ }
+
+ const apiUrl = `${url}/v1/nodes`;
+ const headers = {
+ "Accept-Encoding": "application/json"
+ };
+ if (username && password) {
+ headers.Authorization = `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}`
+ }
+ const params = { method: "GET", headers };
+
+ const [status, contentType, data] = await httpProxy(apiUrl, params);
+
+ if (status === 401) {
+ logger.error("Authorization failure getting data from Longhorn API. Data: %s", data);
+ }
+
+ if (status !== 200) {
+ logger.error("HTTP %d getting data from Longhorn API. Data: %s", status, data);
+ }
+
+ if (contentType) res.setHeader("Content-Type", contentType);
+
+ const nodes = parseLonghornData(data);
+
+ return res.status(200).json({
+ nodes,
+ });
+}