Skip to content

Commit c435148

Browse files
authored
Merge pull request #2699 from ChristoferMendes/feature/add-custom-webhook-notification-provider
feat(notifications): add custom webhook notification provider
2 parents 5360df7 + 5412c5a commit c435148

File tree

27 files changed

+10255
-129
lines changed

27 files changed

+10255
-129
lines changed

apps/dokploy/__test__/server/mechanizeDockerContainer.test.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
1-
import { beforeEach, describe, expect, it, vi } from "vitest";
2-
31
import type { ApplicationNested } from "@dokploy/server/utils/builders";
42
import { mechanizeDockerContainer } from "@dokploy/server/utils/builders";
3+
import { beforeEach, describe, expect, it, vi } from "vitest";
54

65
type MockCreateServiceOptions = {
76
TaskTemplate?: {

apps/dokploy/components/dashboard/project/advanced-environment-selector.tsx

Lines changed: 1 addition & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,8 @@
11
import type { findEnvironmentsByProjectId } from "@dokploy/server";
2-
import {
3-
ChevronDownIcon,
4-
PencilIcon,
5-
PlusIcon,
6-
Terminal,
7-
TrashIcon,
8-
} from "lucide-react";
2+
import { ChevronDownIcon, PencilIcon, PlusIcon, TrashIcon } from "lucide-react";
93
import { useRouter } from "next/router";
104
import { useState } from "react";
115
import { toast } from "sonner";
12-
import { EnvironmentVariables } from "@/components/dashboard/project/environment-variables";
136
import { AlertBlock } from "@/components/shared/alert-block";
147
import { Button } from "@/components/ui/button";
158
import {
@@ -246,20 +239,6 @@ export const AdvancedEnvironmentSelector = ({
246239
)}
247240
</div>
248241
</DropdownMenuItem>
249-
250-
{/* Action buttons for non-production environments */}
251-
{/* <EnvironmentVariables environmentId={environment.environmentId}>
252-
<Button
253-
variant="ghost"
254-
size="sm"
255-
className="h-6 w-6 p-0"
256-
onClick={(e) => {
257-
e.stopPropagation();
258-
}}
259-
>
260-
<Terminal className="h-3 w-3" />
261-
</Button>
262-
</EnvironmentVariables> */}
263242
{environment.name !== "production" && (
264243
<div className="flex items-center gap-1 px-2">
265244
<Button

apps/dokploy/components/dashboard/settings/notifications/handle-notifications.tsx

Lines changed: 191 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
import { zodResolver } from "@hookform/resolvers/zod";
2-
import { AlertTriangle, Mail, PenBoxIcon, PlusIcon } from "lucide-react";
2+
import {
3+
AlertTriangle,
4+
Mail,
5+
PenBoxIcon,
6+
PlusIcon,
7+
Trash2,
8+
} from "lucide-react";
39
import { useEffect, useState } from "react";
410
import { useFieldArray, useForm } from "react-hook-form";
511
import { toast } from "sonner";
@@ -108,6 +114,21 @@ export const notificationSchema = z.discriminatedUnion("type", [
108114
priority: z.number().min(1).max(5).default(3),
109115
})
110116
.merge(notificationBaseSchema),
117+
z
118+
.object({
119+
type: z.literal("custom"),
120+
endpoint: z.string().min(1, { message: "Endpoint URL is required" }),
121+
headers: z
122+
.array(
123+
z.object({
124+
key: z.string(),
125+
value: z.string(),
126+
}),
127+
)
128+
.optional()
129+
.default([]),
130+
})
131+
.merge(notificationBaseSchema),
111132
z
112133
.object({
113134
type: z.literal("lark"),
@@ -145,6 +166,10 @@ export const notificationsMap = {
145166
icon: <NtfyIcon />,
146167
label: "ntfy",
147168
},
169+
custom: {
170+
icon: <PenBoxIcon size={29} className="text-muted-foreground" />,
171+
label: "Custom",
172+
},
148173
};
149174

150175
export type NotificationSchema = z.infer<typeof notificationSchema>;
@@ -180,6 +205,13 @@ export const HandleNotifications = ({ notificationId }: Props) => {
180205
api.notification.testNtfyConnection.useMutation();
181206
const { mutateAsync: testLarkConnection, isLoading: isLoadingLark } =
182207
api.notification.testLarkConnection.useMutation();
208+
209+
const { mutateAsync: testCustomConnection, isLoading: isLoadingCustom } =
210+
api.notification.testCustomConnection.useMutation();
211+
212+
const customMutation = notificationId
213+
? api.notification.updateCustom.useMutation()
214+
: api.notification.createCustom.useMutation();
183215
const slackMutation = notificationId
184216
? api.notification.updateSlack.useMutation()
185217
: api.notification.createSlack.useMutation();
@@ -218,6 +250,15 @@ export const HandleNotifications = ({ notificationId }: Props) => {
218250
name: "toAddresses" as never,
219251
});
220252

253+
const {
254+
fields: headerFields,
255+
append: appendHeader,
256+
remove: removeHeader,
257+
} = useFieldArray({
258+
control: form.control,
259+
name: "headers" as never,
260+
});
261+
221262
useEffect(() => {
222263
if (type === "email" && fields.length === 0) {
223264
append("");
@@ -330,6 +371,26 @@ export const HandleNotifications = ({ notificationId }: Props) => {
330371
dockerCleanup: notification.dockerCleanup,
331372
serverThreshold: notification.serverThreshold,
332373
});
374+
} else if (notification.notificationType === "custom") {
375+
form.reset({
376+
appBuildError: notification.appBuildError,
377+
appDeploy: notification.appDeploy,
378+
dokployRestart: notification.dokployRestart,
379+
databaseBackup: notification.databaseBackup,
380+
type: notification.notificationType,
381+
endpoint: notification.custom?.endpoint || "",
382+
headers: notification.custom?.headers
383+
? Object.entries(notification.custom.headers).map(
384+
([key, value]) => ({
385+
key,
386+
value,
387+
}),
388+
)
389+
: [],
390+
name: notification.name,
391+
dockerCleanup: notification.dockerCleanup,
392+
serverThreshold: notification.serverThreshold,
393+
});
333394
}
334395
} else {
335396
form.reset();
@@ -344,6 +405,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
344405
gotify: gotifyMutation,
345406
ntfy: ntfyMutation,
346407
lark: larkMutation,
408+
custom: customMutation,
347409
};
348410

349411
const onSubmit = async (data: NotificationSchema) => {
@@ -467,6 +529,32 @@ export const HandleNotifications = ({ notificationId }: Props) => {
467529
larkId: notification?.larkId || "",
468530
serverThreshold: serverThreshold,
469531
});
532+
} else if (data.type === "custom") {
533+
// Convert headers array to object
534+
const headersRecord =
535+
data.headers && data.headers.length > 0
536+
? data.headers.reduce(
537+
(acc, { key, value }) => {
538+
if (key.trim()) acc[key] = value;
539+
return acc;
540+
},
541+
{} as Record<string, string>,
542+
)
543+
: undefined;
544+
545+
promise = customMutation.mutateAsync({
546+
appBuildError: appBuildError,
547+
appDeploy: appDeploy,
548+
dokployRestart: dokployRestart,
549+
databaseBackup: databaseBackup,
550+
endpoint: data.endpoint,
551+
headers: headersRecord,
552+
name: data.name,
553+
dockerCleanup: dockerCleanup,
554+
serverThreshold: serverThreshold,
555+
notificationId: notificationId || "",
556+
customId: notification?.customId || "",
557+
});
470558
}
471559

472560
if (promise) {
@@ -1057,7 +1145,92 @@ export const HandleNotifications = ({ notificationId }: Props) => {
10571145
/>
10581146
</>
10591147
)}
1148+
{type === "custom" && (
1149+
<div className="space-y-4">
1150+
<FormField
1151+
control={form.control}
1152+
name="endpoint"
1153+
render={({ field }) => (
1154+
<FormItem>
1155+
<FormLabel>Webhook URL</FormLabel>
1156+
<FormControl>
1157+
<Input
1158+
placeholder="https://api.example.com/webhook"
1159+
{...field}
1160+
/>
1161+
</FormControl>
1162+
<FormDescription>
1163+
The URL where POST requests will be sent with
1164+
notification data.
1165+
</FormDescription>
1166+
<FormMessage />
1167+
</FormItem>
1168+
)}
1169+
/>
1170+
1171+
<div className="space-y-3">
1172+
<div>
1173+
<FormLabel>Headers</FormLabel>
1174+
<FormDescription>
1175+
Optional. Custom headers for your POST request (e.g.,
1176+
Authorization, Content-Type).
1177+
</FormDescription>
1178+
</div>
10601179

1180+
<div className="space-y-2">
1181+
{headerFields.map((field, index) => (
1182+
<div
1183+
key={field.id}
1184+
className="flex items-center gap-2 p-2 border rounded-md bg-muted/50"
1185+
>
1186+
<FormField
1187+
control={form.control}
1188+
name={`headers.${index}.key` as never}
1189+
render={({ field }) => (
1190+
<FormItem className="flex-1">
1191+
<FormControl>
1192+
<Input placeholder="Key" {...field} />
1193+
</FormControl>
1194+
</FormItem>
1195+
)}
1196+
/>
1197+
<FormField
1198+
control={form.control}
1199+
name={`headers.${index}.value` as never}
1200+
render={({ field }) => (
1201+
<FormItem className="flex-[2]">
1202+
<FormControl>
1203+
<Input placeholder="Value" {...field} />
1204+
</FormControl>
1205+
</FormItem>
1206+
)}
1207+
/>
1208+
<Button
1209+
type="button"
1210+
variant="ghost"
1211+
size="sm"
1212+
onClick={() => removeHeader(index)}
1213+
className="text-red-500 hover:text-red-700 hover:bg-red-50"
1214+
>
1215+
<Trash2 className="h-4 w-4" />
1216+
</Button>
1217+
</div>
1218+
))}
1219+
</div>
1220+
1221+
<Button
1222+
type="button"
1223+
variant="outline"
1224+
size="sm"
1225+
onClick={() => appendHeader({ key: "", value: "" })}
1226+
className="w-full"
1227+
>
1228+
<PlusIcon className="h-4 w-4 mr-2" />
1229+
Add header
1230+
</Button>
1231+
</div>
1232+
</div>
1233+
)}
10611234
{type === "lark" && (
10621235
<>
10631236
<FormField
@@ -1250,7 +1423,8 @@ export const HandleNotifications = ({ notificationId }: Props) => {
12501423
isLoadingEmail ||
12511424
isLoadingGotify ||
12521425
isLoadingNtfy ||
1253-
isLoadingLark
1426+
isLoadingLark ||
1427+
isLoadingCustom
12541428
}
12551429
variant="secondary"
12561430
type="button"
@@ -1304,6 +1478,21 @@ export const HandleNotifications = ({ notificationId }: Props) => {
13041478
await testLarkConnection({
13051479
webhookUrl: data.webhookUrl,
13061480
});
1481+
} else if (data.type === "custom") {
1482+
const headersRecord =
1483+
data.headers && data.headers.length > 0
1484+
? data.headers.reduce(
1485+
(acc, { key, value }) => {
1486+
if (key.trim()) acc[key] = value;
1487+
return acc;
1488+
},
1489+
{} as Record<string, string>,
1490+
)
1491+
: undefined;
1492+
await testCustomConnection({
1493+
endpoint: data.endpoint,
1494+
headers: headersRecord,
1495+
});
13071496
}
13081497
toast.success("Connection Success");
13091498
} catch (error) {

apps/dokploy/components/dashboard/settings/notifications/show-notifications.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { Bell, Loader2, Mail, Trash2 } from "lucide-react";
1+
import { Bell, Loader2, Mail, PenBoxIcon, Trash2 } from "lucide-react";
22
import { toast } from "sonner";
33
import {
44
DiscordIcon,
@@ -96,6 +96,11 @@ export const ShowNotifications = () => {
9696
<NtfyIcon className="size-6" />
9797
</div>
9898
)}
99+
{notification.notificationType === "custom" && (
100+
<div className="flex items-center justify-center rounded-lg ">
101+
<PenBoxIcon className="size-6 text-muted-foreground" />
102+
</div>
103+
)}
99104
{notification.notificationType === "lark" && (
100105
<div className="flex items-center justify-center rounded-lg">
101106
<LarkIcon className="size-7 text-muted-foreground" />
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
ALTER TYPE "public"."notificationType" ADD VALUE 'custom' BEFORE 'lark';--> statement-breakpoint
2+
CREATE TABLE "custom" (
3+
"customId" text PRIMARY KEY NOT NULL,
4+
"endpoint" text NOT NULL,
5+
"headers" jsonb
6+
);
7+
--> statement-breakpoint
8+
ALTER TABLE "notification" ADD COLUMN "customId" text;--> statement-breakpoint
9+
ALTER TABLE "notification" ADD CONSTRAINT "notification_customId_custom_customId_fk" FOREIGN KEY ("customId") REFERENCES "public"."custom"("customId") ON DELETE cascade ON UPDATE no action;

0 commit comments

Comments
 (0)