Merge branch 'feature/add-custom-webhook-notification-provider' of github.com:ChristoferMendes/dokploy into feature/add-custom-webhook-notification-provider

This commit is contained in:
ChristoferMendes
2025-09-29 08:54:21 -03:00
11 changed files with 2322 additions and 2313 deletions

View File

@@ -6,157 +6,160 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
interface KeyValuePair {
key: string;
value: string;
enabled: boolean;
key: string;
value: string;
enabled: boolean;
}
interface KeyValueInputProps {
value: string;
onChange: (value: string) => void;
placeholder?: string;
label: string;
description?: string;
value: string;
onChange: (value: string) => void;
placeholder?: string;
label: string;
description?: string;
}
const createEmptyPair = (): KeyValuePair => ({
key: "",
value: "",
enabled: true,
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()];
}
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());
const enabledPairs = pairs.filter((pair) => pair.enabled && pair.key.trim());
if (enabledPairs.length === 0) {
return "";
}
if (enabledPairs.length === 0) {
return "";
}
const obj = enabledPairs.reduce((acc, pair) => {
acc[pair.key.trim()] = pair.value;
return acc;
}, {} as Record<string, string>);
const obj = enabledPairs.reduce(
(acc, pair) => {
acc[pair.key.trim()] = pair.value;
return acc;
},
{} as Record<string, string>,
);
return JSON.stringify(obj, null, 2);
return JSON.stringify(obj, null, 2);
};
export const KeyValueInput = ({
value,
onChange,
label,
description,
value,
onChange,
label,
description,
}: KeyValueInputProps) => {
const [pairs, setPairs] = useState<KeyValuePair[]>([]);
const [pairs, setPairs] = useState<KeyValuePair[]>([]);
useEffect(() => {
const newPairs = value ? parseJsonToPairs(value) : [createEmptyPair()];
setPairs(newPairs);
}, [value]);
useEffect(() => {
const newPairs = value ? parseJsonToPairs(value) : [createEmptyPair()];
setPairs(newPairs);
}, [value]);
const syncPairsWithParent = (newPairs: KeyValuePair[]) => {
setPairs(newPairs);
onChange(convertPairsToJson(newPairs));
};
const syncPairsWithParent = (newPairs: KeyValuePair[]) => {
setPairs(newPairs);
onChange(convertPairsToJson(newPairs));
};
const addPair = () => {
syncPairsWithParent([...pairs, createEmptyPair()]);
};
const addPair = () => {
syncPairsWithParent([...pairs, createEmptyPair()]);
};
const removePair = (index: number) => {
const filteredPairs = pairs.filter((_, i) => i !== index);
syncPairsWithParent(filteredPairs);
};
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);
};
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>
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>
<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>
);
<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>
);
};

View File

