diff --git a/README.md b/README.md index bcbd557d4..f055f6f39 100644 --- a/README.md +++ b/README.md @@ -80,7 +80,9 @@ Usage instructions can be found [here](https://checkmate.so/docs). ## Installation -The quickest way to run Checkmate is the reference Docker Compose file, which runs the all-in-one image (`ghcr.io/bluewave-labs/checkmate`) plus MongoDB: +The quickest way to run Checkmate is the reference Docker Compose file. It starts two services: the all-in-one Checkmate application image (`ghcr.io/bluewave-labs/checkmate`) and a separate MongoDB service. + +> **What “all-in-one” means:** the Checkmate application is packaged in a single image; MongoDB is not embedded in that image and remains required. The reference Compose file starts MongoDB for you. For custom deployments, configure `DB_CONNECTION_STRING` to use an external MongoDB instance. ```bash curl -O https://raw.githubusercontent.com/bluewave-labs/checkmate/master/docker/docker-compose.yaml @@ -151,7 +153,7 @@ Feel free to ask questions or share your ideas - we'd love to hear from you! - Selective disk monitoring with mountpoint selection - Incidents at a glance - Status pages with 4 beautiful themes -- E-mail, Webhooks, Discord, Slack, PagerDuty, Matrix, Microsoft Teams, Telegram, Pushover, Twilio (SMS) notifications +- E-mail, Webhooks, Discord, Slack, PagerDuty, Matrix, Rocket.Chat, Microsoft Teams, Telegram, Pushover, Twilio (SMS) notifications - Scheduled maintenance - JSON query monitoring - Multi-language support for Arabic, Chinese (Simplified), Chinese (Traditional, Taiwan), Czech, English, Finnish, French, German, Japanese, Portuguese (Brazil), Russian, Spanish, Thai, Turkish, Ukrainian, and Vietnamese diff --git a/client/src/Hooks/useNotificationForm.ts b/client/src/Hooks/useNotificationForm.ts index fdd8204d3..112853a59 100644 --- a/client/src/Hooks/useNotificationForm.ts +++ b/client/src/Hooks/useNotificationForm.ts @@ -8,8 +8,6 @@ interface UseNotificationFormOptions { } function buildDefaults(data: Notification | null): NotificationFormData { - // Every channel field defaults to "" (not undefined) so untouched required - // fields fail zod with their custom messages instead of a type error. return { type: data?.type ?? "email", notificationName: data?.notificationName || "", diff --git a/client/src/Types/Notification.ts b/client/src/Types/Notification.ts index 0e389f9e2..a118c4a3d 100644 --- a/client/src/Types/Notification.ts +++ b/client/src/Types/Notification.ts @@ -3,6 +3,7 @@ export const NotificationChannels = [ "slack", "discord", "webhook", + "rocket_chat", "pager_duty", "matrix", "teams", diff --git a/client/src/Validation/notifications.ts b/client/src/Validation/notifications.ts index 6526fa0dd..f7490d526 100644 --- a/client/src/Validation/notifications.ts +++ b/client/src/Validation/notifications.ts @@ -30,6 +30,14 @@ const webhookSchema = baseSchema.extend({ address: z.string().min(1, "Webhook URL is required").url("Please enter a valid URL"), }); +const rocketChatSchema = baseSchema.extend({ + type: z.literal("rocket_chat"), + address: z + .string() + .min(1, "Webhook URL is required") + .url({ protocol: /^https?$/, message: "Please enter a valid URL" }), +}); + const pagerDutySchema = baseSchema.extend({ type: z.literal("pager_duty"), address: z.string().min(1, "Integration key is required"), @@ -81,6 +89,7 @@ export const notificationSchema = z.discriminatedUnion("type", [ slackSchema, discordSchema, webhookSchema, + rocketChatSchema, pagerDutySchema, matrixSchema, teamsSchema, diff --git a/server/openapi.json b/server/openapi.json index 28a22f394..627354c55 100644 --- a/server/openapi.json +++ b/server/openapi.json @@ -34,7 +34,7 @@ }, { "name": "notifications", - "description": "Notification channels (email, webhook, Slack, Discord, PagerDuty, Matrix, Teams, Telegram, Pushover, Twilio) and test alerts." + "description": "Notification channels (email, webhook, Slack, Discord, PagerDuty, Matrix, Rocket.Chat, Teams, Telegram, Pushover, Twilio) and test alerts." }, { "name": "maintenance-window", @@ -879,6 +879,9 @@ { "$ref": "#/components/schemas/WebhookNotification" }, + { + "$ref": "#/components/schemas/RocketChatNotification" + }, { "$ref": "#/components/schemas/SlackNotification" }, @@ -912,6 +915,7 @@ "mapping": { "email": "#/components/schemas/EmailNotification", "webhook": "#/components/schemas/WebhookNotification", + "rocket_chat": "#/components/schemas/RocketChatNotification", "slack": "#/components/schemas/SlackNotification", "discord": "#/components/schemas/DiscordNotification", "pager_duty": "#/components/schemas/PagerDutyNotification", @@ -1036,6 +1040,29 @@ "address": "https://example.com/hooks/checkmate" } }, + "RocketChatNotification": { + "type": "object", + "properties": { + "notificationName": { + "type": "string", + "minLength": 1 + }, + "type": { + "type": "string", + "enum": ["rocket_chat"] + }, + "address": { + "type": "string", + "format": "uri" + } + }, + "required": ["notificationName", "type", "address"], + "example": { + "notificationName": "Rocket.Chat alerts", + "type": "rocket_chat", + "address": "https://chat.example.com/hooks/integration-id/token" + } + }, "SlackNotification": { "type": "object", "properties": { diff --git a/server/openapi/index.ts b/server/openapi/index.ts index ef3e0b1d0..c96cec233 100644 --- a/server/openapi/index.ts +++ b/server/openapi/index.ts @@ -39,7 +39,8 @@ export function getOpenApiSpec(): JsonObject { { name: "incidents", description: "Downtime incidents derived from monitor state changes, including filtering and CSV export." }, { name: "notifications", - description: "Notification channels (email, webhook, Slack, Discord, PagerDuty, Matrix, Teams, Telegram, Pushover, Twilio) and test alerts.", + description: + "Notification channels (email, webhook, Slack, Discord, PagerDuty, Matrix, Rocket.Chat, Teams, Telegram, Pushover, Twilio) and test alerts.", }, { name: "maintenance-window", description: "Scheduled maintenance windows during which monitors do not generate incidents or alerts." }, { name: "status-page", description: "Public status pages, their configuration, and the monitors they expose." }, diff --git a/server/openapi/routes/notification.ts b/server/openapi/routes/notification.ts index f1c70ca6d..7da45adfb 100644 --- a/server/openapi/routes/notification.ts +++ b/server/openapi/routes/notification.ts @@ -23,6 +23,14 @@ const notificationVariantMeta: Record( "slack", "discord", "webhook", + "rocket_chat", "pager_duty", "matrix", "teams", diff --git a/server/src/domain/notifications/notification.type.ts b/server/src/domain/notifications/notification.type.ts index 8ae617a88..7c4c9f75d 100644 --- a/server/src/domain/notifications/notification.type.ts +++ b/server/src/domain/notifications/notification.type.ts @@ -3,6 +3,7 @@ export const NotificationChannels = [ "slack", "discord", "webhook", + "rocket_chat", "pager_duty", "matrix", "teams", diff --git a/server/src/domain/notifications/providers/rocketChat.ts b/server/src/domain/notifications/providers/rocketChat.ts new file mode 100644 index 000000000..345eec8c3 --- /dev/null +++ b/server/src/domain/notifications/providers/rocketChat.ts @@ -0,0 +1,90 @@ +const SERVICE_NAME = "RocketChatProvider"; +import type { Notification, NotificationMessage } 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 RocketChatProvider extends NotificationProvider { + async sendTestAlert(notification: Partial): Promise { + if (!notification.address) { + return false; + } + + try { + await got.post(notification.address, { + json: { text: getTestMessage() }, + headers: { + "Content-Type": "application/json", + }, + ...this.gotRequestOptions(), + }); + return true; + } catch (error) { + const err = error as Error; + this.logger.warn({ + message: "Rocket.Chat test alert failed", + service: SERVICE_NAME, + method: "sendTestAlert", + stack: err?.stack, + }); + return false; + } + } + + async sendMessage(notification: Notification, message: NotificationMessage): Promise { + if (!notification.address) { + return false; + } + + try { + await got.post(notification.address, { + json: { text: this.buildText(message) }, + headers: { + "Content-Type": "application/json", + }, + ...this.gotRequestOptions(), + }); + return true; + } catch (error) { + const err = error as Error; + this.logger.warn({ + message: "Rocket.Chat notification failed", + service: SERVICE_NAME, + method: "sendMessage", + stack: err?.stack, + }); + return false; + } + } + + private buildText(message: NotificationMessage): string { + const lines = [ + message.content.title, + message.content.summary, + "", + `Monitor: ${message.monitor.name}`, + `Type: ${message.monitor.type}`, + `Status: ${message.monitor.status}`, + `URL: ${message.monitor.url}`, + ]; + + if (message.content.details?.length) { + lines.push("", "Details:", ...message.content.details.map((detail) => `- ${detail}`)); + } + if (message.content.thresholds?.length) { + lines.push( + "", + "Thresholds:", + ...message.content.thresholds.map( + (threshold) => `- ${threshold.metric.toUpperCase()}: ${threshold.formattedValue} (threshold: ${threshold.threshold}${threshold.unit})` + ) + ); + } + + if (message.content.incident) { + lines.push("", `Incident: ${message.clientHost}/infrastructure/${message.monitor.id}`); + } + + return lines.join("\n"); + } +} diff --git a/server/test/unit/notifications/notificationType.test.ts b/server/test/unit/notifications/notificationType.test.ts new file mode 100644 index 000000000..0029fbd46 --- /dev/null +++ b/server/test/unit/notifications/notificationType.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from "@jest/globals"; +import { NotificationModel } from "@/domain/notifications/notification.model.js"; +import { NotificationChannels } from "@/domain/notifications/notification.type.js"; + +describe("NotificationChannels", () => { + it("includes Rocket.Chat", () => { + expect(NotificationChannels).toContain("rocket_chat"); + }); + + it("allows Rocket.Chat to be persisted", () => { + const typePath = NotificationModel.schema.path("type") as unknown as { enumValues: string[] }; + + expect(typePath.enumValues).toContain("rocket_chat"); + }); +}); diff --git a/server/test/unit/providers/notifications/rocketChatProvider.test.ts b/server/test/unit/providers/notifications/rocketChatProvider.test.ts new file mode 100644 index 000000000..ac9ffc4c1 --- /dev/null +++ b/server/test/unit/providers/notifications/rocketChatProvider.test.ts @@ -0,0 +1,157 @@ +import { describe, expect, it, jest } from "@jest/globals"; +import { createMockLogger } from "../../../helpers/createMockLogger.ts"; +import { makeMessage, makeMessageWithIncident, makeMessageWithThresholds, makeNotification } from "../../../helpers/notificationMessage.ts"; +import { testNotificationProviderContract } from "../../../helpers/notificationProviderContract.ts"; + +const mockGotPost = jest.fn().mockResolvedValue({}); +jest.unstable_mockModule("got", () => ({ default: { post: mockGotPost } })); + +const { RocketChatProvider } = await import("../../../../src/domain/notifications/providers/rocketChat.ts"); + +const createProvider = () => { + const logger = createMockLogger(); + const provider = new RocketChatProvider(logger as any); + return { provider, logger }; +}; + +testNotificationProviderContract("RocketChatProvider", { + create: () => { + mockGotPost.mockResolvedValue({}); + return createProvider().provider; + }, + makeNotification: () => makeNotification({ type: "rocket_chat" }), +}); + +describe("RocketChatProvider", () => { + beforeEach(() => mockGotPost.mockReset().mockResolvedValue({})); + + it("sends a text-only test alert", async () => { + const { provider } = createProvider(); + + const result = await provider.sendTestAlert(makeNotification({ type: "rocket_chat" })); + + expect(result).toBe(true); + expect(mockGotPost).toHaveBeenCalledWith( + "https://hooks.example.com/webhook", + expect.objectContaining({ + json: { text: "This is a test notification from Checkmate" }, + timeout: { request: 30000 }, + retry: { limit: 0 }, + }) + ); + }); + + it("returns false when the test webhook URL is missing", async () => { + const { provider } = createProvider(); + + const result = await provider.sendTestAlert(makeNotification({ type: "rocket_chat", address: "" })); + + expect(result).toBe(false); + expect(mockGotPost).not.toHaveBeenCalled(); + }); + + it("returns false and logs when test delivery fails", async () => { + mockGotPost.mockRejectedValue(new Error("network failure")); + const { provider, logger } = createProvider(); + + const result = await provider.sendTestAlert(makeNotification({ type: "rocket_chat" })); + + expect(result).toBe(false); + expect(logger.warn).toHaveBeenCalledWith( + expect.objectContaining({ + message: "Rocket.Chat test alert failed", + service: "RocketChatProvider", + method: "sendTestAlert", + }) + ); + }); + + it("sends monitor details as a text-only message", async () => { + const { provider } = createProvider(); + + const result = await provider.sendMessage(makeNotification({ type: "rocket_chat" }), makeMessage()); + + expect(result).toBe(true); + expect(mockGotPost).toHaveBeenCalledWith( + "https://hooks.example.com/webhook", + expect.objectContaining({ + timeout: { request: 30000 }, + retry: { limit: 0 }, + json: { + text: [ + "Monitor Down: Test Monitor", + 'Monitor "Test Monitor" is currently down.', + "", + "Monitor: Test Monitor", + "Type: http", + "Status: down", + "URL: https://example.com", + "", + "Details:", + "- URL: https://example.com", + "- Status: Down", + ].join("\n"), + }, + }) + ); + }); + + it("returns false when the message webhook URL is missing", async () => { + const { provider } = createProvider(); + + const result = await provider.sendMessage(makeNotification({ type: "rocket_chat", address: "" }), makeMessage()); + + expect(result).toBe(false); + expect(mockGotPost).not.toHaveBeenCalled(); + }); + + it("includes threshold breaches in the text", async () => { + const { provider } = createProvider(); + + await provider.sendMessage(makeNotification({ type: "rocket_chat" }), makeMessageWithThresholds()); + + const payload = mockGotPost.mock.calls[0][1].json; + expect(payload.text).toContain("Thresholds:\n- CPU: 90.0% (threshold: 80%)\n- MEMORY: 85.0% (threshold: 70%)"); + }); + + it("includes a monitor-scoped link when incident data is present", async () => { + const { provider } = createProvider(); + + await provider.sendMessage(makeNotification({ type: "rocket_chat" }), makeMessageWithIncident()); + + const payload = mockGotPost.mock.calls[0][1].json; + expect(payload.text).toContain("Incident: https://app.example.com/infrastructure/mon-1"); + }); + + it("omits the incident link when incident data is absent", async () => { + const { provider } = createProvider(); + + await provider.sendMessage( + makeNotification({ type: "rocket_chat" }), + makeMessage({ + type: "monitor_up", + severity: "success", + monitor: { id: "mon-1", name: "Test Monitor", url: "https://example.com", type: "http", status: "up" }, + }) + ); + + const payload = mockGotPost.mock.calls[0][1].json; + expect(payload.text).not.toContain("Incident:"); + }); + + it("returns false and logs when message delivery fails", async () => { + mockGotPost.mockRejectedValue(new Error("network failure")); + const { provider, logger } = createProvider(); + + const result = await provider.sendMessage(makeNotification({ type: "rocket_chat" }), makeMessage()); + + expect(result).toBe(false); + expect(logger.warn).toHaveBeenCalledWith( + expect.objectContaining({ + message: "Rocket.Chat notification failed", + service: "RocketChatProvider", + method: "sendMessage", + }) + ); + }); +}); diff --git a/server/test/unit/validation/notificationValidation.test.ts b/server/test/unit/validation/notificationValidation.test.ts new file mode 100644 index 000000000..5cb2c1d59 --- /dev/null +++ b/server/test/unit/validation/notificationValidation.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from "@jest/globals"; +import { createNotificationBodyValidation } from "@/api/validation/notificationValidation.js"; + +describe("notification validation", () => { + it("accepts a Rocket.Chat incoming webhook", () => { + const result = createNotificationBodyValidation.safeParse({ + notificationName: "Rocket.Chat alerts", + type: "rocket_chat", + address: "https://chat.example.com/hooks/integration-id/token", + }); + + expect(result.success).toBe(true); + }); + + it("accepts a self-hosted Rocket.Chat webhook over HTTP", () => { + const result = createNotificationBodyValidation.safeParse({ + notificationName: "Local Rocket.Chat alerts", + type: "rocket_chat", + address: "http://localhost:3000/hooks/integration-id/token", + }); + + expect(result.success).toBe(true); + }); + + it("rejects an invalid Rocket.Chat incoming webhook URL", () => { + const result = createNotificationBodyValidation.safeParse({ + notificationName: "Rocket.Chat alerts", + type: "rocket_chat", + address: "not-a-url", + }); + + expect(result.success).toBe(false); + }); + + it.each(["ftp://chat.example.com/hooks/integration-id/token", "mailto:admin@example.com", "data:text/plain,hello", "javascript:alert(1)"])( + "rejects a Rocket.Chat webhook using an unsupported protocol: %s", + (address) => { + const result = createNotificationBodyValidation.safeParse({ + notificationName: "Rocket.Chat alerts", + type: "rocket_chat", + address, + }); + + expect(result.success).toBe(false); + } + ); +});