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,