Feature: Maintainerr service widget (#6907)
Docker CI / Docker Build & Push (push) Has been cancelled
Lint / Linting Checks (push) Has been cancelled
Release Drafter / Update Release Draft (push) Has been cancelled
Release Drafter / Auto Label PR (push) Has been cancelled
Tests / vitest (1) (push) Has been cancelled
Tests / vitest (2) (push) Has been cancelled
Tests / vitest (3) (push) Has been cancelled
Tests / vitest (4) (push) Has been cancelled

This commit is contained in:
shamoon
2026-07-28 14:35:49 -07:00
committed by GitHub
parent d01422324b
commit 2ca559c892
9 changed files with 265 additions and 0 deletions
+17
View File
@@ -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
```
+1
View File
@@ -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
+11
View File
@@ -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"
+1
View File
@@ -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")),
+79
View File
@@ -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 <Container service={service} error={error} />;
}
if (!data) {
return (
<Container service={service}>
<Block label="maintainerr.itemsHandled" />
<Block label="maintainerr.episodesHandled" />
<Block label="maintainerr.moviesHandled" />
<Block label="maintainerr.showsHandled" />
<Block label="maintainerr.seasonsHandled" />
<Block label="maintainerr.reclaimable" />
<Block label="maintainerr.movieReclaimable" />
<Block label="maintainerr.showReclaimable" />
<Block label="maintainerr.totalCapacity" />
</Container>
);
}
return (
<Container service={service}>
<Block
label="maintainerr.itemsHandled"
value={t("common.number", { value: data.cleanupTotals?.itemsHandled ?? 0 })}
/>
<Block
label="maintainerr.episodesHandled"
value={t("common.number", { value: data.cleanupTotals?.episodesHandled ?? 0 })}
/>
<Block
label="maintainerr.moviesHandled"
value={t("common.number", { value: data.cleanupTotals?.moviesHandled ?? 0 })}
/>
<Block
label="maintainerr.showsHandled"
value={t("common.number", { value: data.cleanupTotals?.showsHandled ?? 0 })}
/>
<Block
label="maintainerr.seasonsHandled"
value={t("common.number", { value: data.cleanupTotals?.seasonsHandled ?? 0 })}
/>
<Block
label="maintainerr.reclaimable"
value={t("common.bytes", { value: data.collectionSummary?.activeSizeBytes ?? 0 })}
/>
<Block
label="maintainerr.movieReclaimable"
value={t("common.bytes", { value: data.collectionSummary?.movieSizeBytes ?? 0 })}
/>
<Block
label="maintainerr.showReclaimable"
value={t("common.bytes", { value: data.collectionSummary?.showSizeBytes ?? 0 })}
/>
<Block label="maintainerr.totalCapacity" value={t("common.bytes", { value: data.totals?.totalSpace ?? 0 })} />
</Container>
);
}
+135
View File
@@ -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(<Component service={{ widget: { type: "maintainerr" } }} />, {
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(<Component service={{ widget: { type: "maintainerr" } }} />, {
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(<Component service={service} />, {
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(<Component service={service} />, {
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(<Component service={{ widget: { type: "maintainerr" } }} />, {
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(<Component service={{ widget: { type: "maintainerr" } }} />, {
settings: { hideErrors: false },
});
expect(screen.getByText("boom")).toBeInTheDocument();
});
});
+8
View File
@@ -0,0 +1,8 @@
import genericProxyHandler from "utils/proxy/handlers/generic";
const widget = {
api: "{url}/api/storage-metrics",
proxyHandler: genericProxyHandler,
};
export default widget;
+11
View File
@@ -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);
});
});
+2
View File
@@ -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,