diff --git a/apps/dokploy/components/dashboard/application/deployments/show-deployments.tsx b/apps/dokploy/components/dashboard/application/deployments/show-deployments.tsx index 796a002da..ca7a93518 100644 --- a/apps/dokploy/components/dashboard/application/deployments/show-deployments.tsx +++ b/apps/dokploy/components/dashboard/application/deployments/show-deployments.tsx @@ -143,7 +143,7 @@ export const ShowDeployments = ({ See the last 10 deployments for this {type} -
+
{(type === "application" || type === "compose") && ( )} diff --git a/apps/dokploy/components/dashboard/settings/notifications/handle-notifications.tsx b/apps/dokploy/components/dashboard/settings/notifications/handle-notifications.tsx index e43366845..bb11b67f3 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), + volumeBackup: 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, + volumeBackup: notification.volumeBackup, 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, + volumeBackup: notification.volumeBackup, 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, + volumeBackup: notification.volumeBackup, 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, + volumeBackup: notification.volumeBackup, 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, + volumeBackup: notification.volumeBackup, 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, + volumeBackup: notification.volumeBackup, type: notification.notificationType, accessToken: notification.ntfy?.accessToken || "", topic: notification.ntfy?.topic, @@ -345,6 +352,7 @@ export const HandleNotifications = ({ notificationId }: Props) => { appDeploy, dokployRestart, databaseBackup, + volumeBackup, dockerCleanup, serverThreshold, } = data; @@ -355,6 +363,7 @@ export const HandleNotifications = ({ notificationId }: Props) => { appDeploy: appDeploy, dokployRestart: dokployRestart, databaseBackup: databaseBackup, + volumeBackup: volumeBackup, webhookUrl: data.webhookUrl, channel: data.channel, name: data.name, @@ -369,6 +378,7 @@ export const HandleNotifications = ({ notificationId }: Props) => { appDeploy: appDeploy, dokployRestart: dokployRestart, databaseBackup: databaseBackup, + volumeBackup: volumeBackup, botToken: data.botToken, messageThreadId: data.messageThreadId || "", chatId: data.chatId, @@ -384,6 +394,7 @@ export const HandleNotifications = ({ notificationId }: Props) => { appDeploy: appDeploy, dokployRestart: dokployRestart, databaseBackup: databaseBackup, + volumeBackup: volumeBackup, webhookUrl: data.webhookUrl, decoration: data.decoration, name: data.name, @@ -398,6 +409,7 @@ export const HandleNotifications = ({ notificationId }: Props) => { appDeploy: appDeploy, dokployRestart: dokployRestart, databaseBackup: databaseBackup, + volumeBackup: volumeBackup, smtpServer: data.smtpServer, smtpPort: data.smtpPort, username: data.username, @@ -416,6 +428,7 @@ export const HandleNotifications = ({ notificationId }: Props) => { appDeploy: appDeploy, dokployRestart: dokployRestart, databaseBackup: databaseBackup, + volumeBackup: volumeBackup, serverUrl: data.serverUrl, appToken: data.appToken, priority: data.priority, @@ -431,6 +444,7 @@ export const HandleNotifications = ({ notificationId }: Props) => { appDeploy: appDeploy, dokployRestart: dokployRestart, databaseBackup: databaseBackup, + volumeBackup: volumeBackup, serverUrl: data.serverUrl, accessToken: data.accessToken || "", topic: data.topic, @@ -1134,6 +1148,27 @@ export const HandleNotifications = ({ notificationId }: Props) => { )} /> + ( + +
+ Volume Backup + + Trigger the action when a volume backup is created. + +
+ + + +
+ )} + /> + { + const previewText = `Volume backup for ${applicationName} was ${type === "success" ? "successful ✅" : "failed ❌"}`; + return ( + + {previewText} + + + + + +
+ Dokploy +
+ + Volume backup for {applicationName} + + + Hello, + + + Your volume backup for {applicationName} was{" "} + {type === "success" + ? "successful ✅" + : "failed. Please check the error message below. ❌"} + . + +
+ Details: + + Project Name: {projectName} + + + Application Name: {applicationName} + + + Volume Name: {volumeName} + + + Service Type: {serviceType} + + {backupSize && ( + + Backup Size: {backupSize} + + )} + + Date: {date} + +
+ {type === "error" && errorMessage ? ( +
+ Reason: + + {errorMessage || "Error message not provided"} + +
+ ) : null} +
+ +
+ + ); +}; + +export default VolumeBackupEmail; diff --git a/packages/server/src/services/notification.ts b/packages/server/src/services/notification.ts index d0a7ab283..983f6c48a 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, + volumeBackup: input.volumeBackup, 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, + volumeBackup: input.volumeBackup, 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, + volumeBackup: input.volumeBackup, 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, + volumeBackup: input.volumeBackup, 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, + volumeBackup: input.volumeBackup, 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, + volumeBackup: input.volumeBackup, 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, + volumeBackup: input.volumeBackup, 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, + volumeBackup: input.volumeBackup, 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, + volumeBackup: input.volumeBackup, 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, + volumeBackup: input.volumeBackup, 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, + volumeBackup: input.volumeBackup, 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, + volumeBackup: input.volumeBackup, dokployRestart: input.dokployRestart, dockerCleanup: input.dockerCleanup, organizationId: input.organizationId, diff --git a/packages/server/src/services/volume-backups.ts b/packages/server/src/services/volume-backups.ts index 180a01bb7..9a3d6a317 100644 --- a/packages/server/src/services/volume-backups.ts +++ b/packages/server/src/services/volume-backups.ts @@ -12,13 +12,69 @@ export const findVolumeBackupById = async (volumeBackupId: string) => { const volumeBackup = await db.query.volumeBackups.findFirst({ where: eq(volumeBackups.volumeBackupId, volumeBackupId), with: { - application: true, - postgres: true, - mysql: true, - mariadb: true, - mongo: true, - redis: true, - compose: true, + application: { + with: { + environment: { + with: { + project: true, + }, + }, + }, + }, + postgres: { + with: { + environment: { + with: { + project: true, + }, + }, + }, + }, + mysql: { + with: { + environment: { + with: { + project: true, + }, + }, + }, + }, + mariadb: { + with: { + environment: { + with: { + project: true, + }, + }, + }, + }, + mongo: { + with: { + environment: { + with: { + project: true, + }, + }, + }, + }, + redis: { + with: { + environment: { + with: { + project: true, + }, + }, + }, + }, + compose: { + with: { + environment: { + with: { + project: true, + }, + }, + }, + }, destination: true, }, }); diff --git a/packages/server/src/utils/notifications/volume-backup.ts b/packages/server/src/utils/notifications/volume-backup.ts new file mode 100644 index 000000000..bec85f399 --- /dev/null +++ b/packages/server/src/utils/notifications/volume-backup.ts @@ -0,0 +1,274 @@ +import { db } from "@dokploy/server/db"; +import { notifications } from "@dokploy/server/db/schema"; +import { VolumeBackupEmail } from "@dokploy/server/emails/emails/volume-backup"; +import { renderAsync } from "@react-email/components"; +import { format } from "date-fns"; +import { and, eq } from "drizzle-orm"; +import { + sendDiscordNotification, + sendEmailNotification, + sendGotifyNotification, + sendNtfyNotification, + sendSlackNotification, + sendTelegramNotification, +} from "./utils"; + +export const sendVolumeBackupNotifications = async ({ + projectName, + applicationName, + volumeName, + serviceType, + type, + errorMessage, + organizationId, + backupSize, +}: { + projectName: string; + applicationName: string; + volumeName: string; + serviceType: + | "application" + | "postgres" + | "mysql" + | "mongodb" + | "mariadb" + | "redis" + | "compose"; + type: "error" | "success"; + organizationId: string; + errorMessage?: string; + backupSize?: string; +}) => { + const date = new Date(); + const unixDate = ~~(Number(date) / 1000); + const notificationList = await db.query.notifications.findMany({ + where: and( + eq(notifications.volumeBackup, true), + eq(notifications.organizationId, organizationId), + ), + with: { + email: true, + discord: true, + telegram: true, + slack: true, + gotify: true, + ntfy: true, + }, + }); + + for (const notification of notificationList) { + const { email, discord, telegram, slack, gotify, ntfy } = notification; + + if (email) { + const subject = `Volume Backup ${type === "success" ? "Successful" : "Failed"} - ${applicationName}`; + const htmlContent = await renderAsync( + VolumeBackupEmail({ + projectName, + applicationName, + volumeName, + serviceType, + type, + errorMessage, + backupSize, + date: date.toISOString(), + }), + ); + await sendEmailNotification(email, subject, htmlContent); + } + + if (discord) { + const decorate = (decoration: string, text: string) => + `${discord.decoration ? decoration : ""} ${text}`.trim(); + + await sendDiscordNotification(discord, { + title: + type === "success" + ? decorate(">", "`✅` Volume Backup Successful") + : decorate(">", "`❌` Volume Backup Failed"), + color: type === "success" ? 0x57f287 : 0xed4245, + fields: [ + { + name: decorate("`🛠️`", "Project"), + value: projectName, + inline: true, + }, + { + name: decorate("`⚙️`", "Application"), + value: applicationName, + inline: true, + }, + { + name: decorate("`💾`", "Volume Name"), + value: volumeName, + inline: true, + }, + { + name: decorate("`🔧`", "Service Type"), + value: serviceType, + 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("`❓`", "Type"), + 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 Volume Backup Notification", + }, + }); + } + + if (gotify) { + const decorate = (decoration: string, text: string) => + `${gotify.decoration ? decoration : ""} ${text}\n`; + + await sendGotifyNotification( + gotify, + decorate( + type === "success" ? "✅" : "❌", + `Volume Backup ${type === "success" ? "Successful" : "Failed"}`, + ), + `${decorate("🛠️", `Project: ${projectName}`)}` + + `${decorate("⚙️", `Application: ${applicationName}`)}` + + `${decorate("💾", `Volume Name: ${volumeName}`)}` + + `${decorate("🔧", `Service Type: ${serviceType}`)}` + + `${backupSize ? decorate("📊", `Backup Size: ${backupSize}`) : ""}` + + `${decorate("🕒", `Date: ${date.toLocaleString()}`)}` + + `${type === "error" && errorMessage ? decorate("❌", `Error:\n${errorMessage}`) : ""}`, + ); + } + + if (ntfy) { + await sendNtfyNotification( + ntfy, + `Volume Backup ${type === "success" ? "Successful" : "Failed"}`, + `${type === "success" ? "white_check_mark" : "x"}`, + "", + `🛠️Project: ${projectName}\n` + + `⚙️Application: ${applicationName}\n` + + `💾Volume Name: ${volumeName}\n` + + `🔧Service Type: ${serviceType}\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} Volume Backup ${typeStatus}\n\nProject: ${projectName}\nApplication: ${applicationName}\nVolume Name: ${volumeName}\nService Type: ${serviceType}${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: *Volume Backup Successful*" + : ":x: *Volume Backup Failed*", + fields: [ + ...(type === "error" && errorMessage + ? [ + { + title: "Error Message", + value: errorMessage, + short: false, + }, + ] + : []), + { + title: "Project", + value: projectName, + short: true, + }, + { + title: "Application", + value: applicationName, + short: true, + }, + { + title: "Volume Name", + value: volumeName, + short: true, + }, + { + title: "Service Type", + value: serviceType, + short: true, + }, + ...(backupSize + ? [ + { + title: "Backup Size", + value: backupSize, + short: true, + }, + ] + : []), + { + title: "Time", + value: date.toLocaleString(), + short: true, + }, + { + title: "Type", + value: type, + short: true, + }, + { + title: "Status", + value: type === "success" ? "Successful" : "Failed", + short: true, + }, + ], + }, + ], + }); + } + } +}; diff --git a/packages/server/src/utils/volume-backups/utils.ts b/packages/server/src/utils/volume-backups/utils.ts index b6a34e2aa..b508c6b88 100644 --- a/packages/server/src/utils/volume-backups/utils.ts +++ b/packages/server/src/utils/volume-backups/utils.ts @@ -11,8 +11,54 @@ import { } from "@dokploy/server/utils/process/execAsync"; import { scheduledJobs, scheduleJob } from "node-schedule"; import { getS3Credentials, normalizeS3Path } from "../backups/utils"; +import { sendVolumeBackupNotifications } from "../notifications/volume-backup"; import { backupVolume } from "./backup"; +// Helper functions to extract project info from volume backup +const getProjectName = ( + volumeBackup: Awaited>, +): string => { + const services = [ + volumeBackup.application, + volumeBackup.compose, + volumeBackup.postgres, + volumeBackup.mysql, + volumeBackup.mariadb, + volumeBackup.mongo, + volumeBackup.redis, + ]; + + for (const service of services) { + if (service?.environment?.project?.name) { + return service.environment.project.name; + } + } + + return "Unknown Project"; +}; + +const getOrganizationId = ( + volumeBackup: Awaited>, +): string => { + const services = [ + volumeBackup.application, + volumeBackup.compose, + volumeBackup.postgres, + volumeBackup.mysql, + volumeBackup.mariadb, + volumeBackup.mongo, + volumeBackup.redis, + ]; + + for (const service of services) { + if (service?.environment?.project?.organizationId) { + return service.environment.project.organizationId; + } + } + + return ""; +}; + export const scheduleVolumeBackup = async (volumeBackupId: string) => { const volumeBackup = await findVolumeBackupById(volumeBackupId); scheduleJob(volumeBackupId, volumeBackup.cronExpression, async () => { @@ -61,7 +107,8 @@ export const runVolumeBackup = async (volumeBackupId: string) => { title: "Volume Backup", description: "Volume Backup", }); - + const projectName = getProjectName(volumeBackup); + const organizationId = getOrganizationId(volumeBackup); try { const command = await backupVolume(volumeBackup); @@ -77,6 +124,21 @@ export const runVolumeBackup = async (volumeBackupId: string) => { } await updateDeploymentStatus(deployment.deploymentId, "done"); + + // Map service type to match notification function expectations + const mappedServiceType = + volumeBackup.serviceType === "mongo" + ? "mongodb" + : volumeBackup.serviceType; + + await sendVolumeBackupNotifications({ + projectName, + applicationName: volumeBackup.name, + volumeName: volumeBackup.volumeName, + serviceType: mappedServiceType, + type: "success", + organizationId, + }); } catch (error) { const { VOLUME_BACKUPS_PATH } = paths(!!serverId); const volumeBackupPath = path.join( @@ -92,6 +154,20 @@ export const runVolumeBackup = async (volumeBackupId: string) => { } await updateDeploymentStatus(deployment.deploymentId, "error"); - console.error(error); + // Send error notification + const mappedServiceType = + volumeBackup.serviceType === "mongo" + ? "mongodb" + : volumeBackup.serviceType; + + await sendVolumeBackupNotifications({ + projectName, + applicationName: volumeBackup.name, + volumeName: volumeBackup.volumeName, + serviceType: mappedServiceType, + type: "error", + organizationId, + errorMessage: error instanceof Error ? error.message : String(error), + }); } };