Merge pull request #3909 from signalgridco/feat/signalgrid-notifications

feat: add Signalgrid notification provider
This commit is contained in:
Alexander Holliday authored and GitHub committed 2026-09-08 10:14:19 -07:00
commit 75d29124b6
11 files changed
+337

No files matched your search

@@ -126,6 +126,7 @@ const NotificationsCreatePage = () => {
{watchedType !== "matrix" &&
watchedType !== "telegram" &&
watchedType !== "pushover" &&
watchedType !== "signalgrid" &&
watchedType !== "twilio" &&
watchedType !== "ntfy" && (
<ConfigBox
@@ -201,6 +202,31 @@ const NotificationsCreatePage = () => {
}
/>
)}
{watchedType === "signalgrid" && (
<ConfigBox
title={t("pages.notifications.form.signalgrid.title")}
subtitle={t("pages.notifications.form.signalgrid.description")}
rightContent={
<Stack spacing={theme.spacing(8)}>
<FormTextField
name="accessToken"
fieldLabel={t("pages.notifications.form.signalgrid.optionClientKey")}
placeholder={t(
"pages.notifications.form.signalgrid.placeholderClientKey"
)}
/>
<FormTextField
name="address"
fieldLabel={t("pages.notifications.form.signalgrid.optionChannel")}
placeholder={t(
"pages.notifications.form.signalgrid.placeholderChannel"
)}
/>
</Stack>
}
/>
)}
{watchedType === "twilio" && (
<ConfigBox
title={t("pages.notifications.form.twilio.title")}
+1
View File
@@ -9,6 +9,7 @@ export const NotificationChannels = [
"teams",
"telegram",
"pushover",
"signalgrid",
"twilio",
"ntfy",
] as const;
+7
View File
@@ -70,6 +70,12 @@ const pushoverSchema = baseSchema.extend({
accessToken: z.string().min(1, "App token is required"),
});
const signalgridSchema = baseSchema.extend({
type: z.literal("signalgrid"),
address: z.string().min(1, "Channel is required"),
accessToken: z.string().min(1, "Client key is required"),
});
const twilioSchema = baseSchema.extend({
type: z.literal("twilio"),
accountSid: z.string().min(1, "Account SID is required"),
@@ -95,6 +101,7 @@ export const notificationSchema = z.discriminatedUnion("type", [
teamsSchema,
telegramSchema,
pushoverSchema,
signalgridSchema,
twilioSchema,
ntfySchema,
]);
+9
View File
@@ -1329,6 +1329,7 @@
"teams": "Teams",
"telegram": "Telegram",
"pushover": "Pushover",
"signalgrid": "Signalgrid",
"twilio": "Twilio",
"ntfy": "ntfy"
}
@@ -1349,6 +1350,14 @@
"optionUserKey": "User key",
"placeholderUserKey": "uQiRzpo4DXghDmr9QzzfQu27cmVRsG"
},
"signalgrid": {
"title": "Signalgrid configuration",
"description": "Configure Signalgrid to receive push notifications on your devices.",
"optionClientKey": "Client key",
"placeholderClientKey": "Your Signalgrid client key",
"optionChannel": "Channel",
"placeholderChannel": "Your Signalgrid channel"
},
"twilio": {
"title": "Twilio SMS configuration",
"description": "Configure Twilio to send SMS notifications to a phone number.",
+4
View File
@@ -65,6 +65,10 @@ const notificationVariantMeta: Record<string, { component: string; example: Reco
component: "PushoverNotification",
example: { notificationName: "Pushover personal", type: "pushover", address: "u1234567890abcdef", accessToken: "a1234567890abcdef" },
},
signalgrid: {
component: "SignalgridNotification",
example: { notificationName: "Signalgrid", type: "signalgrid", address: "your-channel", accessToken: "your-client-key" },
},
twilio: {
component: "TwilioNotification",
example: {
@@ -123,6 +123,13 @@ export const createNotificationBodyValidation = z.discriminatedUnion("type", [
address: z.string().min(1, "User key is required"),
accessToken: z.string().min(1, "App token is required"),
}),
// Signalgrid notification
z.object({
notificationName: z.string().min(1, "Notification name is required"),
type: z.literal("signalgrid"),
address: z.string().min(1, "Channel is required"),
accessToken: z.string().min(1, "Client key is required"),
}),
// Twilio SMS notification
z.object({
notificationName: z.string().min(1, "Notification name is required"),
+3
View File
@@ -33,6 +33,7 @@ import { MatrixProvider } from "@/domain/notifications/providers/matrix.js";
import { TeamsProvider } from "@/domain/notifications/providers/teams.js";
import { TelegramProvider } from "@/domain/notifications/providers/telegram.js";
import { PushoverProvider } from "@/domain/notifications/providers/pushover.js";
import { SignalgridProvider } from "@/domain/notifications/providers/signalgrid.js";
import { TwilioProvider } from "@/domain/notifications/providers/twilio.js";
import { NtfyProvider } from "@/domain/notifications/providers/ntfy.js";
@@ -178,6 +179,7 @@ export const buildShared = async ({
const teamsProvider = new TeamsProvider(logger);
const telegramProvider = new TelegramProvider(logger);
const pushoverProvider = new PushoverProvider(logger);
const signalgridProvider = new SignalgridProvider(logger);
const twilioProvider = new TwilioProvider(logger);
const ntfyProvider = new NtfyProvider(logger);
@@ -192,6 +194,7 @@ export const buildShared = async ({
teams: teamsProvider,
telegram: telegramProvider,
pushover: pushoverProvider,
signalgrid: signalgridProvider,
twilio: twilioProvider,
ntfy: ntfyProvider,
};
@@ -37,6 +37,7 @@ const NotificationSchema = new Schema<NotificationDocument>(
"teams",
"telegram",
"pushover",
"signalgrid",
"twilio",
"ntfy",
] as NotificationChannel[],
@@ -9,6 +9,7 @@ export const NotificationChannels = [
"teams",
"telegram",
"pushover",
"signalgrid",
"twilio",
"ntfy",
] as const;
@@ -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<Notification>): Promise<boolean> {
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<boolean> {
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");
}
}
@@ -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");
});
});
});