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,