mirror of
https://github.com/gethomepage/homepage.git
synced 2026-05-18 11:27:33 +08: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:
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/nextcloud.md
|
||||||
- widgets/services/nextdns.md
|
- widgets/services/nextdns.md
|
||||||
- widgets/services/nginx-proxy-manager.md
|
- widgets/services/nginx-proxy-manager.md
|
||||||
|
- widgets/services/ntfy.md
|
||||||
- widgets/services/nzbget.md
|
- widgets/services/nzbget.md
|
||||||
- widgets/services/octoprint.md
|
- widgets/services/octoprint.md
|
||||||
- widgets/services/omada.md
|
- widgets/services/omada.md
|
||||||
|
|||||||
@@ -936,6 +936,19 @@
|
|||||||
"warnings": "Warnings",
|
"warnings": "Warnings",
|
||||||
"criticals": "Criticals"
|
"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": {
|
"plantit": {
|
||||||
"events": "Events",
|
"events": "Events",
|
||||||
"plants": "Plants",
|
"plants": "Plants",
|
||||||
|
|||||||
@@ -77,6 +77,12 @@ export default async function credentialedProxyHandler(req, res, map) {
|
|||||||
} else {
|
} else {
|
||||||
headers.Authorization = basicAuthHeader(widget);
|
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") {
|
} else if (widget.type === "proxmox") {
|
||||||
headers.Authorization = `PVEAPIToken=${widget.username}=${widget.password}`;
|
headers.Authorization = `PVEAPIToken=${widget.username}=${widget.password}`;
|
||||||
} else if (widget.type === "proxmoxbackupserver") {
|
} else if (widget.type === "proxmoxbackupserver") {
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ vi.mock("widgets/widgets", () => ({
|
|||||||
paperlessngx: { api: "{url}/api/{endpoint}" },
|
paperlessngx: { api: "{url}/api/{endpoint}" },
|
||||||
proxmox: { api: "{url}/api2/json/{endpoint}" },
|
proxmox: { api: "{url}/api2/json/{endpoint}" },
|
||||||
truenas: { api: "{url}/api/v2.0/{endpoint}" },
|
truenas: { api: "{url}/api/v2.0/{endpoint}" },
|
||||||
|
ntfy: { api: "{url}/{endpoint}" },
|
||||||
proxmoxbackupserver: { api: "{url}/api2/json/{endpoint}" },
|
proxmoxbackupserver: { api: "{url}/api2/json/{endpoint}" },
|
||||||
checkmk: { api: "{url}/{endpoint}" },
|
checkmk: { api: "{url}/{endpoint}" },
|
||||||
stocks: { api: "{url}/{endpoint}" },
|
stocks: { api: "{url}/{endpoint}" },
|
||||||
@@ -185,6 +186,51 @@ describe("utils/proxy/handlers/credentialed", () => {
|
|||||||
expect(params.headers.Authorization).toBe("Bearer k");
|
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([
|
it.each([
|
||||||
[{ type: "paperlessngx", url: "http://x", key: "k" }, { Authorization: "Token k" }],
|
[{ type: "paperlessngx", url: "http://x", key: "k" }, { Authorization: "Token k" }],
|
||||||
[
|
[
|
||||||
|
|||||||
@@ -91,6 +91,7 @@ const components = {
|
|||||||
nextcloud: dynamic(() => import("./nextcloud/component")),
|
nextcloud: dynamic(() => import("./nextcloud/component")),
|
||||||
nextdns: dynamic(() => import("./nextdns/component")),
|
nextdns: dynamic(() => import("./nextdns/component")),
|
||||||
npm: dynamic(() => import("./npm/component")),
|
npm: dynamic(() => import("./npm/component")),
|
||||||
|
ntfy: dynamic(() => import("./ntfy/component")),
|
||||||
nzbget: dynamic(() => import("./nzbget/component")),
|
nzbget: dynamic(() => import("./nzbget/component")),
|
||||||
octoprint: dynamic(() => import("./octoprint/component")),
|
octoprint: dynamic(() => import("./octoprint/component")),
|
||||||
omada: dynamic(() => import("./omada/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 nextcloud from "./nextcloud/widget";
|
||||||
import nextdns from "./nextdns/widget";
|
import nextdns from "./nextdns/widget";
|
||||||
import npm from "./npm/widget";
|
import npm from "./npm/widget";
|
||||||
|
import ntfy from "./ntfy/widget";
|
||||||
import nzbget from "./nzbget/widget";
|
import nzbget from "./nzbget/widget";
|
||||||
import octoprint from "./octoprint/widget";
|
import octoprint from "./octoprint/widget";
|
||||||
import omada from "./omada/widget";
|
import omada from "./omada/widget";
|
||||||
@@ -239,6 +240,7 @@ const widgets = {
|
|||||||
nextcloud,
|
nextcloud,
|
||||||
nextdns,
|
nextdns,
|
||||||
npm,
|
npm,
|
||||||
|
ntfy,
|
||||||
nzbget,
|
nzbget,
|
||||||
octoprint,
|
octoprint,
|
||||||
omada,
|
omada,
|
||||||
|
|||||||
Reference in New Issue
Block a user