diff --git a/docs/widgets/services/unifi-drive.md b/docs/widgets/services/unifi-drive.md
new file mode 100644
index 000000000..90843140e
--- /dev/null
+++ b/docs/widgets/services/unifi-drive.md
@@ -0,0 +1,24 @@
+---
+title: UniFi Drive
+description: UniFi Drive Widget Configuration
+---
+
+Learn more about [UniFi Drive](https://ui.com/integrations/network-storage).
+
+## Configuration
+
+Displays storage statistics from your UniFi Network Attached Storage (UNAS) device. Requires a local UniFi account with at least read privileges.
+
+Allowed fields: `["total", "used", "available", "status"]`
+
+```yaml
+widget:
+ type: unifi_drive
+ url: https://unifi.host.or.ip
+ username: your_username
+ password: your_password
+```
+
+!!! hint
+
+ If you enter incorrect credentials and receive an "API Error", you may need to recreate the container or restart the service to clear the cache.
diff --git a/mkdocs.yml b/mkdocs.yml
index b08277808..1933825c5 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -171,6 +171,7 @@ nav:
- widgets/services/truenas.md
- widgets/services/tubearchivist.md
- widgets/services/unifi-controller.md
+ - widgets/services/unifi-drive.md
- widgets/services/unmanic.md
- widgets/services/unraid.md
- widgets/services/uptime-kuma.md
diff --git a/public/locales/en/common.json b/public/locales/en/common.json
index 66a6a34a7..9528341b8 100644
--- a/public/locales/en/common.json
+++ b/public/locales/en/common.json
@@ -66,6 +66,11 @@
"wait": "Please wait",
"empty_data": "Subsystem status unknown"
},
+ "unifi_drive": {
+ "healthy": "Healthy",
+ "degraded": "Degraded",
+ "no_data": "No storage data available"
+ },
"docker": {
"rx": "RX",
"tx": "TX",
diff --git a/src/widgets/components.js b/src/widgets/components.js
index 472ddd684..c5f144e39 100644
--- a/src/widgets/components.js
+++ b/src/widgets/components.js
@@ -147,6 +147,7 @@ const components = {
tubearchivist: dynamic(() => import("./tubearchivist/component")),
truenas: dynamic(() => import("./truenas/component")),
unifi: dynamic(() => import("./unifi/component")),
+ unifi_drive: dynamic(() => import("./unifi_drive/component")),
unmanic: dynamic(() => import("./unmanic/component")),
unraid: dynamic(() => import("./unraid/component")),
uptimekuma: dynamic(() => import("./uptimekuma/component")),
diff --git a/src/widgets/unifi_drive/component.jsx b/src/widgets/unifi_drive/component.jsx
new file mode 100644
index 000000000..a616dbd20
--- /dev/null
+++ b/src/widgets/unifi_drive/component.jsx
@@ -0,0 +1,58 @@
+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";
+
+export default function Component({ service }) {
+ const { t } = useTranslation();
+ const { widget } = service;
+
+ const { data: storageData, error: storageError } = useWidgetAPI(widget, "storage");
+
+ if (storageError) {
+ return ;
+ }
+
+ if (!storageData) {
+ return (
+
+
+
+
+
+
+ );
+ }
+
+ const { data: storage } = storageData;
+
+ if (!storage) {
+ return (
+
+
+
+ );
+ }
+
+ const { totalQuota, usage, status } = storage;
+ const totalBytes = totalQuota ?? 0;
+ const usedBytes = (usage?.system || 0) + (usage?.myDrives || 0) + (usage?.sharedDrives || 0);
+ const availableBytes = Math.max(0, totalBytes - usedBytes);
+ let statusValue = status;
+ if (status === "healthy") statusValue = t("unifi_drive.healthy");
+ else if (status === "degraded") statusValue = t("unifi_drive.degraded");
+
+ return (
+
+
+
+
+
+
+ );
+}
diff --git a/src/widgets/unifi_drive/component.test.jsx b/src/widgets/unifi_drive/component.test.jsx
new file mode 100644
index 000000000..46f23ce1e
--- /dev/null
+++ b/src/widgets/unifi_drive/component.test.jsx
@@ -0,0 +1,92 @@
+// @vitest-environment jsdom
+
+import { screen } from "@testing-library/react";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+import { renderWithProviders } from "test-utils/render-with-providers";
+import { expectBlockValue } from "test-utils/widget-assertions";
+
+const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
+
+vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI }));
+
+import Component from "./component";
+
+describe("widgets/unifi_drive/component", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("renders placeholders while loading", () => {
+ useWidgetAPI.mockReturnValue({ data: undefined, error: undefined });
+
+ const service = { widget: { type: "unifi_drive" } };
+ const { container } = renderWithProviders(, { settings: { hideErrors: false } });
+
+ expect(container.querySelectorAll(".service-block")).toHaveLength(4);
+ expect(screen.getByText("resources.total")).toBeInTheDocument();
+ expect(screen.getByText("resources.used")).toBeInTheDocument();
+ expect(screen.getByText("resources.free")).toBeInTheDocument();
+ expect(screen.getByText("widget.status")).toBeInTheDocument();
+ });
+
+ it("renders error when API fails", () => {
+ useWidgetAPI.mockReturnValue({ data: undefined, error: new Error("fail") });
+
+ const service = { widget: { type: "unifi_drive" } };
+ renderWithProviders(, { settings: { hideErrors: false } });
+
+ expect(screen.getAllByText("widget.api_error", { exact: false }).length).toBeGreaterThan(0);
+ });
+
+ it("renders no_data when storage data is missing", () => {
+ useWidgetAPI.mockReturnValue({ data: { data: null }, error: undefined });
+
+ const service = { widget: { type: "unifi_drive" } };
+ renderWithProviders(, { settings: { hideErrors: false } });
+
+ expect(screen.getByText("unifi_drive.no_data")).toBeInTheDocument();
+ });
+
+ it("renders storage statistics when data is loaded", () => {
+ useWidgetAPI.mockReturnValue({
+ data: {
+ data: {
+ totalQuota: 1000000000000,
+ usage: { system: 100000000000, myDrives: 200000000000, sharedDrives: 50000000000 },
+ status: "healthy",
+ },
+ },
+ error: undefined,
+ });
+
+ const service = { widget: { type: "unifi_drive" } };
+ const { container } = renderWithProviders(, { settings: { hideErrors: false } });
+
+ expect(container.querySelectorAll(".service-block")).toHaveLength(4);
+ expectBlockValue(container, "resources.total", 1000000000000);
+ expectBlockValue(container, "resources.used", 350000000000);
+ expectBlockValue(container, "resources.free", 650000000000);
+ expectBlockValue(container, "widget.status", "unifi_drive.healthy");
+ });
+
+ it("renders degraded status", () => {
+ useWidgetAPI.mockReturnValue({
+ data: {
+ data: {
+ totalQuota: 100,
+ usage: { system: 10, myDrives: 20, sharedDrives: 5 },
+ status: "degraded",
+ },
+ },
+ error: undefined,
+ });
+
+ const service = { widget: { type: "unifi_drive" } };
+ const { container } = renderWithProviders(, { settings: { hideErrors: false } });
+
+ expect(container.querySelectorAll(".service-block")).toHaveLength(4);
+ expectBlockValue(container, "widget.status", "unifi_drive.degraded");
+ expectBlockValue(container, "resources.free", 65);
+ });
+});
diff --git a/src/widgets/unifi_drive/proxy.js b/src/widgets/unifi_drive/proxy.js
new file mode 100644
index 000000000..02dff2ef3
--- /dev/null
+++ b/src/widgets/unifi_drive/proxy.js
@@ -0,0 +1,36 @@
+import getServiceWidget from "utils/config/service-helpers";
+import createUnifiProxyHandler from "utils/proxy/handlers/unifi";
+import { httpProxy } from "utils/proxy/http";
+
+const drivePrefix = "/proxy/drive";
+
+async function getWidget(req, logger) {
+ const { group, service, index } = req.query;
+ if (!group || !service) return null;
+
+ const widget = await getServiceWidget(group, service, index);
+ if (!widget) {
+ logger.debug("Invalid or missing widget for service '%s' in group '%s'", service, group);
+ return null;
+ }
+ return widget;
+}
+
+async function resolveRequestContext({ cachedPrefix, widget }) {
+ if (cachedPrefix !== null) {
+ return { prefix: cachedPrefix };
+ }
+
+ const [, , , responseHeaders] = await httpProxy(widget.url);
+
+ return {
+ prefix: drivePrefix,
+ csrfToken: responseHeaders?.["x-csrf-token"],
+ };
+}
+
+export default createUnifiProxyHandler({
+ proxyName: "unifiDriveProxyHandler",
+ resolveWidget: getWidget,
+ resolveRequestContext,
+});
diff --git a/src/widgets/unifi_drive/proxy.test.js b/src/widgets/unifi_drive/proxy.test.js
new file mode 100644
index 000000000..07eef7480
--- /dev/null
+++ b/src/widgets/unifi_drive/proxy.test.js
@@ -0,0 +1,82 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+import createMockRes from "test-utils/create-mock-res";
+
+const { httpProxy, getServiceWidget, cache, logger } = vi.hoisted(() => {
+ const store = new Map();
+ return {
+ httpProxy: vi.fn(),
+ getServiceWidget: vi.fn(),
+ cache: {
+ get: vi.fn((k) => (store.has(k) ? store.get(k) : null)),
+ put: vi.fn((k, v) => store.set(k, v)),
+ del: vi.fn((k) => store.delete(k)),
+ _reset: () => store.clear(),
+ },
+ logger: { debug: vi.fn(), error: vi.fn() },
+ };
+});
+
+vi.mock("memory-cache", () => ({ default: cache, ...cache }));
+vi.mock("utils/logger", () => ({ default: () => logger }));
+vi.mock("utils/config/service-helpers", () => ({ default: getServiceWidget }));
+vi.mock("utils/proxy/http", () => ({ httpProxy }));
+vi.mock("widgets/widgets", () => ({
+ default: { unifi_drive: { api: "{url}{prefix}/api/{endpoint}" } },
+}));
+
+import unifiDriveProxyHandler from "./proxy";
+
+const widgetConfig = { type: "unifi_drive", url: "http://unifi", username: "u", password: "p" };
+
+describe("widgets/unifi_drive/proxy", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ cache._reset();
+ });
+
+ it("returns 400 when widget config is missing", async () => {
+ getServiceWidget.mockResolvedValue(null);
+ const res = createMockRes();
+ await unifiDriveProxyHandler(
+ { query: { group: "g", service: "s", endpoint: "v1/systems/storage?type=detail" } },
+ res,
+ );
+ expect(res.statusCode).toBe(400);
+ });
+
+ it("returns 403 when widget type has no API config", async () => {
+ getServiceWidget.mockResolvedValue({ ...widgetConfig, type: "unknown" });
+ const res = createMockRes();
+ await unifiDriveProxyHandler({ query: { group: "g", service: "s", endpoint: "storage" } }, res);
+ expect(res.statusCode).toBe(403);
+ });
+
+ it("uses /proxy/drive prefix and returns data on success", async () => {
+ getServiceWidget.mockResolvedValue({ ...widgetConfig });
+ httpProxy
+ .mockResolvedValueOnce([200, "text/html", Buffer.from(""), {}])
+ .mockResolvedValueOnce([200, "application/json", Buffer.from('{"data":{}}'), {}]);
+
+ const res = createMockRes();
+ await unifiDriveProxyHandler({ query: { group: "g", service: "s", endpoint: "storage" } }, res);
+
+ expect(httpProxy.mock.calls[0][0]).toBe("http://unifi");
+ expect(httpProxy.mock.calls[1][0].toString()).toContain("/proxy/drive/api/");
+ expect(cache.put).toHaveBeenCalledWith("unifiDriveProxyHandler__prefix.s", "/proxy/drive");
+ expect(res.statusCode).toBe(200);
+ });
+
+ it("skips prefix detection when cached", async () => {
+ getServiceWidget.mockResolvedValue({ ...widgetConfig });
+ cache.put("unifiDriveProxyHandler__prefix.s", "/proxy/drive");
+ httpProxy.mockResolvedValueOnce([200, "application/json", Buffer.from('{"data":{}}'), {}]);
+
+ const res = createMockRes();
+ await unifiDriveProxyHandler({ query: { group: "g", service: "s", endpoint: "storage" } }, res);
+
+ expect(httpProxy).toHaveBeenCalledTimes(1);
+ expect(httpProxy.mock.calls[0][0].toString()).toContain("/proxy/drive/api/");
+ expect(res.statusCode).toBe(200);
+ });
+});
diff --git a/src/widgets/unifi_drive/widget.js b/src/widgets/unifi_drive/widget.js
new file mode 100644
index 000000000..4e78c5a5f
--- /dev/null
+++ b/src/widgets/unifi_drive/widget.js
@@ -0,0 +1,14 @@
+import unifiDriveProxyHandler from "./proxy";
+
+const widget = {
+ api: "{url}{prefix}/api/{endpoint}",
+ proxyHandler: unifiDriveProxyHandler,
+
+ mappings: {
+ storage: {
+ endpoint: "v1/systems/storage?type=detail",
+ },
+ },
+};
+
+export default widget;
diff --git a/src/widgets/unifi_drive/widget.test.js b/src/widgets/unifi_drive/widget.test.js
new file mode 100644
index 000000000..87f60f69e
--- /dev/null
+++ b/src/widgets/unifi_drive/widget.test.js
@@ -0,0 +1,11 @@
+import { describe, it } from "vitest";
+
+import { expectWidgetConfigShape } from "test-utils/widget-config";
+
+import widget from "./widget";
+
+describe("unifi_drive widget config", () => {
+ it("exports a valid widget config", () => {
+ expectWidgetConfigShape(widget);
+ });
+});
diff --git a/src/widgets/widgets.js b/src/widgets/widgets.js
index 533410bdc..be7f685e4 100644
--- a/src/widgets/widgets.js
+++ b/src/widgets/widgets.js
@@ -137,6 +137,7 @@ import trilium from "./trilium/widget";
import truenas from "./truenas/widget";
import tubearchivist from "./tubearchivist/widget";
import unifi from "./unifi/widget";
+import unifi_drive from "./unifi_drive/widget";
import unmanic from "./unmanic/widget";
import unraid from "./unraid/widget";
import uptimekuma from "./uptimekuma/widget";
@@ -296,6 +297,7 @@ const widgets = {
truenas,
unifi,
unifi_console: unifi,
+ unifi_drive,
unmanic,
unraid,
uptimekuma,