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,