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,