Feature: ntfy widget (#6601)

Co-authored-by: Jim Strang <jimstrang@users.noreply.github.com>
Co-authored-by: shamoon <4887959+shamoon@users.noreply.github.com>
This commit is contained in:
Jim Strang 2026-04-26 23:38:14 -04:00 committed by GitHub
parent 9b8e771268
commit c3b4fc17c4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 409 additions and 0 deletions

View File

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

View File

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

View File

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

View File

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

View File

@ -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" }],
[

View File

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

View File

@ -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 (
<span className="grid w-full" title={text}>
<span className="truncate">{text}</span>
</span>
);
}
export default function Component({ service }) {
const { t } = useTranslation();
const { widget } = service;
const { data: messagesData, error: messagesError } = useWidgetAPI(widget, "messages");
if (messagesError) {
return <Container service={service} error={messagesError} />;
}
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 (
<Container service={service}>
<Block label="ntfy.title" />
<Block label="ntfy.message" />
<Block label="ntfy.priority" />
<Block label="ntfy.lastReceived" />
<Block label="ntfy.tags" />
</Container>
);
}
return (
<Container service={service}>
<Block label="ntfy.title" value={<Truncated text={messagesData.title ?? t("ntfy.none")} />} />
<Block label="ntfy.message" value={<Truncated text={messagesData.message ?? t("ntfy.none")} />} />
<Block label="ntfy.priority" value={t(`ntfy.${priorityLabels[messagesData.priority] ?? "default"}`)} />
<Block
label="ntfy.lastReceived"
value={messagesData.time ? t("common.relativeDate", { value: messagesData.time * 1000 }) : t("ntfy.none")}
/>
<Block label="ntfy.tags" value={messagesData.tags?.length > 0 ? messagesData.tags.join(", ") : t("ntfy.none")} />
</Container>
);
}

View File

@ -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(<Component service={{ widget: { type: "ntfy" } }} />, {
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(
<Component service={{ widget: { type: "ntfy", url: "https://ntfy.example.com", topic: "alerts" } }} />,
{ 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(
<Component service={{ widget: { type: "ntfy", url: "https://ntfy.example.com", topic: "alerts" } }} />,
{ 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(
<Component service={{ widget: { type: "ntfy", url: "https://ntfy.example.com", topic: "alerts" } }} />,
{ 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(
<Component service={{ widget: { type: "ntfy", url: "https://ntfy.example.com", topic: "alerts" } }} />,
{ 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(<Component service={service} />, { 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(<Component service={service} />, { 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(
<Component service={{ widget: { type: "ntfy", url: "https://ntfy.example.com", topic: "alerts" } }} />,
{ 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(<Component service={service} />, { settings: { hideErrors: false } });
expectBlockValue(container, "ntfy.title", "Disk Alert");
expectBlockValue(container, "ntfy.message", "Disk usage at 90%");
});
});

View File

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

View File

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

View File

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