mirror of
https://github.com/Dokploy/dokploy
synced 2025-06-26 18:27:59 +00:00
Merge branch 'canary' into 187-backups-for-docker-compose
This commit is contained in:
commit
d85fc2e513
@ -19,8 +19,8 @@ See the License for the specific language governing permissions and limitations
|
|||||||
|
|
||||||
The following additional terms apply to the multi-node support, Docker Compose file, Preview Deployments and Multi Server features of Dokploy. In the event of a conflict, these provisions shall take precedence over those in the Apache License:
|
The following additional terms apply to the multi-node support, Docker Compose file, Preview Deployments and Multi Server features of Dokploy. In the event of a conflict, these provisions shall take precedence over those in the Apache License:
|
||||||
|
|
||||||
- **Self-Hosted Version Free**: All features of Dokploy, including multi-node support, Docker Compose file support, Preview Deployments and Multi Server, will always be free to use in the self-hosted version.
|
- **Self-Hosted Version Free**: All features of Dokploy, including multi-node support, Docker Compose file support, Schedules, Preview Deployments and Multi Server, will always be free to use in the self-hosted version.
|
||||||
- **Restriction on Resale**: The multi-node support, Docker Compose file support, Preview Deployments and Multi Server features cannot be sold or offered as a service by any party other than the copyright holder without prior written consent.
|
- **Restriction on Resale**: The multi-node support, Docker Compose file support, Schedules, Preview Deployments and Multi Server features cannot be sold or offered as a service by any party other than the copyright holder without prior written consent.
|
||||||
- **Modification Distribution**: Any modifications to the multi-node support, Docker Compose file support, Preview Deployments and Multi Server features must be distributed freely and cannot be sold or offered as a service.
|
- **Modification Distribution**: Any modifications to the multi-node support, Docker Compose file support, Schedules, Preview Deployments and Multi Server features must be distributed freely and cannot be sold or offered as a service.
|
||||||
|
|
||||||
For further inquiries or permissions, please contact us directly.
|
For further inquiries or permissions, please contact us directly.
|
||||||
|
@ -9,12 +9,13 @@ import {
|
|||||||
CardTitle,
|
CardTitle,
|
||||||
} from "@/components/ui/card";
|
} from "@/components/ui/card";
|
||||||
import { type RouterOutputs, api } from "@/utils/api";
|
import { type RouterOutputs, api } from "@/utils/api";
|
||||||
import { RocketIcon } from "lucide-react";
|
import { RocketIcon, Clock } from "lucide-react";
|
||||||
import React, { useEffect, useState } from "react";
|
import React, { useEffect, useState } from "react";
|
||||||
import { CancelQueues } from "./cancel-queues";
|
import { CancelQueues } from "./cancel-queues";
|
||||||
import { RefreshToken } from "./refresh-token";
|
import { RefreshToken } from "./refresh-token";
|
||||||
import { ShowDeployment } from "./show-deployment";
|
import { ShowDeployment } from "./show-deployment";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { formatDuration } from "../schedules/show-schedules-logs";
|
||||||
interface Props {
|
interface Props {
|
||||||
applicationId: string;
|
applicationId: string;
|
||||||
}
|
}
|
||||||
@ -96,8 +97,23 @@ export const ShowDeployments = ({ applicationId }: Props) => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col items-end gap-2">
|
<div className="flex flex-col items-end gap-2">
|
||||||
<div className="text-sm capitalize text-muted-foreground">
|
<div className="text-sm capitalize text-muted-foreground flex items-center gap-2">
|
||||||
<DateTooltip date={deployment.createdAt} />
|
<DateTooltip date={deployment.createdAt} />
|
||||||
|
{deployment.startedAt && deployment.finishedAt && (
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className="text-[10px] gap-1 flex items-center"
|
||||||
|
>
|
||||||
|
<Clock className="size-3" />
|
||||||
|
{formatDuration(
|
||||||
|
Math.floor(
|
||||||
|
(new Date(deployment.finishedAt).getTime() -
|
||||||
|
new Date(deployment.startedAt).getTime()) /
|
||||||
|
1000,
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
|
@ -0,0 +1,538 @@
|
|||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
FormDescription,
|
||||||
|
} from "@/components/ui/form";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { api } from "@/utils/api";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { z } from "zod";
|
||||||
|
import {
|
||||||
|
Info,
|
||||||
|
PlusCircle,
|
||||||
|
PenBoxIcon,
|
||||||
|
RefreshCw,
|
||||||
|
DatabaseZap,
|
||||||
|
} from "lucide-react";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipProvider,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from "@/components/ui/tooltip";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import type { CacheType } from "../../compose/domains/add-domain";
|
||||||
|
import { AlertBlock } from "@/components/shared/alert-block";
|
||||||
|
import { CodeEditor } from "@/components/shared/code-editor";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
export const commonCronExpressions = [
|
||||||
|
{ label: "Every minute", value: "* * * * *" },
|
||||||
|
{ label: "Every hour", value: "0 * * * *" },
|
||||||
|
{ label: "Every day at midnight", value: "0 0 * * *" },
|
||||||
|
{ label: "Every Sunday at midnight", value: "0 0 * * 0" },
|
||||||
|
{ label: "Every month on the 1st at midnight", value: "0 0 1 * *" },
|
||||||
|
{ label: "Every 15 minutes", value: "*/15 * * * *" },
|
||||||
|
{ label: "Every weekday at midnight", value: "0 0 * * 1-5" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const formSchema = z
|
||||||
|
.object({
|
||||||
|
name: z.string().min(1, "Name is required"),
|
||||||
|
cronExpression: z.string().min(1, "Cron expression is required"),
|
||||||
|
shellType: z.enum(["bash", "sh"]).default("bash"),
|
||||||
|
command: z.string(),
|
||||||
|
enabled: z.boolean().default(true),
|
||||||
|
serviceName: z.string(),
|
||||||
|
scheduleType: z.enum([
|
||||||
|
"application",
|
||||||
|
"compose",
|
||||||
|
"server",
|
||||||
|
"dokploy-server",
|
||||||
|
]),
|
||||||
|
script: z.string(),
|
||||||
|
})
|
||||||
|
.superRefine((data, ctx) => {
|
||||||
|
if (data.scheduleType === "compose" && !data.serviceName) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message: "Service name is required",
|
||||||
|
path: ["serviceName"],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
(data.scheduleType === "dokploy-server" ||
|
||||||
|
data.scheduleType === "server") &&
|
||||||
|
!data.script
|
||||||
|
) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message: "Script is required",
|
||||||
|
path: ["script"],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
(data.scheduleType === "application" ||
|
||||||
|
data.scheduleType === "compose") &&
|
||||||
|
!data.command
|
||||||
|
) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message: "Command is required",
|
||||||
|
path: ["command"],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
id?: string;
|
||||||
|
scheduleId?: string;
|
||||||
|
scheduleType?: "application" | "compose" | "server" | "dokploy-server";
|
||||||
|
}
|
||||||
|
|
||||||
|
export const HandleSchedules = ({ id, scheduleId, scheduleType }: Props) => {
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const [cacheType, setCacheType] = useState<CacheType>("cache");
|
||||||
|
|
||||||
|
const utils = api.useUtils();
|
||||||
|
const form = useForm<z.infer<typeof formSchema>>({
|
||||||
|
resolver: zodResolver(formSchema),
|
||||||
|
defaultValues: {
|
||||||
|
name: "",
|
||||||
|
cronExpression: "",
|
||||||
|
shellType: "bash",
|
||||||
|
command: "",
|
||||||
|
enabled: true,
|
||||||
|
serviceName: "",
|
||||||
|
scheduleType: scheduleType || "application",
|
||||||
|
script: "",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const scheduleTypeForm = form.watch("scheduleType");
|
||||||
|
|
||||||
|
const { data: schedule } = api.schedule.one.useQuery(
|
||||||
|
{ scheduleId: scheduleId || "" },
|
||||||
|
{ enabled: !!scheduleId },
|
||||||
|
);
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: services,
|
||||||
|
isFetching: isLoadingServices,
|
||||||
|
error: errorServices,
|
||||||
|
refetch: refetchServices,
|
||||||
|
} = api.compose.loadServices.useQuery(
|
||||||
|
{
|
||||||
|
composeId: id || "",
|
||||||
|
type: cacheType,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
retry: false,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
enabled: !!id && scheduleType === "compose",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (scheduleId && schedule) {
|
||||||
|
form.reset({
|
||||||
|
name: schedule.name,
|
||||||
|
cronExpression: schedule.cronExpression,
|
||||||
|
shellType: schedule.shellType,
|
||||||
|
command: schedule.command,
|
||||||
|
enabled: schedule.enabled,
|
||||||
|
serviceName: schedule.serviceName || "",
|
||||||
|
scheduleType: schedule.scheduleType,
|
||||||
|
script: schedule.script || "",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [form, schedule, scheduleId]);
|
||||||
|
|
||||||
|
const { mutateAsync, isLoading } = scheduleId
|
||||||
|
? api.schedule.update.useMutation()
|
||||||
|
: api.schedule.create.useMutation();
|
||||||
|
|
||||||
|
const onSubmit = async (values: z.infer<typeof formSchema>) => {
|
||||||
|
if (!id && !scheduleId) return;
|
||||||
|
|
||||||
|
await mutateAsync({
|
||||||
|
...values,
|
||||||
|
scheduleId: scheduleId || "",
|
||||||
|
...(scheduleType === "application" && {
|
||||||
|
applicationId: id || "",
|
||||||
|
}),
|
||||||
|
...(scheduleType === "compose" && {
|
||||||
|
composeId: id || "",
|
||||||
|
}),
|
||||||
|
...(scheduleType === "server" && {
|
||||||
|
serverId: id || "",
|
||||||
|
}),
|
||||||
|
...(scheduleType === "dokploy-server" && {
|
||||||
|
userId: id || "",
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
toast.success(
|
||||||
|
`Schedule ${scheduleId ? "updated" : "created"} successfully`,
|
||||||
|
);
|
||||||
|
utils.schedule.list.invalidate({
|
||||||
|
id,
|
||||||
|
scheduleType,
|
||||||
|
});
|
||||||
|
setIsOpen(false);
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
toast.error(
|
||||||
|
error instanceof Error ? error.message : "An unknown error occurred",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
{scheduleId ? (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="group hover:bg-blue-500/10"
|
||||||
|
>
|
||||||
|
<PenBoxIcon className="size-3.5 text-primary group-hover:text-blue-500" />
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button>
|
||||||
|
<PlusCircle className="w-4 h-4 mr-2" />
|
||||||
|
Add Schedule
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent
|
||||||
|
className={cn(
|
||||||
|
"max-h-screen overflow-y-auto",
|
||||||
|
scheduleTypeForm === "dokploy-server" || scheduleTypeForm === "server"
|
||||||
|
? "max-h-[95vh] sm:max-w-2xl"
|
||||||
|
: " sm:max-w-lg",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{scheduleId ? "Edit" : "Create"} Schedule</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
|
||||||
|
{scheduleTypeForm === "compose" && (
|
||||||
|
<div className="flex flex-col w-full gap-4">
|
||||||
|
{errorServices && (
|
||||||
|
<AlertBlock
|
||||||
|
type="warning"
|
||||||
|
className="[overflow-wrap:anywhere]"
|
||||||
|
>
|
||||||
|
{errorServices?.message}
|
||||||
|
</AlertBlock>
|
||||||
|
)}
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="serviceName"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="w-full">
|
||||||
|
<FormLabel>Service Name</FormLabel>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Select
|
||||||
|
onValueChange={field.onChange}
|
||||||
|
defaultValue={field.value || ""}
|
||||||
|
>
|
||||||
|
<FormControl>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select a service name" />
|
||||||
|
</SelectTrigger>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
<SelectContent>
|
||||||
|
{services?.map((service, index) => (
|
||||||
|
<SelectItem
|
||||||
|
value={service}
|
||||||
|
key={`${service}-${index}`}
|
||||||
|
>
|
||||||
|
{service}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
<SelectItem value="none" disabled>
|
||||||
|
Empty
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<TooltipProvider delayDuration={0}>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
type="button"
|
||||||
|
isLoading={isLoadingServices}
|
||||||
|
onClick={() => {
|
||||||
|
if (cacheType === "fetch") {
|
||||||
|
refetchServices();
|
||||||
|
} else {
|
||||||
|
setCacheType("fetch");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<RefreshCw className="size-4 text-muted-foreground" />
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent
|
||||||
|
side="left"
|
||||||
|
sideOffset={5}
|
||||||
|
className="max-w-[10rem]"
|
||||||
|
>
|
||||||
|
<p>
|
||||||
|
Fetch: Will clone the repository and load the
|
||||||
|
services
|
||||||
|
</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
<TooltipProvider delayDuration={0}>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
type="button"
|
||||||
|
isLoading={isLoadingServices}
|
||||||
|
onClick={() => {
|
||||||
|
if (cacheType === "cache") {
|
||||||
|
refetchServices();
|
||||||
|
} else {
|
||||||
|
setCacheType("cache");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DatabaseZap className="size-4 text-muted-foreground" />
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent
|
||||||
|
side="left"
|
||||||
|
sideOffset={5}
|
||||||
|
className="max-w-[10rem]"
|
||||||
|
>
|
||||||
|
<p>
|
||||||
|
Cache: If you previously deployed this compose,
|
||||||
|
it will read the services from the last
|
||||||
|
deployment/fetch from the repository
|
||||||
|
</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="name"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel className="flex items-center gap-2">
|
||||||
|
Task Name
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="Daily Database Backup" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
A descriptive name for your scheduled task
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="cronExpression"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel className="flex items-center gap-2">
|
||||||
|
Schedule
|
||||||
|
<TooltipProvider>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Info className="w-4 h-4 text-muted-foreground cursor-help" />
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>
|
||||||
|
Cron expression format: minute hour day month
|
||||||
|
weekday
|
||||||
|
</p>
|
||||||
|
<p>Example: 0 0 * * * (daily at midnight)</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
</FormLabel>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Select
|
||||||
|
onValueChange={(value) => {
|
||||||
|
field.onChange(value);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FormControl>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select a predefined schedule" />
|
||||||
|
</SelectTrigger>
|
||||||
|
</FormControl>
|
||||||
|
<SelectContent>
|
||||||
|
{commonCronExpressions.map((expr) => (
|
||||||
|
<SelectItem key={expr.value} value={expr.value}>
|
||||||
|
{expr.label} ({expr.value})
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<div className="relative">
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
placeholder="Custom cron expression (e.g., 0 0 * * *)"
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<FormDescription>
|
||||||
|
Choose a predefined schedule or enter a custom cron
|
||||||
|
expression
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{(scheduleTypeForm === "application" ||
|
||||||
|
scheduleTypeForm === "compose") && (
|
||||||
|
<>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="shellType"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel className="flex items-center gap-2">
|
||||||
|
Shell Type
|
||||||
|
</FormLabel>
|
||||||
|
<Select
|
||||||
|
onValueChange={field.onChange}
|
||||||
|
defaultValue={field.value}
|
||||||
|
>
|
||||||
|
<FormControl>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select shell type" />
|
||||||
|
</SelectTrigger>
|
||||||
|
</FormControl>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="bash">Bash</SelectItem>
|
||||||
|
<SelectItem value="sh">Sh</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<FormDescription>
|
||||||
|
Choose the shell to execute your command
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="command"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel className="flex items-center gap-2">
|
||||||
|
Command
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="npm run backup" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
The command to execute in your container
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(scheduleTypeForm === "dokploy-server" ||
|
||||||
|
scheduleTypeForm === "server") && (
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="script"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Script</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<FormControl>
|
||||||
|
<CodeEditor
|
||||||
|
language="shell"
|
||||||
|
placeholder={`# This is a comment
|
||||||
|
echo "Hello, world!"
|
||||||
|
`}
|
||||||
|
className="h-96 font-mono"
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="enabled"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel className="flex items-center gap-2">
|
||||||
|
<Switch
|
||||||
|
checked={field.value}
|
||||||
|
onCheckedChange={field.onChange}
|
||||||
|
/>
|
||||||
|
Enabled
|
||||||
|
</FormLabel>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button type="submit" isLoading={isLoading} className="w-full">
|
||||||
|
{scheduleId ? "Update" : "Create"} Schedule
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
@ -0,0 +1,131 @@
|
|||||||
|
import { DateTooltip } from "@/components/shared/date-tooltip";
|
||||||
|
import { StatusTooltip } from "@/components/shared/status-tooltip";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
|
||||||
|
import type { RouterOutputs } from "@/utils/api";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { ShowDeployment } from "../deployments/show-deployment";
|
||||||
|
import { ClipboardList, Clock } from "lucide-react";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
deployments: RouterOutputs["deployment"]["all"];
|
||||||
|
serverId?: string;
|
||||||
|
children?: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const formatDuration = (seconds: number) => {
|
||||||
|
if (seconds < 60) return `${seconds}s`;
|
||||||
|
const minutes = Math.floor(seconds / 60);
|
||||||
|
const remainingSeconds = seconds % 60;
|
||||||
|
return `${minutes}m ${remainingSeconds}s`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ShowSchedulesLogs = ({
|
||||||
|
deployments,
|
||||||
|
serverId,
|
||||||
|
children,
|
||||||
|
}: Props) => {
|
||||||
|
const [activeLog, setActiveLog] = useState<
|
||||||
|
RouterOutputs["deployment"]["all"][number] | null
|
||||||
|
>(null);
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
return (
|
||||||
|
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
{children ? (
|
||||||
|
children
|
||||||
|
) : (
|
||||||
|
<Button className="sm:w-auto w-full" size="sm" variant="outline">
|
||||||
|
View Logs
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-5xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Logs</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
See all the logs for this schedule
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
{deployments.length > 0 ? (
|
||||||
|
<div className="grid gap-4">
|
||||||
|
{deployments.map((deployment, index) => (
|
||||||
|
<div
|
||||||
|
key={deployment.deploymentId}
|
||||||
|
className="flex items-center justify-between rounded-lg border p-4 gap-2"
|
||||||
|
>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="flex items-center gap-4 font-medium capitalize text-foreground">
|
||||||
|
{index + 1} {deployment.status}
|
||||||
|
<StatusTooltip
|
||||||
|
status={deployment?.status}
|
||||||
|
className="size-2.5"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
{deployment.title}
|
||||||
|
</span>
|
||||||
|
{deployment.description && (
|
||||||
|
<span className="break-all text-sm text-muted-foreground">
|
||||||
|
{deployment.description}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col items-end gap-2">
|
||||||
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
|
<DateTooltip date={deployment.createdAt} />
|
||||||
|
{deployment.startedAt && deployment.finishedAt && (
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className="text-[10px] gap-1 flex items-center"
|
||||||
|
>
|
||||||
|
<Clock className="size-3" />
|
||||||
|
{formatDuration(
|
||||||
|
Math.floor(
|
||||||
|
(new Date(deployment.finishedAt).getTime() -
|
||||||
|
new Date(deployment.startedAt).getTime()) /
|
||||||
|
1000,
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
setActiveLog(deployment);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
View
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground">
|
||||||
|
<ClipboardList className="size-12 mb-4" />
|
||||||
|
<p className="text-lg font-medium">No logs found</p>
|
||||||
|
<p className="text-sm">This schedule hasn't been executed yet</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
<ShowDeployment
|
||||||
|
serverId={serverId || ""}
|
||||||
|
open={Boolean(activeLog && activeLog.logPath !== null)}
|
||||||
|
onClose={() => setActiveLog(null)}
|
||||||
|
logPath={activeLog?.logPath || ""}
|
||||||
|
errorMessage={activeLog?.errorMessage || ""}
|
||||||
|
/>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
@ -0,0 +1,243 @@
|
|||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { api } from "@/utils/api";
|
||||||
|
import { HandleSchedules } from "./handle-schedules";
|
||||||
|
import {
|
||||||
|
Clock,
|
||||||
|
Play,
|
||||||
|
Terminal,
|
||||||
|
Trash2,
|
||||||
|
ClipboardList,
|
||||||
|
Loader2,
|
||||||
|
} from "lucide-react";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { ShowSchedulesLogs } from "./show-schedules-logs";
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipProvider,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from "@/components/ui/tooltip";
|
||||||
|
import { DialogAction } from "@/components/shared/dialog-action";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
id: string;
|
||||||
|
scheduleType?: "application" | "compose" | "server" | "dokploy-server";
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ShowSchedules = ({ id, scheduleType = "application" }: Props) => {
|
||||||
|
const {
|
||||||
|
data: schedules,
|
||||||
|
isLoading: isLoadingSchedules,
|
||||||
|
refetch: refetchSchedules,
|
||||||
|
} = api.schedule.list.useQuery(
|
||||||
|
{
|
||||||
|
id: id || "",
|
||||||
|
scheduleType,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
enabled: !!id,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const utils = api.useUtils();
|
||||||
|
|
||||||
|
const { mutateAsync: deleteSchedule, isLoading: isDeleting } =
|
||||||
|
api.schedule.delete.useMutation();
|
||||||
|
|
||||||
|
const { mutateAsync: runManually, isLoading } =
|
||||||
|
api.schedule.runManually.useMutation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="border px-6 shadow-none bg-transparent h-full min-h-[50vh]">
|
||||||
|
<CardHeader className="px-0">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<CardTitle className="text-xl font-bold flex items-center gap-2">
|
||||||
|
Scheduled Tasks
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Schedule tasks to run automatically at specified intervals.
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{schedules && schedules.length > 0 && (
|
||||||
|
<HandleSchedules id={id} scheduleType={scheduleType} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="px-0">
|
||||||
|
{isLoadingSchedules ? (
|
||||||
|
<div className="flex gap-4 w-full items-center justify-center text-center mx-auto min-h-[45vh]">
|
||||||
|
<Loader2 className="size-4 text-muted-foreground/70 transition-colors animate-spin self-center" />
|
||||||
|
<span className="text-sm text-muted-foreground/70">
|
||||||
|
Loading scheduled tasks...
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : schedules && schedules.length > 0 ? (
|
||||||
|
<div className="grid xl:grid-cols-2 gap-4 grid-cols-1 h-full">
|
||||||
|
{schedules.map((schedule) => {
|
||||||
|
const serverId =
|
||||||
|
schedule.serverId ||
|
||||||
|
schedule.application?.serverId ||
|
||||||
|
schedule.compose?.serverId;
|
||||||
|
const deployments = schedule.deployments;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={schedule.scheduleId}
|
||||||
|
className=" flex items-center justify-between rounded-lg border p-3 transition-colors bg-muted/50"
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className="flex h-9 w-9 items-center justify-center rounded-full bg-primary/5">
|
||||||
|
<Clock className="size-4 text-primary/70" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<h3 className="text-sm font-medium leading-none">
|
||||||
|
{schedule.name}
|
||||||
|
</h3>
|
||||||
|
<Badge
|
||||||
|
variant={schedule.enabled ? "default" : "secondary"}
|
||||||
|
className="text-[10px] px-1 py-0"
|
||||||
|
>
|
||||||
|
{schedule.enabled ? "Enabled" : "Disabled"}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className="font-mono text-[10px] bg-transparent"
|
||||||
|
>
|
||||||
|
Cron: {schedule.cronExpression}
|
||||||
|
</Badge>
|
||||||
|
{schedule.scheduleType !== "server" &&
|
||||||
|
schedule.scheduleType !== "dokploy-server" && (
|
||||||
|
<>
|
||||||
|
<span className="text-xs text-muted-foreground/50">
|
||||||
|
•
|
||||||
|
</span>
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className="font-mono text-[10px] bg-transparent"
|
||||||
|
>
|
||||||
|
{schedule.shellType}
|
||||||
|
</Badge>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{schedule.command && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Terminal className="size-3.5 text-muted-foreground/70" />
|
||||||
|
<code className="font-mono text-[10px] text-muted-foreground/70">
|
||||||
|
{schedule.command}
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<ShowSchedulesLogs
|
||||||
|
deployments={deployments || []}
|
||||||
|
serverId={serverId || undefined}
|
||||||
|
>
|
||||||
|
<Button variant="ghost" size="icon">
|
||||||
|
<ClipboardList className="size-4 transition-colors " />
|
||||||
|
</Button>
|
||||||
|
</ShowSchedulesLogs>
|
||||||
|
|
||||||
|
<TooltipProvider delayDuration={0}>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
isLoading={isLoading}
|
||||||
|
onClick={async () => {
|
||||||
|
await runManually({
|
||||||
|
scheduleId: schedule.scheduleId,
|
||||||
|
})
|
||||||
|
.then(async () => {
|
||||||
|
toast.success("Schedule run successfully");
|
||||||
|
|
||||||
|
await new Promise((resolve) =>
|
||||||
|
setTimeout(resolve, 1500),
|
||||||
|
);
|
||||||
|
refetchSchedules();
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
toast.error("Error running schedule:");
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Play className="size-4 transition-colors" />
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>Run Manual Schedule</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
|
||||||
|
<HandleSchedules
|
||||||
|
scheduleId={schedule.scheduleId}
|
||||||
|
id={id}
|
||||||
|
scheduleType={scheduleType}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DialogAction
|
||||||
|
title="Delete Schedule"
|
||||||
|
description="Are you sure you want to delete this schedule?"
|
||||||
|
type="destructive"
|
||||||
|
onClick={async () => {
|
||||||
|
await deleteSchedule({
|
||||||
|
scheduleId: schedule.scheduleId,
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
utils.schedule.list.invalidate({
|
||||||
|
id,
|
||||||
|
scheduleType,
|
||||||
|
});
|
||||||
|
toast.success("Schedule deleted successfully");
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
toast.error("Error deleting schedule");
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="group hover:bg-red-500/10 "
|
||||||
|
isLoading={isDeleting}
|
||||||
|
>
|
||||||
|
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
|
||||||
|
</Button>
|
||||||
|
</DialogAction>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-2 items-center justify-center py-12 rounded-lg">
|
||||||
|
<Clock className="size-8 mb-4 text-muted-foreground" />
|
||||||
|
<p className="text-lg font-medium text-muted-foreground">
|
||||||
|
No scheduled tasks
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
|
Create your first scheduled task to automate your workflows
|
||||||
|
</p>
|
||||||
|
<HandleSchedules id={id} scheduleType={scheduleType} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
@ -9,12 +9,13 @@ import {
|
|||||||
CardTitle,
|
CardTitle,
|
||||||
} from "@/components/ui/card";
|
} from "@/components/ui/card";
|
||||||
import { type RouterOutputs, api } from "@/utils/api";
|
import { type RouterOutputs, api } from "@/utils/api";
|
||||||
import { RocketIcon } from "lucide-react";
|
import { RocketIcon, Clock } from "lucide-react";
|
||||||
import React, { useEffect, useState } from "react";
|
import React, { useEffect, useState } from "react";
|
||||||
import { CancelQueuesCompose } from "./cancel-queues-compose";
|
import { CancelQueuesCompose } from "./cancel-queues-compose";
|
||||||
import { RefreshTokenCompose } from "./refresh-token-compose";
|
import { RefreshTokenCompose } from "./refresh-token-compose";
|
||||||
import { ShowDeploymentCompose } from "./show-deployment-compose";
|
import { ShowDeploymentCompose } from "./show-deployment-compose";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { formatDuration } from "@/components/dashboard/application/schedules/show-schedules-logs";
|
||||||
interface Props {
|
interface Props {
|
||||||
composeId: string;
|
composeId: string;
|
||||||
}
|
}
|
||||||
@ -96,8 +97,23 @@ export const ShowDeploymentsCompose = ({ composeId }: Props) => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col items-end gap-2">
|
<div className="flex flex-col items-end gap-2">
|
||||||
<div className="text-sm capitalize text-muted-foreground">
|
<div className="text-sm capitalize text-muted-foreground flex items-center gap-2">
|
||||||
<DateTooltip date={deployment.createdAt} />
|
<DateTooltip date={deployment.createdAt} />
|
||||||
|
{deployment.startedAt && deployment.finishedAt && (
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className="text-[10px] gap-1 flex items-center"
|
||||||
|
>
|
||||||
|
<Clock className="size-3" />
|
||||||
|
{formatDuration(
|
||||||
|
Math.floor(
|
||||||
|
(new Date(deployment.finishedAt).getTime() -
|
||||||
|
new Date(deployment.startedAt).getTime()) /
|
||||||
|
1000,
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
|
@ -0,0 +1,28 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { Dialog, DialogContent, DialogTrigger } from "@/components/ui/dialog";
|
||||||
|
import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
|
||||||
|
import { ShowSchedules } from "@/components/dashboard/application/schedules/show-schedules";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
serverId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ShowSchedulesModal = ({ serverId }: Props) => {
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<DropdownMenuItem
|
||||||
|
className="w-full cursor-pointer "
|
||||||
|
onSelect={(e) => e.preventDefault()}
|
||||||
|
>
|
||||||
|
Show Schedules
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent className="sm:max-w-5xl overflow-y-auto max-h-screen ">
|
||||||
|
<ShowSchedules id={serverId} scheduleType="server" />
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
@ -43,6 +43,7 @@ import { ShowMonitoringModal } from "./show-monitoring-modal";
|
|||||||
import { ShowSwarmOverviewModal } from "./show-swarm-overview-modal";
|
import { ShowSwarmOverviewModal } from "./show-swarm-overview-modal";
|
||||||
import { ShowTraefikFileSystemModal } from "./show-traefik-file-system-modal";
|
import { ShowTraefikFileSystemModal } from "./show-traefik-file-system-modal";
|
||||||
import { WelcomeSuscription } from "./welcome-stripe/welcome-suscription";
|
import { WelcomeSuscription } from "./welcome-stripe/welcome-suscription";
|
||||||
|
import { ShowSchedulesModal } from "./show-schedules-modal";
|
||||||
|
|
||||||
export const ShowServers = () => {
|
export const ShowServers = () => {
|
||||||
const { t } = useTranslation("settings");
|
const { t } = useTranslation("settings");
|
||||||
@ -332,6 +333,10 @@ export const ShowServers = () => {
|
|||||||
<ShowNodesModal
|
<ShowNodesModal
|
||||||
serverId={server.serverId}
|
serverId={server.serverId}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<ShowSchedulesModal
|
||||||
|
serverId={server.serverId}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
|
@ -10,6 +10,7 @@ import {
|
|||||||
ChevronRight,
|
ChevronRight,
|
||||||
ChevronsUpDown,
|
ChevronsUpDown,
|
||||||
CircleHelp,
|
CircleHelp,
|
||||||
|
Clock,
|
||||||
CreditCard,
|
CreditCard,
|
||||||
Database,
|
Database,
|
||||||
Folder,
|
Folder,
|
||||||
@ -158,6 +159,14 @@ const MENU: Menu = {
|
|||||||
// Only enabled in non-cloud environments
|
// Only enabled in non-cloud environments
|
||||||
isEnabled: ({ isCloud }) => !isCloud,
|
isEnabled: ({ isCloud }) => !isCloud,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
isSingle: true,
|
||||||
|
title: "Schedules",
|
||||||
|
url: "/dashboard/schedules",
|
||||||
|
icon: Clock,
|
||||||
|
// Only enabled in non-cloud environments
|
||||||
|
isEnabled: ({ isCloud, auth }) => !isCloud && auth?.role === "owner",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
isSingle: true,
|
isSingle: true,
|
||||||
title: "Traefik File System",
|
title: "Traefik File System",
|
||||||
|
28
apps/dokploy/drizzle/0088_illegal_ma_gnuci.sql
Normal file
28
apps/dokploy/drizzle/0088_illegal_ma_gnuci.sql
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
CREATE TYPE "public"."scheduleType" AS ENUM('application', 'compose', 'server', 'dokploy-server');--> statement-breakpoint
|
||||||
|
CREATE TYPE "public"."shellType" AS ENUM('bash', 'sh');--> statement-breakpoint
|
||||||
|
CREATE TABLE "schedule" (
|
||||||
|
"scheduleId" text PRIMARY KEY NOT NULL,
|
||||||
|
"name" text NOT NULL,
|
||||||
|
"cronExpression" text NOT NULL,
|
||||||
|
"appName" text NOT NULL,
|
||||||
|
"serviceName" text,
|
||||||
|
"shellType" "shellType" DEFAULT 'bash' NOT NULL,
|
||||||
|
"scheduleType" "scheduleType" DEFAULT 'application' NOT NULL,
|
||||||
|
"command" text NOT NULL,
|
||||||
|
"script" text,
|
||||||
|
"applicationId" text,
|
||||||
|
"composeId" text,
|
||||||
|
"serverId" text,
|
||||||
|
"userId" text,
|
||||||
|
"enabled" boolean DEFAULT true NOT NULL,
|
||||||
|
"createdAt" text NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "deployment" ADD COLUMN "startedAt" text;--> statement-breakpoint
|
||||||
|
ALTER TABLE "deployment" ADD COLUMN "finishedAt" text;--> statement-breakpoint
|
||||||
|
ALTER TABLE "deployment" ADD COLUMN "scheduleId" text;--> statement-breakpoint
|
||||||
|
ALTER TABLE "schedule" ADD CONSTRAINT "schedule_applicationId_application_applicationId_fk" FOREIGN KEY ("applicationId") REFERENCES "public"."application"("applicationId") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "schedule" ADD CONSTRAINT "schedule_composeId_compose_composeId_fk" FOREIGN KEY ("composeId") REFERENCES "public"."compose"("composeId") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "schedule" ADD CONSTRAINT "schedule_serverId_server_serverId_fk" FOREIGN KEY ("serverId") REFERENCES "public"."server"("serverId") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "schedule" ADD CONSTRAINT "schedule_userId_user_temp_id_fk" FOREIGN KEY ("userId") REFERENCES "public"."user_temp"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "deployment" ADD CONSTRAINT "deployment_scheduleId_schedule_scheduleId_fk" FOREIGN KEY ("scheduleId") REFERENCES "public"."schedule"("scheduleId") ON DELETE cascade ON UPDATE no action;
|
@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"id": "7fea81ef-e2a7-4a8b-b755-e98903a08b57",
|
"id": "c7eae4ce-5acc-439b-962f-bb2ef8922187",
|
||||||
"prevId": "7fb3716c-3cc6-4b18-b8d1-762844da26be",
|
"prevId": "7fb3716c-3cc6-4b18-b8d1-762844da26be",
|
||||||
"version": "7",
|
"version": "7",
|
||||||
"dialect": "postgresql",
|
"dialect": "postgresql",
|
||||||
@ -1770,12 +1770,6 @@
|
|||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": true
|
"notNull": true
|
||||||
},
|
},
|
||||||
"serviceName": {
|
|
||||||
"name": "serviceName",
|
|
||||||
"type": "text",
|
|
||||||
"primaryKey": false,
|
|
||||||
"notNull": false
|
|
||||||
},
|
|
||||||
"destinationId": {
|
"destinationId": {
|
||||||
"name": "destinationId",
|
"name": "destinationId",
|
||||||
"type": "text",
|
"type": "text",
|
||||||
@ -1788,14 +1782,6 @@
|
|||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": false
|
"notNull": false
|
||||||
},
|
},
|
||||||
"backupType": {
|
|
||||||
"name": "backupType",
|
|
||||||
"type": "backupType",
|
|
||||||
"typeSchema": "public",
|
|
||||||
"primaryKey": false,
|
|
||||||
"notNull": true,
|
|
||||||
"default": "'database'"
|
|
||||||
},
|
|
||||||
"databaseType": {
|
"databaseType": {
|
||||||
"name": "databaseType",
|
"name": "databaseType",
|
||||||
"type": "databaseType",
|
"type": "databaseType",
|
||||||
@ -1803,12 +1789,6 @@
|
|||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": true
|
"notNull": true
|
||||||
},
|
},
|
||||||
"composeId": {
|
|
||||||
"name": "composeId",
|
|
||||||
"type": "text",
|
|
||||||
"primaryKey": false,
|
|
||||||
"notNull": false
|
|
||||||
},
|
|
||||||
"postgresId": {
|
"postgresId": {
|
||||||
"name": "postgresId",
|
"name": "postgresId",
|
||||||
"type": "text",
|
"type": "text",
|
||||||
@ -1855,19 +1835,6 @@
|
|||||||
"onDelete": "cascade",
|
"onDelete": "cascade",
|
||||||
"onUpdate": "no action"
|
"onUpdate": "no action"
|
||||||
},
|
},
|
||||||
"backup_composeId_compose_composeId_fk": {
|
|
||||||
"name": "backup_composeId_compose_composeId_fk",
|
|
||||||
"tableFrom": "backup",
|
|
||||||
"tableTo": "compose",
|
|
||||||
"columnsFrom": [
|
|
||||||
"composeId"
|
|
||||||
],
|
|
||||||
"columnsTo": [
|
|
||||||
"composeId"
|
|
||||||
],
|
|
||||||
"onDelete": "cascade",
|
|
||||||
"onUpdate": "no action"
|
|
||||||
},
|
|
||||||
"backup_postgresId_postgres_postgresId_fk": {
|
"backup_postgresId_postgres_postgresId_fk": {
|
||||||
"name": "backup_postgresId_postgres_postgresId_fk",
|
"name": "backup_postgresId_postgres_postgresId_fk",
|
||||||
"tableFrom": "backup",
|
"tableFrom": "backup",
|
||||||
@ -2101,11 +2068,29 @@
|
|||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": true
|
"notNull": true
|
||||||
},
|
},
|
||||||
|
"startedAt": {
|
||||||
|
"name": "startedAt",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"finishedAt": {
|
||||||
|
"name": "finishedAt",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
"errorMessage": {
|
"errorMessage": {
|
||||||
"name": "errorMessage",
|
"name": "errorMessage",
|
||||||
"type": "text",
|
"type": "text",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": false
|
"notNull": false
|
||||||
|
},
|
||||||
|
"scheduleId": {
|
||||||
|
"name": "scheduleId",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"indexes": {},
|
"indexes": {},
|
||||||
@ -2161,6 +2146,19 @@
|
|||||||
],
|
],
|
||||||
"onDelete": "cascade",
|
"onDelete": "cascade",
|
||||||
"onUpdate": "no action"
|
"onUpdate": "no action"
|
||||||
|
},
|
||||||
|
"deployment_scheduleId_schedule_scheduleId_fk": {
|
||||||
|
"name": "deployment_scheduleId_schedule_scheduleId_fk",
|
||||||
|
"tableFrom": "deployment",
|
||||||
|
"tableTo": "schedule",
|
||||||
|
"columnsFrom": [
|
||||||
|
"scheduleId"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"scheduleId"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"compositePrimaryKeys": {},
|
"compositePrimaryKeys": {},
|
||||||
@ -5255,6 +5253,167 @@
|
|||||||
"policies": {},
|
"policies": {},
|
||||||
"checkConstraints": {},
|
"checkConstraints": {},
|
||||||
"isRLSEnabled": false
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.schedule": {
|
||||||
|
"name": "schedule",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"scheduleId": {
|
||||||
|
"name": "scheduleId",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"name": "name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"cronExpression": {
|
||||||
|
"name": "cronExpression",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"appName": {
|
||||||
|
"name": "appName",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"serviceName": {
|
||||||
|
"name": "serviceName",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"shellType": {
|
||||||
|
"name": "shellType",
|
||||||
|
"type": "shellType",
|
||||||
|
"typeSchema": "public",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "'bash'"
|
||||||
|
},
|
||||||
|
"scheduleType": {
|
||||||
|
"name": "scheduleType",
|
||||||
|
"type": "scheduleType",
|
||||||
|
"typeSchema": "public",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "'application'"
|
||||||
|
},
|
||||||
|
"command": {
|
||||||
|
"name": "command",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"script": {
|
||||||
|
"name": "script",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"applicationId": {
|
||||||
|
"name": "applicationId",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"composeId": {
|
||||||
|
"name": "composeId",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"serverId": {
|
||||||
|
"name": "serverId",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"userId": {
|
||||||
|
"name": "userId",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"enabled": {
|
||||||
|
"name": "enabled",
|
||||||
|
"type": "boolean",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": true
|
||||||
|
},
|
||||||
|
"createdAt": {
|
||||||
|
"name": "createdAt",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"schedule_applicationId_application_applicationId_fk": {
|
||||||
|
"name": "schedule_applicationId_application_applicationId_fk",
|
||||||
|
"tableFrom": "schedule",
|
||||||
|
"tableTo": "application",
|
||||||
|
"columnsFrom": [
|
||||||
|
"applicationId"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"applicationId"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
},
|
||||||
|
"schedule_composeId_compose_composeId_fk": {
|
||||||
|
"name": "schedule_composeId_compose_composeId_fk",
|
||||||
|
"tableFrom": "schedule",
|
||||||
|
"tableTo": "compose",
|
||||||
|
"columnsFrom": [
|
||||||
|
"composeId"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"composeId"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
},
|
||||||
|
"schedule_serverId_server_serverId_fk": {
|
||||||
|
"name": "schedule_serverId_server_serverId_fk",
|
||||||
|
"tableFrom": "schedule",
|
||||||
|
"tableTo": "server",
|
||||||
|
"columnsFrom": [
|
||||||
|
"serverId"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"serverId"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
},
|
||||||
|
"schedule_userId_user_temp_id_fk": {
|
||||||
|
"name": "schedule_userId_user_temp_id_fk",
|
||||||
|
"tableFrom": "schedule",
|
||||||
|
"tableTo": "user_temp",
|
||||||
|
"columnsFrom": [
|
||||||
|
"userId"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"enums": {
|
"enums": {
|
||||||
@ -5292,14 +5451,6 @@
|
|||||||
"preview"
|
"preview"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"public.backupType": {
|
|
||||||
"name": "backupType",
|
|
||||||
"schema": "public",
|
|
||||||
"values": [
|
|
||||||
"database",
|
|
||||||
"compose"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"public.databaseType": {
|
"public.databaseType": {
|
||||||
"name": "databaseType",
|
"name": "databaseType",
|
||||||
"schema": "public",
|
"schema": "public",
|
||||||
@ -5433,6 +5584,24 @@
|
|||||||
"active",
|
"active",
|
||||||
"inactive"
|
"inactive"
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
"public.scheduleType": {
|
||||||
|
"name": "scheduleType",
|
||||||
|
"schema": "public",
|
||||||
|
"values": [
|
||||||
|
"application",
|
||||||
|
"compose",
|
||||||
|
"server",
|
||||||
|
"dokploy-server"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"public.shellType": {
|
||||||
|
"name": "shellType",
|
||||||
|
"schema": "public",
|
||||||
|
"values": [
|
||||||
|
"bash",
|
||||||
|
"sh"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"schemas": {},
|
"schemas": {},
|
||||||
|
@ -621,15 +621,8 @@
|
|||||||
{
|
{
|
||||||
"idx": 88,
|
"idx": 88,
|
||||||
"version": "7",
|
"version": "7",
|
||||||
"when": 1745801614194,
|
"when": 1746256928101,
|
||||||
"tag": "0088_same_ezekiel",
|
"tag": "0088_illegal_ma_gnuci",
|
||||||
"breakpoints": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"idx": 89,
|
|
||||||
"version": "7",
|
|
||||||
"when": 1745812150155,
|
|
||||||
"tag": "0089_dazzling_marrow",
|
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
@ -12,6 +12,7 @@ import { ShowEnvironment } from "@/components/dashboard/application/environment/
|
|||||||
import { ShowGeneralApplication } from "@/components/dashboard/application/general/show";
|
import { ShowGeneralApplication } from "@/components/dashboard/application/general/show";
|
||||||
import { ShowDockerLogs } from "@/components/dashboard/application/logs/show";
|
import { ShowDockerLogs } from "@/components/dashboard/application/logs/show";
|
||||||
import { ShowPreviewDeployments } from "@/components/dashboard/application/preview-deployments/show-preview-deployments";
|
import { ShowPreviewDeployments } from "@/components/dashboard/application/preview-deployments/show-preview-deployments";
|
||||||
|
import { ShowSchedules } from "@/components/dashboard/application/schedules/show-schedules";
|
||||||
import { UpdateApplication } from "@/components/dashboard/application/update-application";
|
import { UpdateApplication } from "@/components/dashboard/application/update-application";
|
||||||
import { DeleteService } from "@/components/dashboard/compose/delete-service";
|
import { DeleteService } from "@/components/dashboard/compose/delete-service";
|
||||||
import { ContainerFreeMonitoring } from "@/components/dashboard/monitoring/free/container/show-free-container-monitoring";
|
import { ContainerFreeMonitoring } from "@/components/dashboard/monitoring/free/container/show-free-container-monitoring";
|
||||||
@ -232,6 +233,7 @@ const Service = (
|
|||||||
<TabsTrigger value="preview-deployments">
|
<TabsTrigger value="preview-deployments">
|
||||||
Preview Deployments
|
Preview Deployments
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="schedules">Schedules</TabsTrigger>
|
||||||
<TabsTrigger value="deployments">Deployments</TabsTrigger>
|
<TabsTrigger value="deployments">Deployments</TabsTrigger>
|
||||||
<TabsTrigger value="logs">Logs</TabsTrigger>
|
<TabsTrigger value="logs">Logs</TabsTrigger>
|
||||||
{((data?.serverId && isCloud) || !data?.server) && (
|
{((data?.serverId && isCloud) || !data?.server) && (
|
||||||
@ -308,6 +310,14 @@ const Service = (
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
<TabsContent value="schedules">
|
||||||
|
<div className="flex flex-col gap-4 pt-2.5">
|
||||||
|
<ShowSchedules
|
||||||
|
id={applicationId}
|
||||||
|
scheduleType="application"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
<TabsContent value="deployments" className="w-full">
|
<TabsContent value="deployments" className="w-full">
|
||||||
<div className="flex flex-col gap-4 pt-2.5">
|
<div className="flex flex-col gap-4 pt-2.5">
|
||||||
<ShowDeployments applicationId={applicationId} />
|
<ShowDeployments applicationId={applicationId} />
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
import { ShowImport } from "@/components/dashboard/application/advanced/import/show-import";
|
import { ShowImport } from "@/components/dashboard/application/advanced/import/show-import";
|
||||||
import { ShowVolumes } from "@/components/dashboard/application/advanced/volumes/show-volumes";
|
import { ShowVolumes } from "@/components/dashboard/application/advanced/volumes/show-volumes";
|
||||||
import { ShowEnvironment } from "@/components/dashboard/application/environment/show-enviroment";
|
import { ShowEnvironment } from "@/components/dashboard/application/environment/show-enviroment";
|
||||||
|
import { ShowSchedules } from "@/components/dashboard/application/schedules/show-schedules";
|
||||||
import { AddCommandCompose } from "@/components/dashboard/compose/advanced/add-command";
|
import { AddCommandCompose } from "@/components/dashboard/compose/advanced/add-command";
|
||||||
import { DeleteService } from "@/components/dashboard/compose/delete-service";
|
import { DeleteService } from "@/components/dashboard/compose/delete-service";
|
||||||
import { ShowDeploymentsCompose } from "@/components/dashboard/compose/deployments/show-deployments-compose";
|
import { ShowDeploymentsCompose } from "@/components/dashboard/compose/deployments/show-deployments-compose";
|
||||||
@ -230,6 +231,7 @@ const Service = (
|
|||||||
<TabsTrigger value="deployments">Deployments</TabsTrigger>
|
<TabsTrigger value="deployments">Deployments</TabsTrigger>
|
||||||
<TabsTrigger value="backups">Backups</TabsTrigger>
|
<TabsTrigger value="backups">Backups</TabsTrigger>
|
||||||
<TabsTrigger value="logs">Logs</TabsTrigger>
|
<TabsTrigger value="logs">Logs</TabsTrigger>
|
||||||
|
<TabsTrigger value="schedules">Schedules</TabsTrigger>
|
||||||
{((data?.serverId && isCloud) || !data?.server) && (
|
{((data?.serverId && isCloud) || !data?.server) && (
|
||||||
<TabsTrigger value="monitoring">Monitoring</TabsTrigger>
|
<TabsTrigger value="monitoring">Monitoring</TabsTrigger>
|
||||||
)}
|
)}
|
||||||
@ -253,6 +255,12 @@ const Service = (
|
|||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="schedules">
|
||||||
|
<div className="flex flex-col gap-4 pt-2.5">
|
||||||
|
<ShowSchedules id={composeId} scheduleType="compose" />
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="monitoring">
|
<TabsContent value="monitoring">
|
||||||
<div className="pt-2.5">
|
<div className="pt-2.5">
|
||||||
<div className="flex flex-col border rounded-lg ">
|
<div className="flex flex-col border rounded-lg ">
|
||||||
|
54
apps/dokploy/pages/dashboard/schedules.tsx
Normal file
54
apps/dokploy/pages/dashboard/schedules.tsx
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
|
||||||
|
import type { ReactElement } from "react";
|
||||||
|
import type { GetServerSidePropsContext } from "next";
|
||||||
|
import { validateRequest } from "@dokploy/server/lib/auth";
|
||||||
|
import { IS_CLOUD } from "@dokploy/server/constants";
|
||||||
|
import { api } from "@/utils/api";
|
||||||
|
import { ShowSchedules } from "@/components/dashboard/application/schedules/show-schedules";
|
||||||
|
import { Card } from "@/components/ui/card";
|
||||||
|
function SchedulesPage() {
|
||||||
|
const { data: user } = api.user.get.useQuery();
|
||||||
|
return (
|
||||||
|
<div className="w-full">
|
||||||
|
<Card className="h-full bg-sidebar p-2.5 rounded-xl max-w-8xl mx-auto min-h-[45vh]">
|
||||||
|
<div className="rounded-xl bg-background shadow-md h-full">
|
||||||
|
<ShowSchedules
|
||||||
|
scheduleType="dokploy-server"
|
||||||
|
id={user?.user.id || ""}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
export default SchedulesPage;
|
||||||
|
|
||||||
|
SchedulesPage.getLayout = (page: ReactElement) => {
|
||||||
|
return <DashboardLayout>{page}</DashboardLayout>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function getServerSideProps(
|
||||||
|
ctx: GetServerSidePropsContext<{ serviceId: string }>,
|
||||||
|
) {
|
||||||
|
if (IS_CLOUD) {
|
||||||
|
return {
|
||||||
|
redirect: {
|
||||||
|
permanent: true,
|
||||||
|
destination: "/dashboard/projects",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const { user } = await validateRequest(ctx.req);
|
||||||
|
if (!user || user.role !== "owner") {
|
||||||
|
return {
|
||||||
|
redirect: {
|
||||||
|
permanent: true,
|
||||||
|
destination: "/",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
props: {},
|
||||||
|
};
|
||||||
|
}
|
@ -35,6 +35,7 @@ import { sshRouter } from "./routers/ssh-key";
|
|||||||
import { stripeRouter } from "./routers/stripe";
|
import { stripeRouter } from "./routers/stripe";
|
||||||
import { swarmRouter } from "./routers/swarm";
|
import { swarmRouter } from "./routers/swarm";
|
||||||
import { userRouter } from "./routers/user";
|
import { userRouter } from "./routers/user";
|
||||||
|
import { scheduleRouter } from "./routers/schedule";
|
||||||
/**
|
/**
|
||||||
* This is the primary router for your server.
|
* This is the primary router for your server.
|
||||||
*
|
*
|
||||||
@ -78,6 +79,7 @@ export const appRouter = createTRPCRouter({
|
|||||||
swarm: swarmRouter,
|
swarm: swarmRouter,
|
||||||
ai: aiRouter,
|
ai: aiRouter,
|
||||||
organization: organizationRouter,
|
organization: organizationRouter,
|
||||||
|
schedule: scheduleRouter,
|
||||||
});
|
});
|
||||||
|
|
||||||
// export type definition of API
|
// export type definition of API
|
||||||
|
142
apps/dokploy/server/api/routers/schedule.ts
Normal file
142
apps/dokploy/server/api/routers/schedule.ts
Normal file
@ -0,0 +1,142 @@
|
|||||||
|
import { TRPCError } from "@trpc/server";
|
||||||
|
import { z } from "zod";
|
||||||
|
import {
|
||||||
|
createScheduleSchema,
|
||||||
|
schedules,
|
||||||
|
updateScheduleSchema,
|
||||||
|
} from "@dokploy/server/db/schema/schedule";
|
||||||
|
import { desc, eq } from "drizzle-orm";
|
||||||
|
import { db } from "@dokploy/server/db";
|
||||||
|
import { createTRPCRouter, protectedProcedure } from "../trpc";
|
||||||
|
import { runCommand } from "@dokploy/server/index";
|
||||||
|
import { deployments } from "@dokploy/server/db/schema/deployment";
|
||||||
|
import {
|
||||||
|
deleteSchedule,
|
||||||
|
findScheduleById,
|
||||||
|
createSchedule,
|
||||||
|
updateSchedule,
|
||||||
|
} from "@dokploy/server/services/schedule";
|
||||||
|
import { IS_CLOUD, scheduleJob } from "@dokploy/server";
|
||||||
|
import { removeJob, schedule } from "@/server/utils/backup";
|
||||||
|
import { removeScheduleJob } from "@dokploy/server";
|
||||||
|
export const scheduleRouter = createTRPCRouter({
|
||||||
|
create: protectedProcedure
|
||||||
|
.input(createScheduleSchema)
|
||||||
|
.mutation(async ({ input }) => {
|
||||||
|
const newSchedule = await createSchedule(input);
|
||||||
|
|
||||||
|
if (newSchedule?.enabled) {
|
||||||
|
if (IS_CLOUD) {
|
||||||
|
schedule({
|
||||||
|
scheduleId: newSchedule.scheduleId,
|
||||||
|
type: "schedule",
|
||||||
|
cronSchedule: newSchedule.cronExpression,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
scheduleJob(newSchedule);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return newSchedule;
|
||||||
|
}),
|
||||||
|
|
||||||
|
update: protectedProcedure
|
||||||
|
.input(updateScheduleSchema)
|
||||||
|
.mutation(async ({ input }) => {
|
||||||
|
const updatedSchedule = await updateSchedule(input);
|
||||||
|
|
||||||
|
if (IS_CLOUD) {
|
||||||
|
if (updatedSchedule?.enabled) {
|
||||||
|
schedule({
|
||||||
|
scheduleId: updatedSchedule.scheduleId,
|
||||||
|
type: "schedule",
|
||||||
|
cronSchedule: updatedSchedule.cronExpression,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await removeJob({
|
||||||
|
cronSchedule: updatedSchedule.cronExpression,
|
||||||
|
scheduleId: updatedSchedule.scheduleId,
|
||||||
|
type: "schedule",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (updatedSchedule?.enabled) {
|
||||||
|
removeScheduleJob(updatedSchedule.scheduleId);
|
||||||
|
scheduleJob(updatedSchedule);
|
||||||
|
} else {
|
||||||
|
removeScheduleJob(updatedSchedule.scheduleId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return updatedSchedule;
|
||||||
|
}),
|
||||||
|
|
||||||
|
delete: protectedProcedure
|
||||||
|
.input(z.object({ scheduleId: z.string() }))
|
||||||
|
.mutation(async ({ input }) => {
|
||||||
|
const schedule = await findScheduleById(input.scheduleId);
|
||||||
|
await deleteSchedule(input.scheduleId);
|
||||||
|
|
||||||
|
if (IS_CLOUD) {
|
||||||
|
await removeJob({
|
||||||
|
cronSchedule: schedule.cronExpression,
|
||||||
|
scheduleId: schedule.scheduleId,
|
||||||
|
type: "schedule",
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
removeScheduleJob(schedule.scheduleId);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}),
|
||||||
|
|
||||||
|
list: protectedProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
id: z.string(),
|
||||||
|
scheduleType: z.enum([
|
||||||
|
"application",
|
||||||
|
"compose",
|
||||||
|
"server",
|
||||||
|
"dokploy-server",
|
||||||
|
]),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.query(async ({ input }) => {
|
||||||
|
const where = {
|
||||||
|
application: eq(schedules.applicationId, input.id),
|
||||||
|
compose: eq(schedules.composeId, input.id),
|
||||||
|
server: eq(schedules.serverId, input.id),
|
||||||
|
"dokploy-server": eq(schedules.userId, input.id),
|
||||||
|
};
|
||||||
|
return db.query.schedules.findMany({
|
||||||
|
where: where[input.scheduleType],
|
||||||
|
with: {
|
||||||
|
application: true,
|
||||||
|
server: true,
|
||||||
|
compose: true,
|
||||||
|
deployments: {
|
||||||
|
orderBy: [desc(deployments.createdAt)],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
|
||||||
|
one: protectedProcedure
|
||||||
|
.input(z.object({ scheduleId: z.string() }))
|
||||||
|
.query(async ({ input }) => {
|
||||||
|
return await findScheduleById(input.scheduleId);
|
||||||
|
}),
|
||||||
|
|
||||||
|
runManually: protectedProcedure
|
||||||
|
.input(z.object({ scheduleId: z.string().min(1) }))
|
||||||
|
.mutation(async ({ input }) => {
|
||||||
|
try {
|
||||||
|
await runCommand(input.scheduleId);
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "INTERNAL_SERVER_ERROR",
|
||||||
|
message:
|
||||||
|
error instanceof Error ? error.message : "Error running schedule",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
});
|
@ -6,6 +6,7 @@ import {
|
|||||||
createDefaultServerTraefikConfig,
|
createDefaultServerTraefikConfig,
|
||||||
createDefaultTraefikConfig,
|
createDefaultTraefikConfig,
|
||||||
initCronJobs,
|
initCronJobs,
|
||||||
|
initSchedules,
|
||||||
initializeNetwork,
|
initializeNetwork,
|
||||||
sendDokployRestartNotifications,
|
sendDokployRestartNotifications,
|
||||||
setupDirectories,
|
setupDirectories,
|
||||||
@ -49,6 +50,7 @@ void app.prepare().then(async () => {
|
|||||||
createDefaultServerTraefikConfig();
|
createDefaultServerTraefikConfig();
|
||||||
await migration();
|
await migration();
|
||||||
await initCronJobs();
|
await initCronJobs();
|
||||||
|
await initSchedules();
|
||||||
await sendDokployRestartNotifications();
|
await sendDokployRestartNotifications();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -14,6 +14,11 @@ type QueueJob =
|
|||||||
type: "server";
|
type: "server";
|
||||||
cronSchedule: string;
|
cronSchedule: string;
|
||||||
serverId: string;
|
serverId: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: "schedule";
|
||||||
|
cronSchedule: string;
|
||||||
|
scheduleId: string;
|
||||||
};
|
};
|
||||||
export const schedule = async (job: QueueJob) => {
|
export const schedule = async (job: QueueJob) => {
|
||||||
try {
|
try {
|
||||||
|
@ -34,8 +34,8 @@ app.use(async (c, next) => {
|
|||||||
app.post("/create-backup", zValidator("json", jobQueueSchema), async (c) => {
|
app.post("/create-backup", zValidator("json", jobQueueSchema), async (c) => {
|
||||||
const data = c.req.valid("json");
|
const data = c.req.valid("json");
|
||||||
scheduleJob(data);
|
scheduleJob(data);
|
||||||
logger.info({ data }, "Backup created successfully");
|
logger.info({ data }, `[${data.type}] created successfully`);
|
||||||
return c.json({ message: "Backup created successfully" });
|
return c.json({ message: `[${data.type}] created successfully` });
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post("/update-backup", zValidator("json", jobQueueSchema), async (c) => {
|
app.post("/update-backup", zValidator("json", jobQueueSchema), async (c) => {
|
||||||
@ -55,6 +55,12 @@ app.post("/update-backup", zValidator("json", jobQueueSchema), async (c) => {
|
|||||||
type: "server",
|
type: "server",
|
||||||
cronSchedule: job.pattern,
|
cronSchedule: job.pattern,
|
||||||
});
|
});
|
||||||
|
} else if (data.type === "schedule") {
|
||||||
|
result = await removeJob({
|
||||||
|
scheduleId: data.scheduleId,
|
||||||
|
type: "schedule",
|
||||||
|
cronSchedule: job.pattern,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
logger.info({ result }, "Job removed");
|
logger.info({ result }, "Job removed");
|
||||||
}
|
}
|
||||||
|
@ -36,6 +36,12 @@ export const scheduleJob = (job: QueueJob) => {
|
|||||||
pattern: job.cronSchedule,
|
pattern: job.cronSchedule,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
} else if (job.type === "schedule") {
|
||||||
|
jobQueue.add(job.scheduleId, job, {
|
||||||
|
repeat: {
|
||||||
|
pattern: job.cronSchedule,
|
||||||
|
},
|
||||||
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -54,7 +60,13 @@ export const removeJob = async (data: QueueJob) => {
|
|||||||
});
|
});
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
if (data.type === "schedule") {
|
||||||
|
const { scheduleId, cronSchedule } = data;
|
||||||
|
const result = await jobQueue.removeRepeatable(scheduleId, {
|
||||||
|
pattern: cronSchedule,
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -72,6 +84,11 @@ export const getJobRepeatable = async (
|
|||||||
const job = repeatableJobs.find((j) => j.name === `${serverId}-cleanup`);
|
const job = repeatableJobs.find((j) => j.name === `${serverId}-cleanup`);
|
||||||
return job ? job : null;
|
return job ? job : null;
|
||||||
}
|
}
|
||||||
|
if (data.type === "schedule") {
|
||||||
|
const { scheduleId } = data;
|
||||||
|
const job = repeatableJobs.find((j) => j.name === scheduleId);
|
||||||
|
return job ? job : null;
|
||||||
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
@ -11,6 +11,11 @@ export const jobQueueSchema = z.discriminatedUnion("type", [
|
|||||||
type: z.literal("server"),
|
type: z.literal("server"),
|
||||||
serverId: z.string(),
|
serverId: z.string(),
|
||||||
}),
|
}),
|
||||||
|
z.object({
|
||||||
|
cronSchedule: z.string(),
|
||||||
|
type: z.literal("schedule"),
|
||||||
|
scheduleId: z.string(),
|
||||||
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export type QueueJob = z.infer<typeof jobQueueSchema>;
|
export type QueueJob = z.infer<typeof jobQueueSchema>;
|
||||||
|
@ -3,8 +3,10 @@ import {
|
|||||||
cleanUpSystemPrune,
|
cleanUpSystemPrune,
|
||||||
cleanUpUnusedImages,
|
cleanUpUnusedImages,
|
||||||
findBackupById,
|
findBackupById,
|
||||||
|
findScheduleById,
|
||||||
findServerById,
|
findServerById,
|
||||||
keepLatestNBackups,
|
keepLatestNBackups,
|
||||||
|
runCommand,
|
||||||
runMariadbBackup,
|
runMariadbBackup,
|
||||||
runMongoBackup,
|
runMongoBackup,
|
||||||
runMySqlBackup,
|
runMySqlBackup,
|
||||||
@ -12,7 +14,7 @@ import {
|
|||||||
runComposeBackup,
|
runComposeBackup,
|
||||||
} from "@dokploy/server";
|
} from "@dokploy/server";
|
||||||
import { db } from "@dokploy/server/dist/db";
|
import { db } from "@dokploy/server/dist/db";
|
||||||
import { backups, server } from "@dokploy/server/dist/db/schema";
|
import { backups, schedules, server } from "@dokploy/server/dist/db/schema";
|
||||||
import { and, eq } from "drizzle-orm";
|
import { and, eq } from "drizzle-orm";
|
||||||
import { logger } from "./logger.js";
|
import { logger } from "./logger.js";
|
||||||
import { scheduleJob } from "./queue.js";
|
import { scheduleJob } from "./queue.js";
|
||||||
@ -75,8 +77,7 @@ export const runJobs = async (job: QueueJob) => {
|
|||||||
}
|
}
|
||||||
await runComposeBackup(compose, backup);
|
await runComposeBackup(compose, backup);
|
||||||
}
|
}
|
||||||
}
|
} else if (job.type === "server") {
|
||||||
if (job.type === "server") {
|
|
||||||
const { serverId } = job;
|
const { serverId } = job;
|
||||||
const server = await findServerById(serverId);
|
const server = await findServerById(serverId);
|
||||||
if (server.serverStatus === "inactive") {
|
if (server.serverStatus === "inactive") {
|
||||||
@ -86,6 +87,12 @@ export const runJobs = async (job: QueueJob) => {
|
|||||||
await cleanUpUnusedImages(serverId);
|
await cleanUpUnusedImages(serverId);
|
||||||
await cleanUpDockerBuilder(serverId);
|
await cleanUpDockerBuilder(serverId);
|
||||||
await cleanUpSystemPrune(serverId);
|
await cleanUpSystemPrune(serverId);
|
||||||
|
} else if (job.type === "schedule") {
|
||||||
|
const { scheduleId } = job;
|
||||||
|
const schedule = await findScheduleById(scheduleId);
|
||||||
|
if (schedule.enabled) {
|
||||||
|
await runCommand(schedule.scheduleId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(error);
|
logger.error(error);
|
||||||
@ -134,4 +141,17 @@ export const initializeJobs = async () => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
logger.info({ Quantity: backupsResult.length }, "Backups Initialized");
|
logger.info({ Quantity: backupsResult.length }, "Backups Initialized");
|
||||||
|
|
||||||
|
const schedulesResult = await db.query.schedules.findMany({
|
||||||
|
where: eq(schedules.enabled, true),
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const schedule of schedulesResult) {
|
||||||
|
scheduleJob({
|
||||||
|
scheduleId: schedule.scheduleId,
|
||||||
|
type: "schedule",
|
||||||
|
cronSchedule: schedule.cronExpression,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
logger.info({ Quantity: schedulesResult.length }, "Schedules Initialized");
|
||||||
};
|
};
|
||||||
|
@ -23,5 +23,6 @@ export const paths = (isServer = false) => {
|
|||||||
CERTIFICATES_PATH: `${DYNAMIC_TRAEFIK_PATH}/certificates`,
|
CERTIFICATES_PATH: `${DYNAMIC_TRAEFIK_PATH}/certificates`,
|
||||||
MONITORING_PATH: `${BASE_PATH}/monitoring`,
|
MONITORING_PATH: `${BASE_PATH}/monitoring`,
|
||||||
REGISTRY_PATH: `${BASE_PATH}/registry`,
|
REGISTRY_PATH: `${BASE_PATH}/registry`,
|
||||||
|
SCHEDULES_PATH: `${BASE_PATH}/schedules`,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
@ -17,6 +17,7 @@ import { sshKeys } from "./ssh-key";
|
|||||||
import { generateAppName } from "./utils";
|
import { generateAppName } from "./utils";
|
||||||
import { backups } from "./backups";
|
import { backups } from "./backups";
|
||||||
|
|
||||||
|
import { schedules } from "./schedule";
|
||||||
export const sourceTypeCompose = pgEnum("sourceTypeCompose", [
|
export const sourceTypeCompose = pgEnum("sourceTypeCompose", [
|
||||||
"git",
|
"git",
|
||||||
"github",
|
"github",
|
||||||
@ -137,6 +138,7 @@ export const composeRelations = relations(compose, ({ one, many }) => ({
|
|||||||
references: [server.serverId],
|
references: [server.serverId],
|
||||||
}),
|
}),
|
||||||
backups: many(backups),
|
backups: many(backups),
|
||||||
|
schedules: many(schedules),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const createSchema = createInsertSchema(compose, {
|
const createSchema = createInsertSchema(compose, {
|
||||||
|
@ -13,7 +13,7 @@ import { applications } from "./application";
|
|||||||
import { compose } from "./compose";
|
import { compose } from "./compose";
|
||||||
import { previewDeployments } from "./preview-deployments";
|
import { previewDeployments } from "./preview-deployments";
|
||||||
import { server } from "./server";
|
import { server } from "./server";
|
||||||
|
import { schedules } from "./schedule";
|
||||||
export const deploymentStatus = pgEnum("deploymentStatus", [
|
export const deploymentStatus = pgEnum("deploymentStatus", [
|
||||||
"running",
|
"running",
|
||||||
"done",
|
"done",
|
||||||
@ -47,7 +47,13 @@ export const deployments = pgTable("deployment", {
|
|||||||
createdAt: text("createdAt")
|
createdAt: text("createdAt")
|
||||||
.notNull()
|
.notNull()
|
||||||
.$defaultFn(() => new Date().toISOString()),
|
.$defaultFn(() => new Date().toISOString()),
|
||||||
|
startedAt: text("startedAt"),
|
||||||
|
finishedAt: text("finishedAt"),
|
||||||
errorMessage: text("errorMessage"),
|
errorMessage: text("errorMessage"),
|
||||||
|
scheduleId: text("scheduleId").references(
|
||||||
|
(): AnyPgColumn => schedules.scheduleId,
|
||||||
|
{ onDelete: "cascade" },
|
||||||
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const deploymentsRelations = relations(deployments, ({ one }) => ({
|
export const deploymentsRelations = relations(deployments, ({ one }) => ({
|
||||||
@ -67,6 +73,10 @@ export const deploymentsRelations = relations(deployments, ({ one }) => ({
|
|||||||
fields: [deployments.previewDeploymentId],
|
fields: [deployments.previewDeploymentId],
|
||||||
references: [previewDeployments.previewDeploymentId],
|
references: [previewDeployments.previewDeploymentId],
|
||||||
}),
|
}),
|
||||||
|
schedule: one(schedules, {
|
||||||
|
fields: [deployments.scheduleId],
|
||||||
|
references: [schedules.scheduleId],
|
||||||
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const schema = createInsertSchema(deployments, {
|
const schema = createInsertSchema(deployments, {
|
||||||
@ -128,6 +138,17 @@ export const apiCreateDeploymentServer = schema
|
|||||||
serverId: z.string().min(1),
|
serverId: z.string().min(1),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const apiCreateDeploymentSchedule = schema
|
||||||
|
.pick({
|
||||||
|
title: true,
|
||||||
|
status: true,
|
||||||
|
logPath: true,
|
||||||
|
description: true,
|
||||||
|
})
|
||||||
|
.extend({
|
||||||
|
scheduleId: z.string().min(1),
|
||||||
|
});
|
||||||
|
|
||||||
export const apiFindAllByApplication = schema
|
export const apiFindAllByApplication = schema
|
||||||
.pick({
|
.pick({
|
||||||
applicationId: true,
|
applicationId: true,
|
||||||
|
@ -31,3 +31,4 @@ export * from "./utils";
|
|||||||
export * from "./preview-deployments";
|
export * from "./preview-deployments";
|
||||||
export * from "./ai";
|
export * from "./ai";
|
||||||
export * from "./account";
|
export * from "./account";
|
||||||
|
export * from "./schedule";
|
||||||
|
83
packages/server/src/db/schema/schedule.ts
Normal file
83
packages/server/src/db/schema/schedule.ts
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
import { relations } from "drizzle-orm";
|
||||||
|
import { boolean, pgEnum, pgTable, text } from "drizzle-orm/pg-core";
|
||||||
|
import { createInsertSchema } from "drizzle-zod";
|
||||||
|
import { nanoid } from "nanoid";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { applications } from "./application";
|
||||||
|
import { deployments } from "./deployment";
|
||||||
|
import { generateAppName } from "./utils";
|
||||||
|
import { compose } from "./compose";
|
||||||
|
import { server } from "./server";
|
||||||
|
import { users_temp } from "./user";
|
||||||
|
export const shellTypes = pgEnum("shellType", ["bash", "sh"]);
|
||||||
|
|
||||||
|
export const scheduleType = pgEnum("scheduleType", [
|
||||||
|
"application",
|
||||||
|
"compose",
|
||||||
|
"server",
|
||||||
|
"dokploy-server",
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const schedules = pgTable("schedule", {
|
||||||
|
scheduleId: text("scheduleId")
|
||||||
|
.notNull()
|
||||||
|
.primaryKey()
|
||||||
|
.$defaultFn(() => nanoid()),
|
||||||
|
name: text("name").notNull(),
|
||||||
|
cronExpression: text("cronExpression").notNull(),
|
||||||
|
appName: text("appName")
|
||||||
|
.notNull()
|
||||||
|
.$defaultFn(() => generateAppName("schedule")),
|
||||||
|
serviceName: text("serviceName"),
|
||||||
|
shellType: shellTypes("shellType").notNull().default("bash"),
|
||||||
|
scheduleType: scheduleType("scheduleType").notNull().default("application"),
|
||||||
|
command: text("command").notNull(),
|
||||||
|
script: text("script"),
|
||||||
|
applicationId: text("applicationId").references(
|
||||||
|
() => applications.applicationId,
|
||||||
|
{
|
||||||
|
onDelete: "cascade",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
composeId: text("composeId").references(() => compose.composeId, {
|
||||||
|
onDelete: "cascade",
|
||||||
|
}),
|
||||||
|
serverId: text("serverId").references(() => server.serverId, {
|
||||||
|
onDelete: "cascade",
|
||||||
|
}),
|
||||||
|
userId: text("userId").references(() => users_temp.id, {
|
||||||
|
onDelete: "cascade",
|
||||||
|
}),
|
||||||
|
enabled: boolean("enabled").notNull().default(true),
|
||||||
|
createdAt: text("createdAt")
|
||||||
|
.notNull()
|
||||||
|
.$defaultFn(() => new Date().toISOString()),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type Schedule = typeof schedules.$inferSelect;
|
||||||
|
|
||||||
|
export const schedulesRelations = relations(schedules, ({ one, many }) => ({
|
||||||
|
application: one(applications, {
|
||||||
|
fields: [schedules.applicationId],
|
||||||
|
references: [applications.applicationId],
|
||||||
|
}),
|
||||||
|
compose: one(compose, {
|
||||||
|
fields: [schedules.composeId],
|
||||||
|
references: [compose.composeId],
|
||||||
|
}),
|
||||||
|
server: one(server, {
|
||||||
|
fields: [schedules.serverId],
|
||||||
|
references: [server.serverId],
|
||||||
|
}),
|
||||||
|
user: one(users_temp, {
|
||||||
|
fields: [schedules.userId],
|
||||||
|
references: [users_temp.id],
|
||||||
|
}),
|
||||||
|
deployments: many(deployments),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const createScheduleSchema = createInsertSchema(schedules);
|
||||||
|
|
||||||
|
export const updateScheduleSchema = createScheduleSchema.extend({
|
||||||
|
scheduleId: z.string().min(1),
|
||||||
|
});
|
@ -22,7 +22,7 @@ import { postgres } from "./postgres";
|
|||||||
import { redis } from "./redis";
|
import { redis } from "./redis";
|
||||||
import { sshKeys } from "./ssh-key";
|
import { sshKeys } from "./ssh-key";
|
||||||
import { generateAppName } from "./utils";
|
import { generateAppName } from "./utils";
|
||||||
|
import { schedules } from "./schedule";
|
||||||
export const serverStatus = pgEnum("serverStatus", ["active", "inactive"]);
|
export const serverStatus = pgEnum("serverStatus", ["active", "inactive"]);
|
||||||
|
|
||||||
export const server = pgTable("server", {
|
export const server = pgTable("server", {
|
||||||
@ -114,6 +114,7 @@ export const serverRelations = relations(server, ({ one, many }) => ({
|
|||||||
fields: [server.organizationId],
|
fields: [server.organizationId],
|
||||||
references: [organization.id],
|
references: [organization.id],
|
||||||
}),
|
}),
|
||||||
|
schedules: many(schedules),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const createSchema = createInsertSchema(server, {
|
const createSchema = createInsertSchema(server, {
|
||||||
|
@ -14,6 +14,7 @@ import { account, apikey, organization } from "./account";
|
|||||||
import { projects } from "./project";
|
import { projects } from "./project";
|
||||||
import { certificateType } from "./shared";
|
import { certificateType } from "./shared";
|
||||||
import { backups } from "./backups";
|
import { backups } from "./backups";
|
||||||
|
import { schedules } from "./schedule";
|
||||||
/**
|
/**
|
||||||
* This is an example of how to use the multi-project schema feature of Drizzle ORM. Use the same
|
* This is an example of how to use the multi-project schema feature of Drizzle ORM. Use the same
|
||||||
* database instance for multiple projects.
|
* database instance for multiple projects.
|
||||||
@ -127,6 +128,7 @@ export const usersRelations = relations(users_temp, ({ one, many }) => ({
|
|||||||
projects: many(projects),
|
projects: many(projects),
|
||||||
apiKeys: many(apikey),
|
apiKeys: many(apikey),
|
||||||
backups: many(backups),
|
backups: many(backups),
|
||||||
|
schedules: many(schedules),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const createSchema = createInsertSchema(users_temp, {
|
const createSchema = createInsertSchema(users_temp, {
|
||||||
|
@ -30,6 +30,7 @@ export * from "./services/github";
|
|||||||
export * from "./services/gitlab";
|
export * from "./services/gitlab";
|
||||||
export * from "./services/gitea";
|
export * from "./services/gitea";
|
||||||
export * from "./services/server";
|
export * from "./services/server";
|
||||||
|
export * from "./services/schedule";
|
||||||
export * from "./services/application";
|
export * from "./services/application";
|
||||||
export * from "./utils/databases/rebuild";
|
export * from "./utils/databases/rebuild";
|
||||||
export * from "./setup/config-paths";
|
export * from "./setup/config-paths";
|
||||||
@ -127,3 +128,6 @@ export {
|
|||||||
stopLogCleanup,
|
stopLogCleanup,
|
||||||
getLogCleanupStatus,
|
getLogCleanupStatus,
|
||||||
} from "./utils/access-log/handler";
|
} from "./utils/access-log/handler";
|
||||||
|
|
||||||
|
export * from "./utils/schedules/utils";
|
||||||
|
export * from "./utils/schedules/index";
|
||||||
|
@ -6,6 +6,7 @@ import {
|
|||||||
type apiCreateDeployment,
|
type apiCreateDeployment,
|
||||||
type apiCreateDeploymentCompose,
|
type apiCreateDeploymentCompose,
|
||||||
type apiCreateDeploymentPreview,
|
type apiCreateDeploymentPreview,
|
||||||
|
type apiCreateDeploymentSchedule,
|
||||||
type apiCreateDeploymentServer,
|
type apiCreateDeploymentServer,
|
||||||
deployments,
|
deployments,
|
||||||
} from "@dokploy/server/db/schema";
|
} from "@dokploy/server/db/schema";
|
||||||
@ -27,6 +28,7 @@ import {
|
|||||||
findPreviewDeploymentById,
|
findPreviewDeploymentById,
|
||||||
updatePreviewDeployment,
|
updatePreviewDeployment,
|
||||||
} from "./preview-deployment";
|
} from "./preview-deployment";
|
||||||
|
import { findScheduleById } from "./schedule";
|
||||||
|
|
||||||
export type Deployment = typeof deployments.$inferSelect;
|
export type Deployment = typeof deployments.$inferSelect;
|
||||||
|
|
||||||
@ -57,6 +59,7 @@ export const createDeployment = async (
|
|||||||
try {
|
try {
|
||||||
await removeLastTenDeployments(
|
await removeLastTenDeployments(
|
||||||
deployment.applicationId,
|
deployment.applicationId,
|
||||||
|
"application",
|
||||||
application.serverId,
|
application.serverId,
|
||||||
);
|
);
|
||||||
const { LOGS_PATH } = paths(!!application.serverId);
|
const { LOGS_PATH } = paths(!!application.serverId);
|
||||||
@ -88,6 +91,7 @@ export const createDeployment = async (
|
|||||||
status: "running",
|
status: "running",
|
||||||
logPath: logFilePath,
|
logPath: logFilePath,
|
||||||
description: deployment.description || "",
|
description: deployment.description || "",
|
||||||
|
startedAt: new Date().toISOString(),
|
||||||
})
|
})
|
||||||
.returning();
|
.returning();
|
||||||
if (deploymentCreate.length === 0 || !deploymentCreate[0]) {
|
if (deploymentCreate.length === 0 || !deploymentCreate[0]) {
|
||||||
@ -107,6 +111,8 @@ export const createDeployment = async (
|
|||||||
logPath: "",
|
logPath: "",
|
||||||
description: deployment.description || "",
|
description: deployment.description || "",
|
||||||
errorMessage: `An error have occured: ${error instanceof Error ? error.message : error}`,
|
errorMessage: `An error have occured: ${error instanceof Error ? error.message : error}`,
|
||||||
|
startedAt: new Date().toISOString(),
|
||||||
|
finishedAt: new Date().toISOString(),
|
||||||
})
|
})
|
||||||
.returning();
|
.returning();
|
||||||
await updateApplicationStatus(application.applicationId, "error");
|
await updateApplicationStatus(application.applicationId, "error");
|
||||||
@ -128,8 +134,9 @@ export const createDeploymentPreview = async (
|
|||||||
deployment.previewDeploymentId,
|
deployment.previewDeploymentId,
|
||||||
);
|
);
|
||||||
try {
|
try {
|
||||||
await removeLastTenPreviewDeploymenById(
|
await removeLastTenDeployments(
|
||||||
deployment.previewDeploymentId,
|
deployment.previewDeploymentId,
|
||||||
|
"previewDeployment",
|
||||||
previewDeployment?.application?.serverId,
|
previewDeployment?.application?.serverId,
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -165,6 +172,7 @@ export const createDeploymentPreview = async (
|
|||||||
logPath: logFilePath,
|
logPath: logFilePath,
|
||||||
description: deployment.description || "",
|
description: deployment.description || "",
|
||||||
previewDeploymentId: deployment.previewDeploymentId,
|
previewDeploymentId: deployment.previewDeploymentId,
|
||||||
|
startedAt: new Date().toISOString(),
|
||||||
})
|
})
|
||||||
.returning();
|
.returning();
|
||||||
if (deploymentCreate.length === 0 || !deploymentCreate[0]) {
|
if (deploymentCreate.length === 0 || !deploymentCreate[0]) {
|
||||||
@ -184,6 +192,8 @@ export const createDeploymentPreview = async (
|
|||||||
logPath: "",
|
logPath: "",
|
||||||
description: deployment.description || "",
|
description: deployment.description || "",
|
||||||
errorMessage: `An error have occured: ${error instanceof Error ? error.message : error}`,
|
errorMessage: `An error have occured: ${error instanceof Error ? error.message : error}`,
|
||||||
|
startedAt: new Date().toISOString(),
|
||||||
|
finishedAt: new Date().toISOString(),
|
||||||
})
|
})
|
||||||
.returning();
|
.returning();
|
||||||
await updatePreviewDeployment(deployment.previewDeploymentId, {
|
await updatePreviewDeployment(deployment.previewDeploymentId, {
|
||||||
@ -205,8 +215,9 @@ export const createDeploymentCompose = async (
|
|||||||
) => {
|
) => {
|
||||||
const compose = await findComposeById(deployment.composeId);
|
const compose = await findComposeById(deployment.composeId);
|
||||||
try {
|
try {
|
||||||
await removeLastTenComposeDeployments(
|
await removeLastTenDeployments(
|
||||||
deployment.composeId,
|
deployment.composeId,
|
||||||
|
"compose",
|
||||||
compose.serverId,
|
compose.serverId,
|
||||||
);
|
);
|
||||||
const { LOGS_PATH } = paths(!!compose.serverId);
|
const { LOGS_PATH } = paths(!!compose.serverId);
|
||||||
@ -238,6 +249,7 @@ echo "Initializing deployment" >> ${logFilePath};
|
|||||||
description: deployment.description || "",
|
description: deployment.description || "",
|
||||||
status: "running",
|
status: "running",
|
||||||
logPath: logFilePath,
|
logPath: logFilePath,
|
||||||
|
startedAt: new Date().toISOString(),
|
||||||
})
|
})
|
||||||
.returning();
|
.returning();
|
||||||
if (deploymentCreate.length === 0 || !deploymentCreate[0]) {
|
if (deploymentCreate.length === 0 || !deploymentCreate[0]) {
|
||||||
@ -257,6 +269,8 @@ echo "Initializing deployment" >> ${logFilePath};
|
|||||||
logPath: "",
|
logPath: "",
|
||||||
description: deployment.description || "",
|
description: deployment.description || "",
|
||||||
errorMessage: `An error have occured: ${error instanceof Error ? error.message : error}`,
|
errorMessage: `An error have occured: ${error instanceof Error ? error.message : error}`,
|
||||||
|
startedAt: new Date().toISOString(),
|
||||||
|
finishedAt: new Date().toISOString(),
|
||||||
})
|
})
|
||||||
.returning();
|
.returning();
|
||||||
await updateCompose(compose.composeId, {
|
await updateCompose(compose.composeId, {
|
||||||
@ -270,6 +284,82 @@ echo "Initializing deployment" >> ${logFilePath};
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const createDeploymentSchedule = async (
|
||||||
|
deployment: Omit<
|
||||||
|
typeof apiCreateDeploymentSchedule._type,
|
||||||
|
"deploymentId" | "createdAt" | "status" | "logPath"
|
||||||
|
>,
|
||||||
|
) => {
|
||||||
|
const schedule = await findScheduleById(deployment.scheduleId);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const serverId =
|
||||||
|
schedule.application?.serverId ||
|
||||||
|
schedule.compose?.serverId ||
|
||||||
|
schedule.server?.serverId;
|
||||||
|
await removeLastTenDeployments(deployment.scheduleId, "schedule", serverId);
|
||||||
|
const { SCHEDULES_PATH } = paths(!!serverId);
|
||||||
|
const formattedDateTime = format(new Date(), "yyyy-MM-dd:HH:mm:ss");
|
||||||
|
const fileName = `${schedule.appName}-${formattedDateTime}.log`;
|
||||||
|
const logFilePath = path.join(SCHEDULES_PATH, schedule.appName, fileName);
|
||||||
|
|
||||||
|
if (serverId) {
|
||||||
|
const server = await findServerById(serverId);
|
||||||
|
|
||||||
|
const command = `
|
||||||
|
mkdir -p ${SCHEDULES_PATH}/${schedule.appName};
|
||||||
|
echo "Initializing schedule" >> ${logFilePath};
|
||||||
|
`;
|
||||||
|
|
||||||
|
await execAsyncRemote(server.serverId, command);
|
||||||
|
} else {
|
||||||
|
await fsPromises.mkdir(path.join(SCHEDULES_PATH, schedule.appName), {
|
||||||
|
recursive: true,
|
||||||
|
});
|
||||||
|
await fsPromises.writeFile(logFilePath, "Initializing schedule\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
const deploymentCreate = await db
|
||||||
|
.insert(deployments)
|
||||||
|
.values({
|
||||||
|
scheduleId: deployment.scheduleId,
|
||||||
|
title: deployment.title || "Deployment",
|
||||||
|
status: "running",
|
||||||
|
logPath: logFilePath,
|
||||||
|
description: deployment.description || "",
|
||||||
|
startedAt: new Date().toISOString(),
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
if (deploymentCreate.length === 0 || !deploymentCreate[0]) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "BAD_REQUEST",
|
||||||
|
message: "Error creating the deployment",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return deploymentCreate[0];
|
||||||
|
} catch (error) {
|
||||||
|
console.log(error);
|
||||||
|
await db
|
||||||
|
.insert(deployments)
|
||||||
|
.values({
|
||||||
|
scheduleId: deployment.scheduleId,
|
||||||
|
title: deployment.title || "Deployment",
|
||||||
|
status: "error",
|
||||||
|
logPath: "",
|
||||||
|
description: deployment.description || "",
|
||||||
|
errorMessage: `An error have occured: ${error instanceof Error ? error.message : error}`,
|
||||||
|
startedAt: new Date().toISOString(),
|
||||||
|
finishedAt: new Date().toISOString(),
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "BAD_REQUEST",
|
||||||
|
message: "Error creating the deployment",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
export const removeDeployment = async (deploymentId: string) => {
|
export const removeDeployment = async (deploymentId: string) => {
|
||||||
try {
|
try {
|
||||||
const deployment = await db
|
const deployment = await db
|
||||||
@ -296,109 +386,15 @@ export const removeDeploymentsByApplicationId = async (
|
|||||||
.returning();
|
.returning();
|
||||||
};
|
};
|
||||||
|
|
||||||
const removeLastTenDeployments = async (
|
const getDeploymentsByType = async (
|
||||||
applicationId: string,
|
id: string,
|
||||||
serverId: string | null,
|
type: "application" | "compose" | "server" | "schedule" | "previewDeployment",
|
||||||
) => {
|
) => {
|
||||||
const deploymentList = await db.query.deployments.findMany({
|
const deploymentList = await db.query.deployments.findMany({
|
||||||
where: eq(deployments.applicationId, applicationId),
|
where: eq(deployments[`${type}Id`], id),
|
||||||
orderBy: desc(deployments.createdAt),
|
orderBy: desc(deployments.createdAt),
|
||||||
});
|
});
|
||||||
|
return deploymentList;
|
||||||
if (deploymentList.length > 10) {
|
|
||||||
const deploymentsToDelete = deploymentList.slice(9);
|
|
||||||
if (serverId) {
|
|
||||||
let command = "";
|
|
||||||
for (const oldDeployment of deploymentsToDelete) {
|
|
||||||
const logPath = path.join(oldDeployment.logPath);
|
|
||||||
|
|
||||||
command += `
|
|
||||||
rm -rf ${logPath};
|
|
||||||
`;
|
|
||||||
await removeDeployment(oldDeployment.deploymentId);
|
|
||||||
}
|
|
||||||
|
|
||||||
await execAsyncRemote(serverId, command);
|
|
||||||
} else {
|
|
||||||
for (const oldDeployment of deploymentsToDelete) {
|
|
||||||
const logPath = path.join(oldDeployment.logPath);
|
|
||||||
if (existsSync(logPath)) {
|
|
||||||
await fsPromises.unlink(logPath);
|
|
||||||
}
|
|
||||||
await removeDeployment(oldDeployment.deploymentId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const removeLastTenComposeDeployments = async (
|
|
||||||
composeId: string,
|
|
||||||
serverId: string | null,
|
|
||||||
) => {
|
|
||||||
const deploymentList = await db.query.deployments.findMany({
|
|
||||||
where: eq(deployments.composeId, composeId),
|
|
||||||
orderBy: desc(deployments.createdAt),
|
|
||||||
});
|
|
||||||
if (deploymentList.length > 10) {
|
|
||||||
if (serverId) {
|
|
||||||
let command = "";
|
|
||||||
const deploymentsToDelete = deploymentList.slice(9);
|
|
||||||
for (const oldDeployment of deploymentsToDelete) {
|
|
||||||
const logPath = path.join(oldDeployment.logPath);
|
|
||||||
|
|
||||||
command += `
|
|
||||||
rm -rf ${logPath};
|
|
||||||
`;
|
|
||||||
await removeDeployment(oldDeployment.deploymentId);
|
|
||||||
}
|
|
||||||
|
|
||||||
await execAsyncRemote(serverId, command);
|
|
||||||
} else {
|
|
||||||
const deploymentsToDelete = deploymentList.slice(9);
|
|
||||||
for (const oldDeployment of deploymentsToDelete) {
|
|
||||||
const logPath = path.join(oldDeployment.logPath);
|
|
||||||
if (existsSync(logPath)) {
|
|
||||||
await fsPromises.unlink(logPath);
|
|
||||||
}
|
|
||||||
await removeDeployment(oldDeployment.deploymentId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const removeLastTenPreviewDeploymenById = async (
|
|
||||||
previewDeploymentId: string,
|
|
||||||
serverId: string | null,
|
|
||||||
) => {
|
|
||||||
const deploymentList = await db.query.deployments.findMany({
|
|
||||||
where: eq(deployments.previewDeploymentId, previewDeploymentId),
|
|
||||||
orderBy: desc(deployments.createdAt),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (deploymentList.length > 10) {
|
|
||||||
const deploymentsToDelete = deploymentList.slice(9);
|
|
||||||
if (serverId) {
|
|
||||||
let command = "";
|
|
||||||
for (const oldDeployment of deploymentsToDelete) {
|
|
||||||
const logPath = path.join(oldDeployment.logPath);
|
|
||||||
|
|
||||||
command += `
|
|
||||||
rm -rf ${logPath};
|
|
||||||
`;
|
|
||||||
await removeDeployment(oldDeployment.deploymentId);
|
|
||||||
}
|
|
||||||
|
|
||||||
await execAsyncRemote(serverId, command);
|
|
||||||
} else {
|
|
||||||
for (const oldDeployment of deploymentsToDelete) {
|
|
||||||
const logPath = path.join(oldDeployment.logPath);
|
|
||||||
if (existsSync(logPath)) {
|
|
||||||
await fsPromises.unlink(logPath);
|
|
||||||
}
|
|
||||||
await removeDeployment(oldDeployment.deploymentId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const removeDeployments = async (application: Application) => {
|
export const removeDeployments = async (application: Application) => {
|
||||||
@ -413,6 +409,38 @@ export const removeDeployments = async (application: Application) => {
|
|||||||
await removeDeploymentsByApplicationId(applicationId);
|
await removeDeploymentsByApplicationId(applicationId);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const removeLastTenDeployments = async (
|
||||||
|
id: string,
|
||||||
|
type: "application" | "compose" | "server" | "schedule" | "previewDeployment",
|
||||||
|
serverId?: string | null,
|
||||||
|
) => {
|
||||||
|
const deploymentList = await getDeploymentsByType(id, type);
|
||||||
|
if (deploymentList.length > 10) {
|
||||||
|
const deploymentsToDelete = deploymentList.slice(10);
|
||||||
|
if (serverId) {
|
||||||
|
let command = "";
|
||||||
|
for (const oldDeployment of deploymentsToDelete) {
|
||||||
|
const logPath = path.join(oldDeployment.logPath);
|
||||||
|
|
||||||
|
command += `
|
||||||
|
rm -rf ${logPath};
|
||||||
|
`;
|
||||||
|
await removeDeployment(oldDeployment.deploymentId);
|
||||||
|
}
|
||||||
|
|
||||||
|
await execAsyncRemote(serverId, command);
|
||||||
|
} else {
|
||||||
|
for (const oldDeployment of deploymentsToDelete) {
|
||||||
|
const logPath = path.join(oldDeployment.logPath);
|
||||||
|
if (existsSync(logPath)) {
|
||||||
|
await fsPromises.unlink(logPath);
|
||||||
|
}
|
||||||
|
await removeDeployment(oldDeployment.deploymentId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
export const removeDeploymentsByPreviewDeploymentId = async (
|
export const removeDeploymentsByPreviewDeploymentId = async (
|
||||||
previewDeployment: PreviewDeployment,
|
previewDeployment: PreviewDeployment,
|
||||||
serverId: string | null,
|
serverId: string | null,
|
||||||
@ -494,6 +522,10 @@ export const updateDeploymentStatus = async (
|
|||||||
.update(deployments)
|
.update(deployments)
|
||||||
.set({
|
.set({
|
||||||
status: deploymentStatus,
|
status: deploymentStatus,
|
||||||
|
finishedAt:
|
||||||
|
deploymentStatus === "done" || deploymentStatus === "error"
|
||||||
|
? new Date().toISOString()
|
||||||
|
: null,
|
||||||
})
|
})
|
||||||
.where(eq(deployments.deploymentId, deploymentId))
|
.where(eq(deployments.deploymentId, deploymentId))
|
||||||
.returning();
|
.returning();
|
||||||
|
126
packages/server/src/services/schedule.ts
Normal file
126
packages/server/src/services/schedule.ts
Normal file
@ -0,0 +1,126 @@
|
|||||||
|
import { type Schedule, schedules } from "../db/schema/schedule";
|
||||||
|
import { db } from "../db";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import { TRPCError } from "@trpc/server";
|
||||||
|
import type { z } from "zod";
|
||||||
|
import type {
|
||||||
|
createScheduleSchema,
|
||||||
|
updateScheduleSchema,
|
||||||
|
} from "../db/schema/schedule";
|
||||||
|
import { execAsync, execAsyncRemote } from "../utils/process/execAsync";
|
||||||
|
import { paths } from "../constants";
|
||||||
|
import path from "node:path";
|
||||||
|
import { encodeBase64 } from "../utils/docker/utils";
|
||||||
|
|
||||||
|
export type ScheduleExtended = Awaited<ReturnType<typeof findScheduleById>>;
|
||||||
|
|
||||||
|
export const createSchedule = async (
|
||||||
|
input: z.infer<typeof createScheduleSchema>,
|
||||||
|
) => {
|
||||||
|
const { scheduleId, ...rest } = input;
|
||||||
|
const [newSchedule] = await db.insert(schedules).values(rest).returning();
|
||||||
|
|
||||||
|
if (
|
||||||
|
newSchedule &&
|
||||||
|
(newSchedule.scheduleType === "dokploy-server" ||
|
||||||
|
newSchedule.scheduleType === "server")
|
||||||
|
) {
|
||||||
|
await handleScript(newSchedule);
|
||||||
|
}
|
||||||
|
|
||||||
|
return newSchedule;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const findScheduleById = async (scheduleId: string) => {
|
||||||
|
const schedule = await db.query.schedules.findFirst({
|
||||||
|
where: eq(schedules.scheduleId, scheduleId),
|
||||||
|
with: {
|
||||||
|
application: true,
|
||||||
|
compose: true,
|
||||||
|
server: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!schedule) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "NOT_FOUND",
|
||||||
|
message: "Schedule not found",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return schedule;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteSchedule = async (scheduleId: string) => {
|
||||||
|
const schedule = await findScheduleById(scheduleId);
|
||||||
|
const serverId =
|
||||||
|
schedule?.serverId ||
|
||||||
|
schedule?.application?.serverId ||
|
||||||
|
schedule?.compose?.serverId;
|
||||||
|
const { SCHEDULES_PATH } = paths(!!serverId);
|
||||||
|
|
||||||
|
const fullPath = path.join(SCHEDULES_PATH, schedule?.appName || "");
|
||||||
|
const command = `rm -rf ${fullPath}`;
|
||||||
|
if (serverId) {
|
||||||
|
await execAsyncRemote(serverId, command);
|
||||||
|
} else {
|
||||||
|
await execAsync(command);
|
||||||
|
}
|
||||||
|
|
||||||
|
const scheduleResult = await db
|
||||||
|
.delete(schedules)
|
||||||
|
.where(eq(schedules.scheduleId, scheduleId));
|
||||||
|
if (!scheduleResult) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "NOT_FOUND",
|
||||||
|
message: "Schedule not found",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updateSchedule = async (
|
||||||
|
input: z.infer<typeof updateScheduleSchema>,
|
||||||
|
) => {
|
||||||
|
const { scheduleId, ...rest } = input;
|
||||||
|
const [updatedSchedule] = await db
|
||||||
|
.update(schedules)
|
||||||
|
.set(rest)
|
||||||
|
.where(eq(schedules.scheduleId, scheduleId))
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
if (!updatedSchedule) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "NOT_FOUND",
|
||||||
|
message: "Schedule not found",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
updatedSchedule?.scheduleType === "dokploy-server" ||
|
||||||
|
updatedSchedule?.scheduleType === "server"
|
||||||
|
) {
|
||||||
|
await handleScript(updatedSchedule);
|
||||||
|
}
|
||||||
|
|
||||||
|
return updatedSchedule;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleScript = async (schedule: Schedule) => {
|
||||||
|
const { SCHEDULES_PATH } = paths(!!schedule?.serverId);
|
||||||
|
const fullPath = path.join(SCHEDULES_PATH, schedule?.appName || "");
|
||||||
|
const encodedContent = encodeBase64(schedule?.script || "");
|
||||||
|
const script = `
|
||||||
|
mkdir -p ${fullPath}
|
||||||
|
rm -f ${fullPath}/script.sh
|
||||||
|
touch ${fullPath}/script.sh
|
||||||
|
chmod +x ${fullPath}/script.sh
|
||||||
|
echo "${encodedContent}" | base64 -d > ${fullPath}/script.sh
|
||||||
|
`;
|
||||||
|
|
||||||
|
if (schedule?.scheduleType === "dokploy-server") {
|
||||||
|
await execAsync(script);
|
||||||
|
} else if (schedule?.scheduleType === "server") {
|
||||||
|
await execAsyncRemote(schedule?.serverId || "", script);
|
||||||
|
}
|
||||||
|
};
|
@ -18,6 +18,7 @@ export const setupDirectories = () => {
|
|||||||
MAIN_TRAEFIK_PATH,
|
MAIN_TRAEFIK_PATH,
|
||||||
MONITORING_PATH,
|
MONITORING_PATH,
|
||||||
SSH_PATH,
|
SSH_PATH,
|
||||||
|
SCHEDULES_PATH,
|
||||||
} = paths();
|
} = paths();
|
||||||
const directories = [
|
const directories = [
|
||||||
BASE_PATH,
|
BASE_PATH,
|
||||||
@ -28,6 +29,7 @@ export const setupDirectories = () => {
|
|||||||
SSH_PATH,
|
SSH_PATH,
|
||||||
CERTIFICATES_PATH,
|
CERTIFICATES_PATH,
|
||||||
MONITORING_PATH,
|
MONITORING_PATH,
|
||||||
|
SCHEDULES_PATH,
|
||||||
];
|
];
|
||||||
|
|
||||||
for (const dir of directories) {
|
for (const dir of directories) {
|
||||||
|
@ -13,6 +13,7 @@ import type { RedisNested } from "../databases/redis";
|
|||||||
import { execAsync, execAsyncRemote } from "../process/execAsync";
|
import { execAsync, execAsyncRemote } from "../process/execAsync";
|
||||||
import { spawnAsync } from "../process/spawnAsync";
|
import { spawnAsync } from "../process/spawnAsync";
|
||||||
import { getRemoteDocker } from "../servers/remote-docker";
|
import { getRemoteDocker } from "../servers/remote-docker";
|
||||||
|
import type { Compose } from "@dokploy/server/services/compose";
|
||||||
|
|
||||||
interface RegistryAuth {
|
interface RegistryAuth {
|
||||||
username: string;
|
username: string;
|
||||||
@ -541,3 +542,67 @@ export const getRemoteServiceContainer = async (
|
|||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const getServiceContainerIV2 = async (
|
||||||
|
appName: string,
|
||||||
|
serverId?: string | null,
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
const filter = {
|
||||||
|
status: ["running"],
|
||||||
|
label: [`com.docker.swarm.service.name=${appName}`],
|
||||||
|
};
|
||||||
|
const remoteDocker = await getRemoteDocker(serverId);
|
||||||
|
const containers = await remoteDocker.listContainers({
|
||||||
|
filters: JSON.stringify(filter),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (containers.length === 0 || !containers[0]) {
|
||||||
|
throw new Error(`No container found with name: ${appName}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const container = containers[0];
|
||||||
|
return container;
|
||||||
|
} catch (error) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getComposeContainer = async (
|
||||||
|
compose: Compose,
|
||||||
|
serviceName: string,
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
const { appName, composeType, serverId } = compose;
|
||||||
|
// 1. Determine the correct labels based on composeType
|
||||||
|
const labels: string[] = [];
|
||||||
|
if (composeType === "stack") {
|
||||||
|
// Labels for Docker Swarm stack services
|
||||||
|
labels.push(`com.docker.stack.namespace=${appName}`);
|
||||||
|
labels.push(`com.docker.swarm.service.name=${appName}_${serviceName}`);
|
||||||
|
} else {
|
||||||
|
// Labels for Docker Compose projects (default)
|
||||||
|
labels.push(`com.docker.compose.project=${appName}`);
|
||||||
|
labels.push(`com.docker.compose.service=${serviceName}`);
|
||||||
|
}
|
||||||
|
const filter = {
|
||||||
|
status: ["running"],
|
||||||
|
label: labels,
|
||||||
|
};
|
||||||
|
|
||||||
|
const remoteDocker = await getRemoteDocker(serverId);
|
||||||
|
const containers = await remoteDocker.listContainers({
|
||||||
|
filters: JSON.stringify(filter),
|
||||||
|
limit: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (containers.length === 0 || !containers[0]) {
|
||||||
|
throw new Error(`No container found with name: ${appName}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const container = containers[0];
|
||||||
|
return container;
|
||||||
|
} catch (error) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
28
packages/server/src/utils/schedules/index.ts
Normal file
28
packages/server/src/utils/schedules/index.ts
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
import { db } from "../../db/index";
|
||||||
|
import { schedules } from "@dokploy/server/db/schema";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import { scheduleJob } from "./utils";
|
||||||
|
|
||||||
|
export const initSchedules = async () => {
|
||||||
|
try {
|
||||||
|
const schedulesResult = await db.query.schedules.findMany({
|
||||||
|
where: eq(schedules.enabled, true),
|
||||||
|
with: {
|
||||||
|
server: true,
|
||||||
|
application: true,
|
||||||
|
compose: true,
|
||||||
|
user: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`Initializing ${schedulesResult.length} schedules`);
|
||||||
|
for (const schedule of schedulesResult) {
|
||||||
|
scheduleJob(schedule);
|
||||||
|
console.log(
|
||||||
|
`Initialized schedule: ${schedule.name} ${schedule.scheduleType} ✅`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.log(`Error initializing schedules: ${error}`);
|
||||||
|
}
|
||||||
|
};
|
149
packages/server/src/utils/schedules/utils.ts
Normal file
149
packages/server/src/utils/schedules/utils.ts
Normal file
@ -0,0 +1,149 @@
|
|||||||
|
import type { Schedule } from "@dokploy/server/db/schema/schedule";
|
||||||
|
import { findScheduleById } from "@dokploy/server/services/schedule";
|
||||||
|
import { scheduledJobs, scheduleJob as scheduleJobNode } from "node-schedule";
|
||||||
|
import { getComposeContainer, getServiceContainerIV2 } from "../docker/utils";
|
||||||
|
import { execAsyncRemote } from "../process/execAsync";
|
||||||
|
import { spawnAsync } from "../process/spawnAsync";
|
||||||
|
import { createDeploymentSchedule } from "@dokploy/server/services/deployment";
|
||||||
|
import { createWriteStream } from "node:fs";
|
||||||
|
import { updateDeploymentStatus } from "@dokploy/server/services/deployment";
|
||||||
|
import { paths } from "@dokploy/server/constants";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
export const scheduleJob = (schedule: Schedule) => {
|
||||||
|
const { cronExpression, scheduleId } = schedule;
|
||||||
|
|
||||||
|
scheduleJobNode(scheduleId, cronExpression, async () => {
|
||||||
|
await runCommand(scheduleId);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const removeScheduleJob = (scheduleId: string) => {
|
||||||
|
const currentJob = scheduledJobs[scheduleId];
|
||||||
|
currentJob?.cancel();
|
||||||
|
};
|
||||||
|
|
||||||
|
export const runCommand = async (scheduleId: string) => {
|
||||||
|
const {
|
||||||
|
application,
|
||||||
|
command,
|
||||||
|
shellType,
|
||||||
|
scheduleType,
|
||||||
|
compose,
|
||||||
|
serviceName,
|
||||||
|
appName,
|
||||||
|
serverId,
|
||||||
|
} = await findScheduleById(scheduleId);
|
||||||
|
|
||||||
|
const deployment = await createDeploymentSchedule({
|
||||||
|
scheduleId,
|
||||||
|
title: "Schedule",
|
||||||
|
description: "Schedule",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (scheduleType === "application" || scheduleType === "compose") {
|
||||||
|
let containerId = "";
|
||||||
|
let serverId = "";
|
||||||
|
if (scheduleType === "application" && application) {
|
||||||
|
const container = await getServiceContainerIV2(
|
||||||
|
application.appName,
|
||||||
|
application.serverId,
|
||||||
|
);
|
||||||
|
containerId = container.Id;
|
||||||
|
serverId = application.serverId || "";
|
||||||
|
}
|
||||||
|
if (scheduleType === "compose" && compose) {
|
||||||
|
const container = await getComposeContainer(compose, serviceName || "");
|
||||||
|
containerId = container.Id;
|
||||||
|
serverId = compose.serverId || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (serverId) {
|
||||||
|
try {
|
||||||
|
await execAsyncRemote(
|
||||||
|
serverId,
|
||||||
|
`
|
||||||
|
set -e
|
||||||
|
echo "Running command: docker exec ${containerId} ${shellType} -c \"${command}\"" >> ${deployment.logPath};
|
||||||
|
docker exec ${containerId} ${shellType} -c "${command}" >> ${deployment.logPath} 2>> ${deployment.logPath} || {
|
||||||
|
echo "❌ Command failed" >> ${deployment.logPath};
|
||||||
|
exit 1;
|
||||||
|
}
|
||||||
|
echo "✅ Command executed successfully" >> ${deployment.logPath};
|
||||||
|
`,
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
await updateDeploymentStatus(deployment.deploymentId, "error");
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const writeStream = createWriteStream(deployment.logPath, { flags: "a" });
|
||||||
|
|
||||||
|
try {
|
||||||
|
writeStream.write(
|
||||||
|
`docker exec ${containerId} ${shellType} -c "${command}"\n`,
|
||||||
|
);
|
||||||
|
await spawnAsync(
|
||||||
|
"docker",
|
||||||
|
["exec", containerId, shellType, "-c", command],
|
||||||
|
(data) => {
|
||||||
|
if (writeStream.writable) {
|
||||||
|
writeStream.write(data);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
writeStream.write("✅ Command executed successfully\n");
|
||||||
|
} catch (error) {
|
||||||
|
writeStream.write("❌ Command failed\n");
|
||||||
|
writeStream.write(
|
||||||
|
error instanceof Error ? error.message : "Unknown error",
|
||||||
|
);
|
||||||
|
writeStream.end();
|
||||||
|
await updateDeploymentStatus(deployment.deploymentId, "error");
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (scheduleType === "dokploy-server") {
|
||||||
|
try {
|
||||||
|
const writeStream = createWriteStream(deployment.logPath, { flags: "a" });
|
||||||
|
const { SCHEDULES_PATH } = paths();
|
||||||
|
const fullPath = path.join(SCHEDULES_PATH, appName || "");
|
||||||
|
|
||||||
|
await spawnAsync(
|
||||||
|
"bash",
|
||||||
|
["-c", "./script.sh"],
|
||||||
|
(data) => {
|
||||||
|
if (writeStream.writable) {
|
||||||
|
writeStream.write(data);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
cwd: fullPath,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
await updateDeploymentStatus(deployment.deploymentId, "error");
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
} else if (scheduleType === "server") {
|
||||||
|
try {
|
||||||
|
const { SCHEDULES_PATH } = paths(true);
|
||||||
|
const fullPath = path.join(SCHEDULES_PATH, appName || "");
|
||||||
|
const command = `
|
||||||
|
set -e
|
||||||
|
echo "Running script" >> ${deployment.logPath};
|
||||||
|
bash -c ${fullPath}/script.sh >> ${deployment.logPath} 2>> ${deployment.logPath} || {
|
||||||
|
echo "❌ Command failed" >> ${deployment.logPath};
|
||||||
|
exit 1;
|
||||||
|
}
|
||||||
|
echo "✅ Command executed successfully" >> ${deployment.logPath};
|
||||||
|
`;
|
||||||
|
await execAsyncRemote(serverId, command);
|
||||||
|
} catch (error) {
|
||||||
|
await updateDeploymentStatus(deployment.deploymentId, "error");
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await updateDeploymentStatus(deployment.deploymentId, "done");
|
||||||
|
};
|
Loading…
Reference in New Issue
Block a user