mirror of
https://github.com/Dokploy/dokploy.git
synced 2026-06-29 11:05:33 +02:00
feat: enhance custom notification handling and schema
- Updated the custom notification type to include an array of headers, allowing for more flexible header management. - Removed the obsolete KeyValueInput component, streamlining the notification settings interface. - Adjusted the database schema to support JSONB for headers in the custom table, improving data handling. - Enhanced the notification testing functionality to accommodate the new headers structure. - Updated related API endpoints and utility functions to reflect these changes.
This commit is contained in:
@@ -1,5 +1,11 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { AlertTriangle, Mail, PenBoxIcon, PlusIcon } from "lucide-react";
|
||||
import {
|
||||
AlertTriangle,
|
||||
Mail,
|
||||
PenBoxIcon,
|
||||
PlusIcon,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useFieldArray, useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
@@ -36,7 +42,6 @@ import { Label } from "@/components/ui/label";
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { api } from "@/utils/api";
|
||||
import { KeyValueInput } from "./key-value-input";
|
||||
|
||||
const notificationBaseSchema = z.object({
|
||||
name: z.string().min(1, {
|
||||
@@ -113,7 +118,15 @@ export const notificationSchema = z.discriminatedUnion("type", [
|
||||
.object({
|
||||
type: z.literal("custom"),
|
||||
endpoint: z.string().min(1, { message: "Endpoint URL is required" }),
|
||||
headers: z.string().optional(),
|
||||
headers: z
|
||||
.array(
|
||||
z.object({
|
||||
key: z.string(),
|
||||
value: z.string(),
|
||||
}),
|
||||
)
|
||||
.optional()
|
||||
.default([]),
|
||||
})
|
||||
.merge(notificationBaseSchema),
|
||||
z
|
||||
@@ -237,6 +250,15 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
||||
name: "toAddresses" as never,
|
||||
});
|
||||
|
||||
const {
|
||||
fields: headerFields,
|
||||
append: appendHeader,
|
||||
remove: removeHeader,
|
||||
} = useFieldArray({
|
||||
control: form.control,
|
||||
name: "headers" as never,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (type === "email" && fields.length === 0) {
|
||||
append("");
|
||||
@@ -357,7 +379,14 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
||||
databaseBackup: notification.databaseBackup,
|
||||
type: notification.notificationType,
|
||||
endpoint: notification.custom?.endpoint || "",
|
||||
headers: notification.custom?.headers || "",
|
||||
headers: notification.custom?.headers
|
||||
? Object.entries(notification.custom.headers).map(
|
||||
([key, value]) => ({
|
||||
key,
|
||||
value,
|
||||
}),
|
||||
)
|
||||
: [],
|
||||
name: notification.name,
|
||||
dockerCleanup: notification.dockerCleanup,
|
||||
serverThreshold: notification.serverThreshold,
|
||||
@@ -501,13 +530,25 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
||||
serverThreshold: serverThreshold,
|
||||
});
|
||||
} else if (data.type === "custom") {
|
||||
// Convert headers array to object
|
||||
const headersRecord =
|
||||
data.headers && data.headers.length > 0
|
||||
? data.headers.reduce(
|
||||
(acc, { key, value }) => {
|
||||
if (key.trim()) acc[key] = value;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, string>,
|
||||
)
|
||||
: undefined;
|
||||
|
||||
promise = customMutation.mutateAsync({
|
||||
appBuildError: appBuildError,
|
||||
appDeploy: appDeploy,
|
||||
dokployRestart: dokployRestart,
|
||||
databaseBackup: databaseBackup,
|
||||
endpoint: data.endpoint,
|
||||
headers: data.headers || "",
|
||||
headers: headersRecord,
|
||||
name: data.name,
|
||||
dockerCleanup: dockerCleanup,
|
||||
serverThreshold: serverThreshold,
|
||||
@@ -1127,23 +1168,67 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="headers"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<KeyValueInput
|
||||
value={field.value || ""}
|
||||
onChange={field.onChange}
|
||||
label="Headers"
|
||||
description="Optional. Custom headers for your POST request (e.g., Authorization, Content-Type)."
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<FormLabel>Headers</FormLabel>
|
||||
<FormDescription>
|
||||
Optional. Custom headers for your POST request (e.g.,
|
||||
Authorization, Content-Type).
|
||||
</FormDescription>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{headerFields.map((field, index) => (
|
||||
<div
|
||||
key={field.id}
|
||||
className="flex items-center gap-2 p-2 border rounded-md bg-muted/50"
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`headers.${index}.key` as never}
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex-1">
|
||||
<FormControl>
|
||||
<Input placeholder="Key" {...field} />
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`headers.${index}.value` as never}
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex-[2]">
|
||||
<FormControl>
|
||||
<Input placeholder="Value" {...field} />
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => removeHeader(index)}
|
||||
className="text-red-500 hover:text-red-700 hover:bg-red-50"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => appendHeader({ key: "", value: "" })}
|
||||
className="w-full"
|
||||
>
|
||||
<PlusIcon className="h-4 w-4 mr-2" />
|
||||
Add header
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{type === "lark" && (
|
||||
@@ -1338,7 +1423,8 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
||||
isLoadingEmail ||
|
||||
isLoadingGotify ||
|
||||
isLoadingNtfy ||
|
||||
isLoadingLark
|
||||
isLoadingLark ||
|
||||
isLoadingCustom
|
||||
}
|
||||
variant="secondary"
|
||||
type="button"
|
||||
@@ -1392,6 +1478,21 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
||||
await testLarkConnection({
|
||||
webhookUrl: data.webhookUrl,
|
||||
});
|
||||
} else if (data.type === "custom") {
|
||||
const headersRecord =
|
||||
data.headers && data.headers.length > 0
|
||||
? data.headers.reduce(
|
||||
(acc, { key, value }) => {
|
||||
if (key.trim()) acc[key] = value;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, string>,
|
||||
)
|
||||
: undefined;
|
||||
await testCustomConnection({
|
||||
endpoint: data.endpoint,
|
||||
headers: headersRecord,
|
||||
});
|
||||
}
|
||||
toast.success("Connection Success");
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,165 +0,0 @@
|
||||
import { Plus, Trash2 } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
||||
interface KeyValuePair {
|
||||
key: string;
|
||||
value: string;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
interface KeyValueInputProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
placeholder?: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
const createEmptyPair = (): KeyValuePair => ({
|
||||
key: "",
|
||||
value: "",
|
||||
enabled: true,
|
||||
});
|
||||
|
||||
const parseJsonToPairs = (jsonString: string): KeyValuePair[] => {
|
||||
try {
|
||||
const parsed = JSON.parse(jsonString);
|
||||
const pairs = Object.entries(parsed).map(([key, val]) => ({
|
||||
key,
|
||||
value: String(val),
|
||||
enabled: true,
|
||||
}));
|
||||
return pairs.length > 0 ? pairs : [createEmptyPair()];
|
||||
} catch {
|
||||
return [createEmptyPair()];
|
||||
}
|
||||
};
|
||||
|
||||
const convertPairsToJson = (pairs: KeyValuePair[]): string => {
|
||||
const enabledPairs = pairs.filter((pair) => pair.enabled && pair.key.trim());
|
||||
|
||||
if (enabledPairs.length === 0) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const obj = enabledPairs.reduce(
|
||||
(acc, pair) => {
|
||||
acc[pair.key.trim()] = pair.value;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, string>,
|
||||
);
|
||||
|
||||
return JSON.stringify(obj, null, 2);
|
||||
};
|
||||
|
||||
export const KeyValueInput = ({
|
||||
value,
|
||||
onChange,
|
||||
label,
|
||||
description,
|
||||
}: KeyValueInputProps) => {
|
||||
const [pairs, setPairs] = useState<KeyValuePair[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
const newPairs = value ? parseJsonToPairs(value) : [createEmptyPair()];
|
||||
setPairs(newPairs);
|
||||
}, [value]);
|
||||
|
||||
const syncPairsWithParent = (newPairs: KeyValuePair[]) => {
|
||||
setPairs(newPairs);
|
||||
onChange(convertPairsToJson(newPairs));
|
||||
};
|
||||
|
||||
const addPair = () => {
|
||||
syncPairsWithParent([...pairs, createEmptyPair()]);
|
||||
};
|
||||
|
||||
const removePair = (index: number) => {
|
||||
const filteredPairs = pairs.filter((_, i) => i !== index);
|
||||
syncPairsWithParent(filteredPairs);
|
||||
};
|
||||
|
||||
const updatePair = (
|
||||
index: number,
|
||||
field: keyof KeyValuePair,
|
||||
newValue: string | boolean,
|
||||
) => {
|
||||
const updatedPairs = pairs.map((pair, i) =>
|
||||
i === index ? { ...pair, [field]: newValue } : pair,
|
||||
);
|
||||
syncPairsWithParent(updatedPairs);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<Label className="text-sm font-medium">{label}</Label>
|
||||
{description && (
|
||||
<p className="text-sm text-muted-foreground mt-1">{description}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{pairs.map((pair, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex items-center gap-2 p-2 border rounded-md bg-muted/50"
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<Checkbox
|
||||
checked={pair.enabled}
|
||||
onCheckedChange={(checked) =>
|
||||
updatePair(index, "enabled", checked)
|
||||
}
|
||||
className="mr-2"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<Input
|
||||
placeholder="Key"
|
||||
value={pair.key}
|
||||
onChange={(e) => updatePair(index, "key", e.target.value)}
|
||||
className="text-sm"
|
||||
disabled={!pair.enabled}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-[2]">
|
||||
<Input
|
||||
placeholder="Value"
|
||||
value={pair.value}
|
||||
onChange={(e) => updatePair(index, "value", e.target.value)}
|
||||
className="text-sm"
|
||||
disabled={!pair.enabled}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => removePair(index)}
|
||||
className="text-red-500 hover:text-red-700 hover:bg-red-50"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={addPair}
|
||||
className="w-full"
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Add {label.toLowerCase()}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
9
apps/dokploy/drizzle/0129_pale_roughhouse.sql
Normal file
9
apps/dokploy/drizzle/0129_pale_roughhouse.sql
Normal file
@@ -0,0 +1,9 @@
|
||||
ALTER TYPE "public"."notificationType" ADD VALUE 'custom' BEFORE 'lark';--> statement-breakpoint
|
||||
CREATE TABLE "custom" (
|
||||
"customId" text PRIMARY KEY NOT NULL,
|
||||
"endpoint" text NOT NULL,
|
||||
"headers" jsonb
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "notification" ADD COLUMN "customId" text;--> statement-breakpoint
|
||||
ALTER TABLE "notification" ADD CONSTRAINT "notification_customId_custom_customId_fk" FOREIGN KEY ("customId") REFERENCES "public"."custom"("customId") ON DELETE cascade ON UPDATE no action;
|
||||
6915
apps/dokploy/drizzle/meta/0129_snapshot.json
Normal file
6915
apps/dokploy/drizzle/meta/0129_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -904,6 +904,13 @@
|
||||
"when": 1765101709413,
|
||||
"tag": "0128_hard_falcon",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 129,
|
||||
"version": "7",
|
||||
"when": 1765136384035,
|
||||
"tag": "0129_pale_roughhouse",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -573,7 +573,7 @@ export const notificationRouter = createTRPCRouter({
|
||||
} catch (error) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Error testing the notification",
|
||||
message: `${error instanceof Error ? error.message : "Unknown error"}`,
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
import { relations } from "drizzle-orm";
|
||||
import { boolean, integer, pgEnum, pgTable, text } from "drizzle-orm/pg-core";
|
||||
import {
|
||||
boolean,
|
||||
integer,
|
||||
jsonb,
|
||||
pgEnum,
|
||||
pgTable,
|
||||
text,
|
||||
} from "drizzle-orm/pg-core";
|
||||
import { createInsertSchema } from "drizzle-zod";
|
||||
import { nanoid } from "nanoid";
|
||||
import { z } from "zod";
|
||||
@@ -131,7 +138,7 @@ export const custom = pgTable("custom", {
|
||||
.primaryKey()
|
||||
.$defaultFn(() => nanoid()),
|
||||
endpoint: text("endpoint").notNull(),
|
||||
headers: text("headers"), // JSON string
|
||||
headers: jsonb("headers").$type<Record<string, string>>(),
|
||||
});
|
||||
|
||||
export const lark = pgTable("lark", {
|
||||
@@ -391,7 +398,7 @@ export const apiCreateCustom = notificationsSchema
|
||||
})
|
||||
.extend({
|
||||
endpoint: z.string().min(1),
|
||||
headers: z.string().optional(),
|
||||
headers: z.record(z.string()).optional(),
|
||||
});
|
||||
|
||||
export const apiUpdateCustom = apiCreateCustom.partial().extend({
|
||||
@@ -402,7 +409,7 @@ export const apiUpdateCustom = apiCreateCustom.partial().extend({
|
||||
|
||||
export const apiTestCustomConnection = z.object({
|
||||
endpoint: z.string().min(1),
|
||||
headers: z.string().optional(),
|
||||
headers: z.record(z.string()).optional(),
|
||||
});
|
||||
|
||||
export const apiCreateLark = notificationsSchema
|
||||
|
||||
@@ -181,17 +181,11 @@ export const sendCustomNotification = async (
|
||||
payload: Record<string, any>,
|
||||
) => {
|
||||
try {
|
||||
// Parse headers if provided
|
||||
let headers: Record<string, string> = {
|
||||
// Merge default headers with custom headers (now already an object from jsonb)
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
...(connection.headers || {}),
|
||||
};
|
||||
if (connection.headers) {
|
||||
try {
|
||||
headers = { ...headers, ...JSON.parse(connection.headers) };
|
||||
} catch (error) {
|
||||
console.error("Error parsing headers:", error);
|
||||
}
|
||||
}
|
||||
|
||||
// Default body with payload
|
||||
const body = JSON.stringify(payload);
|
||||
|
||||
Reference in New Issue
Block a user