From 2a6bb264ee014993cfa2d67892927311c5acef92 Mon Sep 17 00:00:00 2001 From: Miguel Angel Lobato Date: Mon, 13 Jul 2026 01:48:53 +0200 Subject: [PATCH] Feature: Duplicati service widget (#6864) Co-authored-by: shamoon <4887959+shamoon@users.noreply.github.com> --- docs/widgets/services/duplicati.md | 18 ++ mkdocs.yml | 1 + public/locales/en/common.json | 9 + src/widgets/components.js | 1 + src/widgets/duplicati/component.jsx | 62 +++++++ src/widgets/duplicati/component.test.jsx | 82 +++++++++ src/widgets/duplicati/proxy.js | 132 ++++++++++++++ src/widgets/duplicati/proxy.test.js | 211 +++++++++++++++++++++++ src/widgets/duplicati/widget.js | 8 + src/widgets/duplicati/widget.test.js | 12 ++ src/widgets/widgets.js | 2 + 11 files changed, 538 insertions(+) create mode 100644 docs/widgets/services/duplicati.md create mode 100644 src/widgets/duplicati/component.jsx create mode 100644 src/widgets/duplicati/component.test.jsx create mode 100644 src/widgets/duplicati/proxy.js create mode 100644 src/widgets/duplicati/proxy.test.js create mode 100644 src/widgets/duplicati/widget.js create mode 100644 src/widgets/duplicati/widget.test.js diff --git a/docs/widgets/services/duplicati.md b/docs/widgets/services/duplicati.md new file mode 100644 index 000000000..8b48cd37d --- /dev/null +++ b/docs/widgets/services/duplicati.md @@ -0,0 +1,18 @@ +--- +title: Duplicati +description: Duplicati Widget Configuration +--- + +Learn more about [Duplicati](https://www.duplicati.com/). + +Allowed fields: `["jobs", "stored", "lastBackup", "nextRun", "running", "warnings", "errors"]` + +Default fields: `["jobs", "errors", "lastBackup", "nextRun"]` + +```yaml +widget: + type: duplicati + url: http://duplicati.host.or.ip:8200 + password: your_duplicati_ui_password + fields: ["jobs", "errors", "lastBackup", "nextRun"] # optional +``` diff --git a/mkdocs.yml b/mkdocs.yml index 7e3baaaee..ac5ed1aa5 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -59,6 +59,7 @@ nav: - widgets/services/develancacheui.md - widgets/services/diskstation.md - widgets/services/dispatcharr.md + - widgets/services/duplicati.md - widgets/services/dockhand.md - widgets/services/downloadstation.md - widgets/services/emby.md diff --git a/public/locales/en/common.json b/public/locales/en/common.json index 2700d4a18..f38444cc9 100644 --- a/public/locales/en/common.json +++ b/public/locales/en/common.json @@ -71,6 +71,15 @@ "degraded": "Degraded", "no_data": "No storage data available" }, + "duplicati": { + "jobs": "Jobs", + "stored": "Stored", + "lastBackup": "Last Run", + "nextRun": "Next Run", + "running": "Running", + "warnings": "Warnings", + "errors": "Errors" + }, "docker": { "rx": "RX", "tx": "TX", diff --git a/src/widgets/components.js b/src/widgets/components.js index 322a80c4a..5ea052af7 100644 --- a/src/widgets/components.js +++ b/src/widgets/components.js @@ -28,6 +28,7 @@ const components = { deluge: dynamic(() => import("./deluge/component")), develancacheui: dynamic(() => import("./develancacheui/component")), diskstation: dynamic(() => import("./diskstation/component")), + duplicati: dynamic(() => import("./duplicati/component")), dispatcharr: dynamic(() => import("./dispatcharr/component")), downloadstation: dynamic(() => import("./downloadstation/component")), docker: dynamic(() => import("./docker/component")), diff --git a/src/widgets/duplicati/component.jsx b/src/widgets/duplicati/component.jsx new file mode 100644 index 000000000..90e0253ed --- /dev/null +++ b/src/widgets/duplicati/component.jsx @@ -0,0 +1,62 @@ +import Block from "components/services/widget/block"; +import Container from "components/services/widget/container"; +import { useTranslation } from "next-i18next/pages"; + +import useWidgetAPI from "utils/proxy/use-widget-api"; + +const DEFAULT_FIELDS = ["jobs", "errors", "lastBackup", "nextRun"]; + +export default function Component({ service }) { + const { t } = useTranslation(); + const { widget } = service; + + if (!widget.fields?.length) { + widget.fields = DEFAULT_FIELDS; + } else if (widget.fields?.length > 4) { + widget.fields = widget.fields.slice(0, 4); + } + + const { data, error } = useWidgetAPI(widget); + + if (error) { + return ; + } + + if (!data) { + return ( + + + + + + + + + + ); + } + + return ( + + + + + + + + + + ); +} diff --git a/src/widgets/duplicati/component.test.jsx b/src/widgets/duplicati/component.test.jsx new file mode 100644 index 000000000..896439c31 --- /dev/null +++ b/src/widgets/duplicati/component.test.jsx @@ -0,0 +1,82 @@ +// @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/duplicati/component", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("renders default placeholders while loading", () => { + useWidgetAPI.mockReturnValue({ data: undefined, error: undefined }); + + const { container } = renderWithProviders(, { + settings: { hideErrors: false }, + }); + + expect(container.querySelectorAll(".service-block")).toHaveLength(4); + expect(screen.getByText("duplicati.jobs")).toBeInTheDocument(); + expect(screen.getByText("duplicati.errors")).toBeInTheDocument(); + expect(screen.getByText("duplicati.lastBackup")).toBeInTheDocument(); + expect(screen.getByText("duplicati.nextRun")).toBeInTheDocument(); + }); + + it("uses the standard widget API hook without custom refresh settings", () => { + useWidgetAPI.mockReturnValue({ data: undefined, error: undefined }); + + renderWithProviders(, { + settings: { hideErrors: false }, + }); + + expect(useWidgetAPI).toHaveBeenCalledWith(expect.objectContaining({ type: "duplicati" })); + }); + + it("renders summary values", () => { + useWidgetAPI.mockReturnValue({ + data: { + jobs: 2, + stored: 2048, + lastBackup: "2026-07-12T11:00:00Z", + nextRun: "2026-07-13T11:00:00Z", + running: 1, + warnings: 0, + errors: 1, + }, + error: undefined, + }); + + const { container } = renderWithProviders( + , + { + settings: { hideErrors: false }, + }, + ); + + expect(container.querySelectorAll(".service-block")).toHaveLength(3); + expectBlockValue(container, "duplicati.jobs", 2); + expectBlockValue(container, "duplicati.running", 1); + expectBlockValue(container, "duplicati.errors", 1); + }); + + it("renders the error state", () => { + useWidgetAPI.mockReturnValue({ + data: undefined, + error: "boom", + }); + + const { container } = renderWithProviders(, { + settings: { hideErrors: false }, + }); + + expect(container.textContent).toContain("boom"); + }); +}); diff --git a/src/widgets/duplicati/proxy.js b/src/widgets/duplicati/proxy.js new file mode 100644 index 000000000..ebf062539 --- /dev/null +++ b/src/widgets/duplicati/proxy.js @@ -0,0 +1,132 @@ +import { DateTime } from "luxon"; + +import getServiceWidget from "utils/config/service-helpers"; +import createLogger from "utils/logger"; +import { asJson, formatApiCall } from "utils/proxy/api-helpers"; +import { httpProxy } from "utils/proxy/http"; +import widgets from "widgets/widgets"; + +const logger = createLogger("duplicatiProxyHandler"); + +function buildSummary(backups, notifications, serverstate, progressstate) { + const backupNotifications = notifications.filter((notification) => notification.BackupID); + const summary = { + jobs: backups.length, + stored: 0, + lastBackup: null, + nextRun: null, + running: serverstate?.ActiveTask && progressstate?.BackupID ? 1 : 0, + warnings: backupNotifications.filter((notification) => notification.Type === "Warning").length, + errors: backupNotifications.filter((notification) => notification.Type === "Error").length, + }; + let latestBackupTime = null; + let nextRunTime = null; + + backups.forEach((backup) => { + const metadata = backup?.Backup?.Metadata ?? {}; + const lastBackup = DateTime.fromFormat(metadata.LastBackupFinished ?? "", "yyyyMMdd'T'HHmmss'Z'", { zone: "utc" }); + const nextRun = DateTime.fromISO(backup?.Schedule?.Time ?? ""); + + summary.stored += Number(metadata?.TargetFilesSize) || 0; + + if (lastBackup.isValid && (!latestBackupTime || lastBackup > latestBackupTime)) { + latestBackupTime = lastBackup; + } + + if (nextRun.isValid && (!nextRunTime || nextRun < nextRunTime)) { + nextRunTime = nextRun; + } + }); + + return { + ...summary, + lastBackup: latestBackupTime?.toUTC().toISO() ?? null, + nextRun: nextRunTime?.toUTC().toISO() ?? null, + }; +} + +async function login(widget) { + const loginUrl = new URL(formatApiCall(widgets[widget.type].api, { endpoint: "auth/login", ...widget })); + const [status, , data] = await httpProxy(loginUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + Password: String(widget.password), + RememberMe: true, + }), + }); + + if (status !== 200) { + throw new Error(`Unable to login to Duplicati (status ${status})`); + } + + const body = asJson(data); + if (!body?.AccessToken) { + throw new Error("Duplicati login response did not include an access token"); + } + + return body.AccessToken; +} + +async function apiGet(widget, endpoint, accessToken) { + const url = new URL(formatApiCall(widgets[widget.type].api, { endpoint, ...widget })); + const [status, , data] = await httpProxy(url, { + method: "GET", + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }); + + if (status !== 200) { + throw new Error(`Duplicati request failed for ${endpoint}`); + } + + return asJson(data); +} + +export default async function duplicatiProxyHandler(req, res) { + const { group, service, index } = req.query; + const widget = await getServiceWidget(group, service, index); + + if (!widget) { + return res.status(400).json({ error: "Invalid proxy service type" }); + } + + if (!widget.url || !widget.password) { + return res.status(500).json({ + error: { + message: `Duplicati widget is missing required url and password`, + }, + }); + } + + try { + const accessToken = await login(widget); + const [backups, serverstate, notifications, progressstate] = await Promise.all([ + apiGet(widget, "backups", accessToken), + apiGet(widget, "serverstate", accessToken), + apiGet(widget, "notifications", accessToken), + apiGet(widget, "progressstate", accessToken), + ]); + + const summary = buildSummary( + Array.isArray(backups) ? backups : [], + Array.isArray(notifications) ? notifications : [], + serverstate ?? {}, + progressstate ?? {}, + ); + + return res.status(200).json(summary); + } catch (error) { + logger.error("Error communicating with Duplicati: %s", error); + return res.status(500).json({ + error: { + message: "Error communicating with Duplicati", + }, + }); + } +} + +export { buildSummary, login }; diff --git a/src/widgets/duplicati/proxy.test.js b/src/widgets/duplicati/proxy.test.js new file mode 100644 index 000000000..c2d0b3b71 --- /dev/null +++ b/src/widgets/duplicati/proxy.test.js @@ -0,0 +1,211 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import createMockRes from "test-utils/create-mock-res"; + +const { getServiceWidget, httpProxy } = vi.hoisted(() => ({ + getServiceWidget: vi.fn(), + httpProxy: vi.fn(), +})); + +vi.mock("utils/config/service-helpers", () => ({ default: getServiceWidget })); +vi.mock("utils/proxy/http", () => ({ httpProxy })); +vi.mock("widgets/widgets", () => ({ + default: { + duplicati: { api: "{url}/api/v1/{endpoint}" }, + }, +})); + +import duplicatiProxyHandler, { buildSummary, login } from "./proxy"; + +describe("widgets/duplicati/proxy", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns 400 when widget config is missing", async () => { + getServiceWidget.mockResolvedValue(null); + + const res = createMockRes(); + await duplicatiProxyHandler({ query: { group: "g", service: "s" } }, res); + + expect(res.statusCode).toBe(400); + }); + + it("logs in and aggregates duplicati data", async () => { + getServiceWidget.mockResolvedValue({ type: "duplicati", url: "http://dup", password: "secret" }); + httpProxy + .mockResolvedValueOnce([200, "application/json", Buffer.from(JSON.stringify({ AccessToken: "token" }))]) + .mockResolvedValueOnce([ + 200, + "application/json", + Buffer.from( + JSON.stringify([ + { + Backup: { + ID: "1", + Name: "Job One", + Metadata: { + TargetFilesSize: "1024", + LastBackupFinished: "20260712T100000Z", + LastErrorDate: "20260712T090000Z", + }, + }, + Schedule: { Time: "2026-07-13T11:00:00Z" }, + }, + ]), + ), + ]) + .mockResolvedValueOnce([200, "application/json", Buffer.from(JSON.stringify({ ActiveTask: null }))]) + .mockResolvedValueOnce([200, "application/json", Buffer.from(JSON.stringify([]))]) + .mockResolvedValueOnce([200, "application/json", Buffer.from(JSON.stringify({ BackupID: null }))]); + + const res = createMockRes(); + await duplicatiProxyHandler({ query: { group: "g", service: "s" } }, res); + + expect(httpProxy).toHaveBeenCalledTimes(5); + expect(httpProxy.mock.calls[0][0].toString()).toContain("/api/v1/auth/login"); + expect(httpProxy.mock.calls[1][0].toString()).toContain("/api/v1/backups"); + expect(res.statusCode).toBe(200); + expect(res.body.jobs).toBe(1); + expect(res.body.stored).toBe(1024); + expect(res.body.lastBackup).toBe("2026-07-12T10:00:00.000Z"); + }); + + it("returns 500 when login fails", async () => { + getServiceWidget.mockResolvedValue({ type: "duplicati", url: "http://dup", password: "secret" }); + httpProxy.mockResolvedValueOnce([401, "application/json", Buffer.from("{}")]); + + const res = createMockRes(); + await duplicatiProxyHandler({ query: { group: "g", service: "s" } }, res); + + expect(res.statusCode).toBe(500); + expect(res.body.error.message).toContain("Duplicati"); + }); + + it("returns 500 when widget credentials are incomplete", async () => { + getServiceWidget.mockResolvedValue({ type: "duplicati", url: "http://dup" }); + + const res = createMockRes(); + await duplicatiProxyHandler({ query: { group: "g", service: "s" } }, res); + + expect(res.statusCode).toBe(500); + expect(res.body.error.message).toBe("Duplicati widget is missing required url and password"); + }); + + it("fails login when the token is missing from the response", async () => { + httpProxy.mockResolvedValueOnce([200, "application/json", Buffer.from("{}")]); + + await expect(login({ type: "duplicati", url: "http://dup", password: 121284 })).rejects.toThrow("access token"); + + expect(httpProxy.mock.calls[0][1]).toEqual( + expect.objectContaining({ + body: JSON.stringify({ Password: "121284", RememberMe: true }), + }), + ); + }); + + it("summarizes backups, active tasks, and notifications", () => { + const summary = buildSummary( + [ + { + Backup: { + ID: "1", + Name: "Running job", + Metadata: { LastBackupFinished: "20260712T100000Z", TargetFilesSize: "1" }, + }, + Schedule: { Time: "2026-07-13T11:00:00Z" }, + }, + { + Backup: { + ID: "2", + Name: "Warning job", + Metadata: { LastBackupFinished: "20260712T100000Z", TargetFilesSize: "2" }, + }, + Schedule: { Time: "2026-07-13T12:00:00Z" }, + }, + { + Backup: { + ID: "3", + Name: "Errored job", + Metadata: { + LastBackupFinished: "20260712T100000Z", + LastErrorDate: "20260712T120000Z", + LastErrorMessage: "Backend failed", + TargetFilesSize: "3", + }, + }, + Schedule: { Time: "2026-07-13T13:00:00Z" }, + }, + { + Backup: { ID: "4", Name: "Idle job", Metadata: { TargetFilesSize: "4" } }, + Schedule: { Time: "2026-07-13T14:00:00Z" }, + }, + ], + [ + { BackupID: "2", Type: "Warning" }, + { BackupID: "3", Type: "Error" }, + ], + { ActiveTask: { Item1: 7 } }, + { BackupID: "1" }, + ); + + expect(summary.jobs).toBe(4); + expect(summary.stored).toBe(10); + expect(summary.lastBackup).toBe("2026-07-12T10:00:00.000Z"); + expect(summary.nextRun).toBe("2026-07-13T11:00:00.000Z"); + expect(summary.running).toBe(1); + expect(summary.warnings).toBe(1); + expect(summary.errors).toBe(1); + }); + + it("handles empty and incomplete summary inputs", () => { + const summary = buildSummary( + [ + { + Backup: { + ID: "1", + Name: "Errored job", + Metadata: { LastBackupFinished: "20260712T100000Z", TargetFilesSize: "3" }, + }, + Schedule: { Time: "2026-07-13T13:00:00Z" }, + }, + { + Backup: { ID: "2", Name: "No history", Metadata: {} }, + Schedule: {}, + }, + ], + [ + { + BackupID: "1", + Type: "Error", + Timestamp: "2026-07-12T12:00:00Z", + Title: "Backend failed", + Message: "Backend failed\nstack trace", + }, + { Type: "Warning", Timestamp: "2026-07-12T12:00:00Z" }, + ], + { ActiveTask: null }, + {}, + ); + + expect(summary).toEqual({ + jobs: 2, + stored: 3, + lastBackup: "2026-07-12T10:00:00.000Z", + nextRun: "2026-07-13T13:00:00.000Z", + running: 0, + warnings: 0, + errors: 1, + }); + + expect(buildSummary([], [], { ActiveTask: null }, {})).toEqual({ + jobs: 0, + stored: 0, + lastBackup: null, + nextRun: null, + running: 0, + warnings: 0, + errors: 0, + }); + }); +}); diff --git a/src/widgets/duplicati/widget.js b/src/widgets/duplicati/widget.js new file mode 100644 index 000000000..2b52d144c --- /dev/null +++ b/src/widgets/duplicati/widget.js @@ -0,0 +1,8 @@ +import duplicatiProxyHandler from "./proxy"; + +const widget = { + api: "{url}/api/v1/{endpoint}", + proxyHandler: duplicatiProxyHandler, +}; + +export default widget; diff --git a/src/widgets/duplicati/widget.test.js b/src/widgets/duplicati/widget.test.js new file mode 100644 index 000000000..708d3b89d --- /dev/null +++ b/src/widgets/duplicati/widget.test.js @@ -0,0 +1,12 @@ +import { describe, expect, it } from "vitest"; + +import { expectWidgetConfigShape } from "test-utils/widget-config"; + +import widget from "./widget"; + +describe("duplicati widget config", () => { + it("exports a valid widget config", () => { + expectWidgetConfigShape(widget); + expect(widget.api).toContain("/api/v1/"); + }); +}); diff --git a/src/widgets/widgets.js b/src/widgets/widgets.js index 5bc7f7fca..0fee26a21 100644 --- a/src/widgets/widgets.js +++ b/src/widgets/widgets.js @@ -27,6 +27,7 @@ import diskstation from "./diskstation/widget"; import dispatcharr from "./dispatcharr/widget"; import dockhand from "./dockhand/widget"; import downloadstation from "./downloadstation/widget"; +import duplicati from "./duplicati/widget"; import emby from "./emby/widget"; import esphome from "./esphome/widget"; import evcc from "./evcc/widget"; @@ -180,6 +181,7 @@ const widgets = { deluge, develancacheui, diskstation, + duplicati, dispatcharr, dockhand, downloadstation,