diff --git a/apps/dokploy/components/dashboard/settings/notifications/handle-notifications.tsx b/apps/dokploy/components/dashboard/settings/notifications/handle-notifications.tsx index e5fee3a9d..8506446d3 100644 --- a/apps/dokploy/components/dashboard/settings/notifications/handle-notifications.tsx +++ b/apps/dokploy/components/dashboard/settings/notifications/handle-notifications.tsx @@ -44,6 +44,7 @@ const notificationBaseSchema = z.object({ appDeploy: z.boolean().default(false), appBuildError: z.boolean().default(false), databaseBackup: z.boolean().default(false), + dokployBackup: z.boolean().default(false), dokployRestart: z.boolean().default(false), dockerCleanup: z.boolean().default(false), serverThreshold: z.boolean().default(false), @@ -231,6 +232,7 @@ export const HandleNotifications = ({ notificationId }: Props) => { appDeploy: notification.appDeploy, dokployRestart: notification.dokployRestart, databaseBackup: notification.databaseBackup, + dokployBackup: notification.dokployBackup, dockerCleanup: notification.dockerCleanup, webhookUrl: notification.slack?.webhookUrl, channel: notification.slack?.channel || "", @@ -244,6 +246,7 @@ export const HandleNotifications = ({ notificationId }: Props) => { appDeploy: notification.appDeploy, dokployRestart: notification.dokployRestart, databaseBackup: notification.databaseBackup, + dokployBackup: notification.dokployBackup, botToken: notification.telegram?.botToken, messageThreadId: notification.telegram?.messageThreadId || "", chatId: notification.telegram?.chatId, @@ -258,6 +261,7 @@ export const HandleNotifications = ({ notificationId }: Props) => { appDeploy: notification.appDeploy, dokployRestart: notification.dokployRestart, databaseBackup: notification.databaseBackup, + dokployBackup: notification.dokployBackup, type: notification.notificationType, webhookUrl: notification.discord?.webhookUrl, decoration: notification.discord?.decoration || undefined, @@ -271,6 +275,7 @@ export const HandleNotifications = ({ notificationId }: Props) => { appDeploy: notification.appDeploy, dokployRestart: notification.dokployRestart, databaseBackup: notification.databaseBackup, + dokployBackup: notification.dokployBackup, type: notification.notificationType, smtpServer: notification.email?.smtpServer, smtpPort: notification.email?.smtpPort, @@ -288,6 +293,7 @@ export const HandleNotifications = ({ notificationId }: Props) => { appDeploy: notification.appDeploy, dokployRestart: notification.dokployRestart, databaseBackup: notification.databaseBackup, + dokployBackup: notification.dokployBackup, type: notification.notificationType, appToken: notification.gotify?.appToken, decoration: notification.gotify?.decoration || undefined, @@ -302,6 +308,7 @@ export const HandleNotifications = ({ notificationId }: Props) => { appDeploy: notification.appDeploy, dokployRestart: notification.dokployRestart, databaseBackup: notification.databaseBackup, + dokployBackup: notification.dokployBackup, type: notification.notificationType, accessToken: notification.ntfy?.accessToken, topic: notification.ntfy?.topic, @@ -317,6 +324,7 @@ export const HandleNotifications = ({ notificationId }: Props) => { appDeploy: notification.appDeploy, dokployRestart: notification.dokployRestart, databaseBackup: notification.databaseBackup, + dokployBackup: notification.dokployBackup, type: notification.notificationType, webhookUrl: notification.lark?.webhookUrl, name: notification.name, @@ -345,6 +353,7 @@ export const HandleNotifications = ({ notificationId }: Props) => { appDeploy, dokployRestart, databaseBackup, + dokployBackup, dockerCleanup, serverThreshold, } = data; @@ -355,6 +364,7 @@ export const HandleNotifications = ({ notificationId }: Props) => { appDeploy: appDeploy, dokployRestart: dokployRestart, databaseBackup: databaseBackup, + dokployBackup: dokployBackup, webhookUrl: data.webhookUrl, channel: data.channel, name: data.name, @@ -369,6 +379,7 @@ export const HandleNotifications = ({ notificationId }: Props) => { appDeploy: appDeploy, dokployRestart: dokployRestart, databaseBackup: databaseBackup, + dokployBackup: dokployBackup, botToken: data.botToken, messageThreadId: data.messageThreadId || "", chatId: data.chatId, @@ -384,6 +395,7 @@ export const HandleNotifications = ({ notificationId }: Props) => { appDeploy: appDeploy, dokployRestart: dokployRestart, databaseBackup: databaseBackup, + dokployBackup: dokployBackup, webhookUrl: data.webhookUrl, decoration: data.decoration, name: data.name, @@ -398,6 +410,7 @@ export const HandleNotifications = ({ notificationId }: Props) => { appDeploy: appDeploy, dokployRestart: dokployRestart, databaseBackup: databaseBackup, + dokployBackup: dokployBackup, smtpServer: data.smtpServer, smtpPort: data.smtpPort, username: data.username, @@ -416,6 +429,7 @@ export const HandleNotifications = ({ notificationId }: Props) => { appDeploy: appDeploy, dokployRestart: dokployRestart, databaseBackup: databaseBackup, + dokployBackup: dokployBackup, serverUrl: data.serverUrl, appToken: data.appToken, priority: data.priority, @@ -431,6 +445,7 @@ export const HandleNotifications = ({ notificationId }: Props) => { appDeploy: appDeploy, dokployRestart: dokployRestart, databaseBackup: databaseBackup, + dokployBackup: dokployBackup, serverUrl: data.serverUrl, accessToken: data.accessToken, topic: data.topic, @@ -446,6 +461,7 @@ export const HandleNotifications = ({ notificationId }: Props) => { appDeploy: appDeploy, dokployRestart: dokployRestart, databaseBackup: databaseBackup, + dokployBackup: dokployBackup, webhookUrl: data.webhookUrl, name: data.name, dockerCleanup: dockerCleanup, @@ -1130,6 +1146,27 @@ export const HandleNotifications = ({ notificationId }: Props) => { )} /> + ( + +
+ Dokploy Backup + + Trigger the action when a dokploy backup is created. + +
+ + + +
+ )} + /> + { + const previewText = `Dokploy instance backup was ${type === "success" ? "successful ✅" : "failed ❌"}`; + + return ( + + {previewText} + + + + +
+ Dokploy +
+ + Dokploy Instance Backup + + + Hello, + + + Your Dokploy instance backup was{" "} + {type === "success" + ? "successful ✅" + : "failed. Please check the error message below. ❌"} + . + +
+ Details: + + Backup Type: Complete Dokploy Instance + + + Content: /etc/dokploy + PostgreSQL Database + + {backupSize && ( + + Backup Size: {backupSize} + + )} + + Date: {date} + + + Status: {type === "success" ? "Successful" : "Failed"} + +
+ {type === "error" && errorMessage ? ( +
+ Reason: + + {errorMessage || "Error message not provided"} + +
+ ) : null} +
+ +
+ + ); +}; + +export default DokployBackupEmail; \ No newline at end of file diff --git a/packages/server/src/services/notification.ts b/packages/server/src/services/notification.ts index 9eaf812e0..420c269ac 100644 --- a/packages/server/src/services/notification.ts +++ b/packages/server/src/services/notification.ts @@ -57,6 +57,7 @@ export const createSlackNotification = async ( appDeploy: input.appDeploy, appBuildError: input.appBuildError, databaseBackup: input.databaseBackup, + dokployBackup: input.dokployBackup, dokployRestart: input.dokployRestart, dockerCleanup: input.dockerCleanup, notificationType: "slack", @@ -88,6 +89,7 @@ export const updateSlackNotification = async ( appDeploy: input.appDeploy, appBuildError: input.appBuildError, databaseBackup: input.databaseBackup, + dokployBackup: input.dokployBackup, dokployRestart: input.dokployRestart, dockerCleanup: input.dockerCleanup, organizationId: input.organizationId, @@ -148,6 +150,7 @@ export const createTelegramNotification = async ( appDeploy: input.appDeploy, appBuildError: input.appBuildError, databaseBackup: input.databaseBackup, + dokployBackup: input.dokployBackup, dokployRestart: input.dokployRestart, dockerCleanup: input.dockerCleanup, notificationType: "telegram", @@ -179,6 +182,7 @@ export const updateTelegramNotification = async ( appDeploy: input.appDeploy, appBuildError: input.appBuildError, databaseBackup: input.databaseBackup, + dokployBackup: input.dokployBackup, dokployRestart: input.dokployRestart, dockerCleanup: input.dockerCleanup, organizationId: input.organizationId, @@ -239,6 +243,7 @@ export const createDiscordNotification = async ( appDeploy: input.appDeploy, appBuildError: input.appBuildError, databaseBackup: input.databaseBackup, + dokployBackup: input.dokployBackup, dokployRestart: input.dokployRestart, dockerCleanup: input.dockerCleanup, notificationType: "discord", @@ -270,6 +275,7 @@ export const updateDiscordNotification = async ( appDeploy: input.appDeploy, appBuildError: input.appBuildError, databaseBackup: input.databaseBackup, + dokployBackup: input.dokployBackup, dokployRestart: input.dokployRestart, dockerCleanup: input.dockerCleanup, organizationId: input.organizationId, @@ -333,6 +339,7 @@ export const createEmailNotification = async ( appDeploy: input.appDeploy, appBuildError: input.appBuildError, databaseBackup: input.databaseBackup, + dokployBackup: input.dokployBackup, dokployRestart: input.dokployRestart, dockerCleanup: input.dockerCleanup, notificationType: "email", @@ -364,6 +371,7 @@ export const updateEmailNotification = async ( appDeploy: input.appDeploy, appBuildError: input.appBuildError, databaseBackup: input.databaseBackup, + dokployBackup: input.dokployBackup, dokployRestart: input.dokployRestart, dockerCleanup: input.dockerCleanup, organizationId: input.organizationId, @@ -429,6 +437,7 @@ export const createGotifyNotification = async ( appDeploy: input.appDeploy, appBuildError: input.appBuildError, databaseBackup: input.databaseBackup, + dokployBackup: input.dokployBackup, dokployRestart: input.dokployRestart, dockerCleanup: input.dockerCleanup, notificationType: "gotify", @@ -459,6 +468,7 @@ export const updateGotifyNotification = async ( appDeploy: input.appDeploy, appBuildError: input.appBuildError, databaseBackup: input.databaseBackup, + dokployBackup: input.dokployBackup, dokployRestart: input.dokployRestart, dockerCleanup: input.dockerCleanup, organizationId: input.organizationId, @@ -519,6 +529,7 @@ export const createNtfyNotification = async ( appDeploy: input.appDeploy, appBuildError: input.appBuildError, databaseBackup: input.databaseBackup, + dokployBackup: input.dokployBackup, dokployRestart: input.dokployRestart, dockerCleanup: input.dockerCleanup, notificationType: "ntfy", @@ -549,6 +560,7 @@ export const updateNtfyNotification = async ( appDeploy: input.appDeploy, appBuildError: input.appBuildError, databaseBackup: input.databaseBackup, + dokployBackup: input.dokployBackup, dokployRestart: input.dokployRestart, dockerCleanup: input.dockerCleanup, organizationId: input.organizationId, @@ -637,6 +649,7 @@ export const createLarkNotification = async ( appDeploy: input.appDeploy, appBuildError: input.appBuildError, databaseBackup: input.databaseBackup, + dokployBackup: input.dokployBackup, dokployRestart: input.dokployRestart, dockerCleanup: input.dockerCleanup, notificationType: "lark", @@ -668,6 +681,7 @@ export const updateLarkNotification = async ( appDeploy: input.appDeploy, appBuildError: input.appBuildError, databaseBackup: input.databaseBackup, + dokployBackup: input.dokployBackup, dokployRestart: input.dokployRestart, dockerCleanup: input.dockerCleanup, organizationId: input.organizationId, diff --git a/packages/server/src/utils/backups/web-server.ts b/packages/server/src/utils/backups/web-server.ts index 4d13ae31a..54399da82 100644 --- a/packages/server/src/utils/backups/web-server.ts +++ b/packages/server/src/utils/backups/web-server.ts @@ -1,5 +1,5 @@ import { createWriteStream } from "node:fs"; -import { mkdtemp, rm } from "node:fs/promises"; +import { mkdtemp, rm, stat } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { IS_CLOUD, paths } from "@dokploy/server/constants"; @@ -9,9 +9,19 @@ import { updateDeploymentStatus, } from "@dokploy/server/services/deployment"; import { findDestinationById } from "@dokploy/server/services/destination"; +import { sendDokployBackupNotifications } from "../notifications/dokploy-backup"; import { execAsync } from "../process/execAsync"; import { getS3Credentials, normalizeS3Path } from "./utils"; +function formatBytes(bytes?: number) { + if (bytes === undefined) return "Unknown size"; + if (bytes === 0) return "0 B"; + const sizes = ["B", "KB", "MB", "GB", "TB"]; + const i = Math.floor(Math.log(bytes) / Math.log(1024)); + const value = bytes / Math.pow(1024, i); + return `${value.toFixed(2)} ${sizes[i]} (${bytes} bytes)`; +} + export const runWebServerBackup = async (backup: BackupSchedule) => { if (IS_CLOUD) { return; @@ -23,7 +33,7 @@ export const runWebServerBackup = async (backup: BackupSchedule) => { description: "Web Server Backup", }); const writeStream = createWriteStream(deployment.logPath, { flags: "a" }); - + let computedBackupSize: number | undefined; try { const destination = await findDestinationById(backup.destinationId); const rcloneFlags = getS3Credentials(destination); @@ -79,11 +89,24 @@ export const runWebServerBackup = async (backup: BackupSchedule) => { writeStream.write("Zipped database and filesystem\n"); - const uploadCommand = `rclone copyto ${rcloneFlags.join(" ")} "${tempDir}/${backupFileName}" "${s3Path}"`; + const zipPath = join(tempDir, backupFileName); + try { + const { size } = await stat(zipPath); + computedBackupSize = size; + writeStream.write(`Backup size: ${size} bytes\n`); + } catch { + // If stat fails, keep undefined + } + + const uploadCommand = `rclone copyto ${rcloneFlags.join(" ")} "${zipPath}" "${s3Path}"`; writeStream.write("Running command to upload backup to S3\n"); await execAsync(uploadCommand); writeStream.write("Uploaded backup to S3 ✅\n"); writeStream.end(); + await sendDokployBackupNotifications({ + type: "success", + backupSize: formatBytes(computedBackupSize), + }); await updateDeploymentStatus(deployment.deploymentId, "done"); return true; } finally { @@ -100,6 +123,12 @@ export const runWebServerBackup = async (backup: BackupSchedule) => { error instanceof Error ? error.message : "Unknown error\n", ); writeStream.end(); + await sendDokployBackupNotifications({ + type: "error", + // @ts-ignore + errorMessage: error?.message || "Error message not provided", + backupSize: formatBytes(computedBackupSize), + }); await updateDeploymentStatus(deployment.deploymentId, "error"); throw error; } diff --git a/packages/server/src/utils/notifications/dokploy-backup.ts b/packages/server/src/utils/notifications/dokploy-backup.ts new file mode 100644 index 000000000..707f53839 --- /dev/null +++ b/packages/server/src/utils/notifications/dokploy-backup.ts @@ -0,0 +1,322 @@ +import { db } from "@dokploy/server/db"; +import { notifications } from "@dokploy/server/db/schema"; +import DokployBackupEmail from "@dokploy/server/emails/emails/dokploy-backup"; +import { renderAsync } from "@react-email/components"; +import { format } from "date-fns"; +import { eq } from "drizzle-orm"; +import { + sendDiscordNotification, + sendEmailNotification, + sendLarkNotification, + sendGotifyNotification, + sendNtfyNotification, + sendSlackNotification, + sendTelegramNotification, +} from "./utils"; + +export const sendDokployBackupNotifications = async ({ + type, + errorMessage, + backupSize, +}: { + type: "error" | "success"; + errorMessage?: string; + backupSize?: string; +}) => { + const date = new Date(); + const unixDate = ~~(Number(date) / 1000); + const notificationList = await db.query.notifications.findMany({ + where: eq(notifications.dokployBackup, true), + with: { + email: true, + discord: true, + telegram: true, + slack: true, + gotify: true, + ntfy: true, + lark: true, + }, + }); + + for (const notification of notificationList) { + const { email, discord, telegram, slack, gotify, ntfy, lark } = + notification; + + if (email) { + const template = await renderAsync( + DokployBackupEmail({ + type, + errorMessage, + date: date.toLocaleString(), + backupSize, + }), + ).catch(); + await sendEmailNotification( + email, + "Dokploy instance backup", + template, + ); + } + + if (discord) { + const decorate = (decoration: string, text: string) => + `${discord.decoration ? decoration : ""} ${text}`.trim(); + + await sendDiscordNotification(discord, { + title: + type === "success" + ? decorate(">", "`✅` Dokploy Backup Successful") + : decorate(">", "`❌` Dokploy Backup Failed"), + color: type === "success" ? 0x57f287 : 0xed4245, + fields: [ + { + name: decorate("`📦`", "Backup Type"), + value: "Complete Dokploy Instance", + inline: true, + }, + ...(backupSize + ? [ + { + name: decorate("`💾`", "Backup Size"), + value: backupSize, + inline: true, + }, + ] + : []), + { + name: decorate("`📅`", "Date"), + value: ``, + inline: true, + }, + { + name: decorate("`⌚`", "Time"), + value: ``, + inline: true, + }, + { + name: decorate("`❓`", "Status"), + value: type + .replace("error", "Failed") + .replace("success", "Successful"), + inline: true, + }, + ...(type === "error" && errorMessage + ? [ + { + name: decorate("`⚠️`", "Error Message"), + value: `\`\`\`${errorMessage}\`\`\``, + }, + ] + : []), + ], + timestamp: date.toISOString(), + footer: { + text: "Dokploy Instance Backup Notification", + }, + }); + } + + if (gotify) { + const decorate = (decoration: string, text: string) => + `${gotify.decoration ? decoration : ""} ${text}\n`; + + await sendGotifyNotification( + gotify, + decorate( + type === "success" ? "✅" : "❌", + `Dokploy Backup ${type === "success" ? "Successful" : "Failed"}`, + ), + `${decorate("📦", "Backup Type: Complete Dokploy Instance")}` + + `${backupSize ? decorate("💾", `Backup Size: ${backupSize}`) : ""}` + + `${decorate("🕒", `Date: ${date.toLocaleString()}`)}` + + `${type === "error" && errorMessage ? decorate("❌", `Error:\n${errorMessage}`) : ""}`, + ); + } + + if (ntfy) { + await sendNtfyNotification( + ntfy, + `Dokploy Backup ${type === "success" ? "Successful" : "Failed"}`, + `${type === "success" ? "white_check_mark" : "x"}`, + "", + `📦Backup Type: Complete Dokploy Instance\n` + + `${backupSize ? `💾Backup Size: ${backupSize}\n` : ""}` + + `🕒Date: ${date.toLocaleString()}\n` + + `${type === "error" && errorMessage ? `❌Error:\n${errorMessage}` : ""}`, + ); + } + + if (telegram) { + const isError = type === "error" && errorMessage; + + const statusEmoji = type === "success" ? "✅" : "❌"; + const typeStatus = type === "success" ? "Successful" : "Failed"; + const errorMsg = isError + ? `\n\nError:\n
${errorMessage}
` + : ""; + const sizeInfo = backupSize ? `\nBackup Size: ${backupSize}` : ""; + + const messageText = `${statusEmoji} Dokploy Backup ${typeStatus}\n\nBackup Type: Complete Dokploy Instance${sizeInfo}\nDate: ${format(date, "PP")}\nTime: ${format(date, "pp")}${isError ? errorMsg : ""}`; + + await sendTelegramNotification(telegram, messageText); + } + + if (slack) { + const { channel } = slack; + await sendSlackNotification(slack, { + channel: channel, + attachments: [ + { + color: type === "success" ? "#00FF00" : "#FF0000", + pretext: + type === "success" + ? ":white_check_mark: *Dokploy Backup Successful*" + : ":x: *Dokploy Backup Failed*", + fields: [ + ...(type === "error" && errorMessage + ? [ + { + title: "Error Message", + value: errorMessage, + short: false, + }, + ] + : []), + { + title: "Backup Type", + value: "Complete Dokploy Instance", + short: true, + }, + ...(backupSize + ? [ + { + title: "Backup Size", + value: backupSize, + short: true, + }, + ] + : []), + { + title: "Time", + value: date.toLocaleString(), + short: true, + }, + { + title: "Status", + value: type === "success" ? "Successful" : "Failed", + short: true, + }, + ], + }, + ], + }); + } + + if (lark) { + const limitCharacter = 800; + const truncatedErrorMessage = + errorMessage && errorMessage.length > limitCharacter + ? errorMessage.substring(0, limitCharacter) + : errorMessage; + + await sendLarkNotification(lark, { + msg_type: "interactive", + card: { + schema: "2.0", + config: { + update_multi: true, + style: { + text_size: { + normal_v2: { + default: "normal", + pc: "normal", + mobile: "heading", + }, + }, + }, + }, + header: { + title: { + tag: "plain_text", + content: + type === "success" + ? "✅ Dokploy Backup Successful" + : "❌ Dokploy Backup Failed", + }, + subtitle: { + tag: "plain_text", + content: "", + }, + template: type === "success" ? "green" : "red", + padding: "12px 12px 12px 12px", + }, + body: { + direction: "vertical", + padding: "12px 12px 12px 12px", + elements: [ + { + tag: "column_set", + columns: [ + { + tag: "column", + width: "weighted", + elements: [ + { + tag: "markdown", + content: `**Backup Type:**\nComplete Dokploy Instance`, + text_align: "left", + text_size: "normal_v2", + }, + { + tag: "markdown", + content: `**Status:**\n${type === "success" ? "Successful" : "Failed"}`, + text_align: "left", + text_size: "normal_v2", + }, + ], + vertical_align: "top", + weight: 1, + }, + { + tag: "column", + width: "weighted", + elements: [ + ...(backupSize + ? [ + { + tag: "markdown", + content: `**Backup Size:**\n${backupSize}`, + text_align: "left", + text_size: "normal_v2", + }, + ] + : []), + { + tag: "markdown", + content: `**Date:**\n${format(date, "PP pp")}`, + text_align: "left", + text_size: "normal_v2", + }, + ], + vertical_align: "top", + weight: 1, + }, + ], + }, + ...(type === "error" && truncatedErrorMessage + ? [ + { + tag: "markdown", + content: `**Error Message:**\n\`\`\`\n${truncatedErrorMessage}\n\`\`\``, + text_align: "left", + text_size: "normal_v2", + }, + ] + : []), + ], + }, + }, + }); + } + } +}; \ No newline at end of file