Feature: Backrest widget (#5741)

Co-authored-by: Renan Greca <renangreca@gmail.com>
Co-authored-by: shamoon <4887959+shamoon@users.noreply.github.com>
This commit is contained in:
Derek Kaser 2025-09-03 17:47:10 -04:00 committed by GitHub
parent 8d37cad871
commit 7b60a60d4e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 190 additions and 0 deletions

View File

@ -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
```

View File

@ -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)

View File

@ -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

View File

@ -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"
}
}

View File

@ -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 <Container service={service} error={error} />;
}
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 (
<Container service={service}>
<Block label="backrest.num_plans" />
<Block label="backrest.num_success_latest" />
<Block label="backrest.num_failure_latest" />
<Block label="backrest.num_success_30" />
<Block label="backrest.num_failure_30" />
<Block label="backrest.bytes_added_30" />
</Container>
);
}
return (
<Container service={service}>
<Block label="backrest.num_plans" value={t("common.number", { value: data.numPlans })} />
<Block label="backrest.num_success_latest" value={t("common.number", { value: data.numSuccessLatest })} />
<Block label="backrest.num_failure_latest" value={t("common.number", { value: data.numFailureLatest })} />
<Block label="backrest.num_success_30" value={t("common.number", { value: data.numSuccess30Days })} />
<Block label="backrest.num_failure_30" value={t("common.number", { value: data.numFailure30Days })} />
<Block label="backrest.bytes_added_30" value={t("common.bytes", { value: data.bytesAdded30Days })} />
</Container>
);
}

View File

@ -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 });
}
}

View File

@ -0,0 +1,14 @@
import backrestProxyHandler from "./proxy";
const widget = {
api: "{url}/v1.Backrest/{endpoint}",
proxyHandler: backrestProxyHandler,
mappings: {
summary: {
endpoint: "GetSummaryDashboard",
},
},
};
export default widget;

View File

@ -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")),

View File

@ -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,