mirror of
https://github.com/gethomepage/homepage.git
synced 2026-04-28 11:50:37 -04:00
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:
parent
9b8e771268
commit
c3b4fc17c4
36
docs/widgets/services/ntfy.md
Normal file
36
docs/widgets/services/ntfy.md
Normal 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
|
||||
```
|
||||
@ -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
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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") {
|
||||
|
||||
@ -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" }],
|
||||
[
|
||||
|
||||
@ -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")),
|
||||
|
||||
63
src/widgets/ntfy/component.jsx
Normal file
63
src/widgets/ntfy/component.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
216
src/widgets/ntfy/component.test.jsx
Normal file
216
src/widgets/ntfy/component.test.jsx
Normal 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%");
|
||||
});
|
||||
});
|
||||
14
src/widgets/ntfy/widget.js
Normal file
14
src/widgets/ntfy/widget.js
Normal 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;
|
||||
11
src/widgets/ntfy/widget.test.js
Normal file
11
src/widgets/ntfy/widget.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
@ -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,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user