From c13f26a4c31f124cff85eed88944255927e9d3e3 Mon Sep 17 00:00:00 2001 From: signalgridco Date: Tue, 1 Sep 2026 23:17:11 +0200 Subject: [PATCH] feat: add Signalgrid notification provider --- .../src/Pages/Notifications/create/index.tsx | 26 +++ client/src/Types/Notification.ts | 1 + client/src/Validation/notifications.ts | 7 + client/src/locales/en.json | 9 + server/openapi/routes/notification.ts | 4 + .../api/validation/notificationValidation.ts | 7 + server/src/config/services.shared.ts | 3 + .../notifications/notification.model.ts | 1 + .../domain/notifications/notification.type.ts | 1 + .../notifications/providers/signalgrid.ts | 123 ++++++++++++++ .../notifications/signalgridProvider.test.ts | 155 ++++++++++++++++++ 11 files changed, 337 insertions(+) create mode 100644 server/src/domain/notifications/providers/signalgrid.ts create mode 100644 server/test/unit/providers/notifications/signalgridProvider.test.ts diff --git a/client/src/Pages/Notifications/create/index.tsx b/client/src/Pages/Notifications/create/index.tsx index 8063f16b6..c4f339bf1 100644 --- a/client/src/Pages/Notifications/create/index.tsx +++ b/client/src/Pages/Notifications/create/index.tsx @@ -126,6 +126,7 @@ const NotificationsCreatePage = () => { {watchedType !== "matrix" && watchedType !== "telegram" && watchedType !== "pushover" && + watchedType !== "signalgrid" && watchedType !== "twilio" && watchedType !== "ntfy" && ( { } /> )} + {watchedType === "signalgrid" && ( + + + + + + } + /> + )} {watchedType === "twilio" && ( ( "teams", "telegram", "pushover", + "signalgrid", "twilio", "ntfy", ] as NotificationChannel[], diff --git a/server/src/domain/notifications/notification.type.ts b/server/src/domain/notifications/notification.type.ts index a45d4c4f8..628633d56 100644 --- a/server/src/domain/notifications/notification.type.ts +++ b/server/src/domain/notifications/notification.type.ts @@ -9,6 +9,7 @@ export const NotificationChannels = [ "teams", "telegram", "pushover", + "signalgrid", "twilio", "ntfy", ] as const; diff --git a/server/src/domain/notifications/providers/signalgrid.ts b/server/src/domain/notifications/providers/signalgrid.ts new file mode 100644 index 000000000..fdd930724 --- /dev/null +++ b/server/src/domain/notifications/providers/signalgrid.ts @@ -0,0 +1,123 @@ +const SERVICE_NAME = "SignalgridProvider"; +import type { Notification, NotificationMessage, NotificationSeverity } from "@/domain/notifications/notification.type.js"; +import { NotificationProvider } from "@/domain/notifications/providers/INotificationProvider.js"; +import { getTestMessage } from "@/domain/notifications/providers/utils.js"; +import got from "got"; + +export class SignalgridProvider extends NotificationProvider { + async sendTestAlert(notification: Partial): Promise { + if (!notification.address || !notification.accessToken) { + return false; + } + + try { + await got.post("https://api.signalgrid.co/v1/push", { + form: { + client_key: notification.accessToken, + channel: notification.address, + title: "Checkmate Test Notification", + body: getTestMessage(), + type: "INFO", + critical: false, + }, + ...this.gotRequestOptions(), + }); + return true; + } catch (error) { + const errMsg = error instanceof Error ? error.message : "unknown error"; + const errStack = error instanceof Error ? error.stack : undefined; + this.logger.warn({ + message: "Signalgrid test alert failed", + service: SERVICE_NAME, + method: "sendTestAlert", + stack: errStack, + details: { error: errMsg }, + }); + return false; + } + } + + async sendMessage(notification: Notification, message: NotificationMessage): Promise { + if (!notification.address || !notification.accessToken) { + return false; + } + + try { + await got.post("https://api.signalgrid.co/v1/push", { + form: { + client_key: notification.accessToken, + channel: notification.address, + title: message.content.title, + body: this.buildSignalgridText(message), + type: this.mapSeverity(message.severity), + critical: false, + }, + ...this.gotRequestOptions(), + }); + + this.logger.info({ + message: "Signalgrid notification sent", + service: SERVICE_NAME, + method: "sendMessage", + }); + return true; + } catch (error) { + const errMsg = error instanceof Error ? error.message : "unknown error"; + const errStack = error instanceof Error ? error.stack : undefined; + this.logger.warn({ + message: "Signalgrid alert failed", + service: SERVICE_NAME, + method: "sendMessage", + stack: errStack, + details: { error: errMsg }, + }); + return false; + } + } + + private mapSeverity(severity: NotificationSeverity): "CRIT" | "WARN" | "INFO" | "SUCCESS" { + switch (severity) { + case "critical": + return "CRIT"; + case "warning": + return "WARN"; + case "success": + return "SUCCESS"; + default: + return "INFO"; + } + } + + private buildSignalgridText(message: NotificationMessage): string { + const lines: string[] = []; + + lines.push(message.content.summary); + lines.push(""); + lines.push("Monitor Details:"); + lines.push(`• Name: ${message.monitor.name}`); + lines.push(`• URL: ${message.monitor.url}`); + lines.push(`• Type: ${message.monitor.type}`); + lines.push(`• Status: ${message.monitor.status}`); + + if (message.content.details && message.content.details.length > 0) { + lines.push(""); + lines.push("Additional Information:"); + message.content.details.forEach((detail) => lines.push(`• ${detail}`)); + } + + if (message.content.thresholds && message.content.thresholds.length > 0) { + lines.push(""); + lines.push("Threshold Breaches:"); + message.content.thresholds.forEach((breach) => { + lines.push(`• ${breach.metric.toUpperCase()}: ${breach.formattedValue} (threshold: ${breach.threshold}${breach.unit})`); + }); + } + + if (message.content.incident) { + lines.push(""); + lines.push(`View Incident: ${message.clientHost}/incidents/${message.monitor.id}`); + } + + return lines.join("\n"); + } +} diff --git a/server/test/unit/providers/notifications/signalgridProvider.test.ts b/server/test/unit/providers/notifications/signalgridProvider.test.ts new file mode 100644 index 000000000..8c40bb1d9 --- /dev/null +++ b/server/test/unit/providers/notifications/signalgridProvider.test.ts @@ -0,0 +1,155 @@ +import { describe, expect, it, jest, beforeEach } from "@jest/globals"; +import { createMockLogger } from "../../../helpers/createMockLogger.ts"; +import { makeNotification, makeMessage, makeMessageWithThresholds, makeMessageWithIncident } from "../../../helpers/notificationMessage.ts"; +import { testNotificationProviderContract } from "../../../helpers/notificationProviderContract.ts"; + +const mockGotPost = jest.fn().mockResolvedValue({}); +jest.unstable_mockModule("got", () => ({ default: { post: mockGotPost } })); + +const { SignalgridProvider } = await import("../../../../src/domain/notifications/providers/signalgrid.ts"); + +const createProvider = () => { + const logger = createMockLogger(); + return { provider: new SignalgridProvider(logger as any), logger }; +}; + +testNotificationProviderContract("SignalgridProvider", { + create: () => { + mockGotPost.mockResolvedValue({}); + return createProvider().provider; + }, + makeNotification: () => makeNotification(), +}); + +describe("SignalgridProvider", () => { + beforeEach(() => mockGotPost.mockReset().mockResolvedValue({})); + + describe("sendTestAlert", () => { + it("sends to Signalgrid API and returns true", async () => { + expect(await createProvider().provider.sendTestAlert(makeNotification())).toBe(true); + expect(mockGotPost).toHaveBeenCalledWith( + "https://api.signalgrid.co/v1/push", + expect.objectContaining({ + form: expect.objectContaining({ + client_key: "token-abc", + channel: "https://hooks.example.com/webhook", + type: "INFO", + critical: false, + }), + }) + ); + }); + + it("returns false when address is missing", async () => { + expect(await createProvider().provider.sendTestAlert(makeNotification({ address: "" }))).toBe(false); + }); + + it("returns false when accessToken is missing", async () => { + expect(await createProvider().provider.sendTestAlert(makeNotification({ accessToken: undefined }))).toBe(false); + }); + + it("returns false and logs on error", async () => { + mockGotPost.mockRejectedValue(new Error("fail")); + const { provider, logger } = createProvider(); + expect(await provider.sendTestAlert(makeNotification())).toBe(false); + expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ method: "sendTestAlert", details: { error: "fail" } })); + }); + + it("handles non-Error thrown values in sendTestAlert", async () => { + mockGotPost.mockRejectedValue("string error"); + const { provider, logger } = createProvider(); + expect(await provider.sendTestAlert(makeNotification())).toBe(false); + expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ stack: undefined, details: { error: "unknown error" } })); + }); + }); + + describe("sendMessage", () => { + it("sends message and returns true", async () => { + const { provider } = createProvider(); + expect(await provider.sendMessage(makeNotification() as any, makeMessage())).toBe(true); + const form = mockGotPost.mock.calls[0][1].form; + expect(form.body).toContain("Monitor Details:"); + expect(form.title).toBeDefined(); + expect(form.critical).toBe(false); + }); + + it("maps critical severity to CRIT", async () => { + const { provider } = createProvider(); + const message = makeMessage(); + message.severity = "critical"; + await provider.sendMessage(makeNotification() as any, message); + expect(mockGotPost.mock.calls[0][1].form.type).toBe("CRIT"); + }); + + it("maps warning severity to WARN", async () => { + const { provider } = createProvider(); + const message = makeMessage(); + message.severity = "warning"; + await provider.sendMessage(makeNotification() as any, message); + expect(mockGotPost.mock.calls[0][1].form.type).toBe("WARN"); + }); + + it("maps success severity to SUCCESS", async () => { + const { provider } = createProvider(); + const message = makeMessage(); + message.severity = "success"; + await provider.sendMessage(makeNotification() as any, message); + expect(mockGotPost.mock.calls[0][1].form.type).toBe("SUCCESS"); + }); + + it("maps info severity to INFO", async () => { + const { provider } = createProvider(); + const message = makeMessage(); + message.severity = "info"; + await provider.sendMessage(makeNotification() as any, message); + expect(mockGotPost.mock.calls[0][1].form.type).toBe("INFO"); + }); + + it("returns false when address is missing", async () => { + expect(await createProvider().provider.sendMessage(makeNotification({ address: "" }) as any, makeMessage())).toBe(false); + }); + + it("returns false when accessToken is missing", async () => { + expect(await createProvider().provider.sendMessage(makeNotification({ accessToken: undefined }) as any, makeMessage())).toBe(false); + }); + + it("returns false and logs on error", async () => { + mockGotPost.mockRejectedValue(new Error("fail")); + const { provider, logger } = createProvider(); + expect(await provider.sendMessage(makeNotification() as any, makeMessage())).toBe(false); + expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ method: "sendMessage" })); + }); + + it("handles non-Error thrown values in sendMessage", async () => { + mockGotPost.mockRejectedValue(42); + const { provider, logger } = createProvider(); + expect(await provider.sendMessage(makeNotification() as any, makeMessage())).toBe(false); + expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ details: { error: "unknown error" } })); + }); + + it("includes thresholds in text", async () => { + const { provider } = createProvider(); + await provider.sendMessage(makeNotification() as any, makeMessageWithThresholds()); + expect(mockGotPost.mock.calls[0][1].form.body).toContain("CPU"); + }); + + it("includes incident link in text", async () => { + const { provider } = createProvider(); + await provider.sendMessage(makeNotification() as any, makeMessageWithIncident()); + expect(mockGotPost.mock.calls[0][1].form.body).toContain("View Incident"); + }); + + it("omits optional sections when not present", async () => { + const { provider } = createProvider(); + const msg = makeMessage(); + msg.content.thresholds = undefined; + msg.content.details = undefined; + msg.content.incident = undefined; + await provider.sendMessage(makeNotification() as any, msg); + const text = mockGotPost.mock.calls[0][1].form.body; + expect(text).not.toContain("Threshold"); + expect(text).not.toContain("Additional"); + expect(text).not.toContain("View Incident"); + }); + }); +});