@@ -1,164 +1,164 @@
import {
Bell,
Loader2,
Mail,
MessageCircleMore,
PenBoxIcon,
Trash2,
Bell,
Loader2,
Mail,
MessageCircleMore,
PenBoxIcon,
Trash2,
} from "lucide-react";
import { toast } from "sonner";
import {
DiscordIcon,
SlackIcon,
TelegramIcon,
DiscordIcon,
SlackIcon,
TelegramIcon,
} from "@/components/icons/notification-icons";
import { DialogAction } from "@/components/shared/dialog-action";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { api } from "@/utils/api";
import { HandleNotifications } from "./handle-notifications";
export const ShowNotifications = () => {
const { data, isLoading, refetch } = api.notification.all.useQuery();
const { mutateAsync, isLoading: isRemoving } =
api.notification.remove.useMutation();
const { data, isLoading, refetch } = api.notification.all.useQuery();
const { mutateAsync, isLoading: isRemoving } =
api.notification.remove.useMutation();
return (
<div className="w-full">
<Card className="h-full bg-sidebar p-2.5 rounded-xl max-w-5xl mx-auto">
<div className="rounded-xl bg-background shadow-md ">
<CardHeader className="">
<CardTitle className="text-xl flex flex-row gap-2">
<Bell className="size-6 text-muted-foreground self-center" />
Notifications
</CardTitle>
<CardDescription>
Add your providers to receive notifications, like Discord, Slack,
Telegram, Email.
</CardDescription>
</CardHeader>
<CardContent className="space-y-2 py-8 border-t">
{isLoading ? (
<div className="flex flex-row gap-2 items-center justify-center text-sm text-muted-foreground min-h-[25vh]">
<span>Loading...</span>
<Loader2 className="animate-spin size-4" />
</div>
) : (
<>
{data?.length === 0 ? (
<div className="flex flex-col items-center gap-3 min-h-[25vh] justify-center">
<Bell />
<span className="text-base text-muted-foreground text-center">
To send notifications it is required to set at least 1
provider.
</span>
<HandleNotifications />
</div>
) : (
<div className="flex flex-col gap-4 min-h-[25vh]">
<div className="flex flex-col gap-4 rounded-lg ">
{data?.map((notification, _index) => (
<div
key={notification.notificationId}
className="flex items-center justify-between bg-sidebar p-1 w-full rounded-lg"
>
<div className="flex items-center justify-between p-3.5 rounded-lg bg-background border w-full">
<span className="text-sm flex flex-row items-center gap-4">
{notification.notificationType === "slack" && (
<div className="flex items-center justify-center rounded-lg">
<SlackIcon className="size-6" />
</div>
)}
{notification.notificationType === "telegram" && (
<div className="flex items-center justify-center rounded-lg ">
<TelegramIcon className="size-7 " />
</div>
)}
{notification.notificationType === "discord" && (
<div className="flex items-center justify-center rounded-lg">
<DiscordIcon className="size-7 " />
</div>
)}
{notification.notificationType === "email" && (
<div className="flex items-center justify-center rounded-lg ">
<Mail className="size-6 text-muted-foreground" />
</div>
)}
{notification.notificationType === "gotify" && (
<div className="flex items-center justify-center rounded-lg ">
<MessageCircleMore className="size-6 text-muted-foreground" />
</div>
)}
{notification.notificationType === "ntfy" && (
<div className="flex items-center justify-center rounded-lg ">
<MessageCircleMore className="size-6 text-muted-foreground" />
</div>
)}
{notification.notificationType === "custom" && (
<div className="flex items-center justify-center rounded-lg ">
<PenBoxIcon className="size-6 text-muted-foreground" />
</div>
)}
return (
<div className="w-full">
<Card className="h-full bg-sidebar p-2.5 rounded-xl max-w-5xl mx-auto">
<div className="rounded-xl bg-background shadow-md ">
<CardHeader className="">
<CardTitle className="text-xl flex flex-row gap-2">
<Bell className="size-6 text-muted-foreground self-center" />
Notifications
</CardTitle>
<CardDescription>
Add your providers to receive notifications, like Discord, Slack,
Telegram, Email.
</CardDescription>
</CardHeader>
<CardContent className="space-y-2 py-8 border-t">
{isLoading ? (
<div className="flex flex-row gap-2 items-center justify-center text-sm text-muted-foreground min-h-[25vh]">
<span>Loading...</span>
<Loader2 className="animate-spin size-4" />
</div>
) : (
<>
{data?.length === 0 ? (
<div className="flex flex-col items-center gap-3 min-h-[25vh] justify-center">
<Bell />
<span className="text-base text-muted-foreground text-center">
To send notifications it is required to set at least 1
provider.
</span>
<HandleNotifications />
</div>
) : (
<div className="flex flex-col gap-4 min-h-[25vh]">
<div className="flex flex-col gap-4 rounded-lg ">
{data?.map((notification, _index) => (
<div
key={notification.notificationId}
className="flex items-center justify-between bg-sidebar p-1 w-full rounded-lg"
>
<div className="flex items-center justify-between p-3.5 rounded-lg bg-background border w-full">
<span className="text-sm flex flex-row items-center gap-4">
{notification.notificationType === "slack" && (
<div className="flex items-center justify-center rounded-lg">
<SlackIcon className="size-6" />
</div>
)}
{notification.notificationType === "telegram" && (
<div className="flex items-center justify-center rounded-lg ">
<TelegramIcon className="size-7 " />
</div>
)}
{notification.notificationType === "discord" && (
<div className="flex items-center justify-center rounded-lg">
<DiscordIcon className="size-7 " />
</div>
)}
{notification.notificationType === "email" && (
<div className="flex items-center justify-center rounded-lg ">
<Mail className="size-6 text-muted-foreground" />
</div>
)}
{notification.notificationType === "gotify" && (
<div className="flex items-center justify-center rounded-lg ">
<MessageCircleMore className="size-6 text-muted-foreground" />
</div>
)}
{notification.notificationType === "ntfy" && (
<div className="flex items-center justify-center rounded-lg ">
<MessageCircleMore className="size-6 text-muted-foreground" />
</div>
)}
{notification.notificationType === "custom" && (
<div className="flex items-center justify-center rounded-lg ">
<PenBoxIcon className="size-6 text-muted-foreground" />
</div>
)}
{notification.name}
</span>
<div className="flex flex-row gap-1">
<HandleNotifications
notificationId={notification.notificationId}
/>
{notification.name}
</span>
<div className="flex flex-row gap-1">
<HandleNotifications
notificationId={notification.notificationId}
/>
<DialogAction
title="Delete Notification"
description="Are you sure you want to delete this notification?"
type="destructive"
onClick={async () => {
await mutateAsync({
notificationId: notification.notificationId,
})
.then(() => {
toast.success(
"Notification deleted successfully"
);
refetch();
})
.catch(() => {
toast.error(
"Error deleting notification"
);
});
}}
>
<Button
variant="ghost"
size="icon"
className="group hover:bg-red-500/10 "
isLoading={isRemoving}
>
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
</Button>
</DialogAction>
</div>
</div>
</div>
))}
</div>
<DialogAction
title="Delete Notification"
description="Are you sure you want to delete this notification?"
type="destructive"
onClick={async () => {
await mutateAsync({
notificationId: notification.notificationId,
})
.then(() => {
toast.success(
"Notification deleted successfully",
);
refetch();
})
.catch(() => {
toast.error(
"Error deleting notification",
);
});
}}
>
<Button
variant="ghost"
size="icon"
className="group hover:bg-red-500/10 "
isLoading={isRemoving}
>
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
</Button>
</DialogAction>
</div>
</div>
</div>
))}
</div>
<div className="flex flex-row gap-2 flex-wrap w-full justify-end mr-4">
<HandleNotifications />
</div>
</div>
)}
</>
)}
</CardContent>
</div>
</Card>
</div>
);
<div className="flex flex-row gap-2 flex-wrap w-full justify-end mr-4">
<HandleNotifications />
</div>
</div>
)}
</>
)}
</CardContent>
</div>
</Card>
</div>
);
};

File diff suppressed because it is too large Load Diff