From c3b4fc17c45f93069b5e460efd138a708cb66a37 Mon Sep 17 00:00:00 2001 From: Jim Strang Date: Sun, 26 Apr 2026 23:38:14 -0400 Subject: [PATCH] Feature: ntfy widget (#6601) Co-authored-by: Jim Strang Co-authored-by: shamoon <4887959+shamoon@users.noreply.github.com> --- docs/widgets/services/ntfy.md | 36 +++ mkdocs.yml | 1 + public/locales/en/common.json | 13 ++ src/utils/proxy/handlers/credentialed.js | 6 + src/utils/proxy/handlers/credentialed.test.js | 46 ++++ src/widgets/components.js | 1 + src/widgets/ntfy/component.jsx | 63 +++++ src/widgets/ntfy/component.test.jsx | 216 ++++++++++++++++++ src/widgets/ntfy/widget.js | 14 ++ src/widgets/ntfy/widget.test.js | 11 + src/widgets/widgets.js | 2 + 11 files changed, 409 insertions(+) create mode 100644 docs/widgets/services/ntfy.md create mode 100644 src/widgets/ntfy/component.jsx create mode 100644 src/widgets/ntfy/component.test.jsx create mode 100644 src/widgets/ntfy/widget.js create mode 100644 src/widgets/ntfy/widget.test.js diff --git a/docs/widgets/services/ntfy.md b/docs/widgets/services/ntfy.md new file mode 100644 index 000000000..e8d243705 --- /dev/null +++ b/docs/widgets/services/ntfy.md @@ -0,0 +1,36 @@ +--- +title: ntfy +description: ntfy Widget Configuration +--- + +Learn more about [ntfy](https://github.com/binwiederhier/ntfy). + +This widget shows the latest notification for a ntfy topic, including the title or body, priority level, and when it was received. Works with both self-hosted ntfy instances and the public [ntfy.sh](https://ntfy.sh) service. + +Allowed fields: `["title", "message", "priority", "lastReceived", "tags"]`. + +Default fields: `["title", "message", "priority", "lastReceived"]`. + +If more than 4 fields are provided, only the first 4 are displayed. + +## Authentication + +ntfy supports both public and private topics. For private instances or access-controlled topics, you can authenticate using either a **Bearer token** (ntfy access token) or **Basic auth** (username/password). + +| Auth Method | Config Fields | Notes | +| ------------ | ------------------------------ | --------------------------------- | +| None | _(omit key/username/password)_ | For public topics | +| Bearer token | `key` | ntfy access tokens (`tk_` prefix) | +| Basic auth | `username` + `password` | Username/password credentials | + +See the [ntfy documentation](https://docs.ntfy.sh/config/#access-control) for details on access control. + +```yaml +widget: + type: ntfy + url: http://ntfy.host.or.ip:port # required + topic: mytopic # required + # key: tk_accesstoken # optional — for token auth + # username: user # optional — for basic auth + # password: pass # optional — for basic auth +``` diff --git a/mkdocs.yml b/mkdocs.yml index 1933825c5..e954e0219 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -116,6 +116,7 @@ nav: - widgets/services/nextcloud.md - widgets/services/nextdns.md - widgets/services/nginx-proxy-manager.md + - widgets/services/ntfy.md - widgets/services/nzbget.md - widgets/services/octoprint.md - widgets/services/omada.md diff --git a/public/locales/en/common.json b/public/locales/en/common.json index 6d2204774..8908133b8 100644 --- a/public/locales/en/common.json +++ b/public/locales/en/common.json @@ -936,6 +936,19 @@ "warnings": "Warnings", "criticals": "Criticals" }, + "ntfy": { + "title": "Title", + "priority": "Priority", + "lastReceived": "Last Received", + "message": "Message", + "tags": "Tags", + "none": "None", + "min": "Min", + "low": "Low", + "default": "Default", + "high": "High", + "urgent": "Urgent" + }, "plantit": { "events": "Events", "plants": "Plants", diff --git a/src/utils/proxy/handlers/credentialed.js b/src/utils/proxy/handlers/credentialed.js index 27f02f3e5..afe53457d 100644 --- a/src/utils/proxy/handlers/credentialed.js +++ b/src/utils/proxy/handlers/credentialed.js @@ -77,6 +77,12 @@ export default async function credentialedProxyHandler(req, res, map) { } else { headers.Authorization = basicAuthHeader(widget); } + } else if (widget.type === "ntfy") { + if (widget.key) { + headers.Authorization = `Bearer ${widget.key}`; + } else if (widget.username && widget.password) { + headers.Authorization = basicAuthHeader(widget); + } } else if (widget.type === "proxmox") { headers.Authorization = `PVEAPIToken=${widget.username}=${widget.password}`; } else if (widget.type === "proxmoxbackupserver") { diff --git a/src/utils/proxy/handlers/credentialed.test.js b/src/utils/proxy/handlers/credentialed.test.js index 72e7b5e3d..ef720669a 100644 --- a/src/utils/proxy/handlers/credentialed.test.js +++ b/src/utils/proxy/handlers/credentialed.test.js @@ -34,6 +34,7 @@ vi.mock("widgets/widgets", () => ({ paperlessngx: { api: "{url}/api/{endpoint}" }, proxmox: { api: "{url}/api2/json/{endpoint}" }, truenas: { api: "{url}/api/v2.0/{endpoint}" }, + ntfy: { api: "{url}/{endpoint}" }, proxmoxbackupserver: { api: "{url}/api2/json/{endpoint}" }, checkmk: { api: "{url}/{endpoint}" }, stocks: { api: "{url}/{endpoint}" }, @@ -185,6 +186,51 @@ describe("utils/proxy/handlers/credentialed", () => { expect(params.headers.Authorization).toBe("Bearer k"); }); + it("uses Bearer auth for ntfy when key is provided", async () => { + getServiceWidget.mockResolvedValue({ type: "ntfy", url: "http://ntfy", topic: "alerts", key: "tk_test" }); + httpProxy.mockResolvedValue([200, "application/json", { ok: true }]); + + const req = { method: "GET", query: { group: "g", service: "s", endpoint: "alerts/json", index: 0 } }; + const res = createMockRes(); + + await credentialedProxyHandler(req, res); + + const [, params] = httpProxy.mock.calls.at(-1); + expect(params.headers.Authorization).toBe("Bearer tk_test"); + }); + + it("uses Basic auth for ntfy when username/password are provided", async () => { + getServiceWidget.mockResolvedValue({ + type: "ntfy", + url: "http://ntfy", + topic: "alerts", + username: "u", + password: "p", + }); + httpProxy.mockResolvedValue([200, "application/json", { ok: true }]); + + const req = { method: "GET", query: { group: "g", service: "s", endpoint: "alerts/json", index: 0 } }; + const res = createMockRes(); + + await credentialedProxyHandler(req, res); + + const [, params] = httpProxy.mock.calls.at(-1); + expect(params.headers.Authorization).toMatch(/^Basic /); + }); + + it("sends no auth header for ntfy when no credentials are configured", async () => { + getServiceWidget.mockResolvedValue({ type: "ntfy", url: "http://ntfy", topic: "alerts" }); + httpProxy.mockResolvedValue([200, "application/json", { ok: true }]); + + const req = { method: "GET", query: { group: "g", service: "s", endpoint: "alerts/json", index: 0 } }; + const res = createMockRes(); + + await credentialedProxyHandler(req, res); + + const [, params] = httpProxy.mock.calls.at(-1); + expect(params.headers.Authorization).toBeUndefined(); + }); + it.each([ [{ type: "paperlessngx", url: "http://x", key: "k" }, { Authorization: "Token k" }], [ diff --git a/src/widgets/components.js b/src/widgets/components.js index c5f144e39..fbc34d3a2 100644 --- a/src/widgets/components.js +++ b/src/widgets/components.js @@ -91,6 +91,7 @@ const components = { nextcloud: dynamic(() => import("./nextcloud/component")), nextdns: dynamic(() => import("./nextdns/component")), npm: dynamic(() => import("./npm/component")), + ntfy: dynamic(() => import("./ntfy/component")), nzbget: dynamic(() => import("./nzbget/component")), octoprint: dynamic(() => import("./octoprint/component")), omada: dynamic(() => import("./omada/component")), diff --git a/src/widgets/ntfy/component.jsx b/src/widgets/ntfy/component.jsx new file mode 100644 index 000000000..e4f2b191a --- /dev/null +++ b/src/widgets/ntfy/component.jsx @@ -0,0 +1,63 @@ +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 priorityLabels = { + 1: "min", + 2: "low", + 3: "default", + 4: "high", + 5: "urgent", +}; + +function Truncated({ text }) { + return ( + + {text} + + ); +} + +export default function Component({ service }) { + const { t } = useTranslation(); + const { widget } = service; + + const { data: messagesData, error: messagesError } = useWidgetAPI(widget, "messages"); + + if (messagesError) { + return ; + } + + if (!widget.fields || widget.fields.length === 0) { + widget.fields = ["title", "message", "priority", "lastReceived"]; + } else if (widget.fields?.length > 4) { + widget.fields = widget.fields.slice(0, 4); + } + + if (!messagesData) { + return ( + + + + + + + + ); + } + + return ( + + } /> + } /> + + + 0 ? messagesData.tags.join(", ") : t("ntfy.none")} /> + + ); +} diff --git a/src/widgets/ntfy/component.test.jsx b/src/widgets/ntfy/component.test.jsx new file mode 100644 index 000000000..1132d459a --- /dev/null +++ b/src/widgets/ntfy/component.test.jsx @@ -0,0 +1,216 @@ +// @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/ntfy/component", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("renders placeholders while loading", () => { + useWidgetAPI.mockImplementation(() => ({ data: undefined, error: undefined })); + + const { container } = renderWithProviders(, { + settings: { hideErrors: false }, + }); + + expect(container.querySelectorAll(".service-block")).toHaveLength(4); + expect(screen.getByText("ntfy.title")).toBeInTheDocument(); + expect(screen.getByText("ntfy.message")).toBeInTheDocument(); + expect(screen.getByText("ntfy.priority")).toBeInTheDocument(); + expect(screen.getByText("ntfy.lastReceived")).toBeInTheDocument(); + }); + + it("renders message data with default fields", () => { + useWidgetAPI.mockImplementation(() => ({ + data: { + title: "Disk Alert", + message: "Disk usage at 90%", + priority: 4, + time: 1700000000, + tags: ["warning"], + }, + error: undefined, + })); + + const { container } = renderWithProviders( + , + { settings: { hideErrors: false } }, + ); + + expect(container.querySelectorAll(".service-block")).toHaveLength(4); + expectBlockValue(container, "ntfy.title", "Disk Alert"); + expectBlockValue(container, "ntfy.message", "Disk usage at 90%"); + expectBlockValue(container, "ntfy.priority", "ntfy.high"); + }); + + it("shows placeholder for title when message has no title set", () => { + useWidgetAPI.mockImplementation(() => ({ + data: { + title: null, + message: "Simple notification", + priority: 3, + time: 1700000000, + tags: [], + }, + error: undefined, + })); + + const { container } = renderWithProviders( + , + { settings: { hideErrors: false } }, + ); + + expectBlockValue(container, "ntfy.title", "ntfy.none"); + expectBlockValue(container, "ntfy.message", "Simple notification"); + expectBlockValue(container, "ntfy.priority", "ntfy.default"); + }); + + it("renders no messages state", () => { + useWidgetAPI.mockImplementation(() => ({ + data: { + title: null, + message: null, + priority: 3, + time: null, + tags: [], + }, + error: undefined, + })); + + const { container } = renderWithProviders( + , + { settings: { hideErrors: false } }, + ); + + expectBlockValue(container, "ntfy.title", "ntfy.none"); + expectBlockValue(container, "ntfy.message", "ntfy.none"); + expectBlockValue(container, "ntfy.lastReceived", "ntfy.none"); + }); + + it("renders error when API fails", () => { + useWidgetAPI.mockImplementation(() => ({ + data: undefined, + error: { message: "Request failed" }, + })); + + renderWithProviders( + , + { settings: { hideErrors: false } }, + ); + + expect(screen.getByText("Request failed")).toBeInTheDocument(); + }); + + it("renders optional tags field when included", () => { + useWidgetAPI.mockImplementation(() => ({ + data: { + title: "Alert", + message: "Test", + priority: 5, + time: 1700000000, + tags: ["warning", "skull"], + }, + error: undefined, + })); + + const service = { + widget: { + type: "ntfy", + url: "https://ntfy.example.com", + topic: "alerts", + fields: ["title", "priority", "lastReceived", "tags"], + }, + }; + + const { container } = renderWithProviders(, { settings: { hideErrors: false } }); + + expectBlockValue(container, "ntfy.tags", "warning, skull"); + expectBlockValue(container, "ntfy.priority", "ntfy.urgent"); + }); + + it("caps visible blocks at 4 when more than 4 fields are configured", () => { + useWidgetAPI.mockImplementation(() => ({ + data: { + title: "Alert", + message: "Body", + priority: 3, + time: 1700000000, + tags: ["a"], + }, + error: undefined, + })); + + const service = { + widget: { + type: "ntfy", + url: "https://ntfy.example.com", + topic: "alerts", + fields: ["title", "message", "priority", "lastReceived", "tags"], + }, + }; + + const { container } = renderWithProviders(, { settings: { hideErrors: false } }); + + expect(container.querySelectorAll(".service-block")).toHaveLength(4); + }); + + it("falls back to default priority label when priority is out of range", () => { + useWidgetAPI.mockImplementation(() => ({ + data: { + title: "Alert", + message: "Body", + priority: 99, + time: 1700000000, + tags: [], + }, + error: undefined, + })); + + const { container } = renderWithProviders( + , + { settings: { hideErrors: false } }, + ); + + expectBlockValue(container, "ntfy.priority", "ntfy.default"); + }); + + it("renders optional message field when included", () => { + useWidgetAPI.mockImplementation(() => ({ + data: { + title: "Disk Alert", + message: "Disk usage at 90%", + priority: 4, + time: 1700000000, + tags: [], + }, + error: undefined, + })); + + const service = { + widget: { + type: "ntfy", + url: "https://ntfy.example.com", + topic: "alerts", + fields: ["title", "priority", "message"], + }, + }; + + const { container } = renderWithProviders(, { settings: { hideErrors: false } }); + + expectBlockValue(container, "ntfy.title", "Disk Alert"); + expectBlockValue(container, "ntfy.message", "Disk usage at 90%"); + }); +}); diff --git a/src/widgets/ntfy/widget.js b/src/widgets/ntfy/widget.js new file mode 100644 index 000000000..577e124a7 --- /dev/null +++ b/src/widgets/ntfy/widget.js @@ -0,0 +1,14 @@ +import credentialedProxyHandler from "utils/proxy/handlers/credentialed"; + +const widget = { + api: "{url}/{endpoint}", + proxyHandler: credentialedProxyHandler, + + mappings: { + messages: { + endpoint: "{topic}/json?poll=1&since=latest", + }, + }, +}; + +export default widget; diff --git a/src/widgets/ntfy/widget.test.js b/src/widgets/ntfy/widget.test.js new file mode 100644 index 000000000..4827c60b2 --- /dev/null +++ b/src/widgets/ntfy/widget.test.js @@ -0,0 +1,11 @@ +import { describe, it } from "vitest"; + +import { expectWidgetConfigShape } from "test-utils/widget-config"; + +import widget from "./widget"; + +describe("ntfy widget config", () => { + it("exports a valid widget config", () => { + expectWidgetConfigShape(widget); + }); +}); diff --git a/src/widgets/widgets.js b/src/widgets/widgets.js index be7f685e4..7f18efad3 100644 --- a/src/widgets/widgets.js +++ b/src/widgets/widgets.js @@ -82,6 +82,7 @@ import netdata from "./netdata/widget"; import nextcloud from "./nextcloud/widget"; import nextdns from "./nextdns/widget"; import npm from "./npm/widget"; +import ntfy from "./ntfy/widget"; import nzbget from "./nzbget/widget"; import octoprint from "./octoprint/widget"; import omada from "./omada/widget"; @@ -239,6 +240,7 @@ const widgets = { nextcloud, nextdns, npm, + ntfy, nzbget, octoprint, omada,