Merge branch 'canary' into filter-projects-shortcut

This commit is contained in:
Mauricio Siu
2025-09-06 14:19:35 -06:00
26 changed files with 7310 additions and 111 deletions
+6 -6
View File
@@ -3,8 +3,8 @@ import { z } from "zod";
export const deployJobSchema = z.discriminatedUnion("applicationType", [ export const deployJobSchema = z.discriminatedUnion("applicationType", [
z.object({ z.object({
applicationId: z.string(), applicationId: z.string(),
titleLog: z.string(), titleLog: z.string().optional(),
descriptionLog: z.string(), descriptionLog: z.string().optional(),
server: z.boolean().optional(), server: z.boolean().optional(),
type: z.enum(["deploy", "redeploy"]), type: z.enum(["deploy", "redeploy"]),
applicationType: z.literal("application"), applicationType: z.literal("application"),
@@ -12,8 +12,8 @@ export const deployJobSchema = z.discriminatedUnion("applicationType", [
}), }),
z.object({ z.object({
composeId: z.string(), composeId: z.string(),
titleLog: z.string(), titleLog: z.string().optional(),
descriptionLog: z.string(), descriptionLog: z.string().optional(),
server: z.boolean().optional(), server: z.boolean().optional(),
type: z.enum(["deploy", "redeploy"]), type: z.enum(["deploy", "redeploy"]),
applicationType: z.literal("compose"), applicationType: z.literal("compose"),
@@ -22,8 +22,8 @@ export const deployJobSchema = z.discriminatedUnion("applicationType", [
z.object({ z.object({
applicationId: z.string(), applicationId: z.string(),
previewDeploymentId: z.string(), previewDeploymentId: z.string(),
titleLog: z.string(), titleLog: z.string().optional(),
descriptionLog: z.string(), descriptionLog: z.string().optional(),
server: z.boolean().optional(), server: z.boolean().optional(),
type: z.enum(["deploy"]), type: z.enum(["deploy"]),
applicationType: z.literal("application-preview"), applicationType: z.literal("application-preview"),
+10 -10
View File
@@ -18,14 +18,14 @@ export const deploy = async (job: DeployJob) => {
if (job.type === "redeploy") { if (job.type === "redeploy") {
await rebuildRemoteApplication({ await rebuildRemoteApplication({
applicationId: job.applicationId, applicationId: job.applicationId,
titleLog: job.titleLog, titleLog: job.titleLog || "Rebuild deployment",
descriptionLog: job.descriptionLog, descriptionLog: job.descriptionLog || "",
}); });
} else if (job.type === "deploy") { } else if (job.type === "deploy") {
await deployRemoteApplication({ await deployRemoteApplication({
applicationId: job.applicationId, applicationId: job.applicationId,
titleLog: job.titleLog, titleLog: job.titleLog || "Manual deployment",
descriptionLog: job.descriptionLog, descriptionLog: job.descriptionLog || "",
}); });
} }
} }
@@ -38,14 +38,14 @@ export const deploy = async (job: DeployJob) => {
if (job.type === "redeploy") { if (job.type === "redeploy") {
await rebuildRemoteCompose({ await rebuildRemoteCompose({
composeId: job.composeId, composeId: job.composeId,
titleLog: job.titleLog, titleLog: job.titleLog || "Rebuild deployment",
descriptionLog: job.descriptionLog, descriptionLog: job.descriptionLog || "",
}); });
} else if (job.type === "deploy") { } else if (job.type === "deploy") {
await deployRemoteCompose({ await deployRemoteCompose({
composeId: job.composeId, composeId: job.composeId,
titleLog: job.titleLog, titleLog: job.titleLog || "Manual deployment",
descriptionLog: job.descriptionLog, descriptionLog: job.descriptionLog || "",
}); });
} }
} }
@@ -57,8 +57,8 @@ export const deploy = async (job: DeployJob) => {
if (job.type === "deploy") { if (job.type === "deploy") {
await deployRemotePreviewApplication({ await deployRemotePreviewApplication({
applicationId: job.applicationId, applicationId: job.applicationId,
titleLog: job.titleLog, titleLog: job.titleLog || "Preview Deployment",
descriptionLog: job.descriptionLog, descriptionLog: job.descriptionLog || "",
previewDeploymentId: job.previewDeploymentId, previewDeploymentId: job.previewDeploymentId,
}); });
} }
@@ -76,6 +76,10 @@ export const AddApplication = ({ environmentId, projectName }: Props) => {
const { data: servers } = api.server.withSSHKey.useQuery(); const { data: servers } = api.server.withSSHKey.useQuery();
const hasServers = servers && servers.length > 0; const hasServers = servers && servers.length > 0;
// Show dropdown logic based on cloud environment
// Cloud: show only if there are remote servers (no Dokploy option)
// Self-hosted: show only if there are remote servers (Dokploy is default, hide if no remote servers)
const shouldShowServerDropdown = hasServers;
const { mutateAsync, isLoading, error, isError } = const { mutateAsync, isLoading, error, isError } =
api.application.create.useMutation(); api.application.create.useMutation();
@@ -94,8 +98,8 @@ export const AddApplication = ({ environmentId, projectName }: Props) => {
name: data.name, name: data.name,
appName: data.appName, appName: data.appName,
description: data.description, description: data.description,
serverId: data.serverId === "dokploy" ? undefined : data.serverId,
environmentId, environmentId,
serverId: data.serverId,
}) })
.then(async () => { .then(async () => {
toast.success("Service Created"); toast.success("Service Created");
@@ -157,7 +161,7 @@ export const AddApplication = ({ environmentId, projectName }: Props) => {
</FormItem> </FormItem>
)} )}
/> />
{hasServers && ( {shouldShowServerDropdown && (
<FormField <FormField
control={form.control} control={form.control}
name="serverId" name="serverId"
@@ -186,13 +190,27 @@ export const AddApplication = ({ environmentId, projectName }: Props) => {
<Select <Select
onValueChange={field.onChange} onValueChange={field.onChange}
defaultValue={field.value} defaultValue={
field.value || (!isCloud ? "dokploy" : undefined)
}
> >
<SelectTrigger> <SelectTrigger>
<SelectValue placeholder="Select a Server" /> <SelectValue
placeholder={!isCloud ? "Dokploy" : "Select a Server"}
/>
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectGroup> <SelectGroup>
{!isCloud && (
<SelectItem value="dokploy">
<span className="flex items-center gap-2 justify-between w-full">
<span>Dokploy</span>
<span className="text-muted-foreground text-xs self-center">
Default
</span>
</span>
</SelectItem>
)}
{servers?.map((server) => ( {servers?.map((server) => (
<SelectItem <SelectItem
key={server.serverId} key={server.serverId}
@@ -206,7 +224,9 @@ export const AddApplication = ({ environmentId, projectName }: Props) => {
</span> </span>
</SelectItem> </SelectItem>
))} ))}
<SelectLabel>Servers ({servers?.length})</SelectLabel> <SelectLabel>
Servers ({servers?.length + (!isCloud ? 1 : 0)})
</SelectLabel>
</SelectGroup> </SelectGroup>
</SelectContent> </SelectContent>
</Select> </Select>
@@ -82,6 +82,10 @@ export const AddCompose = ({ environmentId, projectName }: Props) => {
const { data: environment } = api.environment.one.useQuery({ environmentId }); const { data: environment } = api.environment.one.useQuery({ environmentId });
const hasServers = servers && servers.length > 0; const hasServers = servers && servers.length > 0;
// Show dropdown logic based on cloud environment
// Cloud: show only if there are remote servers (no Dokploy option)
// Self-hosted: show only if there are remote servers (Dokploy is default, hide if no remote servers)
const shouldShowServerDropdown = hasServers;
const form = useForm<AddCompose>({ const form = useForm<AddCompose>({
defaultValues: { defaultValues: {
@@ -104,7 +108,7 @@ export const AddCompose = ({ environmentId, projectName }: Props) => {
environmentId, environmentId,
composeType: data.composeType, composeType: data.composeType,
appName: data.appName, appName: data.appName,
serverId: data.serverId, serverId: data.serverId === "dokploy" ? undefined : data.serverId,
}) })
.then(async () => { .then(async () => {
toast.success("Compose Created"); toast.success("Compose Created");
@@ -169,7 +173,7 @@ export const AddCompose = ({ environmentId, projectName }: Props) => {
)} )}
/> />
</div> </div>
{hasServers && ( {shouldShowServerDropdown && (
<FormField <FormField
control={form.control} control={form.control}
name="serverId" name="serverId"
@@ -198,13 +202,27 @@ export const AddCompose = ({ environmentId, projectName }: Props) => {
<Select <Select
onValueChange={field.onChange} onValueChange={field.onChange}
defaultValue={field.value} defaultValue={
field.value || (!isCloud ? "dokploy" : undefined)
}
> >
<SelectTrigger> <SelectTrigger>
<SelectValue placeholder="Select a Server" /> <SelectValue
placeholder={!isCloud ? "Dokploy" : "Select a Server"}
/>
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectGroup> <SelectGroup>
{!isCloud && (
<SelectItem value="dokploy">
<span className="flex items-center gap-2 justify-between w-full">
<span>Dokploy</span>
<span className="text-muted-foreground text-xs self-center">
Default
</span>
</span>
</SelectItem>
)}
{servers?.map((server) => ( {servers?.map((server) => (
<SelectItem <SelectItem
key={server.serverId} key={server.serverId}
@@ -218,7 +236,9 @@ export const AddCompose = ({ environmentId, projectName }: Props) => {
</span> </span>
</SelectItem> </SelectItem>
))} ))}
<SelectLabel>Servers ({servers?.length})</SelectLabel> <SelectLabel>
Servers ({servers?.length + (!isCloud ? 1 : 0)})
</SelectLabel>
</SelectGroup> </SelectGroup>
</SelectContent> </SelectContent>
</Select> </Select>
@@ -179,6 +179,7 @@ export const AddDatabase = ({ environmentId, projectName }: Props) => {
const utils = api.useUtils(); const utils = api.useUtils();
const [visible, setVisible] = useState(false); const [visible, setVisible] = useState(false);
const slug = slugify(projectName); const slug = slugify(projectName);
const { data: isCloud } = api.settings.isCloud.useQuery();
const { data: servers } = api.server.withSSHKey.useQuery(); const { data: servers } = api.server.withSSHKey.useQuery();
const postgresMutation = api.postgres.create.useMutation(); const postgresMutation = api.postgres.create.useMutation();
const mongoMutation = api.mongo.create.useMutation(); const mongoMutation = api.mongo.create.useMutation();
@@ -190,6 +191,10 @@ export const AddDatabase = ({ environmentId, projectName }: Props) => {
const { data: environment } = api.environment.one.useQuery({ environmentId }); const { data: environment } = api.environment.one.useQuery({ environmentId });
const hasServers = servers && servers.length > 0; const hasServers = servers && servers.length > 0;
// Show dropdown logic based on cloud environment
// Cloud: show only if there are remote servers (no Dokploy option)
// Self-hosted: show only if there are remote servers (Dokploy is default, hide if no remote servers)
const shouldShowServerDropdown = hasServers;
const form = useForm<AddDatabase>({ const form = useForm<AddDatabase>({
defaultValues: { defaultValues: {
@@ -223,9 +228,8 @@ export const AddDatabase = ({ environmentId, projectName }: Props) => {
name: data.name, name: data.name,
appName: data.appName, appName: data.appName,
dockerImage: defaultDockerImage, dockerImage: defaultDockerImage,
projectId: environment?.projectId || "", serverId: data.serverId === "dokploy" ? undefined : data.serverId,
environmentId, environmentId,
serverId: data.serverId,
description: data.description, description: data.description,
}; };
@@ -237,7 +241,7 @@ export const AddDatabase = ({ environmentId, projectName }: Props) => {
databaseUser: databaseUser:
data.databaseUser || databasesUserDefaultPlaceholder[data.type], data.databaseUser || databasesUserDefaultPlaceholder[data.type],
serverId: data.serverId, serverId: data.serverId === "dokploy" ? null : data.serverId,
}); });
} else if (data.type === "mongo") { } else if (data.type === "mongo") {
promise = mongoMutation.mutateAsync({ promise = mongoMutation.mutateAsync({
@@ -245,14 +249,14 @@ export const AddDatabase = ({ environmentId, projectName }: Props) => {
databasePassword: data.databasePassword, databasePassword: data.databasePassword,
databaseUser: databaseUser:
data.databaseUser || databasesUserDefaultPlaceholder[data.type], data.databaseUser || databasesUserDefaultPlaceholder[data.type],
serverId: data.serverId, serverId: data.serverId === "dokploy" ? null : data.serverId,
replicaSets: data.replicaSets, replicaSets: data.replicaSets,
}); });
} else if (data.type === "redis") { } else if (data.type === "redis") {
promise = redisMutation.mutateAsync({ promise = redisMutation.mutateAsync({
...commonParams, ...commonParams,
databasePassword: data.databasePassword, databasePassword: data.databasePassword,
serverId: data.serverId, serverId: data.serverId === "dokploy" ? null : data.serverId,
}); });
} else if (data.type === "mariadb") { } else if (data.type === "mariadb") {
promise = mariadbMutation.mutateAsync({ promise = mariadbMutation.mutateAsync({
@@ -262,7 +266,7 @@ export const AddDatabase = ({ environmentId, projectName }: Props) => {
databaseName: data.databaseName || "mariadb", databaseName: data.databaseName || "mariadb",
databaseUser: databaseUser:
data.databaseUser || databasesUserDefaultPlaceholder[data.type], data.databaseUser || databasesUserDefaultPlaceholder[data.type],
serverId: data.serverId, serverId: data.serverId === "dokploy" ? null : data.serverId,
}); });
} else if (data.type === "mysql") { } else if (data.type === "mysql") {
promise = mysqlMutation.mutateAsync({ promise = mysqlMutation.mutateAsync({
@@ -271,8 +275,8 @@ export const AddDatabase = ({ environmentId, projectName }: Props) => {
databaseName: data.databaseName || "mysql", databaseName: data.databaseName || "mysql",
databaseUser: databaseUser:
data.databaseUser || databasesUserDefaultPlaceholder[data.type], data.databaseUser || databasesUserDefaultPlaceholder[data.type],
serverId: data.serverId === "dokploy" ? null : data.serverId,
databaseRootPassword: data.databaseRootPassword || "", databaseRootPassword: data.databaseRootPassword || "",
serverId: data.serverId,
}); });
} }
@@ -403,7 +407,7 @@ export const AddDatabase = ({ environmentId, projectName }: Props) => {
</FormItem> </FormItem>
)} )}
/> />
{hasServers && ( {shouldShowServerDropdown && (
<FormField <FormField
control={form.control} control={form.control}
name="serverId" name="serverId"
@@ -412,13 +416,29 @@ export const AddDatabase = ({ environmentId, projectName }: Props) => {
<FormLabel>Select a Server</FormLabel> <FormLabel>Select a Server</FormLabel>
<Select <Select
onValueChange={field.onChange} onValueChange={field.onChange}
defaultValue={field.value || ""} defaultValue={
field.value || (!isCloud ? "dokploy" : undefined)
}
> >
<SelectTrigger> <SelectTrigger>
<SelectValue placeholder="Select a Server" /> <SelectValue
placeholder={
!isCloud ? "Dokploy" : "Select a Server"
}
/>
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectGroup> <SelectGroup>
{!isCloud && (
<SelectItem value="dokploy">
<span className="flex items-center gap-2 justify-between w-full">
<span>Dokploy</span>
<span className="text-muted-foreground text-xs self-center">
Default
</span>
</span>
</SelectItem>
)}
{servers?.map((server) => ( {servers?.map((server) => (
<SelectItem <SelectItem
key={server.serverId} key={server.serverId}
@@ -428,7 +448,7 @@ export const AddDatabase = ({ environmentId, projectName }: Props) => {
</SelectItem> </SelectItem>
))} ))}
<SelectLabel> <SelectLabel>
Servers ({servers?.length}) Servers ({servers?.length + (!isCloud ? 1 : 0)})
</SelectLabel> </SelectLabel>
</SelectGroup> </SelectGroup>
</SelectContent> </SelectContent>
@@ -141,6 +141,10 @@ export const AddTemplate = ({ environmentId, baseUrl }: Props) => {
}) || []; }) || [];
const hasServers = servers && servers.length > 0; const hasServers = servers && servers.length > 0;
// Show dropdown logic based on cloud environment
// Cloud: show only if there are remote servers (no Dokploy option)
// Self-hosted: show only if there are remote servers (Dokploy is default, hide if no remote servers)
const shouldShowServerDropdown = hasServers;
return ( return (
<Dialog open={open} onOpenChange={setOpen}> <Dialog open={open} onOpenChange={setOpen}>
@@ -430,7 +434,7 @@ export const AddTemplate = ({ environmentId, baseUrl }: Props) => {
project. project.
</AlertDialogDescription> </AlertDialogDescription>
{hasServers && ( {shouldShowServerDropdown && (
<div> <div>
<TooltipProvider delayDuration={0}> <TooltipProvider delayDuration={0}>
<Tooltip> <Tooltip>
@@ -459,12 +463,29 @@ export const AddTemplate = ({ environmentId, baseUrl }: Props) => {
onValueChange={(e) => { onValueChange={(e) => {
setServerId(e); setServerId(e);
}} }}
defaultValue={
!isCloud ? "dokploy" : undefined
}
> >
<SelectTrigger> <SelectTrigger>
<SelectValue placeholder="Select a Server" /> <SelectValue
placeholder={
!isCloud ? "Dokploy" : "Select a Server"
}
/>
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectGroup> <SelectGroup>
{!isCloud && (
<SelectItem value="dokploy">
<span className="flex items-center gap-2 justify-between w-full">
<span>Dokploy</span>
<span className="text-muted-foreground text-xs self-center">
Default
</span>
</span>
</SelectItem>
)}
{servers?.map((server) => ( {servers?.map((server) => (
<SelectItem <SelectItem
key={server.serverId} key={server.serverId}
@@ -479,7 +500,8 @@ export const AddTemplate = ({ environmentId, baseUrl }: Props) => {
</SelectItem> </SelectItem>
))} ))}
<SelectLabel> <SelectLabel>
Servers ({servers?.length}) Servers (
{servers?.length + (!isCloud ? 1 : 0)})
</SelectLabel> </SelectLabel>
</SelectGroup> </SelectGroup>
</SelectContent> </SelectContent>
@@ -493,8 +515,11 @@ export const AddTemplate = ({ environmentId, baseUrl }: Props) => {
disabled={isLoading} disabled={isLoading}
onClick={async () => { onClick={async () => {
const promise = mutateAsync({ const promise = mutateAsync({
serverId:
serverId === "dokploy"
? undefined
: serverId,
environmentId, environmentId,
serverId: serverId || undefined,
id: template.id, id: template.id,
baseUrl: customBaseUrl, baseUrl: customBaseUrl,
}); });
@@ -25,7 +25,12 @@ const examples = [
export const StepOne = ({ setTemplateInfo, templateInfo }: any) => { export const StepOne = ({ setTemplateInfo, templateInfo }: any) => {
// Get servers from the API // Get servers from the API
const { data: servers } = api.server.withSSHKey.useQuery(); const { data: servers } = api.server.withSSHKey.useQuery();
const { data: isCloud } = api.settings.isCloud.useQuery();
const hasServers = servers && servers.length > 0; const hasServers = servers && servers.length > 0;
// Show dropdown logic based on cloud environment
// Cloud: show only if there are remote servers (no Dokploy option)
// Self-hosted: show only if there are remote servers (Dokploy is default, hide if no remote servers)
const shouldShowServerDropdown = hasServers;
const handleExampleClick = (example: string) => { const handleExampleClick = (example: string) => {
setTemplateInfo({ ...templateInfo, userInput: example }); setTemplateInfo({ ...templateInfo, userInput: example });
@@ -48,34 +53,58 @@ export const StepOne = ({ setTemplateInfo, templateInfo }: any) => {
/> />
</div> </div>
{hasServers && ( {shouldShowServerDropdown && (
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="server-deploy"> <Label htmlFor="server-deploy">
Select the server where you want to deploy (optional) Select the server where you want to deploy (optional)
</Label> </Label>
<Select <Select
value={templateInfo.server?.serverId} value={
templateInfo.server?.serverId ||
(!isCloud ? "dokploy" : undefined)
}
onValueChange={(value) => { onValueChange={(value) => {
const server = servers?.find((s) => s.serverId === value); if (value === "dokploy") {
if (server) {
setTemplateInfo({ setTemplateInfo({
...templateInfo, ...templateInfo,
server: server, server: undefined,
}); });
} else {
const server = servers?.find((s) => s.serverId === value);
if (server) {
setTemplateInfo({
...templateInfo,
server: server,
});
}
} }
}} }}
> >
<SelectTrigger className="w-full"> <SelectTrigger className="w-full">
<SelectValue placeholder="Select a server" /> <SelectValue
placeholder={!isCloud ? "Dokploy" : "Select a Server"}
/>
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectGroup> <SelectGroup>
{!isCloud && (
<SelectItem value="dokploy">
<span className="flex items-center gap-2 justify-between w-full">
<span>Dokploy</span>
<span className="text-muted-foreground text-xs self-center">
Default
</span>
</span>
</SelectItem>
)}
{servers?.map((server) => ( {servers?.map((server) => (
<SelectItem key={server.serverId} value={server.serverId}> <SelectItem key={server.serverId} value={server.serverId}>
{server.name} {server.name}
</SelectItem> </SelectItem>
))} ))}
<SelectLabel>Servers ({servers?.length})</SelectLabel> <SelectLabel>
Servers ({servers?.length + (!isCloud ? 1 : 0)})
</SelectLabel>
</SelectGroup> </SelectGroup>
</SelectContent> </SelectContent>
</Select> </Select>
@@ -65,6 +65,11 @@ export const AddCertificate = () => {
const { mutateAsync, isError, error, isLoading } = const { mutateAsync, isError, error, isLoading } =
api.certificates.create.useMutation(); api.certificates.create.useMutation();
const { data: servers } = api.server.withSSHKey.useQuery(); const { data: servers } = api.server.withSSHKey.useQuery();
const hasServers = servers && servers.length > 0;
// Show dropdown logic based on cloud environment
// Cloud: show only if there are remote servers (no Dokploy option)
// Self-hosted: show only if there are remote servers (Dokploy is default, hide if no remote servers)
const shouldShowServerDropdown = hasServers;
const form = useForm<AddCertificate>({ const form = useForm<AddCertificate>({
defaultValues: { defaultValues: {
@@ -85,7 +90,7 @@ export const AddCertificate = () => {
certificateData: data.certificateData, certificateData: data.certificateData,
privateKey: data.privateKey, privateKey: data.privateKey,
autoRenew: data.autoRenew, autoRenew: data.autoRenew,
serverId: data.serverId, serverId: data.serverId === "dokploy" ? undefined : data.serverId,
organizationId: "", organizationId: "",
}) })
.then(async () => { .then(async () => {
@@ -174,52 +179,70 @@ export const AddCertificate = () => {
</FormItem> </FormItem>
)} )}
/> />
<FormField {shouldShowServerDropdown && (
control={form.control} <FormField
name="serverId" control={form.control}
render={({ field }) => ( name="serverId"
<FormItem> render={({ field }) => (
<TooltipProvider delayDuration={0}> <FormItem>
<Tooltip> <TooltipProvider delayDuration={0}>
<TooltipTrigger asChild> <Tooltip>
<FormLabel className="break-all w-fit flex flex-row gap-1 items-center"> <TooltipTrigger asChild>
Select a Server {!isCloud && "(Optional)"} <FormLabel className="break-all w-fit flex flex-row gap-1 items-center">
<HelpCircle className="size-4 text-muted-foreground" /> Select a Server {!isCloud && "(Optional)"}
</FormLabel> <HelpCircle className="size-4 text-muted-foreground" />
</TooltipTrigger> </FormLabel>
</Tooltip> </TooltipTrigger>
</TooltipProvider> </Tooltip>
</TooltipProvider>
<Select <Select
onValueChange={field.onChange} onValueChange={field.onChange}
defaultValue={field.value} defaultValue={
> field.value || (!isCloud ? "dokploy" : undefined)
<SelectTrigger> }
<SelectValue placeholder="Select a Server" /> >
</SelectTrigger> <SelectTrigger>
<SelectContent> <SelectValue
<SelectGroup> placeholder={!isCloud ? "Dokploy" : "Select a Server"}
{servers?.map((server) => ( />
<SelectItem </SelectTrigger>
key={server.serverId} <SelectContent>
value={server.serverId} <SelectGroup>
> {!isCloud && (
<span className="flex items-center gap-2 justify-between w-full"> <SelectItem value="dokploy">
<span>{server.name}</span> <span className="flex items-center gap-2 justify-between w-full">
<span className="text-muted-foreground text-xs self-center"> <span>Dokploy</span>
{server.ipAddress} <span className="text-muted-foreground text-xs self-center">
Default
</span>
</span> </span>
</span> </SelectItem>
</SelectItem> )}
))} {servers?.map((server) => (
<SelectLabel>Servers ({servers?.length})</SelectLabel> <SelectItem
</SelectGroup> key={server.serverId}
</SelectContent> value={server.serverId}
</Select> >
<FormMessage /> <span className="flex items-center gap-2 justify-between w-full">
</FormItem> <span>{server.name}</span>
)} <span className="text-muted-foreground text-xs self-center">
/> {server.ipAddress}
</span>
</span>
</SelectItem>
))}
<SelectLabel>
Servers ({servers?.length + (!isCloud ? 1 : 0)})
</SelectLabel>
</SelectGroup>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
)}
</form> </form>
<DialogFooter className="flex w-full flex-row !justify-end"> <DialogFooter className="flex w-full flex-row !justify-end">
@@ -101,6 +101,15 @@ export const notificationSchema = z.discriminatedUnion("type", [
decoration: z.boolean().default(true), decoration: z.boolean().default(true),
}) })
.merge(notificationBaseSchema), .merge(notificationBaseSchema),
z
.object({
type: z.literal("ntfy"),
serverUrl: z.string().min(1, { message: "Server URL is required" }),
topic: z.string().min(1, { message: "Topic is required" }),
accessToken: z.string().min(1, { message: "Access Token is required" }),
priority: z.number().min(1).max(5).default(3),
})
.merge(notificationBaseSchema),
]); ]);
export const notificationsMap = { export const notificationsMap = {
@@ -124,6 +133,10 @@ export const notificationsMap = {
icon: <MessageCircleMore size={29} className="text-muted-foreground" />, icon: <MessageCircleMore size={29} className="text-muted-foreground" />,
label: "Gotify", label: "Gotify",
}, },
ntfy: {
icon: <MessageCircleMore size={29} className="text-muted-foreground" />,
label: "ntfy",
},
}; };
export type NotificationSchema = z.infer<typeof notificationSchema>; export type NotificationSchema = z.infer<typeof notificationSchema>;
@@ -155,6 +168,8 @@ export const HandleNotifications = ({ notificationId }: Props) => {
api.notification.testEmailConnection.useMutation(); api.notification.testEmailConnection.useMutation();
const { mutateAsync: testGotifyConnection, isLoading: isLoadingGotify } = const { mutateAsync: testGotifyConnection, isLoading: isLoadingGotify } =
api.notification.testGotifyConnection.useMutation(); api.notification.testGotifyConnection.useMutation();
const { mutateAsync: testNtfyConnection, isLoading: isLoadingNtfy } =
api.notification.testNtfyConnection.useMutation();
const slackMutation = notificationId const slackMutation = notificationId
? api.notification.updateSlack.useMutation() ? api.notification.updateSlack.useMutation()
: api.notification.createSlack.useMutation(); : api.notification.createSlack.useMutation();
@@ -170,6 +185,9 @@ export const HandleNotifications = ({ notificationId }: Props) => {
const gotifyMutation = notificationId const gotifyMutation = notificationId
? api.notification.updateGotify.useMutation() ? api.notification.updateGotify.useMutation()
: api.notification.createGotify.useMutation(); : api.notification.createGotify.useMutation();
const ntfyMutation = notificationId
? api.notification.updateNtfy.useMutation()
: api.notification.createNtfy.useMutation();
const form = useForm<NotificationSchema>({ const form = useForm<NotificationSchema>({
defaultValues: { defaultValues: {
@@ -266,6 +284,20 @@ export const HandleNotifications = ({ notificationId }: Props) => {
name: notification.name, name: notification.name,
dockerCleanup: notification.dockerCleanup, dockerCleanup: notification.dockerCleanup,
}); });
} else if (notification.notificationType === "ntfy") {
form.reset({
appBuildError: notification.appBuildError,
appDeploy: notification.appDeploy,
dokployRestart: notification.dokployRestart,
databaseBackup: notification.databaseBackup,
type: notification.notificationType,
accessToken: notification.ntfy?.accessToken,
topic: notification.ntfy?.topic,
priority: notification.ntfy?.priority,
serverUrl: notification.ntfy?.serverUrl,
name: notification.name,
dockerCleanup: notification.dockerCleanup,
});
} }
} else { } else {
form.reset(); form.reset();
@@ -278,6 +310,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
discord: discordMutation, discord: discordMutation,
email: emailMutation, email: emailMutation,
gotify: gotifyMutation, gotify: gotifyMutation,
ntfy: ntfyMutation,
}; };
const onSubmit = async (data: NotificationSchema) => { const onSubmit = async (data: NotificationSchema) => {
@@ -366,6 +399,21 @@ export const HandleNotifications = ({ notificationId }: Props) => {
notificationId: notificationId || "", notificationId: notificationId || "",
gotifyId: notification?.gotifyId || "", gotifyId: notification?.gotifyId || "",
}); });
} else if (data.type === "ntfy") {
promise = ntfyMutation.mutateAsync({
appBuildError: appBuildError,
appDeploy: appDeploy,
dokployRestart: dokployRestart,
databaseBackup: databaseBackup,
serverUrl: data.serverUrl,
accessToken: data.accessToken,
topic: data.topic,
priority: data.priority,
name: data.name,
dockerCleanup: dockerCleanup,
notificationId: notificationId || "",
ntfyId: notification?.ntfyId || "",
});
} }
if (promise) { if (promise) {
@@ -875,6 +923,83 @@ export const HandleNotifications = ({ notificationId }: Props) => {
/> />
</> </>
)} )}
{type === "ntfy" && (
<>
<FormField
control={form.control}
name="serverUrl"
render={({ field }) => (
<FormItem>
<FormLabel>Server URL</FormLabel>
<FormControl>
<Input placeholder="https://ntfy.sh" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="topic"
render={({ field }) => (
<FormItem>
<FormLabel>Topic</FormLabel>
<FormControl>
<Input placeholder="deployments" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="accessToken"
render={({ field }) => (
<FormItem>
<FormLabel>Access Token</FormLabel>
<FormControl>
<Input
placeholder="AzxcvbnmKjhgfdsa..."
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="priority"
defaultValue={3}
render={({ field }) => (
<FormItem className="w-full">
<FormLabel>Priority</FormLabel>
<FormControl>
<Input
placeholder="3"
{...field}
onChange={(e) => {
const value = e.target.value;
if (value) {
const port = Number.parseInt(value);
if (port > 0 && port <= 5) {
field.onChange(port);
}
}
}}
type="number"
/>
</FormControl>
<FormDescription>
Message priority (1-5, default: 3)
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</>
)}
</div> </div>
</div> </div>
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
@@ -1024,7 +1149,8 @@ export const HandleNotifications = ({ notificationId }: Props) => {
isLoadingTelegram || isLoadingTelegram ||
isLoadingDiscord || isLoadingDiscord ||
isLoadingEmail || isLoadingEmail ||
isLoadingGotify isLoadingGotify ||
isLoadingNtfy
} }
variant="secondary" variant="secondary"
onClick={async () => { onClick={async () => {
@@ -1061,6 +1187,13 @@ export const HandleNotifications = ({ notificationId }: Props) => {
priority: form.getValues("priority"), priority: form.getValues("priority"),
decoration: form.getValues("decoration"), decoration: form.getValues("decoration"),
}); });
} else if (type === "ntfy") {
await testNtfyConnection({
serverUrl: form.getValues("serverUrl"),
topic: form.getValues("topic"),
accessToken: form.getValues("accessToken"),
priority: form.getValues("priority"),
});
} }
toast.success("Connection Success"); toast.success("Connection Success");
} catch { } catch {
@@ -88,6 +88,11 @@ export const ShowNotifications = () => {
<MessageCircleMore className="size-6 text-muted-foreground" /> <MessageCircleMore className="size-6 text-muted-foreground" />
</div> </div>
)} )}
{notification.notificationType === "ntfy" && (
<div className="flex items-center justify-center rounded-lg ">
<MessageCircleMore className="size-6 text-muted-foreground" />
</div>
)}
{notification.name} {notification.name}
</span> </span>
@@ -0,0 +1,11 @@
ALTER TYPE "public"."notificationType" ADD VALUE 'ntfy';--> statement-breakpoint
CREATE TABLE "ntfy" (
"ntfyId" text PRIMARY KEY NOT NULL,
"serverUrl" text NOT NULL,
"topic" text NOT NULL,
"accessToken" text NOT NULL,
"priority" integer DEFAULT 3 NOT NULL
);
--> statement-breakpoint
ALTER TABLE "notification" ADD COLUMN "ntfyId" text;--> statement-breakpoint
ALTER TABLE "notification" ADD CONSTRAINT "notification_ntfyId_ntfy_ntfyId_fk" FOREIGN KEY ("ntfyId") REFERENCES "public"."ntfy"("ntfyId") ON DELETE cascade ON UPDATE no action;
File diff suppressed because it is too large Load Diff
+7
View File
@@ -771,6 +771,13 @@
"when": 1757052053574, "when": 1757052053574,
"tag": "0109_remarkable_sauron", "tag": "0109_remarkable_sauron",
"breakpoints": true "breakpoints": true
},
{
"idx": 110,
"version": "7",
"when": 1757189541734,
"tag": "0110_red_psynapse",
"breakpoints": true
} }
] ]
} }
@@ -40,8 +40,10 @@ import {
import { db } from "@/server/db"; import { db } from "@/server/db";
import { import {
apiCreateApplication, apiCreateApplication,
apiDeployApplication,
apiFindMonitoringStats, apiFindMonitoringStats,
apiFindOneApplication, apiFindOneApplication,
apiRedeployApplication,
apiReloadApplication, apiReloadApplication,
apiSaveBitbucketProvider, apiSaveBitbucketProvider,
apiSaveBuildType, apiSaveBuildType,
@@ -306,7 +308,7 @@ export const applicationRouter = createTRPCRouter({
}), }),
redeploy: protectedProcedure redeploy: protectedProcedure
.input(apiFindOneApplication) .input(apiRedeployApplication)
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
const application = await findApplicationById(input.applicationId); const application = await findApplicationById(input.applicationId);
if ( if (
@@ -320,8 +322,8 @@ export const applicationRouter = createTRPCRouter({
} }
const jobData: DeploymentJob = { const jobData: DeploymentJob = {
applicationId: input.applicationId, applicationId: input.applicationId,
titleLog: "Rebuild deployment", titleLog: input.title || "Rebuild deployment",
descriptionLog: "", descriptionLog: input.description || "",
type: "redeploy", type: "redeploy",
applicationType: "application", applicationType: "application",
server: !!application.serverId, server: !!application.serverId,
@@ -670,7 +672,7 @@ export const applicationRouter = createTRPCRouter({
return true; return true;
}), }),
deploy: protectedProcedure deploy: protectedProcedure
.input(apiFindOneApplication) .input(apiDeployApplication)
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
const application = await findApplicationById(input.applicationId); const application = await findApplicationById(input.applicationId);
if ( if (
@@ -684,8 +686,8 @@ export const applicationRouter = createTRPCRouter({
} }
const jobData: DeploymentJob = { const jobData: DeploymentJob = {
applicationId: input.applicationId, applicationId: input.applicationId,
titleLog: "Manual deployment", titleLog: input.title || "Manual deployment",
descriptionLog: "", descriptionLog: input.description || "",
type: "deploy", type: "deploy",
applicationType: "application", applicationType: "application",
server: !!application.serverId, server: !!application.serverId,
+8 -6
View File
@@ -48,9 +48,11 @@ import { db } from "@/server/db";
import { import {
apiCreateCompose, apiCreateCompose,
apiDeleteCompose, apiDeleteCompose,
apiDeployCompose,
apiFetchServices, apiFetchServices,
apiFindCompose, apiFindCompose,
apiRandomizeCompose, apiRandomizeCompose,
apiRedeployCompose,
apiUpdateCompose, apiUpdateCompose,
compose as composeTable, compose as composeTable,
} from "@/server/db/schema"; } from "@/server/db/schema";
@@ -367,7 +369,7 @@ export const composeRouter = createTRPCRouter({
}), }),
deploy: protectedProcedure deploy: protectedProcedure
.input(apiFindCompose) .input(apiDeployCompose)
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
const compose = await findComposeById(input.composeId); const compose = await findComposeById(input.composeId);
@@ -382,10 +384,10 @@ export const composeRouter = createTRPCRouter({
} }
const jobData: DeploymentJob = { const jobData: DeploymentJob = {
composeId: input.composeId, composeId: input.composeId,
titleLog: "Manual deployment", titleLog: input.title || "Manual deployment",
type: "deploy", type: "deploy",
applicationType: "compose", applicationType: "compose",
descriptionLog: "", descriptionLog: input.description || "",
server: !!compose.serverId, server: !!compose.serverId,
}; };
@@ -404,7 +406,7 @@ export const composeRouter = createTRPCRouter({
); );
}), }),
redeploy: protectedProcedure redeploy: protectedProcedure
.input(apiFindCompose) .input(apiRedeployCompose)
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
const compose = await findComposeById(input.composeId); const compose = await findComposeById(input.composeId);
if ( if (
@@ -418,10 +420,10 @@ export const composeRouter = createTRPCRouter({
} }
const jobData: DeploymentJob = { const jobData: DeploymentJob = {
composeId: input.composeId, composeId: input.composeId,
titleLog: "Rebuild deployment", titleLog: input.title || "Rebuild deployment",
type: "redeploy", type: "redeploy",
applicationType: "compose", applicationType: "compose",
descriptionLog: "", descriptionLog: input.description || "",
server: !!compose.serverId, server: !!compose.serverId,
}; };
if (IS_CLOUD && compose.serverId) { if (IS_CLOUD && compose.serverId) {
@@ -2,6 +2,7 @@ import {
createDiscordNotification, createDiscordNotification,
createEmailNotification, createEmailNotification,
createGotifyNotification, createGotifyNotification,
createNtfyNotification,
createSlackNotification, createSlackNotification,
createTelegramNotification, createTelegramNotification,
findNotificationById, findNotificationById,
@@ -10,12 +11,14 @@ import {
sendDiscordNotification, sendDiscordNotification,
sendEmailNotification, sendEmailNotification,
sendGotifyNotification, sendGotifyNotification,
sendNtfyNotification,
sendServerThresholdNotifications, sendServerThresholdNotifications,
sendSlackNotification, sendSlackNotification,
sendTelegramNotification, sendTelegramNotification,
updateDiscordNotification, updateDiscordNotification,
updateEmailNotification, updateEmailNotification,
updateGotifyNotification, updateGotifyNotification,
updateNtfyNotification,
updateSlackNotification, updateSlackNotification,
updateTelegramNotification, updateTelegramNotification,
} from "@dokploy/server"; } from "@dokploy/server";
@@ -33,17 +36,20 @@ import {
apiCreateDiscord, apiCreateDiscord,
apiCreateEmail, apiCreateEmail,
apiCreateGotify, apiCreateGotify,
apiCreateNtfy,
apiCreateSlack, apiCreateSlack,
apiCreateTelegram, apiCreateTelegram,
apiFindOneNotification, apiFindOneNotification,
apiTestDiscordConnection, apiTestDiscordConnection,
apiTestEmailConnection, apiTestEmailConnection,
apiTestGotifyConnection, apiTestGotifyConnection,
apiTestNtfyConnection,
apiTestSlackConnection, apiTestSlackConnection,
apiTestTelegramConnection, apiTestTelegramConnection,
apiUpdateDiscord, apiUpdateDiscord,
apiUpdateEmail, apiUpdateEmail,
apiUpdateGotify, apiUpdateGotify,
apiUpdateNtfy,
apiUpdateSlack, apiUpdateSlack,
apiUpdateTelegram, apiUpdateTelegram,
notifications, notifications,
@@ -321,6 +327,7 @@ export const notificationRouter = createTRPCRouter({
discord: true, discord: true,
email: true, email: true,
gotify: true, gotify: true,
ntfy: true,
}, },
orderBy: desc(notifications.createdAt), orderBy: desc(notifications.createdAt),
where: eq(notifications.organizationId, ctx.session.activeOrganizationId), where: eq(notifications.organizationId, ctx.session.activeOrganizationId),
@@ -446,6 +453,64 @@ export const notificationRouter = createTRPCRouter({
}); });
} }
}), }),
createNtfy: adminProcedure
.input(apiCreateNtfy)
.mutation(async ({ input, ctx }) => {
try {
return await createNtfyNotification(
input,
ctx.session.activeOrganizationId,
);
} catch (error) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Error creating the notification",
cause: error,
});
}
}),
updateNtfy: adminProcedure
.input(apiUpdateNtfy)
.mutation(async ({ input, ctx }) => {
try {
const notification = await findNotificationById(input.notificationId);
if (
IS_CLOUD &&
notification.organizationId !== ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to update this notification",
});
}
return await updateNtfyNotification({
...input,
organizationId: ctx.session.activeOrganizationId,
});
} catch (error) {
throw error;
}
}),
testNtfyConnection: adminProcedure
.input(apiTestNtfyConnection)
.mutation(async ({ input }) => {
try {
await sendNtfyNotification(
input,
"Test Notification",
"",
"view, visit Dokploy on Github, https://github.com/dokploy/dokploy, clear=true;",
"Hi, From Dokploy 👋",
);
return true;
} catch (error) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Error testing the notification",
cause: error,
});
}
}),
getEmailProviders: adminProcedure.query(async ({ ctx }) => { getEmailProviders: adminProcedure.query(async ({ ctx }) => {
return await db.query.notifications.findMany({ return await db.query.notifications.findMany({
where: eq(notifications.organizationId, ctx.session.activeOrganizationId), where: eq(notifications.organizationId, ctx.session.activeOrganizationId),
@@ -328,6 +328,26 @@ export const apiFindOneApplication = createSchema
}) })
.required(); .required();
export const apiDeployApplication = createSchema
.pick({
applicationId: true,
})
.extend({
applicationId: z.string().min(1),
title: z.string().optional(),
description: z.string().optional(),
});
export const apiRedeployApplication = createSchema
.pick({
applicationId: true,
})
.extend({
applicationId: z.string().min(1),
title: z.string().optional(),
description: z.string().optional(),
});
export const apiReloadApplication = createSchema export const apiReloadApplication = createSchema
.pick({ .pick({
appName: true, appName: true,
+12
View File
@@ -181,6 +181,18 @@ export const apiFindCompose = z.object({
composeId: z.string().min(1), composeId: z.string().min(1),
}); });
export const apiDeployCompose = z.object({
composeId: z.string().min(1),
title: z.string().optional(),
description: z.string().optional(),
});
export const apiRedeployCompose = z.object({
composeId: z.string().min(1),
title: z.string().optional(),
description: z.string().optional(),
});
export const apiDeleteCompose = z.object({ export const apiDeleteCompose = z.object({
composeId: z.string().min(1), composeId: z.string().min(1),
deleteVolumes: z.boolean(), deleteVolumes: z.boolean(),
@@ -11,6 +11,7 @@ export const notificationType = pgEnum("notificationType", [
"discord", "discord",
"email", "email",
"gotify", "gotify",
"ntfy",
]); ]);
export const notifications = pgTable("notification", { export const notifications = pgTable("notification", {
@@ -44,6 +45,9 @@ export const notifications = pgTable("notification", {
gotifyId: text("gotifyId").references(() => gotify.gotifyId, { gotifyId: text("gotifyId").references(() => gotify.gotifyId, {
onDelete: "cascade", onDelete: "cascade",
}), }),
ntfyId: text("ntfyId").references(() => ntfy.ntfyId, {
onDelete: "cascade",
}),
organizationId: text("organizationId") organizationId: text("organizationId")
.notNull() .notNull()
.references(() => organization.id, { onDelete: "cascade" }), .references(() => organization.id, { onDelete: "cascade" }),
@@ -101,6 +105,17 @@ export const gotify = pgTable("gotify", {
decoration: boolean("decoration"), decoration: boolean("decoration"),
}); });
export const ntfy = pgTable("ntfy", {
ntfyId: text("ntfyId")
.notNull()
.primaryKey()
.$defaultFn(() => nanoid()),
serverUrl: text("serverUrl").notNull(),
topic: text("topic").notNull(),
accessToken: text("accessToken").notNull(),
priority: integer("priority").notNull().default(3),
});
export const notificationsRelations = relations(notifications, ({ one }) => ({ export const notificationsRelations = relations(notifications, ({ one }) => ({
slack: one(slack, { slack: one(slack, {
fields: [notifications.slackId], fields: [notifications.slackId],
@@ -122,6 +137,10 @@ export const notificationsRelations = relations(notifications, ({ one }) => ({
fields: [notifications.gotifyId], fields: [notifications.gotifyId],
references: [gotify.gotifyId], references: [gotify.gotifyId],
}), }),
ntfy: one(ntfy, {
fields: [notifications.ntfyId],
references: [ntfy.ntfyId],
}),
organization: one(organization, { organization: one(organization, {
fields: [notifications.organizationId], fields: [notifications.organizationId],
references: [organization.id], references: [organization.id],
@@ -284,6 +303,36 @@ export const apiTestGotifyConnection = apiCreateGotify
decoration: z.boolean().optional(), decoration: z.boolean().optional(),
}); });
export const apiCreateNtfy = notificationsSchema
.pick({
appBuildError: true,
databaseBackup: true,
dokployRestart: true,
name: true,
appDeploy: true,
dockerCleanup: true,
})
.extend({
serverUrl: z.string().min(1),
topic: z.string().min(1),
accessToken: z.string().min(1),
priority: z.number().min(1),
})
.required();
export const apiUpdateNtfy = apiCreateNtfy.partial().extend({
notificationId: z.string().min(1),
ntfyId: z.string().min(1),
organizationId: z.string().optional(),
});
export const apiTestNtfyConnection = apiCreateNtfy.pick({
serverUrl: true,
topic: true,
accessToken: true,
priority: true,
});
export const apiFindOneNotification = notificationsSchema export const apiFindOneNotification = notificationsSchema
.pick({ .pick({
notificationId: true, notificationId: true,
@@ -303,7 +352,9 @@ export const apiSendTest = notificationsSchema
password: z.string(), password: z.string(),
toAddresses: z.array(z.string()), toAddresses: z.array(z.string()),
serverUrl: z.string(), serverUrl: z.string(),
topic: z.string(),
appToken: z.string(), appToken: z.string(),
accessToken: z.string(),
priority: z.number(), priority: z.number(),
}) })
.partial(); .partial();
@@ -3,17 +3,20 @@ import {
type apiCreateDiscord, type apiCreateDiscord,
type apiCreateEmail, type apiCreateEmail,
type apiCreateGotify, type apiCreateGotify,
type apiCreateNtfy,
type apiCreateSlack, type apiCreateSlack,
type apiCreateTelegram, type apiCreateTelegram,
type apiUpdateDiscord, type apiUpdateDiscord,
type apiUpdateEmail, type apiUpdateEmail,
type apiUpdateGotify, type apiUpdateGotify,
type apiUpdateNtfy,
type apiUpdateSlack, type apiUpdateSlack,
type apiUpdateTelegram, type apiUpdateTelegram,
discord, discord,
email, email,
gotify, gotify,
notifications, notifications,
ntfy,
slack, slack,
telegram, telegram,
} from "@dokploy/server/db/schema"; } from "@dokploy/server/db/schema";
@@ -482,6 +485,96 @@ export const updateGotifyNotification = async (
}); });
}; };
export const createNtfyNotification = async (
input: typeof apiCreateNtfy._type,
organizationId: string,
) => {
await db.transaction(async (tx) => {
const newNtfy = await tx
.insert(ntfy)
.values({
serverUrl: input.serverUrl,
topic: input.topic,
accessToken: input.accessToken,
priority: input.priority,
})
.returning()
.then((value) => value[0]);
if (!newNtfy) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Error input: Inserting ntfy",
});
}
const newDestination = await tx
.insert(notifications)
.values({
ntfyId: newNtfy.ntfyId,
name: input.name,
appDeploy: input.appDeploy,
appBuildError: input.appBuildError,
databaseBackup: input.databaseBackup,
dokployRestart: input.dokployRestart,
dockerCleanup: input.dockerCleanup,
notificationType: "ntfy",
organizationId: organizationId,
})
.returning()
.then((value) => value[0]);
if (!newDestination) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Error input: Inserting notification",
});
}
return newDestination;
});
};
export const updateNtfyNotification = async (
input: typeof apiUpdateNtfy._type,
) => {
await db.transaction(async (tx) => {
const newDestination = await tx
.update(notifications)
.set({
name: input.name,
appDeploy: input.appDeploy,
appBuildError: input.appBuildError,
databaseBackup: input.databaseBackup,
dokployRestart: input.dokployRestart,
dockerCleanup: input.dockerCleanup,
organizationId: input.organizationId,
})
.where(eq(notifications.notificationId, input.notificationId))
.returning()
.then((value) => value[0]);
if (!newDestination) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Error Updating notification",
});
}
await tx
.update(ntfy)
.set({
serverUrl: input.serverUrl,
topic: input.topic,
accessToken: input.accessToken,
priority: input.priority,
})
.where(eq(ntfy.ntfyId, input.ntfyId));
return newDestination;
});
};
export const findNotificationById = async (notificationId: string) => { export const findNotificationById = async (notificationId: string) => {
const notification = await db.query.notifications.findFirst({ const notification = await db.query.notifications.findFirst({
where: eq(notifications.notificationId, notificationId), where: eq(notifications.notificationId, notificationId),
@@ -491,6 +584,7 @@ export const findNotificationById = async (notificationId: string) => {
discord: true, discord: true,
email: true, email: true,
gotify: true, gotify: true,
ntfy: true,
}, },
}); });
if (!notification) { if (!notification) {
@@ -8,6 +8,7 @@ import {
sendDiscordNotification, sendDiscordNotification,
sendEmailNotification, sendEmailNotification,
sendGotifyNotification, sendGotifyNotification,
sendNtfyNotification,
sendSlackNotification, sendSlackNotification,
sendTelegramNotification, sendTelegramNotification,
} from "./utils"; } from "./utils";
@@ -42,11 +43,12 @@ export const sendBuildErrorNotifications = async ({
telegram: true, telegram: true,
slack: true, slack: true,
gotify: true, gotify: true,
ntfy: true,
}, },
}); });
for (const notification of notificationList) { for (const notification of notificationList) {
const { email, discord, telegram, slack, gotify } = notification; const { email, discord, telegram, slack, gotify, ntfy } = notification;
if (email) { if (email) {
const template = await renderAsync( const template = await renderAsync(
BuildFailedEmail({ BuildFailedEmail({
@@ -132,6 +134,20 @@ export const sendBuildErrorNotifications = async ({
); );
} }
if (ntfy) {
await sendNtfyNotification(
ntfy,
"Build Failed",
"warning",
`view, Build details, ${buildLink}, clear=true;`,
`🛠️Project: ${projectName}\n` +
`⚙️Application: ${applicationName}\n` +
`❔Type: ${applicationType}\n` +
`🕒Date: ${date.toLocaleString()}\n` +
`⚠️Error:\n${errorMessage}`,
);
}
if (telegram) { if (telegram) {
const inlineButton = [ const inlineButton = [
[ [
@@ -9,6 +9,7 @@ import {
sendDiscordNotification, sendDiscordNotification,
sendEmailNotification, sendEmailNotification,
sendGotifyNotification, sendGotifyNotification,
sendNtfyNotification,
sendSlackNotification, sendSlackNotification,
sendTelegramNotification, sendTelegramNotification,
} from "./utils"; } from "./utils";
@@ -43,11 +44,12 @@ export const sendBuildSuccessNotifications = async ({
telegram: true, telegram: true,
slack: true, slack: true,
gotify: true, gotify: true,
ntfy: true,
}, },
}); });
for (const notification of notificationList) { for (const notification of notificationList) {
const { email, discord, telegram, slack, gotify } = notification; const { email, discord, telegram, slack, gotify, ntfy } = notification;
if (email) { if (email) {
const template = await renderAsync( const template = await renderAsync(
@@ -126,6 +128,19 @@ export const sendBuildSuccessNotifications = async ({
); );
} }
if (ntfy) {
await sendNtfyNotification(
ntfy,
"Build Success",
"white_check_mark",
`view, Build details, ${buildLink}, clear=true;`,
`🛠Project: ${projectName}\n` +
`⚙️Application: ${applicationName}\n` +
`❔Type: ${applicationType}\n` +
`🕒Date: ${date.toLocaleString()}`,
);
}
if (telegram) { if (telegram) {
const chunkArray = <T>(array: T[], chunkSize: number): T[][] => const chunkArray = <T>(array: T[], chunkSize: number): T[][] =>
Array.from({ length: Math.ceil(array.length / chunkSize) }, (_, i) => Array.from({ length: Math.ceil(array.length / chunkSize) }, (_, i) =>
@@ -8,6 +8,7 @@ import {
sendDiscordNotification, sendDiscordNotification,
sendEmailNotification, sendEmailNotification,
sendGotifyNotification, sendGotifyNotification,
sendNtfyNotification,
sendSlackNotification, sendSlackNotification,
sendTelegramNotification, sendTelegramNotification,
} from "./utils"; } from "./utils";
@@ -42,11 +43,12 @@ export const sendDatabaseBackupNotifications = async ({
telegram: true, telegram: true,
slack: true, slack: true,
gotify: true, gotify: true,
ntfy: true,
}, },
}); });
for (const notification of notificationList) { for (const notification of notificationList) {
const { email, discord, telegram, slack, gotify } = notification; const { email, discord, telegram, slack, gotify, ntfy } = notification;
if (email) { if (email) {
const template = await renderAsync( const template = await renderAsync(
@@ -149,6 +151,21 @@ export const sendDatabaseBackupNotifications = async ({
); );
} }
if (ntfy) {
await sendNtfyNotification(
ntfy,
`Database Backup ${type === "success" ? "Successful" : "Failed"}`,
`${type === "success" ? "white_check_mark" : "x"}`,
"",
`🛠Project: ${projectName}\n` +
`⚙️Application: ${applicationName}\n` +
`❔Type: ${databaseType}\n` +
`📂Database Name: ${databaseName}` +
`🕒Date: ${date.toLocaleString()}\n` +
`${type === "error" && errorMessage ? `❌Error:\n${errorMessage}` : ""}`,
);
}
if (telegram) { if (telegram) {
const isError = type === "error" && errorMessage; const isError = type === "error" && errorMessage;
@@ -8,6 +8,7 @@ import {
sendDiscordNotification, sendDiscordNotification,
sendEmailNotification, sendEmailNotification,
sendGotifyNotification, sendGotifyNotification,
sendNtfyNotification,
sendSlackNotification, sendSlackNotification,
sendTelegramNotification, sendTelegramNotification,
} from "./utils"; } from "./utils";
@@ -29,11 +30,12 @@ export const sendDockerCleanupNotifications = async (
telegram: true, telegram: true,
slack: true, slack: true,
gotify: true, gotify: true,
ntfy: true,
}, },
}); });
for (const notification of notificationList) { for (const notification of notificationList) {
const { email, discord, telegram, slack, gotify } = notification; const { email, discord, telegram, slack, gotify, ntfy } = notification;
if (email) { if (email) {
const template = await renderAsync( const template = await renderAsync(
@@ -93,6 +95,16 @@ export const sendDockerCleanupNotifications = async (
); );
} }
if (ntfy) {
await sendNtfyNotification(
ntfy,
"Docker Cleanup",
"white_check_mark",
"",
`🕒Date: ${date.toLocaleString()}\n` + `📜Message:\n${message}`,
);
}
if (telegram) { if (telegram) {
await sendTelegramNotification( await sendTelegramNotification(
telegram, telegram,
@@ -8,6 +8,7 @@ import {
sendDiscordNotification, sendDiscordNotification,
sendEmailNotification, sendEmailNotification,
sendGotifyNotification, sendGotifyNotification,
sendNtfyNotification,
sendSlackNotification, sendSlackNotification,
sendTelegramNotification, sendTelegramNotification,
} from "./utils"; } from "./utils";
@@ -23,11 +24,12 @@ export const sendDokployRestartNotifications = async () => {
telegram: true, telegram: true,
slack: true, slack: true,
gotify: true, gotify: true,
ntfy: true,
}, },
}); });
for (const notification of notificationList) { for (const notification of notificationList) {
const { email, discord, telegram, slack, gotify } = notification; const { email, discord, telegram, slack, gotify, ntfy } = notification;
if (email) { if (email) {
const template = await renderAsync( const template = await renderAsync(
@@ -85,6 +87,20 @@ export const sendDokployRestartNotifications = async () => {
} }
} }
if (ntfy) {
try {
await sendNtfyNotification(
ntfy,
"Dokploy Server Restarted",
"white_check_mark",
"",
`🕒Date: ${date.toLocaleString()}`,
);
} catch (error) {
console.log(error);
}
}
if (telegram) { if (telegram) {
try { try {
await sendTelegramNotification( await sendTelegramNotification(
@@ -2,6 +2,7 @@ import type {
discord, discord,
email, email,
gotify, gotify,
ntfy,
slack, slack,
telegram, telegram,
} from "@dokploy/server/db/schema"; } from "@dokploy/server/db/schema";
@@ -126,3 +127,27 @@ export const sendGotifyNotification = async (
); );
} }
}; };
export const sendNtfyNotification = async (
connection: typeof ntfy.$inferInsert,
title: string,
tags: string,
actions: string,
message: string,
) => {
const response = await fetch(`${connection.serverUrl}/${connection.topic}`, {
method: "POST",
headers: {
Authorization: `Bearer ${connection.accessToken}`,
"X-Priority": connection.priority?.toString() || "3",
"X-Title": title,
"X-Tags": tags,
"X-Actions": actions,
},
body: message,
});
if (!response.ok) {
throw new Error(`Failed to send ntfy notification: ${response.statusText}`);
}
};