diff --git a/apps/dokploy/components/dashboard/settings/notifications/handle-notifications.tsx b/apps/dokploy/components/dashboard/settings/notifications/handle-notifications.tsx index e5fee3a9d..97adb0f91 100644 --- a/apps/dokploy/components/dashboard/settings/notifications/handle-notifications.tsx +++ b/apps/dokploy/components/dashboard/settings/notifications/handle-notifications.tsx @@ -6,6 +6,7 @@ import { toast } from "sonner"; import { z } from "zod"; import { DiscordIcon, + MattermostIcon, GotifyIcon, LarkIcon, NtfyIcon, @@ -108,6 +109,14 @@ export const notificationSchema = z.discriminatedUnion("type", [ }) .merge(notificationBaseSchema), z + .object({ + type: z.literal("mattermost"), + webhookUrl: z.string().min(1, { message: "Webhook URL is required" }), + channel: z.string().optional(), + username: z.string().optional(), + }) + .merge(notificationBaseSchema), + z .object({ type: z.literal("lark"), webhookUrl: z.string().min(1, { message: "Webhook URL is required" }), @@ -144,6 +153,10 @@ export const notificationsMap = { icon: , label: "ntfy", }, + mattermost: { + icon: , + label: "Mattermost", + }, }; export type NotificationSchema = z.infer; @@ -177,6 +190,8 @@ export const HandleNotifications = ({ notificationId }: Props) => { api.notification.testGotifyConnection.useMutation(); const { mutateAsync: testNtfyConnection, isLoading: isLoadingNtfy } = api.notification.testNtfyConnection.useMutation(); + const { mutateAsync: testMattermostConnection, isLoading: isLoadingMattermost } = + api.notification.testMattermostConnection.useMutation(); const { mutateAsync: testLarkConnection, isLoading: isLoadingLark } = api.notification.testLarkConnection.useMutation(); const slackMutation = notificationId @@ -197,6 +212,9 @@ export const HandleNotifications = ({ notificationId }: Props) => { const ntfyMutation = notificationId ? api.notification.updateNtfy.useMutation() : api.notification.createNtfy.useMutation(); + const mattermostMutation = notificationId + ? api.notification.updateMattermost.useMutation() + : api.notification.createMattermost.useMutation(); const larkMutation = notificationId ? api.notification.updateLark.useMutation() : api.notification.createLark.useMutation(); @@ -323,6 +341,20 @@ export const HandleNotifications = ({ notificationId }: Props) => { dockerCleanup: notification.dockerCleanup, serverThreshold: notification.serverThreshold, }); + } else if (notification.notificationType === "mattermost") { + form.reset({ + appBuildError: notification.appBuildError, + appDeploy: notification.appDeploy, + dokployRestart: notification.dokployRestart, + databaseBackup: notification.databaseBackup, + type: notification.notificationType, + webhookUrl: notification.mattermost?.webhookUrl, + channel: notification.mattermost?.channel || "", + username: notification.mattermost?.username || "", + name: notification.name, + dockerCleanup: notification.dockerCleanup, + serverThreshold: notification.serverThreshold, + }); } } else { form.reset(); @@ -336,6 +368,7 @@ export const HandleNotifications = ({ notificationId }: Props) => { email: emailMutation, gotify: gotifyMutation, ntfy: ntfyMutation, + mattermost: mattermostMutation, lark: larkMutation, }; @@ -440,6 +473,21 @@ export const HandleNotifications = ({ notificationId }: Props) => { notificationId: notificationId || "", ntfyId: notification?.ntfyId || "", }); + } else if (data.type === "mattermost") { + promise = mattermostMutation.mutateAsync({ + appBuildError: appBuildError, + appDeploy: appDeploy, + dokployRestart: dokployRestart, + databaseBackup: databaseBackup, + webhookUrl: data.webhookUrl, + channel: data.channel || undefined, + username: data.username || undefined, + name: data.name, + dockerCleanup: dockerCleanup, + notificationId: notificationId || "", + mattermostId: notification?.mattermostId || "", + serverThreshold: serverThreshold, + }); } else if (data.type === "lark") { promise = larkMutation.mutateAsync({ appBuildError: appBuildError, @@ -451,6 +499,8 @@ export const HandleNotifications = ({ notificationId }: Props) => { dockerCleanup: dockerCleanup, notificationId: notificationId || "", larkId: notification?.larkId || "", + + serverThreshold: serverThreshold, }); } @@ -1040,6 +1090,60 @@ export const HandleNotifications = ({ notificationId }: Props) => { )} + {type === "mattermost" && ( + <> + ( + + Webhook URL + + + + + + )} + /> + + ( + + Channel + + + + + Optional. Channel to post to (without #). + + + + )} + /> + + ( + + Username + + + + + Optional. Display name for the webhook. + + + + )} + /> + + )} {type === "lark" && ( <> { isLoadingEmail || isLoadingGotify || isLoadingNtfy || + isLoadingMattermost || isLoadingLark } variant="secondary" @@ -1255,6 +1360,12 @@ export const HandleNotifications = ({ notificationId }: Props) => { accessToken: form.getValues("accessToken"), priority: form.getValues("priority"), }); + } else if (type === "mattermost") { + await testMattermostConnection({ + webhookUrl: form.getValues("webhookUrl"), + channel: form.getValues("channel") || undefined, + username: form.getValues("username") || undefined, + }); } else if (type === "lark") { await testLarkConnection({ webhookUrl: form.getValues("webhookUrl"), diff --git a/apps/dokploy/components/icons/notification-icons.tsx b/apps/dokploy/components/icons/notification-icons.tsx index cc54327a8..d73186f2b 100644 --- a/apps/dokploy/components/icons/notification-icons.tsx +++ b/apps/dokploy/components/icons/notification-icons.tsx @@ -88,6 +88,20 @@ export const DiscordIcon = ({ className }: Props) => { ); }; + +export const MattermostIcon = ({ className }: Props) => { + return ( + + + + + ); +}; export const LarkIcon = ({ className }: Props) => { return ( { + try { + return await createMattermostNotification( + input, + ctx.session.activeOrganizationId, + ); + } catch (error) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Error creating the notification", + cause: error, + }); + } + }), + updateMattermost: adminProcedure + .input(apiUpdateMattermost) + .mutation(async ({ input, ctx }) => { + try { + const notification = await findNotificationById(input.notificationId); + if ( + IS_CLOUD && + notification.organizationId !== ctx.session.activeOrganizationId + ) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not authorized to update this notification", + }); + } + return await updateMattermostNotification({ + ...input, + organizationId: ctx.session.activeOrganizationId, + }); + } catch (error) { + throw error; + } + }), + testMattermostConnection: adminProcedure + .input(apiTestMattermostConnection) + .mutation(async ({ input }) => { + try { + await sendMattermostNotification(input, { + text: "Hi, From Dokploy 👋", + channel: input.channel, + username: input.username || "Dokploy Bot", + }); + return true; + } catch (error) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Error testing the notification", + cause: error, + }); + } + }), + createLark: adminProcedure .input(apiCreateLark) .mutation(async ({ input, ctx }) => { try { diff --git a/packages/server/src/db/schema/notification.ts b/packages/server/src/db/schema/notification.ts index f7818b4cf..a0e99dd67 100644 --- a/packages/server/src/db/schema/notification.ts +++ b/packages/server/src/db/schema/notification.ts @@ -12,6 +12,7 @@ export const notificationType = pgEnum("notificationType", [ "email", "gotify", "ntfy", + "mattermost", "lark", ]); @@ -49,6 +50,9 @@ export const notifications = pgTable("notification", { ntfyId: text("ntfyId").references(() => ntfy.ntfyId, { onDelete: "cascade", }), + mattermostId: text("mattermostId").references(() => mattermost.mattermostId, { + onDelete: "cascade", + }), larkId: text("larkId").references(() => lark.larkId, { onDelete: "cascade", }), @@ -120,6 +124,16 @@ export const ntfy = pgTable("ntfy", { priority: integer("priority").notNull().default(3), }); +export const mattermost = pgTable("mattermost", { + mattermostId: text("mattermostId") + .notNull() + .primaryKey() + .$defaultFn(() => nanoid()), + webhookUrl: text("webhookUrl").notNull(), + channel: text("channel"), + username: text("username"), +}); + export const lark = pgTable("lark", { larkId: text("larkId") .notNull() @@ -153,6 +167,10 @@ export const notificationsRelations = relations(notifications, ({ one }) => ({ fields: [notifications.ntfyId], references: [ntfy.ntfyId], }), + mattermost: one(mattermost, { + fields: [notifications.mattermostId], + references: [mattermost.mattermostId], + }), lark: one(lark, { fields: [notifications.larkId], references: [lark.larkId], @@ -349,6 +367,49 @@ export const apiTestNtfyConnection = apiCreateNtfy.pick({ priority: true, }); +export const apiCreateMattermost = notificationsSchema + .pick({ + appBuildError: true, + databaseBackup: true, + dokployRestart: true, + name: true, + appDeploy: true, + dockerCleanup: true, + serverThreshold: true, + }) + .extend({ + webhookUrl: z.string().min(1), + channel: z.string().optional(), + username: z.string().optional(), + }) + .required({ + name: true, + webhookUrl: true, + appBuildError: true, + databaseBackup: true, + dokployRestart: true, + appDeploy: true, + dockerCleanup: true, + serverThreshold: true, + }); + +export const apiUpdateMattermost = apiCreateMattermost.partial().extend({ + notificationId: z.string().min(1), + mattermostId: z.string().min(1), + organizationId: z.string().optional(), +}); + +export const apiTestMattermostConnection = apiCreateMattermost + .pick({ + webhookUrl: true, + channel: true, + username: true, + }) + .extend({ + channel: z.string().optional(), + username: z.string().optional(), + }); + export const apiFindOneNotification = notificationsSchema .pick({ notificationId: true, diff --git a/packages/server/src/services/notification.ts b/packages/server/src/services/notification.ts index 9eaf812e0..0ed054541 100644 --- a/packages/server/src/services/notification.ts +++ b/packages/server/src/services/notification.ts @@ -4,6 +4,7 @@ import { type apiCreateEmail, type apiCreateLark, type apiCreateGotify, + type apiCreateMattermost, type apiCreateNtfy, type apiCreateSlack, type apiCreateTelegram, @@ -11,6 +12,7 @@ import { type apiUpdateEmail, type apiUpdateLark, type apiUpdateGotify, + type apiUpdateMattermost, type apiUpdateNtfy, type apiUpdateSlack, type apiUpdateTelegram, @@ -18,6 +20,7 @@ import { email, lark, gotify, + mattermost, notifications, ntfy, slack, @@ -588,6 +591,7 @@ export const findNotificationById = async (notificationId: string) => { email: true, gotify: true, ntfy: true, + mattermost: true, lark: true, }, }); @@ -711,3 +715,95 @@ export const updateNotificationById = async ( return result[0]; }; + +export const createMattermostNotification = async ( + input: typeof apiCreateMattermost._type, + organizationId: string, +) => { + await db.transaction(async (tx) => { + const newMattermost = await tx + .insert(mattermost) + .values({ + webhookUrl: input.webhookUrl, + channel: input.channel, + username: input.username, + }) + .returning() + .then((value) => value[0]); + + if (!newMattermost) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Error input: Inserting mattermost", + }); + } + + const newDestination = await tx + .insert(notifications) + .values({ + mattermostId: newMattermost.mattermostId, + name: input.name, + appDeploy: input.appDeploy, + appBuildError: input.appBuildError, + databaseBackup: input.databaseBackup, + dokployRestart: input.dokployRestart, + dockerCleanup: input.dockerCleanup, + notificationType: "mattermost", + organizationId: organizationId, + serverThreshold: input.serverThreshold, + }) + .returning() + .then((value) => value[0]); + + if (!newDestination) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Error input: Inserting notification", + }); + } + + return newDestination; + }); +}; + +export const updateMattermostNotification = async ( + input: typeof apiUpdateMattermost._type, +) => { + await db.transaction(async (tx) => { + const newDestination = await tx + .update(notifications) + .set({ + name: input.name, + appDeploy: input.appDeploy, + appBuildError: input.appBuildError, + databaseBackup: input.databaseBackup, + dokployRestart: input.dokployRestart, + dockerCleanup: input.dockerCleanup, + organizationId: input.organizationId, + serverThreshold: input.serverThreshold, + }) + .where(eq(notifications.notificationId, input.notificationId)) + .returning() + .then((value) => value[0]); + + if (!newDestination) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Error Updating notification", + }); + } + + await tx + .update(mattermost) + .set({ + webhookUrl: input.webhookUrl, + channel: input.channel, + username: input.username, + }) + .where(eq(mattermost.mattermostId, input.mattermostId)) + .returning() + .then((value) => value[0]); + + return newDestination; + }); +}; diff --git a/packages/server/src/utils/notifications/build-error.ts b/packages/server/src/utils/notifications/build-error.ts index 67c568b72..24b1cad08 100644 --- a/packages/server/src/utils/notifications/build-error.ts +++ b/packages/server/src/utils/notifications/build-error.ts @@ -9,6 +9,7 @@ import { sendEmailNotification, sendLarkNotification, sendGotifyNotification, + sendMattermostNotification, sendNtfyNotification, sendSlackNotification, sendTelegramNotification, @@ -45,13 +46,13 @@ export const sendBuildErrorNotifications = async ({ slack: true, gotify: true, ntfy: true, + mattermost: true, lark: true, }, }); for (const notification of notificationList) { - const { email, discord, telegram, slack, gotify, ntfy, lark } = - notification; + const { email, discord, telegram, slack, gotify, ntfy, mattermost, lark } = notification; if (email) { const template = await renderAsync( BuildFailedEmail({ @@ -215,6 +216,26 @@ export const sendBuildErrorNotifications = async ({ }); } + if (mattermost) { + await sendMattermostNotification(mattermost, { + text: `:warning: **Build Failed** + +**Project:** ${projectName} +**Application:** ${applicationName} +**Type:** ${applicationType} +**Time:** ${date.toLocaleString()} + +**Error:** +\`\`\` +${errorMessage} +\`\`\` + +[View Build Details](${buildLink})`, + channel: mattermost.channel, + username: mattermost.username || "Dokploy Bot", + }); + } + if (lark) { const limitCharacter = 800; const truncatedErrorMessage = errorMessage.substring(0, limitCharacter); diff --git a/packages/server/src/utils/notifications/build-success.ts b/packages/server/src/utils/notifications/build-success.ts index a93b3d547..4a4f49a8e 100644 --- a/packages/server/src/utils/notifications/build-success.ts +++ b/packages/server/src/utils/notifications/build-success.ts @@ -10,6 +10,7 @@ import { sendEmailNotification, sendLarkNotification, sendGotifyNotification, + sendMattermostNotification, sendNtfyNotification, sendSlackNotification, sendTelegramNotification, @@ -46,13 +47,13 @@ export const sendBuildSuccessNotifications = async ({ slack: true, gotify: true, ntfy: true, + mattermost: true, lark: true, }, }); for (const notification of notificationList) { - const { email, discord, telegram, slack, gotify, ntfy, lark } = - notification; + const { email, discord, telegram, slack, gotify, ntfy, mattermost, lark } = notification; if (email) { const template = await renderAsync( @@ -317,5 +318,13 @@ export const sendBuildSuccessNotifications = async ({ }, }); } + + if (mattermost) { + await sendMattermostNotification(mattermost, { + text: `**✅ Build Success**\n\n**Project:** ${projectName}\n**Application:** ${applicationName}\n**Type:** ${applicationType}\n**Date:** ${format(date, "PP")}\n**Time:** ${format(date, "pp")}\n\n[View Build Details](${buildLink})`, + channel: mattermost.channel, + username: mattermost.username || "Dokploy", + }); + } } }; diff --git a/packages/server/src/utils/notifications/database-backup.ts b/packages/server/src/utils/notifications/database-backup.ts index c5cb68dbc..9ba76f0d5 100644 --- a/packages/server/src/utils/notifications/database-backup.ts +++ b/packages/server/src/utils/notifications/database-backup.ts @@ -9,6 +9,7 @@ import { sendEmailNotification, sendLarkNotification, sendGotifyNotification, + sendMattermostNotification, sendNtfyNotification, sendSlackNotification, sendTelegramNotification, @@ -45,13 +46,13 @@ export const sendDatabaseBackupNotifications = async ({ slack: true, gotify: true, ntfy: true, + mattermost: true, lark: true, }, }); for (const notification of notificationList) { - const { email, discord, telegram, slack, gotify, ntfy, lark } = - notification; + const { email, discord, telegram, slack, gotify, ntfy, mattermost, lark } = notification; if (email) { const template = await renderAsync( @@ -356,5 +357,19 @@ export const sendDatabaseBackupNotifications = async ({ }, }); } + + if (mattermost) { + const statusEmoji = type === "success" ? "✅" : "❌"; + const typeStatus = type === "success" ? "Successful" : "Failed"; + const errorMsg = type === "error" && errorMessage + ? `\n\n**Error:**\n\`\`\`\n${errorMessage}\n\`\`\`` + : ""; + + await sendMattermostNotification(mattermost, { + text: `**${statusEmoji} Database Backup ${typeStatus}**\n\n**Project:** ${projectName}\n**Application:** ${applicationName}\n**Type:** ${databaseType}\n**Database Name:** ${databaseName}\n**Date:** ${format(date, "PP")}\n**Time:** ${format(date, "pp")}${errorMsg}`, + channel: mattermost.channel, + username: mattermost.username || "Dokploy", + }); + } } }; diff --git a/packages/server/src/utils/notifications/docker-cleanup.ts b/packages/server/src/utils/notifications/docker-cleanup.ts index 062da9d49..543bba01a 100644 --- a/packages/server/src/utils/notifications/docker-cleanup.ts +++ b/packages/server/src/utils/notifications/docker-cleanup.ts @@ -9,6 +9,7 @@ import { sendEmailNotification, sendLarkNotification, sendGotifyNotification, + sendMattermostNotification, sendNtfyNotification, sendSlackNotification, sendTelegramNotification, @@ -32,13 +33,13 @@ export const sendDockerCleanupNotifications = async ( slack: true, gotify: true, ntfy: true, + mattermost: true, lark: true, }, }); for (const notification of notificationList) { - const { email, discord, telegram, slack, gotify, ntfy, lark } = - notification; + const { email, discord, telegram, slack, gotify, ntfy, mattermost, lark } = notification; if (email) { const template = await renderAsync( @@ -139,7 +140,15 @@ export const sendDockerCleanupNotifications = async ( }); } - if (lark) { + if (mattermost) { + await sendMattermostNotification(mattermost, { + text: `**✅ Docker Cleanup**\n\n**Message:** ${message}\n**Date:** ${format(date, "PP")}\n**Time:** ${format(date, "pp")}`, + channel: mattermost.channel, + username: mattermost.username || "Dokploy", + }); + } + + if (lark) { await sendLarkNotification(lark, { msg_type: "interactive", card: { diff --git a/packages/server/src/utils/notifications/dokploy-restart.ts b/packages/server/src/utils/notifications/dokploy-restart.ts index 2582c92d1..c7a9600b3 100644 --- a/packages/server/src/utils/notifications/dokploy-restart.ts +++ b/packages/server/src/utils/notifications/dokploy-restart.ts @@ -9,6 +9,7 @@ import { sendEmailNotification, sendLarkNotification, sendGotifyNotification, + sendMattermostNotification, sendNtfyNotification, sendSlackNotification, sendTelegramNotification, @@ -26,13 +27,13 @@ export const sendDokployRestartNotifications = async () => { slack: true, gotify: true, ntfy: true, + mattermost: true, lark: true, }, }); for (const notification of notificationList) { - const { email, discord, telegram, slack, gotify, ntfy, lark } = - notification; + const { email, discord, telegram, slack, gotify, ntfy, mattermost, lark } = notification; if (email) { const template = await renderAsync( @@ -139,6 +140,18 @@ export const sendDokployRestartNotifications = async () => { } } + if (mattermost) { + try { + await sendMattermostNotification(mattermost, { + text: `**✅ Dokploy Server Restarted**\n\n**Date:** ${format(date, "PP")}\n**Time:** ${format(date, "pp")}`, + channel: mattermost.channel, + username: mattermost.username || "Dokploy", + }); + } catch (error) { + console.log(error); + } + } + if (lark) { try { await sendLarkNotification(lark, { diff --git a/packages/server/src/utils/notifications/server-threshold.ts b/packages/server/src/utils/notifications/server-threshold.ts index b8d627425..626227523 100644 --- a/packages/server/src/utils/notifications/server-threshold.ts +++ b/packages/server/src/utils/notifications/server-threshold.ts @@ -3,6 +3,7 @@ import { db } from "../../db"; import { notifications } from "../../db/schema"; import { sendDiscordNotification, + sendMattermostNotification, sendLarkNotification, sendSlackNotification, sendTelegramNotification, @@ -35,6 +36,7 @@ export const sendServerThresholdNotifications = async ( discord: true, telegram: true, slack: true, + mattermost: true, lark: true, }, }); @@ -43,7 +45,7 @@ export const sendServerThresholdNotifications = async ( const typeColor = 0xff0000; // Rojo para indicar alerta for (const notification of notificationList) { - const { discord, telegram, slack, lark } = notification; + const { discord, telegram, slack, mattermost, lark } = notification; if (discord) { const decorate = (decoration: string, text: string) => @@ -154,7 +156,15 @@ export const sendServerThresholdNotifications = async ( }); } - if (lark) { + if (mattermost) { + await sendMattermostNotification(mattermost, { + text: `**⚠️ Server ${payload.Type} Alert**\n\n**Server Name:** ${payload.ServerName}\n**Type:** ${payload.Type}\n**Current Value:** ${payload.Value.toFixed(2)}%\n**Threshold:** ${payload.Threshold.toFixed(2)}%\n**Message:** ${payload.Message}\n**Time:** ${date.toLocaleString()}`, + channel: mattermost.channel, + username: mattermost.username || "Dokploy", + }); + } + + if (lark) { await sendLarkNotification(lark, { msg_type: "interactive", card: { diff --git a/packages/server/src/utils/notifications/utils.ts b/packages/server/src/utils/notifications/utils.ts index a56a70918..1b20ab577 100644 --- a/packages/server/src/utils/notifications/utils.ts +++ b/packages/server/src/utils/notifications/utils.ts @@ -3,6 +3,7 @@ import type { email, lark, gotify, + mattermost, ntfy, slack, telegram, @@ -154,6 +155,28 @@ export const sendNtfyNotification = async ( } }; +export const sendMattermostNotification = async ( + connection: typeof mattermost.$inferInsert, + message: any, +) => { + try { + const payload = { + ...message, + // Only include username if it's provided and not empty + ...(message.username && message.username.trim() && { username: message.username }), + // Only include wchannel if it's provided and not empty + ...(message.channel && message.channel.trim() && { channel: `#${message.channel.replace('#', '')}` }), + }; + + await fetch(connection.webhookUrl, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + } catch (err) { + console.log(err); + } + export const sendLarkNotification = async ( connection: typeof lark.$inferInsert, message: any,