From 2ca559c892f79eae92ae0f861db09374b0b8ea79 Mon Sep 17 00:00:00 2001
From: shamoon <4887959+shamoon@users.noreply.github.com>
Date: Tue, 28 Jul 2026 14:35:49 -0700
Subject: [PATCH] Feature: Maintainerr service widget (#6907)
---
docs/widgets/services/maintainerr.md | 17 +++
mkdocs.yml | 1 +
public/locales/en/common.json | 11 ++
src/widgets/components.js | 1 +
src/widgets/maintainerr/component.jsx | 79 ++++++++++++
src/widgets/maintainerr/component.test.jsx | 135 +++++++++++++++++++++
src/widgets/maintainerr/widget.js | 8 ++
src/widgets/maintainerr/widget.test.js | 11 ++
src/widgets/widgets.js | 2 +
9 files changed, 265 insertions(+)
create mode 100644 docs/widgets/services/maintainerr.md
create mode 100644 src/widgets/maintainerr/component.jsx
create mode 100644 src/widgets/maintainerr/component.test.jsx
create mode 100644 src/widgets/maintainerr/widget.js
create mode 100644 src/widgets/maintainerr/widget.test.js
diff --git a/docs/widgets/services/maintainerr.md b/docs/widgets/services/maintainerr.md
new file mode 100644
index 000000000..751e81547
--- /dev/null
+++ b/docs/widgets/services/maintainerr.md
@@ -0,0 +1,17 @@
+---
+title: Maintainerr
+description: Maintainerr Widget Configuration
+---
+
+Learn more about [Maintainerr](https://github.com/maintainerr/maintainerr).
+
+Allowed fields: `["itemsHandled", "episodesHandled", "moviesHandled", "showsHandled", "seasonsHandled", "reclaimable", "movieReclaimable", "showReclaimable", "totalCapacity"]` (maximum of 4).
+
+Default fields: `["itemsHandled", "episodesHandled", "moviesHandled", "reclaimable"]`.
+
+```yaml
+widget:
+ type: maintainerr
+ url: http://maintainerr.host.or.ip:port
+ fields: ["itemsHandled", "episodesHandled", "moviesHandled", "reclaimable"] # optional
+```
diff --git a/mkdocs.yml b/mkdocs.yml
index 41543407f..f1a8936d9 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -103,6 +103,7 @@ nav:
- widgets/services/lubelogger.md
- widgets/services/mastodon.md
- widgets/services/mailcow.md
+ - widgets/services/maintainerr.md
- widgets/services/mealie.md
- widgets/services/medusa.md
- widgets/services/mikrotik.md
diff --git a/public/locales/en/common.json b/public/locales/en/common.json
index dfa2140e8..16e5af7fa 100644
--- a/public/locales/en/common.json
+++ b/public/locales/en/common.json
@@ -946,6 +946,17 @@
"mails": "Mails",
"storage": "Storage"
},
+ "maintainerr": {
+ "itemsHandled": "Items Handled",
+ "episodesHandled": "Episodes Handled",
+ "moviesHandled": "Movies Handled",
+ "showsHandled": "Shows Handled",
+ "seasonsHandled": "Seasons Handled",
+ "reclaimable": "Reclaimable",
+ "movieReclaimable": "Movie Reclaimable",
+ "showReclaimable": "Show Reclaimable",
+ "totalCapacity": "Total Capacity"
+ },
"netdata": {
"warnings": "Warnings",
"criticals": "Criticals"
diff --git a/src/widgets/components.js b/src/widgets/components.js
index 2d0dd6bfc..dc1c87c9f 100644
--- a/src/widgets/components.js
+++ b/src/widgets/components.js
@@ -76,6 +76,7 @@ const components = {
linkwarden: dynamic(() => import("./linkwarden/component")),
lubelogger: dynamic(() => import("./lubelogger/component")),
mailcow: dynamic(() => import("./mailcow/component")),
+ maintainerr: dynamic(() => import("./maintainerr/component")),
mastodon: dynamic(() => import("./mastodon/component")),
mealie: dynamic(() => import("./mealie/component")),
medusa: dynamic(() => import("./medusa/component")),
diff --git a/src/widgets/maintainerr/component.jsx b/src/widgets/maintainerr/component.jsx
new file mode 100644
index 000000000..83600afe5
--- /dev/null
+++ b/src/widgets/maintainerr/component.jsx
@@ -0,0 +1,79 @@
+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 = ["itemsHandled", "episodesHandled", "moviesHandled", "reclaimable"];
+const MAX_FIELDS = 4;
+
+export default function Component({ service }) {
+ const { t } = useTranslation();
+ const { widget } = service;
+
+ if (!widget.fields?.length) {
+ widget.fields = DEFAULT_FIELDS;
+ } else if (widget.fields.length > MAX_FIELDS) {
+ widget.fields = widget.fields.slice(0, MAX_FIELDS);
+ }
+
+ const { data, error } = useWidgetAPI(widget);
+
+ if (error) {
+ return ;
+ }
+
+ if (!data) {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/widgets/maintainerr/component.test.jsx b/src/widgets/maintainerr/component.test.jsx
new file mode 100644
index 000000000..b999cf0f5
--- /dev/null
+++ b/src/widgets/maintainerr/component.test.jsx
@@ -0,0 +1,135 @@
+// @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/maintainerr/component", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("renders placeholders for the default fields while loading", () => {
+ useWidgetAPI.mockReturnValue({ data: undefined, error: undefined });
+
+ const { container } = renderWithProviders(, {
+ settings: { hideErrors: false },
+ });
+
+ expect(container.querySelectorAll(".service-block")).toHaveLength(4);
+ expect(screen.getByText("maintainerr.itemsHandled")).toBeInTheDocument();
+ expect(screen.getByText("maintainerr.episodesHandled")).toBeInTheDocument();
+ expect(screen.getByText("maintainerr.moviesHandled")).toBeInTheDocument();
+ expect(screen.getByText("maintainerr.reclaimable")).toBeInTheDocument();
+ });
+
+ it("renders the default storage metrics", () => {
+ useWidgetAPI.mockReturnValue({
+ data: {
+ cleanupTotals: {
+ itemsHandled: 12,
+ episodesHandled: 7,
+ moviesHandled: 5,
+ },
+ collectionSummary: {
+ activeSizeBytes: 1_500_000_000,
+ },
+ },
+ error: undefined,
+ });
+
+ const { container } = renderWithProviders(, {
+ settings: { hideErrors: false },
+ });
+
+ expectBlockValue(container, "maintainerr.itemsHandled", 12);
+ expectBlockValue(container, "maintainerr.episodesHandled", 7);
+ expectBlockValue(container, "maintainerr.moviesHandled", 5);
+ expectBlockValue(container, "maintainerr.reclaimable", 1_500_000_000);
+ });
+
+ it("renders configured optional fields", () => {
+ useWidgetAPI.mockReturnValue({
+ data: {
+ cleanupTotals: {
+ showsHandled: 3,
+ seasonsHandled: 8,
+ },
+ collectionSummary: {
+ movieSizeBytes: 1_000_000_000,
+ },
+ totals: {
+ totalSpace: 2_000_000_000,
+ },
+ },
+ error: undefined,
+ });
+
+ const service = {
+ widget: {
+ type: "maintainerr",
+ fields: ["showsHandled", "seasonsHandled", "movieReclaimable", "totalCapacity"],
+ },
+ };
+ const { container } = renderWithProviders(, {
+ settings: { hideErrors: false },
+ });
+
+ expect(container.querySelectorAll(".service-block")).toHaveLength(4);
+ expectBlockValue(container, "maintainerr.showsHandled", 3);
+ expectBlockValue(container, "maintainerr.seasonsHandled", 8);
+ expectBlockValue(container, "maintainerr.movieReclaimable", 1_000_000_000);
+ expectBlockValue(container, "maintainerr.totalCapacity", 2_000_000_000);
+ });
+
+ it("limits configured fields to the first four", () => {
+ useWidgetAPI.mockReturnValue({ data: undefined, error: undefined });
+
+ const service = {
+ widget: {
+ type: "maintainerr",
+ fields: ["itemsHandled", "episodesHandled", "moviesHandled", "showsHandled", "seasonsHandled"],
+ },
+ };
+ const { container } = renderWithProviders(, {
+ settings: { hideErrors: false },
+ });
+
+ expect(container.querySelectorAll(".service-block")).toHaveLength(4);
+ expect(screen.getByText("maintainerr.showsHandled")).toBeInTheDocument();
+ expect(screen.queryByText("maintainerr.seasonsHandled")).toBeNull();
+ });
+
+ it("renders zero for missing metrics", () => {
+ useWidgetAPI.mockReturnValue({ data: {}, error: undefined });
+
+ const { container } = renderWithProviders(, {
+ settings: { hideErrors: false },
+ });
+
+ expectBlockValue(container, "maintainerr.itemsHandled", 0);
+ expectBlockValue(container, "maintainerr.episodesHandled", 0);
+ expectBlockValue(container, "maintainerr.moviesHandled", 0);
+ expectBlockValue(container, "maintainerr.reclaimable", 0);
+ });
+
+ it("renders the error state", () => {
+ useWidgetAPI.mockReturnValue({ data: undefined, error: { message: "boom" } });
+
+ renderWithProviders(, {
+ settings: { hideErrors: false },
+ });
+
+ expect(screen.getByText("boom")).toBeInTheDocument();
+ });
+});
diff --git a/src/widgets/maintainerr/widget.js b/src/widgets/maintainerr/widget.js
new file mode 100644
index 000000000..496c435b9
--- /dev/null
+++ b/src/widgets/maintainerr/widget.js
@@ -0,0 +1,8 @@
+import genericProxyHandler from "utils/proxy/handlers/generic";
+
+const widget = {
+ api: "{url}/api/storage-metrics",
+ proxyHandler: genericProxyHandler,
+};
+
+export default widget;
diff --git a/src/widgets/maintainerr/widget.test.js b/src/widgets/maintainerr/widget.test.js
new file mode 100644
index 000000000..9680dc4d4
--- /dev/null
+++ b/src/widgets/maintainerr/widget.test.js
@@ -0,0 +1,11 @@
+import { describe, it } from "vitest";
+
+import { expectWidgetConfigShape } from "test-utils/widget-config";
+
+import widget from "./widget";
+
+describe("maintainerr widget config", () => {
+ it("exports a valid widget config", () => {
+ expectWidgetConfigShape(widget);
+ });
+});
diff --git a/src/widgets/widgets.js b/src/widgets/widgets.js
index 17be5aedc..aaa024f8d 100644
--- a/src/widgets/widgets.js
+++ b/src/widgets/widgets.js
@@ -67,6 +67,7 @@ import lidarr from "./lidarr/widget";
import linkwarden from "./linkwarden/widget";
import lubelogger from "./lubelogger/widget";
import mailcow from "./mailcow/widget";
+import maintainerr from "./maintainerr/widget";
import mastodon from "./mastodon/widget";
import mealie from "./mealie/widget";
import medusa from "./medusa/widget";
@@ -229,6 +230,7 @@ const widgets = {
linkwarden,
lubelogger,
mailcow,
+ maintainerr,
mastodon,
mealie,
medusa,