diff --git a/docs/widgets/services/backrest.md b/docs/widgets/services/backrest.md
new file mode 100644
index 000000000..d70ba9e19
--- /dev/null
+++ b/docs/widgets/services/backrest.md
@@ -0,0 +1,17 @@
+---
+title: Backrest
+description: Backrest Widget Configuration
+---
+
+[Backrest](https://garethgeorge.github.io/backrest/) is a web-based frontend for
+the [Restic](https://restic.net/) backup tool.
+
+**Allowed fields:** `["num_success_latest","num_failure_latest","num_success_30","num_plans","num_failure_30","bytes_added_30"]`
+
+```yaml
+widget:
+ type: backrest
+ url: http://backrest.host.or.ip
+ username: admin # optional if auth is enabled in Backrest
+ password: admin # optional if auth is enabled in Backrest
+```
diff --git a/docs/widgets/services/index.md b/docs/widgets/services/index.md
index 221fe47b2..8ae84ee9e 100644
--- a/docs/widgets/services/index.md
+++ b/docs/widgets/services/index.md
@@ -15,6 +15,7 @@ You can also find a list of all available service widgets in the sidebar navigat
- [Authentik](authentik.md)
- [Autobrr](autobrr.md)
- [Azure DevOps](azuredevops.md)
+- [Backrest](backrest.md)
- [Bazarr](bazarr.md)
- [Beszel](beszel.md)
- [Caddy](caddy.md)
diff --git a/mkdocs.yml b/mkdocs.yml
index 720a7bd49..d46382923 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -39,6 +39,7 @@ nav:
- widgets/services/authentik.md
- widgets/services/autobrr.md
- widgets/services/azuredevops.md
+ - widgets/services/backrest.md
- widgets/services/bazarr.md
- widgets/services/beszel.md
- widgets/services/caddy.md
diff --git a/public/locales/en/common.json b/public/locales/en/common.json
index f4d1f5976..49d1325d5 100644
--- a/public/locales/en/common.json
+++ b/public/locales/en/common.json
@@ -1106,5 +1106,13 @@
"arrayFree": "Array Free",
"poolUsed": "{{pool}} Used",
"poolFree": "{{pool}} Free"
+ },
+ "backrest": {
+ "num_plans": "Plans",
+ "num_success_30": "Successes",
+ "num_failure_30": "Failures",
+ "num_success_latest": "Succeeding",
+ "num_failure_latest": "Failing",
+ "bytes_added_30": "Bytes Added"
}
}
diff --git a/src/widgets/backrest/component.jsx b/src/widgets/backrest/component.jsx
new file mode 100644
index 000000000..af344590f
--- /dev/null
+++ b/src/widgets/backrest/component.jsx
@@ -0,0 +1,50 @@
+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";
+
+const BACKREST_DEFAULT_FIELDS = ["num_success_latest", "num_failure_latest", "num_failure_30", "bytes_added_30"];
+const MAX_ALLOWED_FIELDS = 4;
+
+export default function Component({ service }) {
+ const { t } = useTranslation();
+
+ const { widget } = service;
+
+ const { data, error } = useWidgetAPI(widget, "summary");
+
+ if (error) {
+ return ;
+ }
+
+ if (!widget.fields?.length) {
+ widget.fields = BACKREST_DEFAULT_FIELDS;
+ } else if (widget.fields.length > MAX_ALLOWED_FIELDS) {
+ widget.fields = widget.fields.slice(0, MAX_ALLOWED_FIELDS);
+ }
+
+ if (!data) {
+ return (
+
+
+
+
+
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/widgets/backrest/proxy.js b/src/widgets/backrest/proxy.js
new file mode 100644
index 000000000..610f76fcb
--- /dev/null
+++ b/src/widgets/backrest/proxy.js
@@ -0,0 +1,96 @@
+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 proxyName = "backrestProxyHandler";
+const logger = createLogger(proxyName);
+
+function sumField(plans, field) {
+ return plans.reduce((sum, plan) => {
+ const num = Number(plan[field]);
+ return sum + (Number.isNaN(num) ? 0 : num);
+ }, 0);
+}
+
+function buildResponse(plans) {
+ const numSuccess30Days = sumField(plans, "backupsSuccessLast30days");
+ const numFailure30Days = sumField(plans, "backupsFailed30days");
+ const bytesAdded30Days = sumField(plans, "bytesAddedLast30days");
+
+ var numSuccessLatest = 0;
+ var numFailureLatest = 0;
+
+ plans.forEach((plan) => {
+ const statuses = plan?.recentBackups?.status;
+ if (Array.isArray(statuses) && statuses.length > 0) {
+ if (statuses[0] === "STATUS_SUCCESS") {
+ numSuccessLatest++;
+ } else {
+ numFailureLatest++;
+ }
+ }
+ });
+
+ return {
+ numPlans: plans.length,
+ numSuccess30Days,
+ numFailure30Days,
+ numSuccessLatest,
+ numFailureLatest,
+ bytesAdded30Days,
+ };
+}
+
+export default async function backrestProxyHandler(req, res) {
+ const { group, service, endpoint, index } = req.query;
+
+ if (!group || !service) {
+ logger.debug("Invalid or missing service '%s' or group '%s'", service, group);
+ return res.status(400).json({ error: "Invalid proxy service type" });
+ }
+
+ const widget = await getServiceWidget(group, service, index);
+
+ if (!widget) {
+ logger.debug("Invalid or missing widget for service '%s' in group '%s'", service, group);
+ return res.status(400).json({ error: "Invalid proxy service type" });
+ }
+
+ const headers = {
+ "content-type": "application/json",
+ };
+
+ if (widget.username && widget.password) {
+ headers.Authorization = `Basic ${Buffer.from(`${widget.username}:${widget.password}`).toString("base64")}`;
+ }
+
+ const { api } = widgets[widget.type];
+ const url = new URL(formatApiCall(api, { endpoint, ...widget }));
+
+ try {
+ const [status, contentType, data] = await httpProxy(url, {
+ method: "POST",
+ body: JSON.stringify({}),
+ headers,
+ });
+
+ if (status !== 200) {
+ logger.error("Error getting data from Backrest: %d. Data: %s", status, data);
+ return res.status(500).send({ error: { message: "Error getting data from Backrest", url, data } });
+ }
+
+ if (contentType) res.setHeader("Content-Type", "application/json");
+ const plans = asJson(data).planSummaries;
+ if (!Array.isArray(plans)) {
+ logger.error("Invalid plans data: %s", JSON.stringify(plans));
+ return res.status(500).send({ error: { message: "Invalid plans data", url, data } });
+ }
+ const response = buildResponse(plans);
+ return res.status(status).send(response);
+ } catch (error) {
+ logger.error("Exception calling Backrest API: %s", error.message);
+ return res.status(500).json({ error: "Backrest API Error", message: error.message });
+ }
+}
diff --git a/src/widgets/backrest/widget.js b/src/widgets/backrest/widget.js
new file mode 100644
index 000000000..869d620d4
--- /dev/null
+++ b/src/widgets/backrest/widget.js
@@ -0,0 +1,14 @@
+import backrestProxyHandler from "./proxy";
+
+const widget = {
+ api: "{url}/v1.Backrest/{endpoint}",
+ proxyHandler: backrestProxyHandler,
+
+ mappings: {
+ summary: {
+ endpoint: "GetSummaryDashboard",
+ },
+ },
+};
+
+export default widget;
diff --git a/src/widgets/components.js b/src/widgets/components.js
index 490e0032a..4b8d4e715 100644
--- a/src/widgets/components.js
+++ b/src/widgets/components.js
@@ -9,6 +9,7 @@ const components = {
authentik: dynamic(() => import("./authentik/component")),
autobrr: dynamic(() => import("./autobrr/component")),
azuredevops: dynamic(() => import("./azuredevops/component")),
+ backrest: dynamic(() => import("./backrest/component")),
bazarr: dynamic(() => import("./bazarr/component")),
beszel: dynamic(() => import("./beszel/component")),
caddy: dynamic(() => import("./caddy/component")),
diff --git a/src/widgets/widgets.js b/src/widgets/widgets.js
index 25f2007a5..bc9f137d3 100644
--- a/src/widgets/widgets.js
+++ b/src/widgets/widgets.js
@@ -6,6 +6,7 @@ import audiobookshelf from "./audiobookshelf/widget";
import authentik from "./authentik/widget";
import autobrr from "./autobrr/widget";
import azuredevops from "./azuredevops/widget";
+import backrest from "./backrest/widget";
import bazarr from "./bazarr/widget";
import beszel from "./beszel/widget";
import caddy from "./caddy/widget";
@@ -151,6 +152,7 @@ const widgets = {
authentik,
autobrr,
azuredevops,
+ backrest,
bazarr,
beszel,
caddy,