feat(FN-2339): add configurable ntfy server support
- Add ntfy base URL to global settings schema/types with persistence coverage and regression tests - Extend dashboard settings API/routes and Settings modal UI to edit and save a custom ntfy server - Update notifier runtime to use configured ntfy base URL when sending notifications - Document the new setting and include a changeset for @runfusion/fusion
This commit is contained in:
@@ -1098,7 +1098,7 @@ describe("NtfyNotifier", () => {
|
||||
});
|
||||
|
||||
describe("custom base URL", () => {
|
||||
it("uses custom ntfy base URL when provided", async () => {
|
||||
it("uses custom ntfy base URL when provided in notifier options", async () => {
|
||||
store.setSettings({ ntfyEnabled: true, ntfyTopic: "test-topic" });
|
||||
fetchMock.mockResolvedValue({ ok: true });
|
||||
|
||||
@@ -1114,6 +1114,67 @@ describe("NtfyNotifier", () => {
|
||||
expect.any(Object)
|
||||
);
|
||||
});
|
||||
|
||||
it("uses ntfyBaseUrl from settings when configured", async () => {
|
||||
store.setSettings({
|
||||
ntfyEnabled: true,
|
||||
ntfyTopic: "test-topic",
|
||||
ntfyBaseUrl: "https://ntfy.internal.example///",
|
||||
});
|
||||
fetchMock.mockResolvedValue({ ok: true });
|
||||
|
||||
notifier = new NtfyNotifier(store);
|
||||
await notifier.start();
|
||||
|
||||
store.triggerTaskMoved(createTask("FN-101", "Configured URL Task"), "in-progress", "in-review");
|
||||
await flushAsyncWork();
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"https://ntfy.internal.example/test-topic",
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to default ntfy.sh when settings ntfyBaseUrl is blank", async () => {
|
||||
store.setSettings({
|
||||
ntfyEnabled: true,
|
||||
ntfyTopic: "test-topic",
|
||||
ntfyBaseUrl: " ",
|
||||
});
|
||||
fetchMock.mockResolvedValue({ ok: true });
|
||||
|
||||
notifier = new NtfyNotifier(store);
|
||||
await notifier.start();
|
||||
|
||||
store.triggerTaskMoved(createTask("FN-102", "Blank URL Task"), "in-progress", "in-review");
|
||||
await flushAsyncWork();
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"https://ntfy.sh/test-topic",
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it("applies updated ntfyBaseUrl from settings changes at runtime", async () => {
|
||||
store.setSettings({ ntfyEnabled: true, ntfyTopic: "test-topic" });
|
||||
fetchMock.mockResolvedValue({ ok: true });
|
||||
|
||||
notifier = new NtfyNotifier(store);
|
||||
await notifier.start();
|
||||
|
||||
store.triggerTaskMoved(createTask("FN-103", "Before Update"), "in-progress", "in-review");
|
||||
await flushAsyncWork();
|
||||
expect(fetchMock).toHaveBeenLastCalledWith("https://ntfy.sh/test-topic", expect.any(Object));
|
||||
|
||||
store.setSettings({ ntfyBaseUrl: "https://ntfy.changed.example" });
|
||||
store.triggerTaskMoved(createTask("FN-104", "After Update"), "in-progress", "in-review");
|
||||
await flushAsyncWork();
|
||||
|
||||
expect(fetchMock).toHaveBeenLastCalledWith(
|
||||
"https://ntfy.changed.example/test-topic",
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("stop()", () => {
|
||||
|
||||
@@ -10,6 +10,8 @@ export interface NtfyNotifierOptions {
|
||||
|
||||
export type NtfyNotificationPriority = "low" | "default" | "high" | "urgent";
|
||||
|
||||
const DEFAULT_NTFY_BASE_URL = "https://ntfy.sh";
|
||||
|
||||
export const DEFAULT_NTFY_EVENTS: readonly NtfyNotificationEvent[] = [
|
||||
"in-review",
|
||||
"merged",
|
||||
@@ -71,6 +73,14 @@ interface NtfyNotifierStore {
|
||||
off(event: string, listener: (...args: any[]) => void): void;
|
||||
}
|
||||
|
||||
function resolveNtfyBaseUrl(baseUrl: string | undefined, fallback = DEFAULT_NTFY_BASE_URL): string {
|
||||
const trimmed = baseUrl?.trim();
|
||||
if (!trimmed) {
|
||||
return fallback;
|
||||
}
|
||||
return trimmed.replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
export function resolveNtfyEvents(events?: NtfyNotificationEvent[]): NtfyNotificationEvent[] {
|
||||
return events ? [...events] : [...DEFAULT_NTFY_EVENTS];
|
||||
}
|
||||
@@ -108,7 +118,7 @@ export function buildNtfyClickUrl(options: {
|
||||
* Errors are logged and swallowed so callers can treat delivery as best-effort.
|
||||
*/
|
||||
export async function sendNtfyNotification({
|
||||
ntfyBaseUrl = "https://ntfy.sh",
|
||||
ntfyBaseUrl,
|
||||
topic,
|
||||
title,
|
||||
message,
|
||||
@@ -127,7 +137,8 @@ export async function sendNtfyNotification({
|
||||
headers.Click = clickUrl;
|
||||
}
|
||||
|
||||
const response = await fetch(`${ntfyBaseUrl}/${topic}`, {
|
||||
const resolvedBaseUrl = resolveNtfyBaseUrl(ntfyBaseUrl);
|
||||
const response = await fetch(`${resolvedBaseUrl}/${topic}`, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: message,
|
||||
@@ -158,6 +169,7 @@ export class NtfyNotifier {
|
||||
events: [...DEFAULT_NTFY_EVENTS],
|
||||
};
|
||||
private ntfyBaseUrl: string;
|
||||
private readonly defaultNtfyBaseUrl: string;
|
||||
private projectId?: string;
|
||||
private notifiedEvents: Set<string> = new Set();
|
||||
private abortController: AbortController | null = null;
|
||||
@@ -166,7 +178,8 @@ export class NtfyNotifier {
|
||||
private store: NtfyNotifierStore,
|
||||
options: NtfyNotifierOptions = {},
|
||||
) {
|
||||
this.ntfyBaseUrl = options.ntfyBaseUrl ?? "https://ntfy.sh";
|
||||
this.defaultNtfyBaseUrl = resolveNtfyBaseUrl(options.ntfyBaseUrl);
|
||||
this.ntfyBaseUrl = this.defaultNtfyBaseUrl;
|
||||
this.projectId = options.projectId;
|
||||
}
|
||||
|
||||
@@ -314,6 +327,7 @@ export class NtfyNotifier {
|
||||
|
||||
if (settings.ntfyEnabled !== previous.ntfyEnabled ||
|
||||
settings.ntfyTopic !== previous.ntfyTopic ||
|
||||
settings.ntfyBaseUrl !== previous.ntfyBaseUrl ||
|
||||
settings.ntfyDashboardHost !== previous.ntfyDashboardHost ||
|
||||
JSON.stringify(settings.ntfyEvents) !== JSON.stringify(previous.ntfyEvents)) {
|
||||
const wasEnabled = this.config.enabled;
|
||||
@@ -325,6 +339,8 @@ export class NtfyNotifier {
|
||||
schedulerLog.log("NtfyNotifier disabled");
|
||||
} else if (this.config.topic !== previous.ntfyTopic) {
|
||||
schedulerLog.log("NtfyNotifier topic updated");
|
||||
} else if (this.ntfyBaseUrl !== resolveNtfyBaseUrl(previous.ntfyBaseUrl)) {
|
||||
schedulerLog.log("NtfyNotifier base URL updated");
|
||||
} else if (this.config.dashboardHost !== previous.ntfyDashboardHost) {
|
||||
schedulerLog.log("NtfyNotifier dashboard host updated");
|
||||
} else if (JSON.stringify(this.config.events) !== JSON.stringify(previous.ntfyEvents)) {
|
||||
@@ -340,6 +356,7 @@ export class NtfyNotifier {
|
||||
dashboardHost: settings.ntfyDashboardHost,
|
||||
events: resolveNtfyEvents(settings.ntfyEvents),
|
||||
};
|
||||
this.ntfyBaseUrl = resolveNtfyBaseUrl(settings.ntfyBaseUrl, this.defaultNtfyBaseUrl);
|
||||
}
|
||||
|
||||
private isEventEnabled(event: TaskNotificationEvent): boolean {
|
||||
|
||||
Reference in New Issue
Block a user