Merge pull request #1801 from Dokploy/187-backups-for-docker-compose

187 backups for docker compose for cloud version
This commit is contained in:
Mauricio Siu
2025-05-04 15:17:08 -06:00
committed by GitHub
75 changed files with 9606 additions and 2771 deletions

View File

@@ -15,11 +15,15 @@ import { Paintbrush } from "lucide-react";
import { toast } from "sonner";
interface Props {
applicationId: string;
id: string;
type: "application" | "compose";
}
export const CancelQueues = ({ applicationId }: Props) => {
const { mutateAsync, isLoading } = api.application.cleanQueues.useMutation();
export const CancelQueues = ({ id, type }: Props) => {
const { mutateAsync, isLoading } =
type === "application"
? api.application.cleanQueues.useMutation()
: api.compose.cleanQueues.useMutation();
const { data: isCloud } = api.settings.isCloud.useQuery();
if (isCloud) {
@@ -48,7 +52,8 @@ export const CancelQueues = ({ applicationId }: Props) => {
<AlertDialogAction
onClick={async () => {
await mutateAsync({
applicationId,
applicationId: id || "",
composeId: id || "",
})
.then(() => {
toast.success("Queues are being cleaned");

View File

@@ -14,10 +14,14 @@ import { RefreshCcw } from "lucide-react";
import { toast } from "sonner";
interface Props {
applicationId: string;
id: string;
type: "application" | "compose";
}
export const RefreshToken = ({ applicationId }: Props) => {
const { mutateAsync } = api.application.refreshToken.useMutation();
export const RefreshToken = ({ id, type }: Props) => {
const { mutateAsync } =
type === "application"
? api.application.refreshToken.useMutation()
: api.compose.refreshToken.useMutation();
const utils = api.useUtils();
return (
<AlertDialog>
@@ -37,12 +41,19 @@ export const RefreshToken = ({ applicationId }: Props) => {
<AlertDialogAction
onClick={async () => {
await mutateAsync({
applicationId,
applicationId: id || "",
composeId: id || "",
})
.then(() => {
utils.application.one.invalidate({
applicationId,
});
if (type === "application") {
utils.application.one.invalidate({
applicationId: id,
});
} else {
utils.compose.one.invalidate({
composeId: id,
});
}
toast.success("Refresh updated");
})
.catch(() => {

View File

@@ -0,0 +1,69 @@
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogTrigger } from "@/components/ui/dialog";
import type { RouterOutputs } from "@/utils/api";
import { useState } from "react";
import { ShowDeployment } from "../deployments/show-deployment";
import { ShowDeployments } from "./show-deployments";
interface Props {
id: string;
type:
| "application"
| "compose"
| "schedule"
| "server"
| "backup"
| "previewDeployment";
serverId?: string;
refreshToken?: 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 ShowDeploymentsModal = ({
id,
type,
serverId,
refreshToken,
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 p-0">
<ShowDeployments
id={id}
type={type}
serverId={serverId}
refreshToken={refreshToken}
/>
</DialogContent>
<ShowDeployment
serverId={serverId || ""}
open={Boolean(activeLog && activeLog.logPath !== null)}
onClose={() => setActiveLog(null)}
logPath={activeLog?.logPath || ""}
errorMessage={activeLog?.errorMessage || ""}
/>
</Dialog>
);
};

View File

@@ -9,29 +9,53 @@ import {
CardTitle,
} from "@/components/ui/card";
import { type RouterOutputs, api } from "@/utils/api";
import { RocketIcon, Clock } from "lucide-react";
import { RocketIcon, Clock, Loader2 } from "lucide-react";
import React, { useEffect, useState } from "react";
import { CancelQueues } from "./cancel-queues";
import { RefreshToken } from "./refresh-token";
import { ShowDeployment } from "./show-deployment";
import { Badge } from "@/components/ui/badge";
import { formatDuration } from "../schedules/show-schedules-logs";
interface Props {
applicationId: string;
id: string;
type:
| "application"
| "compose"
| "schedule"
| "server"
| "backup"
| "previewDeployment";
refreshToken?: string;
serverId?: string;
}
export const ShowDeployments = ({ applicationId }: Props) => {
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 ShowDeployments = ({
id,
type,
refreshToken,
serverId,
}: Props) => {
const [activeLog, setActiveLog] = useState<
RouterOutputs["deployment"]["all"][number] | null
>(null);
const { data } = api.application.one.useQuery({ applicationId });
const { data: deployments } = api.deployment.all.useQuery(
{ applicationId },
{
enabled: !!applicationId,
refetchInterval: 1000,
},
);
const { data: deployments, isLoading: isLoadingDeployments } =
api.deployment.allByType.useQuery(
{
id,
type,
},
{
enabled: !!id,
refetchInterval: 1000,
},
);
const [url, setUrl] = React.useState("");
useEffect(() => {
@@ -39,34 +63,48 @@ export const ShowDeployments = ({ applicationId }: Props) => {
}, []);
return (
<Card className="bg-background">
<Card className="bg-background border-none">
<CardHeader className="flex flex-row items-center justify-between flex-wrap gap-2">
<div className="flex flex-col gap-2">
<CardTitle className="text-xl">Deployments</CardTitle>
<CardDescription>
See all the 10 last deployments for this application
See all the 10 last deployments for this {type}
</CardDescription>
</div>
<CancelQueues applicationId={applicationId} />
{(type === "application" || type === "compose") && (
<CancelQueues id={id} type={type} />
)}
</CardHeader>
<CardContent className="flex flex-col gap-4">
<div className="flex flex-col gap-2 text-sm">
<span>
If you want to re-deploy this application use this URL in the config
of your git provider or docker
</span>
<div className="flex flex-row items-center gap-2 flex-wrap">
<span>Webhook URL: </span>
<div className="flex flex-row items-center gap-2">
<span className="break-all text-muted-foreground">
{`${url}/api/deploy/${data?.refreshToken}`}
</span>
<RefreshToken applicationId={applicationId} />
{refreshToken && (
<div className="flex flex-col gap-2 text-sm">
<span>
If you want to re-deploy this application use this URL in the
config of your git provider or docker
</span>
<div className="flex flex-row items-center gap-2 flex-wrap">
<span>Webhook URL: </span>
<div className="flex flex-row items-center gap-2">
<span className="break-all text-muted-foreground">
{`${url}/api/deploy/${refreshToken}`}
</span>
{(type === "application" || type === "compose") && (
<RefreshToken id={id} type={type} />
)}
</div>
</div>
</div>
</div>
{data?.deployments?.length === 0 ? (
<div className="flex w-full flex-col items-center justify-center gap-3 pt-10">
)}
{isLoadingDeployments ? (
<div className="flex w-full flex-row items-center justify-center gap-3 pt-10 min-h-[25vh]">
<Loader2 className="size-6 text-muted-foreground animate-spin" />
<span className="text-base text-muted-foreground">
Loading deployments...
</span>
</div>
) : deployments?.length === 0 ? (
<div className="flex w-full flex-col items-center justify-center gap-3 pt-10 min-h-[25vh]">
<RocketIcon className="size-8 text-muted-foreground" />
<span className="text-base text-muted-foreground">
No deployments found
@@ -129,7 +167,7 @@ export const ShowDeployments = ({ applicationId }: Props) => {
</div>
)}
<ShowDeployment
serverId={data?.serverId || ""}
serverId={serverId}
open={Boolean(activeLog && activeLog.logPath !== null)}
onClose={() => setActiveLog(null)}
logPath={activeLog?.logPath || ""}

View File

@@ -0,0 +1,109 @@
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { AlertBlock } from "@/components/shared/alert-block";
import { Copy, HelpCircle, Server } from "lucide-react";
import { toast } from "sonner";
interface Props {
domain: {
host: string;
https: boolean;
path?: string;
};
serverIp?: string;
}
export const DnsHelperModal = ({ domain, serverIp }: Props) => {
const copyToClipboard = (text: string) => {
navigator.clipboard.writeText(text);
toast.success("Copied to clipboard!");
};
return (
<Dialog>
<DialogTrigger>
<Button variant="ghost" size="icon" className="group">
<HelpCircle className="size-4" />
</Button>
</DialogTrigger>
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-2xl">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Server className="size-5" />
DNS Configuration Guide
</DialogTitle>
<DialogDescription>
Follow these steps to configure your DNS records for {domain.host}
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-4">
<AlertBlock type="info">
To make your domain accessible, you need to configure your DNS
records with your domain provider (e.g., Cloudflare, GoDaddy,
NameCheap).
</AlertBlock>
<div className="flex flex-col gap-6">
<div className="rounded-lg border p-4">
<h3 className="font-medium mb-2">1. Add A Record</h3>
<div className="flex flex-col gap-3">
<p className="text-sm text-muted-foreground">
Create an A record that points your domain to the server's IP
address:
</p>
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between gap-2 bg-muted p-3 rounded-md">
<div>
<p className="text-sm font-medium">Type: A</p>
<p className="text-sm">
Name: @ or {domain.host.split(".")[0]}
</p>
<p className="text-sm">
Value: {serverIp || "Your server IP"}
</p>
</div>
<Button
variant="ghost"
size="icon"
onClick={() => copyToClipboard(serverIp || "")}
disabled={!serverIp}
>
<Copy className="size-4" />
</Button>
</div>
</div>
</div>
</div>
<div className="rounded-lg border p-4">
<h3 className="font-medium mb-2">2. Verify Configuration</h3>
<div className="flex flex-col gap-3">
<p className="text-sm text-muted-foreground">
After configuring your DNS records:
</p>
<ul className="list-disc list-inside space-y-1 text-sm">
<li>Wait for DNS propagation (usually 15-30 minutes)</li>
<li>
Test your domain by visiting:{" "}
{domain.https ? "https://" : "http://"}
{domain.host}
{domain.path || "/"}
</li>
<li>Use a DNS lookup tool to verify your records</li>
</ul>
</div>
</div>
</div>
</div>
</DialogContent>
</Dialog>
);
};

View File

@@ -38,26 +38,67 @@ import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { domain } from "@/server/db/validations/domain";
import { zodResolver } from "@hookform/resolvers/zod";
import { Dices } from "lucide-react";
import { DatabaseZap, Dices, RefreshCw } from "lucide-react";
import Link from "next/link";
import type z from "zod";
import z from "zod";
export type CacheType = "fetch" | "cache";
export const domain = z
.object({
host: z.string().min(1, { message: "Add a hostname" }),
path: z.string().min(1).optional(),
port: z
.number()
.min(1, { message: "Port must be at least 1" })
.max(65535, { message: "Port must be 65535 or below" })
.optional(),
https: z.boolean().optional(),
certificateType: z.enum(["letsencrypt", "none", "custom"]).optional(),
customCertResolver: z.string().optional(),
serviceName: z.string().optional(),
domainType: z.enum(["application", "compose", "preview"]).optional(),
})
.superRefine((input, ctx) => {
if (input.https && !input.certificateType) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["certificateType"],
message: "Required",
});
}
if (input.certificateType === "custom" && !input.customCertResolver) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["customCertResolver"],
message: "Required",
});
}
if (input.domainType === "compose" && !input.serviceName) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["serviceName"],
message: "Required",
});
}
});
type Domain = z.infer<typeof domain>;
interface Props {
applicationId: string;
id: string;
type: "application" | "compose";
domainId?: string;
children: React.ReactNode;
}
export const AddDomain = ({
applicationId,
domainId = "",
children,
}: Props) => {
export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
const [isOpen, setIsOpen] = useState(false);
const [cacheType, setCacheType] = useState<CacheType>("cache");
const utils = api.useUtils();
const { data, refetch } = api.domain.one.useQuery(
{
@@ -68,14 +109,24 @@ export const AddDomain = ({
},
);
const { data: application } = api.application.one.useQuery(
{
applicationId,
},
{
enabled: !!applicationId,
},
);
const { data: application } =
type === "application"
? api.application.one.useQuery(
{
applicationId: id,
},
{
enabled: !!id,
},
)
: api.compose.one.useQuery(
{
composeId: id,
},
{
enabled: !!id,
},
);
const { mutateAsync, isError, error, isLoading } = domainId
? api.domain.update.useMutation()
@@ -89,7 +140,22 @@ export const AddDomain = ({
serverId: application?.serverId || "",
});
console.log("canGenerateTraefikMeDomains", canGenerateTraefikMeDomains);
const {
data: services,
isFetching: isLoadingServices,
error: errorServices,
refetch: refetchServices,
} = api.compose.loadServices.useQuery(
{
composeId: id,
type: cacheType,
},
{
retry: false,
refetchOnWindowFocus: false,
enabled: type === "compose" && !!id,
},
);
const form = useForm<Domain>({
resolver: zodResolver(domain),
@@ -100,12 +166,15 @@ export const AddDomain = ({
https: false,
certificateType: undefined,
customCertResolver: undefined,
serviceName: undefined,
domainType: type,
},
mode: "onChange",
});
const certificateType = form.watch("certificateType");
const https = form.watch("https");
const domainType = form.watch("domainType");
useEffect(() => {
if (data) {
@@ -116,6 +185,8 @@ export const AddDomain = ({
port: data?.port || undefined,
certificateType: data?.certificateType || undefined,
customCertResolver: data?.customCertResolver || undefined,
serviceName: data?.serviceName || undefined,
domainType: data?.domainType || type,
});
}
@@ -127,6 +198,7 @@ export const AddDomain = ({
https: false,
certificateType: undefined,
customCertResolver: undefined,
domainType: type,
});
}
}, [form, data, isLoading, domainId]);
@@ -150,22 +222,37 @@ export const AddDomain = ({
const onSubmit = async (data: Domain) => {
await mutateAsync({
domainId,
applicationId,
...(data.domainType === "application" && {
applicationId: id,
}),
...(data.domainType === "compose" && {
composeId: id,
}),
...data,
})
.then(async () => {
toast.success(dictionary.success);
await utils.domain.byApplicationId.invalidate({
applicationId,
});
await utils.application.readTraefikConfig.invalidate({ applicationId });
if (data.domainType === "application") {
await utils.domain.byApplicationId.invalidate({
applicationId: id,
});
await utils.application.readTraefikConfig.invalidate({
applicationId: id,
});
} else if (data.domainType === "compose") {
await utils.domain.byComposeId.invalidate({
composeId: id,
});
}
if (domainId) {
refetch();
}
setIsOpen(false);
})
.catch(() => {
.catch((e) => {
console.log(e);
toast.error(dictionary.error);
});
};
@@ -189,6 +276,119 @@ export const AddDomain = ({
>
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<div className="flex flex-row items-end w-full gap-4">
{domainType === "compose" && (
<div className="flex flex-col gap-2 w-full">
{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>
)}
</div>
<FormField
control={form.control}
name="host"
@@ -276,6 +476,11 @@ export const AddDomain = ({
return (
<FormItem>
<FormLabel>Container Port</FormLabel>
<FormDescription>
The port where your application is running inside the
container (e.g., 3000 for Node.js, 80 for Nginx, 8080
for Java)
</FormDescription>
<FormControl>
<NumberInput placeholder={"3000"} {...field} />
</FormControl>

View File

@@ -8,28 +8,133 @@ import {
CardTitle,
} from "@/components/ui/card";
import { api } from "@/utils/api";
import { ExternalLink, GlobeIcon, PenBoxIcon, Trash2 } from "lucide-react";
import {
CheckCircle2,
ExternalLink,
GlobeIcon,
InfoIcon,
Loader2,
PenBoxIcon,
RefreshCw,
Server,
Trash2,
XCircle,
} from "lucide-react";
import Link from "next/link";
import { toast } from "sonner";
import { AddDomain } from "./add-domain";
import { AddDomain } from "./handle-domain";
import { useState } from "react";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { DnsHelperModal } from "./dns-helper-modal";
import { Badge } from "@/components/ui/badge";
export type ValidationState = {
isLoading: boolean;
isValid?: boolean;
error?: string;
resolvedIp?: string;
message?: string;
};
export type ValidationStates = Record<string, ValidationState>;
interface Props {
applicationId: string;
id: string;
type: "application" | "compose";
}
export const ShowDomains = ({ applicationId }: Props) => {
const { data, refetch } = api.domain.byApplicationId.useQuery(
{
applicationId,
},
{
enabled: !!applicationId,
},
export const ShowDomains = ({ id, type }: Props) => {
const { data: application } =
type === "application"
? api.application.one.useQuery(
{
applicationId: id,
},
{
enabled: !!id,
},
)
: api.compose.one.useQuery(
{
composeId: id,
},
{
enabled: !!id,
},
);
const [validationStates, setValidationStates] = useState<ValidationStates>(
{},
);
const { data: ip } = api.settings.getIp.useQuery();
const {
data,
refetch,
isLoading: isLoadingDomains,
} = type === "application"
? api.domain.byApplicationId.useQuery(
{
applicationId: id,
},
{
enabled: !!id,
},
)
: api.domain.byComposeId.useQuery(
{
composeId: id,
},
{
enabled: !!id,
},
);
const { mutateAsync: validateDomain } =
api.domain.validateDomain.useMutation();
const { mutateAsync: deleteDomain, isLoading: isRemoving } =
api.domain.delete.useMutation();
const handleValidateDomain = async (host: string) => {
setValidationStates((prev) => ({
...prev,
[host]: { isLoading: true },
}));
try {
const result = await validateDomain({
domain: host,
serverIp:
application?.server?.ipAddress?.toString() || ip?.toString() || "",
});
setValidationStates((prev) => ({
...prev,
[host]: {
isLoading: false,
isValid: result.isValid,
error: result.error,
resolvedIp: result.resolvedIp,
message: result.error && result.isValid ? result.error : undefined,
},
}));
} catch (err) {
const error = err as Error;
setValidationStates((prev) => ({
...prev,
[host]: {
isLoading: false,
isValid: false,
error: error.message || "Failed to validate domain",
},
}));
}
};
return (
<div className="flex w-full flex-col gap-5 ">
<Card className="bg-background">
@@ -43,7 +148,7 @@ export const ShowDomains = ({ applicationId }: Props) => {
<div className="flex flex-row gap-4 flex-wrap">
{data && data?.length > 0 && (
<AddDomain applicationId={applicationId}>
<AddDomain id={id} type={type}>
<Button>
<GlobeIcon className="size-4" /> Add Domain
</Button>
@@ -52,15 +157,22 @@ export const ShowDomains = ({ applicationId }: Props) => {
</div>
</CardHeader>
<CardContent className="flex w-full flex-row gap-4">
{data?.length === 0 ? (
<div className="flex w-full flex-col items-center justify-center gap-3">
{isLoadingDomains ? (
<div className="flex w-full flex-row gap-4 min-h-[40vh] justify-center items-center">
<Loader2 className="size-5 animate-spin text-muted-foreground" />
<span className="text-base text-muted-foreground">
Loading domains...
</span>
</div>
) : data?.length === 0 ? (
<div className="flex w-full flex-col items-center justify-center gap-3 min-h-[40vh]">
<GlobeIcon className="size-8 text-muted-foreground" />
<span className="text-base text-muted-foreground">
To access the application it is required to set at least 1
domain
</span>
<div className="flex flex-row gap-4 flex-wrap">
<AddDomain applicationId={applicationId}>
<AddDomain id={id} type={type}>
<Button>
<GlobeIcon className="size-4" /> Add Domain
</Button>
@@ -68,73 +180,216 @@ export const ShowDomains = ({ applicationId }: Props) => {
</div>
</div>
) : (
<div className="flex w-full flex-col gap-4">
<div className="grid grid-cols-1 gap-4 xl:grid-cols-2 w-full min-h-[40vh] ">
{data?.map((item) => {
const validationState = validationStates[item.host];
return (
<div
<Card
key={item.domainId}
className="flex w-full items-center justify-between gap-4 border p-4 md:px-6 rounded-lg flex-wrap"
className="relative overflow-hidden w-full border bg-card transition-all hover:shadow-md bg-transparent h-fit"
>
<Link
className="md:basis-1/2 flex gap-2 items-center hover:underline transition-all w-full"
target="_blank"
href={`${item.https ? "https" : "http"}://${item.host}${item.path}`}
>
<span className="truncate max-w-full text-sm">
{item.host}
</span>
<ExternalLink className="size-4 min-w-4" />
</Link>
<CardContent className="p-6">
<div className="flex flex-col gap-4">
{/* Service & Domain Info */}
<div className="flex items-start justify-between">
<div className="flex flex-col gap-2">
{item.serviceName && (
<Badge variant="outline" className="w-fit">
<Server className="size-3 mr-1" />
{item.serviceName}
</Badge>
)}
<div className="flex gap-8">
<div className="flex gap-8 opacity-50 items-center h-10 text-center text-sm font-medium">
<span>{item.path}</span>
<span>{item.port}</span>
<span>{item.https ? "HTTPS" : "HTTP"}</span>
</div>
<Link
className="flex items-center gap-2 text-base font-medium hover:underline"
target="_blank"
href={`${item.https ? "https" : "http"}://${item.host}${item.path}`}
>
{item.host}
<ExternalLink className="size-4" />
</Link>
</div>
<div className="flex gap-2">
{!item.host.includes("traefik.me") && (
<DnsHelperModal
domain={{
host: item.host,
https: item.https,
path: item.path || undefined,
}}
serverIp={
application?.server?.ipAddress?.toString() ||
ip?.toString()
}
/>
)}
<AddDomain
id={id}
type={type}
domainId={item.domainId}
>
<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>
</AddDomain>
<DialogAction
title="Delete Domain"
description="Are you sure you want to delete this domain?"
type="destructive"
onClick={async () => {
await deleteDomain({
domainId: item.domainId,
})
.then((_data) => {
refetch();
toast.success(
"Domain deleted successfully",
);
})
.catch(() => {
toast.error("Error deleting domain");
});
}}
>
<Button
variant="ghost"
size="icon"
className="group hover:bg-red-500/10"
isLoading={isRemoving}
>
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
</Button>
</DialogAction>
</div>
</div>
<div className="flex gap-2">
<AddDomain
applicationId={applicationId}
domainId={item.domainId}
>
<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>
</AddDomain>
<DialogAction
title="Delete Domain"
description="Are you sure you want to delete this domain?"
type="destructive"
onClick={async () => {
await deleteDomain({
domainId: item.domainId,
})
.then(() => {
refetch();
toast.success("Domain deleted successfully");
})
.catch(() => {
toast.error("Error deleting domain");
});
}}
>
<Button
variant="ghost"
size="icon"
className="group hover:bg-red-500/10"
isLoading={isRemoving}
>
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
</Button>
</DialogAction>
{/* Domain Details */}
<div className="flex flex-wrap gap-3">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Badge variant="secondary">
<InfoIcon className="size-3 mr-1" />
Path: {item.path || "/"}
</Badge>
</TooltipTrigger>
<TooltipContent>
<p>URL path for this service</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Badge variant="secondary">
<InfoIcon className="size-3 mr-1" />
Port: {item.port}
</Badge>
</TooltipTrigger>
<TooltipContent>
<p>Container port exposed</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Badge
variant={item.https ? "outline" : "secondary"}
>
{item.https ? "HTTPS" : "HTTP"}
</Badge>
</TooltipTrigger>
<TooltipContent>
<p>
{item.https
? "Secure HTTPS connection"
: "Standard HTTP connection"}
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
{item.certificateType && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Badge variant="outline">
Cert: {item.certificateType}
</Badge>
</TooltipTrigger>
<TooltipContent>
<p>SSL Certificate Provider</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Badge
variant="outline"
className={
validationState?.isValid
? "bg-green-500/10 text-green-500 cursor-pointer"
: validationState?.error
? "bg-red-500/10 text-red-500 cursor-pointer"
: "bg-yellow-500/10 text-yellow-500 cursor-pointer"
}
onClick={() =>
handleValidateDomain(item.host)
}
>
{validationState?.isLoading ? (
<>
<Loader2 className="size-3 mr-1 animate-spin" />
Checking DNS...
</>
) : validationState?.isValid ? (
<>
<CheckCircle2 className="size-3 mr-1" />
{validationState.message
? "Behind Cloudflare"
: "DNS Valid"}
</>
) : validationState?.error ? (
<>
<XCircle className="size-3 mr-1" />
{validationState.error}
</>
) : (
<>
<RefreshCw className="size-3 mr-1" />
Validate DNS
</>
)}
</Badge>
</TooltipTrigger>
<TooltipContent className="max-w-xs">
{validationState?.error ? (
<div className="flex flex-col gap-1">
<p className="font-medium text-red-500">
Error:
</p>
<p>{validationState.error}</p>
</div>
) : (
"Click to validate DNS configuration"
)}
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</div>
</div>
</div>
</CardContent>
</Card>
);
})}
</div>

View File

@@ -13,7 +13,7 @@ import {
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { api } from "@/utils/api";
import { GitBranch, UploadCloud } from "lucide-react";
import { GitBranch, Loader2, UploadCloud } from "lucide-react";
import Link from "next/link";
import { useState } from "react";
import { SaveBitbucketProvider } from "./save-bitbucket-provider";
@@ -34,14 +34,49 @@ interface Props {
}
export const ShowProviderForm = ({ applicationId }: Props) => {
const { data: githubProviders } = api.github.githubProviders.useQuery();
const { data: gitlabProviders } = api.gitlab.gitlabProviders.useQuery();
const { data: bitbucketProviders } =
const { data: githubProviders, isLoading: isLoadingGithub } =
api.github.githubProviders.useQuery();
const { data: gitlabProviders, isLoading: isLoadingGitlab } =
api.gitlab.gitlabProviders.useQuery();
const { data: bitbucketProviders, isLoading: isLoadingBitbucket } =
api.bitbucket.bitbucketProviders.useQuery();
const { data: giteaProviders } = api.gitea.giteaProviders.useQuery();
const { data: giteaProviders, isLoading: isLoadingGitea } =
api.gitea.giteaProviders.useQuery();
const { data: application } = api.application.one.useQuery({ applicationId });
const [tab, setSab] = useState<TabState>(application?.sourceType || "github");
const isLoading =
isLoadingGithub || isLoadingGitlab || isLoadingBitbucket || isLoadingGitea;
if (isLoading) {
return (
<Card className="group relative w-full bg-transparent">
<CardHeader>
<CardTitle className="flex items-start justify-between">
<div className="flex flex-col gap-2">
<span className="flex flex-col space-y-0.5">Provider</span>
<p className="flex items-center text-sm font-normal text-muted-foreground">
Select the source of your code
</p>
</div>
<div className="hidden space-y-1 text-sm font-normal md:block">
<GitBranch className="size-6 text-muted-foreground" />
</div>
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex min-h-[25vh] items-center justify-center">
<div className="flex items-center gap-2 text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
<span>Loading providers...</span>
</div>
</div>
</CardContent>
</Card>
);
}
return (
<Card className="group relative w-full bg-transparent">
<CardHeader>
@@ -123,7 +158,7 @@ export const ShowProviderForm = ({ applicationId }: Props) => {
{githubProviders && githubProviders?.length > 0 ? (
<SaveGithubProvider applicationId={applicationId} />
) : (
<div className="flex flex-col items-center gap-3 min-h-[15vh] justify-center">
<div className="flex flex-col items-center gap-3 min-h-[25vh] justify-center">
<GithubIcon className="size-8 text-muted-foreground" />
<span className="text-base text-muted-foreground">
To deploy using GitHub, you need to configure your account
@@ -143,7 +178,7 @@ export const ShowProviderForm = ({ applicationId }: Props) => {
{gitlabProviders && gitlabProviders?.length > 0 ? (
<SaveGitlabProvider applicationId={applicationId} />
) : (
<div className="flex flex-col items-center gap-3 min-h-[15vh] justify-center">
<div className="flex flex-col items-center gap-3 min-h-[25vh] justify-center">
<GitlabIcon className="size-8 text-muted-foreground" />
<span className="text-base text-muted-foreground">
To deploy using GitLab, you need to configure your account
@@ -163,7 +198,7 @@ export const ShowProviderForm = ({ applicationId }: Props) => {
{bitbucketProviders && bitbucketProviders?.length > 0 ? (
<SaveBitbucketProvider applicationId={applicationId} />
) : (
<div className="flex flex-col items-center gap-3 min-h-[15vh] justify-center">
<div className="flex flex-col items-center gap-3 min-h-[25vh] justify-center">
<BitbucketIcon className="size-8 text-muted-foreground" />
<span className="text-base text-muted-foreground">
To deploy using Bitbucket, you need to configure your account
@@ -183,7 +218,7 @@ export const ShowProviderForm = ({ applicationId }: Props) => {
{giteaProviders && giteaProviders?.length > 0 ? (
<SaveGiteaProvider applicationId={applicationId} />
) : (
<div className="flex flex-col items-center gap-3 min-h-[15vh] justify-center">
<div className="flex flex-col items-center gap-3 min-h-[25vh] justify-center">
<GiteaIcon className="size-8 text-muted-foreground" />
<span className="text-base text-muted-foreground">
To deploy using Gitea, you need to configure your account

View File

@@ -1,100 +0,0 @@
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";
interface Props {
deployments: RouterOutputs["deployment"]["all"];
serverId?: string;
trigger?: React.ReactNode;
}
export const ShowPreviewBuilds = ({
deployments,
serverId,
trigger,
}: Props) => {
const [activeLog, setActiveLog] = useState<
RouterOutputs["deployment"]["all"][number] | null
>(null);
const [isOpen, setIsOpen] = useState(false);
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
{trigger ? (
trigger
) : (
<Button className="sm:w-auto w-full" size="sm" variant="outline">
View Builds
</Button>
)}
</DialogTrigger>
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-5xl">
<DialogHeader>
<DialogTitle>Preview Builds</DialogTitle>
<DialogDescription>
See all the preview builds for this application on this Pull Request
</DialogDescription>
</DialogHeader>
<div className="grid gap-4">
{deployments?.map((deployment) => (
<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">
{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="text-sm capitalize text-muted-foreground">
<DateTooltip date={deployment.createdAt} />
</div>
<Button
onClick={() => {
setActiveLog(deployment);
}}
>
View
</Button>
</div>
</div>
))}
</div>
</DialogContent>
<ShowDeployment
serverId={serverId || ""}
open={Boolean(activeLog && activeLog.logPath !== null)}
onClose={() => setActiveLog(null)}
logPath={activeLog?.logPath || ""}
errorMessage={activeLog?.errorMessage || ""}
/>
</Dialog>
);
};

View File

@@ -17,7 +17,7 @@ import {
ExternalLink,
FileText,
GitPullRequest,
Layers,
Loader2,
PenSquare,
RocketIcon,
Trash2,
@@ -25,8 +25,8 @@ import {
import { toast } from "sonner";
import { ShowModalLogs } from "../../settings/web-server/show-modal-logs";
import { AddPreviewDomain } from "./add-preview-domain";
import { ShowPreviewBuilds } from "./show-preview-builds";
import { ShowPreviewSettings } from "./show-preview-settings";
import { ShowDeploymentsModal } from "../deployments/show-deployments-modal";
interface Props {
applicationId: string;
@@ -38,13 +38,16 @@ export const ShowPreviewDeployments = ({ applicationId }: Props) => {
const { mutateAsync: deletePreviewDeployment, isLoading } =
api.previewDeployment.delete.useMutation();
const { data: previewDeployments, refetch: refetchPreviewDeployments } =
api.previewDeployment.all.useQuery(
{ applicationId },
{
enabled: !!applicationId,
},
);
const {
data: previewDeployments,
refetch: refetchPreviewDeployments,
isLoading: isLoadingPreviewDeployments,
} = api.previewDeployment.all.useQuery(
{ applicationId },
{
enabled: !!applicationId,
},
);
const handleDeletePreviewDeployment = async (previewDeploymentId: string) => {
deletePreviewDeployment({
@@ -80,8 +83,15 @@ export const ShowPreviewDeployments = ({ applicationId }: Props) => {
each pull request you create.
</span>
</div>
{!previewDeployments?.length ? (
<div className="flex w-full flex-col items-center justify-center gap-3 pt-10">
{isLoadingPreviewDeployments ? (
<div className="flex w-full flex-row items-center justify-center gap-3 min-h-[35vh]">
<Loader2 className="size-5 text-muted-foreground animate-spin" />
<span className="text-base text-muted-foreground">
Loading preview deployments...
</span>
</div>
) : !previewDeployments?.length ? (
<div className="flex w-full flex-col items-center justify-center gap-3 min-h-[35vh]">
<RocketIcon className="size-8 text-muted-foreground" />
<span className="text-base text-muted-foreground">
No preview deployments found
@@ -168,19 +178,10 @@ export const ShowPreviewDeployments = ({ applicationId }: Props) => {
</Button>
</ShowModalLogs>
<ShowPreviewBuilds
deployments={deployment.deployments || []}
<ShowDeploymentsModal
id={deployment.previewDeploymentId}
type="previewDeployment"
serverId={data?.serverId || ""}
trigger={
<Button
variant="outline"
size="sm"
className="gap-2"
>
<Layers className="size-4" />
Builds
</Button>
}
/>
<AddPreviewDomain

View File

@@ -43,10 +43,10 @@ import {
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";
import type { CacheType } from "../domains/handle-domain";
export const commonCronExpressions = [
{ label: "Every minute", value: "* * * * *" },

View File

@@ -1,131 +0,0 @@
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>
);
};

View File

@@ -18,7 +18,6 @@ import {
} from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { toast } from "sonner";
import { ShowSchedulesLogs } from "./show-schedules-logs";
import {
Tooltip,
TooltipContent,
@@ -26,6 +25,7 @@ import {
TooltipTrigger,
} from "@/components/ui/tooltip";
import { DialogAction } from "@/components/shared/dialog-action";
import { ShowDeploymentsModal } from "../deployments/show-deployments-modal";
interface Props {
id: string;
@@ -88,7 +88,6 @@ export const ShowSchedules = ({ id, scheduleType = "application" }: Props) => {
schedule.serverId ||
schedule.application?.serverId ||
schedule.compose?.serverId;
const deployments = schedule.deployments;
return (
<div
key={schedule.scheduleId}
@@ -144,14 +143,15 @@ export const ShowSchedules = ({ id, scheduleType = "application" }: Props) => {
</div>
<div className="flex items-center gap-1.5">
<ShowSchedulesLogs
deployments={deployments || []}
<ShowDeploymentsModal
id={schedule.scheduleId}
type="schedule"
serverId={serverId || undefined}
>
<Button variant="ghost" size="icon">
<ClipboardList className="size-4 transition-colors " />
</Button>
</ShowSchedulesLogs>
</ShowDeploymentsModal>
<TooltipProvider delayDuration={0}>
<Tooltip>

View File

@@ -1,66 +0,0 @@
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import { api } from "@/utils/api";
import { Paintbrush } from "lucide-react";
import { toast } from "sonner";
interface Props {
composeId: string;
}
export const CancelQueuesCompose = ({ composeId }: Props) => {
const { mutateAsync, isLoading } = api.compose.cleanQueues.useMutation();
const { data: isCloud } = api.settings.isCloud.useQuery();
if (isCloud) {
return null;
}
return (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="destructive" className="w-fit" isLoading={isLoading}>
Cancel Queues
<Paintbrush className="size-4" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
Are you sure to cancel the incoming deployments?
</AlertDialogTitle>
<AlertDialogDescription>
This will cancel all the incoming deployments
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={async () => {
await mutateAsync({
composeId,
})
.then(() => {
toast.success("Queues are being cleaned");
})
.catch((err) => {
toast.error(err.message);
});
}}
>
Confirm
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
};

View File

@@ -1,59 +0,0 @@
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { api } from "@/utils/api";
import { RefreshCcw } from "lucide-react";
import { toast } from "sonner";
interface Props {
composeId: string;
}
export const RefreshTokenCompose = ({ composeId }: Props) => {
const { mutateAsync } = api.compose.refreshToken.useMutation();
const utils = api.useUtils();
return (
<AlertDialog>
<AlertDialogTrigger>
<RefreshCcw className="h-4 w-4 cursor-pointer text-muted-foreground" />
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
<AlertDialogDescription>
This action cannot be undone. This will permanently change the token
and all the previous tokens will be invalidated
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={async () => {
await mutateAsync({
composeId,
})
.then(() => {
utils.compose.one.invalidate({
composeId,
});
toast.success("Refresh Token updated");
})
.catch(() => {
toast.error("Error updating the refresh token");
});
}}
>
Confirm
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
};

View File

@@ -1,184 +0,0 @@
import { Badge } from "@/components/ui/badge";
import { Checkbox } from "@/components/ui/checkbox";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Loader2 } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { TerminalLine } from "../../docker/logs/terminal-line";
import { type LogLine, parseLogs } from "../../docker/logs/utils";
interface Props {
logPath: string | null;
serverId?: string;
open: boolean;
onClose: () => void;
errorMessage?: string;
}
export const ShowDeploymentCompose = ({
logPath,
open,
onClose,
serverId,
errorMessage,
}: Props) => {
const [data, setData] = useState("");
const [filteredLogs, setFilteredLogs] = useState<LogLine[]>([]);
const [showExtraLogs, setShowExtraLogs] = useState(false);
const wsRef = useRef<WebSocket | null>(null); // Ref to hold WebSocket instance
const [autoScroll, setAutoScroll] = useState(true);
const scrollRef = useRef<HTMLDivElement>(null);
const scrollToBottom = () => {
if (autoScroll && scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
}
};
const handleScroll = () => {
if (!scrollRef.current) return;
const { scrollTop, scrollHeight, clientHeight } = scrollRef.current;
const isAtBottom = Math.abs(scrollHeight - scrollTop - clientHeight) < 10;
setAutoScroll(isAtBottom);
};
useEffect(() => {
if (!open || !logPath) return;
setData("");
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
const wsUrl = `${protocol}//${window.location.host}/listen-deployment?logPath=${logPath}&serverId=${serverId}`;
const ws = new WebSocket(wsUrl);
wsRef.current = ws; // Store WebSocket instance in ref
ws.onmessage = (e) => {
setData((currentData) => currentData + e.data);
};
ws.onerror = (error) => {
console.error("WebSocket error: ", error);
};
ws.onclose = () => {
wsRef.current = null;
};
return () => {
if (wsRef.current?.readyState === WebSocket.OPEN) {
ws.close();
wsRef.current = null;
}
};
}, [logPath, open]);
useEffect(() => {
const logs = parseLogs(data);
let filteredLogsResult = logs;
if (serverId) {
let hideSubsequentLogs = false;
filteredLogsResult = logs.filter((log) => {
if (
log.message.includes(
"===================================EXTRA LOGS============================================",
)
) {
hideSubsequentLogs = true;
return showExtraLogs;
}
return showExtraLogs ? true : !hideSubsequentLogs;
});
}
setFilteredLogs(filteredLogsResult);
}, [data, showExtraLogs]);
useEffect(() => {
scrollToBottom();
if (autoScroll && scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
}
}, [filteredLogs, autoScroll]);
const optionalErrors = parseLogs(errorMessage || "");
return (
<Dialog
open={open}
onOpenChange={(e) => {
onClose();
if (!e) {
setData("");
}
if (wsRef.current) {
if (wsRef.current.readyState === WebSocket.OPEN) {
wsRef.current.close();
}
}
}}
>
<DialogContent className={"sm:max-w-5xl max-h-screen"}>
<DialogHeader>
<DialogTitle>Deployment</DialogTitle>
<DialogDescription className="flex items-center gap-2">
<span>
See all the details of this deployment |{" "}
<Badge variant="blank" className="text-xs">
{filteredLogs.length} lines
</Badge>
</span>
{serverId && (
<div className="flex items-center space-x-2">
<Checkbox
id="show-extra-logs"
checked={showExtraLogs}
onCheckedChange={(checked) =>
setShowExtraLogs(checked as boolean)
}
/>
<label
htmlFor="show-extra-logs"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
Show Extra Logs
</label>
</div>
)}
</DialogDescription>
</DialogHeader>
<div
ref={scrollRef}
onScroll={handleScroll}
className="h-[720px] overflow-y-auto space-y-0 border p-4 bg-[#fafafa] dark:bg-[#050506] rounded custom-logs-scrollbar"
>
{filteredLogs.length > 0 ? (
filteredLogs.map((log: LogLine, index: number) => (
<TerminalLine key={index} log={log} noTimestamp />
))
) : (
<>
{optionalErrors.length > 0 ? (
optionalErrors.map((log: LogLine, index: number) => (
<TerminalLine key={`extra-${index}`} log={log} noTimestamp />
))
) : (
<div className="flex justify-center items-center h-full text-muted-foreground">
<Loader2 className="h-6 w-6 animate-spin" />
</div>
)}
</>
)}
</div>
</DialogContent>
</Dialog>
);
};

View File

@@ -1,141 +0,0 @@
import { DateTooltip } from "@/components/shared/date-tooltip";
import { StatusTooltip } from "@/components/shared/status-tooltip";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { type RouterOutputs, api } from "@/utils/api";
import { RocketIcon, Clock } from "lucide-react";
import React, { useEffect, useState } from "react";
import { CancelQueuesCompose } from "./cancel-queues-compose";
import { RefreshTokenCompose } from "./refresh-token-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 {
composeId: string;
}
export const ShowDeploymentsCompose = ({ composeId }: Props) => {
const [activeLog, setActiveLog] = useState<
RouterOutputs["deployment"]["all"][number] | null
>(null);
const { data } = api.compose.one.useQuery({ composeId });
const { data: deployments } = api.deployment.allByCompose.useQuery(
{ composeId },
{
enabled: !!composeId,
refetchInterval: 5000,
},
);
const [url, setUrl] = React.useState("");
useEffect(() => {
setUrl(document.location.origin);
}, []);
return (
<Card className="bg-background">
<CardHeader className="flex flex-row items-center justify-between flex-wrap gap-2">
<div className="flex flex-col gap-2">
<CardTitle className="text-xl">Deployments</CardTitle>
<CardDescription>
See all the 10 last deployments for this compose
</CardDescription>
</div>
<CancelQueuesCompose composeId={composeId} />
{/* <CancelQueues applicationId={applicationId} /> */}
</CardHeader>
<CardContent className="flex flex-col gap-4">
<div className="flex flex-col gap-2 text-sm">
<span>
If you want to re-deploy this application use this URL in the config
of your git provider or docker
</span>
<div className="flex flex-row items-center gap-2 flex-wrap">
<span>Webhook URL: </span>
<div className="flex flex-row items-center gap-2">
<span className="text-muted-foreground">
{`${url}/api/deploy/compose/${data?.refreshToken}`}
</span>
<RefreshTokenCompose composeId={composeId} />
</div>
</div>
</div>
{data?.deployments?.length === 0 ? (
<div className="flex w-full flex-col items-center justify-center gap-3 pt-10">
<RocketIcon className="size-8 text-muted-foreground" />
<span className="text-base text-muted-foreground">
No deployments found
</span>
</div>
) : (
<div className="flex flex-col gap-4">
{deployments?.map((deployment) => (
<div
key={deployment.deploymentId}
className="flex items-center justify-between rounded-lg border p-4"
>
<div className="flex flex-col">
<span className="flex items-center gap-4 font-medium capitalize text-foreground">
{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="text-sm text-muted-foreground">
{deployment.description}
</span>
)}
</div>
<div className="flex flex-col items-end gap-2">
<div className="text-sm capitalize text-muted-foreground flex items-center gap-2">
<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>
)}
<ShowDeploymentCompose
serverId={data?.serverId || ""}
open={Boolean(activeLog && activeLog.logPath !== null)}
onClose={() => setActiveLog(null)}
logPath={activeLog?.logPath || ""}
errorMessage={activeLog?.errorMessage || ""}
/>
</CardContent>
</Card>
);
};

View File

@@ -1,503 +0,0 @@
import { AlertBlock } from "@/components/shared/alert-block";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input, NumberInput } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { api } from "@/utils/api";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { domainCompose } from "@/server/db/validations/domain";
import { zodResolver } from "@hookform/resolvers/zod";
import { DatabaseZap, Dices, RefreshCw } from "lucide-react";
import Link from "next/link";
import type z from "zod";
type Domain = z.infer<typeof domainCompose>;
export type CacheType = "fetch" | "cache";
interface Props {
composeId: string;
domainId?: string;
children: React.ReactNode;
}
export const AddDomainCompose = ({
composeId,
domainId = "",
children,
}: Props) => {
const [isOpen, setIsOpen] = useState(false);
const [cacheType, setCacheType] = useState<CacheType>("cache");
const utils = api.useUtils();
const { data, refetch } = api.domain.one.useQuery(
{
domainId,
},
{
enabled: !!domainId,
},
);
const { data: compose } = api.compose.one.useQuery(
{
composeId,
},
{
enabled: !!composeId,
},
);
const {
data: services,
isFetching: isLoadingServices,
error: errorServices,
refetch: refetchServices,
} = api.compose.loadServices.useQuery(
{
composeId,
type: cacheType,
},
{
retry: false,
refetchOnWindowFocus: false,
},
);
const { mutateAsync: generateDomain, isLoading: isLoadingGenerate } =
api.domain.generateDomain.useMutation();
const { mutateAsync, isError, error, isLoading } = domainId
? api.domain.update.useMutation()
: api.domain.create.useMutation();
const { data: canGenerateTraefikMeDomains } =
api.domain.canGenerateTraefikMeDomains.useQuery({
serverId: compose?.serverId || "",
});
const form = useForm<Domain>({
resolver: zodResolver(domainCompose),
defaultValues: {
host: "",
path: undefined,
port: undefined,
https: false,
certificateType: undefined,
customCertResolver: undefined,
serviceName: "",
},
});
const https = form.watch("https");
useEffect(() => {
if (data) {
form.reset({
...data,
/* Convert null to undefined */
path: data?.path || undefined,
port: data?.port || undefined,
serviceName: data?.serviceName || undefined,
certificateType: data?.certificateType || undefined,
customCertResolver: data?.customCertResolver || undefined,
});
}
if (!domainId) {
form.reset({
host: "",
path: undefined,
port: undefined,
https: false,
certificateType: undefined,
customCertResolver: undefined,
serviceName: "",
});
}
}, [form, form.reset, data, isLoading]);
const dictionary = {
success: domainId ? "Domain Updated" : "Domain Created",
error: domainId ? "Error updating the domain" : "Error creating the domain",
submit: domainId ? "Update" : "Create",
dialogDescription: domainId
? "In this section you can edit a domain"
: "In this section you can add domains",
};
const onSubmit = async (data: Domain) => {
await mutateAsync({
domainId,
composeId,
domainType: "compose",
...data,
})
.then(async () => {
await utils.domain.byComposeId.invalidate({
composeId,
});
toast.success(dictionary.success);
if (domainId) {
refetch();
}
setIsOpen(false);
})
.catch(() => {
toast.error(dictionary.error);
});
};
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger className="" asChild>
{children}
</DialogTrigger>
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-2xl">
<DialogHeader>
<DialogTitle>Domain</DialogTitle>
<DialogDescription>{dictionary.dialogDescription}</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-4">
<AlertBlock type="info">
Deploy is required to apply changes after creating or updating a
domain.
</AlertBlock>
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
</div>
<Form {...form}>
<form
id="hook-form"
onSubmit={form.handleSubmit(onSubmit)}
className="grid w-full gap-8 "
>
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
{errorServices && (
<AlertBlock
type="warning"
className="[overflow-wrap:anywhere]"
>
{errorServices?.message}
</AlertBlock>
)}
<div className="flex flex-row items-end w-full gap-4">
<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="host"
render={({ field }) => (
<FormItem>
{!canGenerateTraefikMeDomains &&
field.value.includes("traefik.me") && (
<AlertBlock type="warning">
You need to set an IP address in your{" "}
<Link
href="/dashboard/settings/server"
className="text-primary"
>
{compose?.serverId
? "Remote Servers -> Server -> Edit Server -> Update IP Address"
: "Web Server -> Server -> Update Server IP"}
</Link>{" "}
to make your traefik.me domain work.
</AlertBlock>
)}
<FormLabel>Host</FormLabel>
<div className="flex gap-2">
<FormControl>
<Input placeholder="api.dokploy.com" {...field} />
</FormControl>
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="secondary"
type="button"
isLoading={isLoadingGenerate}
onClick={() => {
generateDomain({
serverId: compose?.serverId || "",
appName: compose?.appName || "",
})
.then((domain) => {
field.onChange(domain);
})
.catch((err) => {
toast.error(err.message);
});
}}
>
<Dices className="size-4 text-muted-foreground" />
</Button>
</TooltipTrigger>
<TooltipContent
side="left"
sideOffset={5}
className="max-w-[10rem]"
>
<p>Generate traefik.me domain</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="path"
render={({ field }) => {
return (
<FormItem>
<FormLabel>Path</FormLabel>
<FormControl>
<Input placeholder={"/"} {...field} />
</FormControl>
<FormMessage />
</FormItem>
);
}}
/>
<FormField
control={form.control}
name="port"
render={({ field }) => {
return (
<FormItem>
<FormLabel>Container Port</FormLabel>
<FormControl>
<NumberInput placeholder={"3000"} {...field} />
</FormControl>
<FormMessage />
</FormItem>
);
}}
/>
<FormField
control={form.control}
name="https"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between p-3 mt-4 border rounded-lg shadow-sm">
<div className="space-y-0.5">
<FormLabel>HTTPS</FormLabel>
<FormDescription>
Automatically provision SSL Certificate.
</FormDescription>
<FormMessage />
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
{https && (
<>
<FormField
control={form.control}
name="certificateType"
render={({ field }) => (
<FormItem className="col-span-2">
<FormLabel>Certificate Provider</FormLabel>
<Select
onValueChange={field.onChange}
defaultValue={field.value || ""}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select a certificate provider" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="none">None</SelectItem>
<SelectItem value={"letsencrypt"}>
Let's Encrypt
</SelectItem>
<SelectItem value={"custom"}>Custom</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
{form.getValues().certificateType === "custom" && (
<FormField
control={form.control}
name="customCertResolver"
render={({ field }) => (
<FormItem className="col-span-2">
<FormLabel>Custom Certificate Resolver</FormLabel>
<FormControl>
<Input
placeholder="Enter your custom certificate resolver"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
</>
)}
</div>
</div>
</form>
<DialogFooter>
<Button
isLoading={form.formState.isSubmitting}
form="hook-form"
type="submit"
>
{dictionary.submit}
</Button>
</DialogFooter>
</Form>
</DialogContent>
</Dialog>
);
};

View File

@@ -1,150 +0,0 @@
import { DialogAction } from "@/components/shared/dialog-action";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { api } from "@/utils/api";
import { ExternalLink, GlobeIcon, PenBoxIcon, Trash2 } from "lucide-react";
import Link from "next/link";
import { toast } from "sonner";
import { AddDomainCompose } from "./add-domain";
interface Props {
composeId: string;
}
export const ShowDomainsCompose = ({ composeId }: Props) => {
const { data, refetch } = api.domain.byComposeId.useQuery(
{
composeId,
},
{
enabled: !!composeId,
},
);
const { mutateAsync: deleteDomain, isLoading: isRemoving } =
api.domain.delete.useMutation();
return (
<div className="flex w-full flex-col gap-5 ">
<Card className="bg-background">
<CardHeader className="flex flex-row items-center flex-wrap gap-4 justify-between">
<div className="flex flex-col gap-1">
<CardTitle className="text-xl">Domains</CardTitle>
<CardDescription>
Domains are used to access to the application
</CardDescription>
</div>
<div className="flex flex-row gap-4 flex-wrap">
{data && data?.length > 0 && (
<AddDomainCompose composeId={composeId}>
<Button>
<GlobeIcon className="size-4" /> Add Domain
</Button>
</AddDomainCompose>
)}
</div>
</CardHeader>
<CardContent className="flex w-full flex-row gap-4">
{data?.length === 0 ? (
<div className="flex w-full flex-col items-center justify-center gap-3">
<GlobeIcon className="size-8 text-muted-foreground" />
<span className="text-base text-muted-foreground">
To access to the application it is required to set at least 1
domain
</span>
<div className="flex flex-row gap-4 flex-wrap">
<AddDomainCompose composeId={composeId}>
<Button>
<GlobeIcon className="size-4" /> Add Domain
</Button>
</AddDomainCompose>
</div>
</div>
) : (
<div className="flex w-full flex-col gap-4">
{data?.map((item) => {
return (
<div
key={item.domainId}
className="flex w-full items-center justify-between gap-4 border p-4 md:px-6 rounded-lg flex-wrap"
>
<div className="md:basis-1/2 flex gap-6 w-full items-center">
<span className="opacity-50 text-center font-medium text-sm whitespace-nowrap">
{item.serviceName}
</span>
<Link
className="flex gap-2 items-center hover:underline transition-all w-full max-w-[calc(100%-4rem)]"
target="_blank"
href={`${item.https ? "https" : "http"}://${item.host}${item.path}`}
>
<span className="truncate text-sm">{item.host}</span>
<ExternalLink className="size-4 min-w-4" />
</Link>
</div>
<div className="flex gap-8">
<div className="flex gap-8 opacity-50 items-center h-10 text-center text-sm font-medium">
<span>{item.path}</span>
<span>{item.port}</span>
<span>{item.https ? "HTTPS" : "HTTP"}</span>
</div>
<div className="flex gap-2">
<AddDomainCompose
composeId={composeId}
domainId={item.domainId}
>
<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>
</AddDomainCompose>
<DialogAction
title="Delete Domain"
description="Are you sure you want to delete this domain?"
type="destructive"
onClick={async () => {
await deleteDomain({
domainId: item.domainId,
})
.then((_data) => {
refetch();
toast.success("Domain deleted successfully");
})
.catch(() => {
toast.error("Error deleting domain");
});
}}
>
<Button
variant="ghost"
size="icon"
className="group hover:bg-red-500/10"
isLoading={isRemoving}
>
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
</Button>
</DialogAction>
</div>
</div>
</div>
);
})}
</div>
)}
</CardContent>
</Card>
</div>
);
};

View File

@@ -8,7 +8,7 @@ import {
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { api } from "@/utils/api";
import { CodeIcon, GitBranch } from "lucide-react";
import { CodeIcon, GitBranch, Loader2 } from "lucide-react";
import Link from "next/link";
import { useState } from "react";
import { ComposeFileEditor } from "../compose-file-editor";
@@ -25,15 +25,49 @@ interface Props {
}
export const ShowProviderFormCompose = ({ composeId }: Props) => {
const { data: githubProviders } = api.github.githubProviders.useQuery();
const { data: gitlabProviders } = api.gitlab.gitlabProviders.useQuery();
const { data: bitbucketProviders } =
const { data: githubProviders, isLoading: isLoadingGithub } =
api.github.githubProviders.useQuery();
const { data: gitlabProviders, isLoading: isLoadingGitlab } =
api.gitlab.gitlabProviders.useQuery();
const { data: bitbucketProviders, isLoading: isLoadingBitbucket } =
api.bitbucket.bitbucketProviders.useQuery();
const { data: giteaProviders } = api.gitea.giteaProviders.useQuery();
const { data: giteaProviders, isLoading: isLoadingGitea } =
api.gitea.giteaProviders.useQuery();
const { data: compose } = api.compose.one.useQuery({ composeId });
const [tab, setSab] = useState<TabState>(compose?.sourceType || "github");
const isLoading =
isLoadingGithub || isLoadingGitlab || isLoadingBitbucket || isLoadingGitea;
if (isLoading) {
return (
<Card className="group relative w-full bg-transparent">
<CardHeader>
<CardTitle className="flex items-start justify-between">
<div className="flex flex-col gap-2">
<span className="flex flex-col space-y-0.5">Provider</span>
<p className="flex items-center text-sm font-normal text-muted-foreground">
Select the source of your code
</p>
</div>
<div className="hidden space-y-1 text-sm font-normal md:block">
<GitBranch className="size-6 text-muted-foreground" />
</div>
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex min-h-[25vh] items-center justify-center">
<div className="flex items-center gap-2 text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
<span>Loading providers...</span>
</div>
</div>
</CardContent>
</Card>
);
}
return (
<Card className="group relative w-full bg-transparent">
<CardHeader>
@@ -108,7 +142,7 @@ export const ShowProviderFormCompose = ({ composeId }: Props) => {
{githubProviders && githubProviders?.length > 0 ? (
<SaveGithubProviderCompose composeId={composeId} />
) : (
<div className="flex flex-col items-center gap-3 min-h-[15vh] justify-center">
<div className="flex flex-col items-center gap-3 min-h-[25vh] justify-center">
<GithubIcon className="size-8 text-muted-foreground" />
<span className="text-base text-muted-foreground">
To deploy using GitHub, you need to configure your account
@@ -128,7 +162,7 @@ export const ShowProviderFormCompose = ({ composeId }: Props) => {
{gitlabProviders && gitlabProviders?.length > 0 ? (
<SaveGitlabProviderCompose composeId={composeId} />
) : (
<div className="flex flex-col items-center gap-3 min-h-[15vh] justify-center">
<div className="flex flex-col items-center gap-3 min-h-[25vh] justify-center">
<GitlabIcon className="size-8 text-muted-foreground" />
<span className="text-base text-muted-foreground">
To deploy using GitLab, you need to configure your account
@@ -148,7 +182,7 @@ export const ShowProviderFormCompose = ({ composeId }: Props) => {
{bitbucketProviders && bitbucketProviders?.length > 0 ? (
<SaveBitbucketProviderCompose composeId={composeId} />
) : (
<div className="flex flex-col items-center gap-3 min-h-[15vh] justify-center">
<div className="flex flex-col items-center gap-3 min-h-[25vh] justify-center">
<BitbucketIcon className="size-8 text-muted-foreground" />
<span className="text-base text-muted-foreground">
To deploy using Bitbucket, you need to configure your account
@@ -168,7 +202,7 @@ export const ShowProviderFormCompose = ({ composeId }: Props) => {
{giteaProviders && giteaProviders?.length > 0 ? (
<SaveGiteaProviderCompose composeId={composeId} />
) : (
<div className="flex flex-col items-center gap-3 min-h-[15vh] justify-center">
<div className="flex flex-col items-center gap-3 min-h-[25vh] justify-center">
<GiteaIcon className="size-8 text-muted-foreground" />
<span className="text-base text-muted-foreground">
To deploy using Gitea, you need to configure your account

View File

@@ -1,347 +0,0 @@
import { Button } from "@/components/ui/button";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
} from "@/components/ui/command";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Switch } from "@/components/ui/switch";
import { cn } from "@/lib/utils";
import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod";
import { PlusIcon } from "lucide-react";
import { CheckIcon, ChevronsUpDown } from "lucide-react";
import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
const AddPostgresBackup1Schema = z.object({
destinationId: z.string().min(1, "Destination required"),
schedule: z.string().min(1, "Schedule (Cron) required"),
// .regex(
// new RegExp(
// /^(\*|([0-9]|1[0-9]|2[0-9]|3[0-9]|4[0-9]|5[0-9])|\*\/([0-9]|1[0-9]|2[0-9]|3[0-9]|4[0-9]|5[0-9])) (\*|([0-9]|1[0-9]|2[0-3])|\*\/([0-9]|1[0-9]|2[0-3])) (\*|([1-9]|1[0-9]|2[0-9]|3[0-1])|\*\/([1-9]|1[0-9]|2[0-9]|3[0-1])) (\*|([1-9]|1[0-2])|\*\/([1-9]|1[0-2])) (\*|([0-6])|\*\/([0-6]))$/,
// ),
// "Invalid Cron",
// ),
prefix: z.string().min(1, "Prefix required"),
enabled: z.boolean(),
database: z.string().min(1, "Database required"),
keepLatestCount: z.coerce.number().optional(),
});
type AddPostgresBackup = z.infer<typeof AddPostgresBackup1Schema>;
interface Props {
databaseId: string;
databaseType: "postgres" | "mariadb" | "mysql" | "mongo" | "web-server";
refetch: () => void;
}
export const AddBackup = ({ databaseId, databaseType, refetch }: Props) => {
const { data, isLoading } = api.destination.all.useQuery();
const { mutateAsync: createBackup, isLoading: isCreatingPostgresBackup } =
api.backup.create.useMutation();
const form = useForm<AddPostgresBackup>({
defaultValues: {
database: "",
destinationId: "",
enabled: true,
prefix: "/",
schedule: "",
keepLatestCount: undefined,
},
resolver: zodResolver(AddPostgresBackup1Schema),
});
useEffect(() => {
form.reset({
database: databaseType === "web-server" ? "dokploy" : "",
destinationId: "",
enabled: true,
prefix: "/",
schedule: "",
keepLatestCount: undefined,
});
}, [form, form.reset, form.formState.isSubmitSuccessful]);
const onSubmit = async (data: AddPostgresBackup) => {
const getDatabaseId =
databaseType === "postgres"
? {
postgresId: databaseId,
}
: databaseType === "mariadb"
? {
mariadbId: databaseId,
}
: databaseType === "mysql"
? {
mysqlId: databaseId,
}
: databaseType === "mongo"
? {
mongoId: databaseId,
}
: databaseType === "web-server"
? {
userId: databaseId,
}
: undefined;
await createBackup({
destinationId: data.destinationId,
prefix: data.prefix,
schedule: data.schedule,
enabled: data.enabled,
database: data.database,
keepLatestCount: data.keepLatestCount,
databaseType,
...getDatabaseId,
})
.then(async () => {
toast.success("Backup Created");
refetch();
})
.catch(() => {
toast.error("Error creating a backup");
});
};
return (
<Dialog>
<DialogTrigger asChild>
<Button>
<PlusIcon className="h-4 w-4" />
Create Backup
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-lg max-h-screen overflow-y-auto">
<DialogHeader>
<DialogTitle>Create a backup</DialogTitle>
<DialogDescription>Add a new backup</DialogDescription>
</DialogHeader>
<Form {...form}>
<form
id="hook-form-add-backup"
onSubmit={form.handleSubmit(onSubmit)}
className="grid w-full gap-4"
>
<div className="grid grid-cols-1 gap-4">
<FormField
control={form.control}
name="destinationId"
render={({ field }) => (
<FormItem className="">
<FormLabel>Destination</FormLabel>
<Popover>
<PopoverTrigger asChild>
<FormControl>
<Button
variant="outline"
className={cn(
"w-full justify-between !bg-input",
!field.value && "text-muted-foreground",
)}
>
{isLoading
? "Loading...."
: field.value
? data?.find(
(destination) =>
destination.destinationId === field.value,
)?.name
: "Select Destination"}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent className="p-0" align="start">
<Command>
<CommandInput
placeholder="Search Destination..."
className="h-9"
/>
{isLoading && (
<span className="py-6 text-center text-sm">
Loading Destinations....
</span>
)}
<CommandEmpty>No destinations found.</CommandEmpty>
<ScrollArea className="h-64">
<CommandGroup>
{data?.map((destination) => (
<CommandItem
value={destination.destinationId}
key={destination.destinationId}
onSelect={() => {
form.setValue(
"destinationId",
destination.destinationId,
);
}}
>
{destination.name}
<CheckIcon
className={cn(
"ml-auto h-4 w-4",
destination.destinationId === field.value
? "opacity-100"
: "opacity-0",
)}
/>
</CommandItem>
))}
</CommandGroup>
</ScrollArea>
</Command>
</PopoverContent>
</Popover>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="database"
render={({ field }) => {
return (
<FormItem>
<FormLabel>Database</FormLabel>
<FormControl>
<Input
disabled={databaseType === "web-server"}
placeholder={"dokploy"}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
);
}}
/>
<FormField
control={form.control}
name="schedule"
render={({ field }) => {
return (
<FormItem>
<FormLabel>Schedule (Cron)</FormLabel>
<FormControl>
<Input placeholder={"0 0 * * *"} {...field} />
</FormControl>
<FormMessage />
</FormItem>
);
}}
/>
<FormField
control={form.control}
name="prefix"
render={({ field }) => {
return (
<FormItem>
<FormLabel>Prefix Destination</FormLabel>
<FormControl>
<Input placeholder={"dokploy/"} {...field} />
</FormControl>
<FormDescription>
Use if you want to back up in a specific path of your
destination/bucket
</FormDescription>
<FormMessage />
</FormItem>
);
}}
/>
<FormField
control={form.control}
name="keepLatestCount"
render={({ field }) => {
return (
<FormItem>
<FormLabel>Keep the latest</FormLabel>
<FormControl>
<Input
type="number"
placeholder={"keeps all the backups if left empty"}
{...field}
/>
</FormControl>
<FormDescription>
Optional. If provided, only keeps the latest N backups
in the cloud.
</FormDescription>
<FormMessage />
</FormItem>
);
}}
/>
<FormField
control={form.control}
name="enabled"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3 ">
<div className="space-y-0.5">
<FormLabel>Enabled</FormLabel>
<FormDescription>
Enable or disable the backup
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
</div>
<DialogFooter>
<Button
isLoading={isCreatingPostgresBackup}
form="hook-form-add-backup"
type="submit"
>
Create
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
);
};

View File

@@ -0,0 +1,828 @@
import { AlertBlock } from "@/components/shared/alert-block";
import { Button } from "@/components/ui/button";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
} from "@/components/ui/command";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod";
import {
DatabaseZap,
Info,
PenBoxIcon,
PlusIcon,
RefreshCw,
} from "lucide-react";
import { CheckIcon, ChevronsUpDown } from "lucide-react";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { commonCronExpressions } from "../../application/schedules/handle-schedules";
type CacheType = "cache" | "fetch";
type DatabaseType = "postgres" | "mariadb" | "mysql" | "mongo" | "web-server";
const Schema = z
.object({
destinationId: z.string().min(1, "Destination required"),
schedule: z.string().min(1, "Schedule (Cron) required"),
prefix: z.string().min(1, "Prefix required"),
enabled: z.boolean(),
database: z.string().min(1, "Database required"),
keepLatestCount: z.coerce.number().optional(),
serviceName: z.string().nullable(),
databaseType: z
.enum(["postgres", "mariadb", "mysql", "mongo", "web-server"])
.optional(),
backupType: z.enum(["database", "compose"]),
metadata: z
.object({
postgres: z
.object({
databaseUser: z.string(),
})
.optional(),
mariadb: z
.object({
databaseUser: z.string(),
databasePassword: z.string(),
})
.optional(),
mongo: z
.object({
databaseUser: z.string(),
databasePassword: z.string(),
})
.optional(),
mysql: z
.object({
databaseRootPassword: z.string(),
})
.optional(),
})
.optional(),
})
.superRefine((data, ctx) => {
if (data.backupType === "compose" && !data.databaseType) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Database type is required for compose backups",
path: ["databaseType"],
});
}
if (data.backupType === "compose" && !data.serviceName) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Service name is required for compose backups",
path: ["serviceName"],
});
}
if (data.backupType === "compose" && data.databaseType) {
if (data.databaseType === "postgres") {
if (!data.metadata?.postgres?.databaseUser) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Database user is required for PostgreSQL",
path: ["metadata", "postgres", "databaseUser"],
});
}
} else if (data.databaseType === "mariadb") {
if (!data.metadata?.mariadb?.databaseUser) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Database user is required for MariaDB",
path: ["metadata", "mariadb", "databaseUser"],
});
}
if (!data.metadata?.mariadb?.databasePassword) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Database password is required for MariaDB",
path: ["metadata", "mariadb", "databasePassword"],
});
}
} else if (data.databaseType === "mongo") {
if (!data.metadata?.mongo?.databaseUser) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Database user is required for MongoDB",
path: ["metadata", "mongo", "databaseUser"],
});
}
if (!data.metadata?.mongo?.databasePassword) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Database password is required for MongoDB",
path: ["metadata", "mongo", "databasePassword"],
});
}
} else if (data.databaseType === "mysql") {
if (!data.metadata?.mysql?.databaseRootPassword) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Root password is required for MySQL",
path: ["metadata", "mysql", "databaseRootPassword"],
});
}
}
}
});
interface Props {
id?: string;
backupId?: string;
databaseType?: DatabaseType;
refetch: () => void;
backupType: "database" | "compose";
}
export const HandleBackup = ({
id,
backupId,
databaseType = "postgres",
refetch,
backupType = "database",
}: Props) => {
const [isOpen, setIsOpen] = useState(false);
const { data, isLoading } = api.destination.all.useQuery();
const { data: backup } = api.backup.one.useQuery(
{
backupId: backupId ?? "",
},
{
enabled: !!backupId,
},
);
const [cacheType, setCacheType] = useState<CacheType>("cache");
const { mutateAsync: createBackup, isLoading: isCreatingPostgresBackup } =
backupId
? api.backup.update.useMutation()
: api.backup.create.useMutation();
const form = useForm<z.infer<typeof Schema>>({
defaultValues: {
database: databaseType === "web-server" ? "dokploy" : "",
destinationId: "",
enabled: true,
prefix: "/",
schedule: "",
keepLatestCount: undefined,
serviceName: null,
databaseType: backupType === "compose" ? undefined : databaseType,
backupType: backupType,
metadata: {},
},
resolver: zodResolver(Schema),
});
const {
data: services,
isFetching: isLoadingServices,
error: errorServices,
refetch: refetchServices,
} = api.compose.loadServices.useQuery(
{
composeId: backup?.composeId ?? id ?? "",
type: cacheType,
},
{
retry: false,
refetchOnWindowFocus: false,
enabled: backupType === "compose" && !!backup?.composeId && !!id,
},
);
useEffect(() => {
form.reset({
database: backup?.database
? backup?.database
: databaseType === "web-server"
? "dokploy"
: "",
destinationId: backup?.destinationId ?? "",
enabled: backup?.enabled ?? true,
prefix: backup?.prefix ?? "/",
schedule: backup?.schedule ?? "",
keepLatestCount: backup?.keepLatestCount ?? undefined,
serviceName: backup?.serviceName ?? null,
databaseType: backup?.databaseType ?? databaseType,
backupType: backup?.backupType ?? backupType,
metadata: backup?.metadata ?? {},
});
}, [form, form.reset, backupId, backup]);
const onSubmit = async (data: z.infer<typeof Schema>) => {
const getDatabaseId =
backupType === "compose"
? {
composeId: id,
}
: databaseType === "postgres"
? {
postgresId: id,
}
: databaseType === "mariadb"
? {
mariadbId: id,
}
: databaseType === "mysql"
? {
mysqlId: id,
}
: databaseType === "mongo"
? {
mongoId: id,
}
: databaseType === "web-server"
? {
userId: id,
}
: undefined;
await createBackup({
destinationId: data.destinationId,
prefix: data.prefix,
schedule: data.schedule,
enabled: data.enabled,
database: data.database,
keepLatestCount: data.keepLatestCount ?? null,
databaseType: data.databaseType || databaseType,
serviceName: data.serviceName,
...getDatabaseId,
backupId: backupId ?? "",
backupType,
metadata: data.metadata,
})
.then(async () => {
toast.success(`Backup ${backupId ? "Updated" : "Created"}`);
refetch();
setIsOpen(false);
})
.catch(() => {
toast.error(`Error ${backupId ? "updating" : "creating"} a backup`);
});
};
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
{backupId ? (
<Button
variant="ghost"
size="icon"
className="group hover:bg-blue-500/10 size-8"
>
<PenBoxIcon className="size-3.5 text-primary group-hover:text-blue-500" />
</Button>
) : (
<Button>
<PlusIcon className="h-4 w-4" />
{backupId ? "Update Backup" : "Create Backup"}
</Button>
)}
</DialogTrigger>
<DialogContent className="sm:max-w-2xl max-h-screen overflow-y-auto">
<DialogHeader>
<DialogTitle>
{backupId ? "Update Backup" : "Create Backup"}
</DialogTitle>
<DialogDescription>
{backupId ? "Update a backup" : "Add a new backup"}
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form
id="hook-form-add-backup"
onSubmit={form.handleSubmit(onSubmit)}
className="grid w-full gap-4"
>
<div className="grid grid-cols-1 gap-4">
{errorServices && (
<AlertBlock type="warning" className="[overflow-wrap:anywhere]">
{errorServices?.message}
</AlertBlock>
)}
{backupType === "compose" && (
<FormField
control={form.control}
name="databaseType"
render={({ field }) => (
<FormItem>
<FormLabel>Database Type</FormLabel>
<Select
value={field.value}
onValueChange={(value) => {
field.onChange(value as DatabaseType);
form.setValue("metadata", {});
}}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select a database type" />
</SelectTrigger>
<SelectContent>
<SelectItem value="postgres">PostgreSQL</SelectItem>
<SelectItem value="mariadb">MariaDB</SelectItem>
<SelectItem value="mysql">MySQL</SelectItem>
<SelectItem value="mongo">MongoDB</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
)}
<FormField
control={form.control}
name="destinationId"
render={({ field }) => (
<FormItem className="">
<FormLabel>Destination</FormLabel>
<Popover>
<PopoverTrigger asChild>
<FormControl>
<Button
variant="outline"
className={cn(
"w-full justify-between !bg-input",
!field.value && "text-muted-foreground",
)}
>
{isLoading
? "Loading...."
: field.value
? data?.find(
(destination) =>
destination.destinationId === field.value,
)?.name
: "Select Destination"}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent className="p-0" align="start">
<Command>
<CommandInput
placeholder="Search Destination..."
className="h-9"
/>
{isLoading && (
<span className="py-6 text-center text-sm">
Loading Destinations....
</span>
)}
<CommandEmpty>No destinations found.</CommandEmpty>
<ScrollArea className="h-64">
<CommandGroup>
{data?.map((destination) => (
<CommandItem
value={destination.destinationId}
key={destination.destinationId}
onSelect={() => {
form.setValue(
"destinationId",
destination.destinationId,
);
}}
>
{destination.name}
<CheckIcon
className={cn(
"ml-auto h-4 w-4",
destination.destinationId === field.value
? "opacity-100"
: "opacity-0",
)}
/>
</CommandItem>
))}
</CommandGroup>
</ScrollArea>
</Command>
</PopoverContent>
</Popover>
<FormMessage />
</FormItem>
)}
/>
{backupType === "compose" && (
<div className="flex flex-row items-end w-full gap-4">
<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}
value={field.value || undefined}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select a service name" />
</SelectTrigger>
</FormControl>
<SelectContent>
{services?.map((service, index) => (
<SelectItem
value={service}
key={`${service}-${index}`}
>
{service}
</SelectItem>
))}
{(!services || services.length === 0) && (
<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="database"
render={({ field }) => {
return (
<FormItem>
<FormLabel>Database</FormLabel>
<FormControl>
<Input
disabled={databaseType === "web-server"}
placeholder={"dokploy"}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
);
}}
/>
<FormField
control={form.control}
name="schedule"
render={({ field }) => {
return (
<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>
);
}}
/>
<FormField
control={form.control}
name="prefix"
render={({ field }) => {
return (
<FormItem>
<FormLabel>Prefix Destination</FormLabel>
<FormControl>
<Input placeholder={"dokploy/"} {...field} />
</FormControl>
<FormDescription>
Use if you want to back up in a specific path of your
destination/bucket
</FormDescription>
<FormMessage />
</FormItem>
);
}}
/>
<FormField
control={form.control}
name="keepLatestCount"
render={({ field }) => {
return (
<FormItem>
<FormLabel>Keep the latest</FormLabel>
<FormControl>
<Input
type="number"
placeholder={"keeps all the backups if left empty"}
{...field}
/>
</FormControl>
<FormDescription>
Optional. If provided, only keeps the latest N backups
in the cloud.
</FormDescription>
<FormMessage />
</FormItem>
);
}}
/>
<FormField
control={form.control}
name="enabled"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3 ">
<div className="space-y-0.5">
<FormLabel>Enabled</FormLabel>
<FormDescription>
Enable or disable the backup
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
{backupType === "compose" && (
<>
{form.watch("databaseType") === "postgres" && (
<FormField
control={form.control}
name="metadata.postgres.databaseUser"
render={({ field }) => (
<FormItem>
<FormLabel>Database User</FormLabel>
<FormControl>
<Input placeholder="postgres" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
{form.watch("databaseType") === "mariadb" && (
<>
<FormField
control={form.control}
name="metadata.mariadb.databaseUser"
render={({ field }) => (
<FormItem>
<FormLabel>Database User</FormLabel>
<FormControl>
<Input placeholder="mariadb" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="metadata.mariadb.databasePassword"
render={({ field }) => (
<FormItem>
<FormLabel>Database Password</FormLabel>
<FormControl>
<Input
type="password"
placeholder="••••••••"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</>
)}
{form.watch("databaseType") === "mongo" && (
<>
<FormField
control={form.control}
name="metadata.mongo.databaseUser"
render={({ field }) => (
<FormItem>
<FormLabel>Database User</FormLabel>
<FormControl>
<Input placeholder="mongo" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="metadata.mongo.databasePassword"
render={({ field }) => (
<FormItem>
<FormLabel>Database Password</FormLabel>
<FormControl>
<Input
type="password"
placeholder="••••••••"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</>
)}
{form.watch("databaseType") === "mysql" && (
<FormField
control={form.control}
name="metadata.mysql.databaseRootPassword"
render={({ field }) => (
<FormItem>
<FormLabel>Root Password</FormLabel>
<FormControl>
<Input
type="password"
placeholder="••••••••"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
</>
)}
</div>
<DialogFooter>
<Button
isLoading={isCreatingPostgresBackup}
form="hook-form-add-backup"
type="submit"
>
{backupId ? "Update" : "Create"}
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
);
};

View File

@@ -32,50 +32,172 @@ import {
PopoverTrigger,
} from "@/components/ui/popover";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { cn } from "@/lib/utils";
import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod";
import copy from "copy-to-clipboard";
import { debounce } from "lodash";
import { CheckIcon, ChevronsUpDown, Copy, RotateCcw } from "lucide-react";
import {
CheckIcon,
ChevronsUpDown,
Copy,
RotateCcw,
RefreshCw,
DatabaseZap,
} from "lucide-react";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import type { ServiceType } from "../../application/advanced/show-resources";
import { type LogLine, parseLogs } from "../../docker/logs/utils";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
type DatabaseType =
| Exclude<ServiceType, "application" | "redis">
| "web-server";
interface Props {
databaseId: string;
databaseType: Exclude<ServiceType, "application" | "redis"> | "web-server";
id: string;
databaseType?: DatabaseType;
serverId?: string | null;
backupType?: "database" | "compose";
}
const RestoreBackupSchema = z.object({
destinationId: z
.string({
required_error: "Please select a destination",
})
.min(1, {
message: "Destination is required",
}),
backupFile: z
.string({
required_error: "Please select a backup file",
})
.min(1, {
message: "Backup file is required",
}),
databaseName: z
.string({
required_error: "Please enter a database name",
})
.min(1, {
message: "Database name is required",
}),
});
const RestoreBackupSchema = z
.object({
destinationId: z
.string({
required_error: "Please select a destination",
})
.min(1, {
message: "Destination is required",
}),
backupFile: z
.string({
required_error: "Please select a backup file",
})
.min(1, {
message: "Backup file is required",
}),
databaseName: z
.string({
required_error: "Please enter a database name",
})
.min(1, {
message: "Database name is required",
}),
databaseType: z
.enum(["postgres", "mariadb", "mysql", "mongo", "web-server"])
.optional(),
backupType: z.enum(["database", "compose"]).default("database"),
metadata: z
.object({
postgres: z
.object({
databaseUser: z.string(),
})
.optional(),
mariadb: z
.object({
databaseUser: z.string(),
databasePassword: z.string(),
})
.optional(),
mongo: z
.object({
databaseUser: z.string(),
databasePassword: z.string(),
})
.optional(),
mysql: z
.object({
databaseRootPassword: z.string(),
})
.optional(),
serviceName: z.string().optional(),
})
.optional(),
})
.superRefine((data, ctx) => {
if (data.backupType === "compose" && !data.databaseType) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Database type is required for compose backups",
path: ["databaseType"],
});
}
type RestoreBackup = z.infer<typeof RestoreBackupSchema>;
if (data.backupType === "compose" && !data.metadata?.serviceName) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Service name is required for compose backups",
path: ["metadata", "serviceName"],
});
}
if (data.backupType === "compose" && data.databaseType) {
if (data.databaseType === "postgres") {
if (!data.metadata?.postgres?.databaseUser) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Database user is required for PostgreSQL",
path: ["metadata", "postgres", "databaseUser"],
});
}
} else if (data.databaseType === "mariadb") {
if (!data.metadata?.mariadb?.databaseUser) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Database user is required for MariaDB",
path: ["metadata", "mariadb", "databaseUser"],
});
}
if (!data.metadata?.mariadb?.databasePassword) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Database password is required for MariaDB",
path: ["metadata", "mariadb", "databasePassword"],
});
}
} else if (data.databaseType === "mongo") {
if (!data.metadata?.mongo?.databaseUser) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Database user is required for MongoDB",
path: ["metadata", "mongo", "databaseUser"],
});
}
if (!data.metadata?.mongo?.databasePassword) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Database password is required for MongoDB",
path: ["metadata", "mongo", "databasePassword"],
});
}
} else if (data.databaseType === "mysql") {
if (!data.metadata?.mysql?.databaseRootPassword) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Root password is required for MySQL",
path: ["metadata", "mysql", "databaseRootPassword"],
});
}
}
}
});
const formatBytes = (bytes: number): string => {
if (bytes === 0) return "0 Bytes";
@@ -86,9 +208,10 @@ const formatBytes = (bytes: number): string => {
};
export const RestoreBackup = ({
databaseId,
id,
databaseType,
serverId,
backupType = "database",
}: Props) => {
const [isOpen, setIsOpen] = useState(false);
const [search, setSearch] = useState("");
@@ -96,16 +219,22 @@ export const RestoreBackup = ({
const { data: destinations = [] } = api.destination.all.useQuery();
const form = useForm<RestoreBackup>({
const form = useForm<z.infer<typeof RestoreBackupSchema>>({
defaultValues: {
destinationId: "",
backupFile: "",
databaseName: databaseType === "web-server" ? "dokploy" : "",
databaseType:
backupType === "compose" ? ("postgres" as DatabaseType) : databaseType,
backupType: backupType,
metadata: {},
},
resolver: zodResolver(RestoreBackupSchema),
});
const destionationId = form.watch("destinationId");
const currentDatabaseType = form.watch("databaseType");
const metadata = form.watch("metadata");
const debouncedSetSearch = debounce((value: string) => {
setDebouncedSearchTerm(value);
@@ -131,16 +260,15 @@ export const RestoreBackup = ({
const [filteredLogs, setFilteredLogs] = useState<LogLine[]>([]);
const [isDeploying, setIsDeploying] = useState(false);
// const { mutateAsync: restore, isLoading: isRestoring } =
// api.backup.restoreBackup.useMutation();
api.backup.restoreBackupWithLogs.useSubscription(
{
databaseId,
databaseType,
databaseId: id,
databaseType: currentDatabaseType as DatabaseType,
databaseName: form.watch("databaseName"),
backupFile: form.watch("backupFile"),
destinationId: form.watch("destinationId"),
backupType: backupType,
metadata: metadata,
},
{
enabled: isDeploying,
@@ -162,10 +290,32 @@ export const RestoreBackup = ({
},
);
const onSubmit = async (_data: RestoreBackup) => {
const onSubmit = async (data: z.infer<typeof RestoreBackupSchema>) => {
if (backupType === "compose" && !data.databaseType) {
toast.error("Please select a database type");
return;
}
console.log({ data });
setIsDeploying(true);
};
const [cacheType, setCacheType] = useState<"fetch" | "cache">("cache");
const {
data: services = [],
isLoading: isLoadingServices,
refetch: refetchServices,
} = api.compose.loadServices.useQuery(
{
composeId: id,
type: cacheType,
},
{
retry: false,
refetchOnWindowFocus: false,
enabled: backupType === "compose",
},
);
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
@@ -174,7 +324,7 @@ export const RestoreBackup = ({
Restore Backup
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-lg">
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-lg">
<DialogHeader>
<DialogTitle className="flex items-center">
<RotateCcw className="mr-2 size-4" />
@@ -377,25 +527,274 @@ export const RestoreBackup = ({
control={form.control}
name="databaseName"
render={({ field }) => (
<FormItem className="">
<FormItem>
<FormLabel>Database Name</FormLabel>
<FormControl>
<Input
disabled={databaseType === "web-server"}
{...field}
placeholder="Enter database name"
{...field}
disabled={databaseType === "web-server"}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{backupType === "compose" && (
<>
<FormField
control={form.control}
name="databaseType"
render={({ field }) => (
<FormItem>
<FormLabel>Database Type</FormLabel>
<Select
value={field.value}
onValueChange={(value: DatabaseType) => {
field.onChange(value);
form.setValue("metadata", {});
}}
>
<SelectTrigger>
<SelectValue placeholder="Select database type" />
</SelectTrigger>
<SelectContent>
<SelectItem value="postgres">PostgreSQL</SelectItem>
<SelectItem value="mariadb">MariaDB</SelectItem>
<SelectItem value="mongo">MongoDB</SelectItem>
<SelectItem value="mysql">MySQL</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="metadata.serviceName"
render={({ field }) => (
<FormItem className="w-full">
<FormLabel>Service Name</FormLabel>
<div className="flex gap-2">
<Select
onValueChange={field.onChange}
value={field.value || undefined}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select a service name" />
</SelectTrigger>
</FormControl>
<SelectContent>
{services?.map((service, index) => (
<SelectItem
value={service}
key={`${service}-${index}`}
>
{service}
</SelectItem>
))}
{(!services || services.length === 0) && (
<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>
)}
/>
{currentDatabaseType === "postgres" && (
<FormField
control={form.control}
name="metadata.postgres.databaseUser"
render={({ field }) => (
<FormItem>
<FormLabel>Database User</FormLabel>
<FormControl>
<Input placeholder="Enter database user" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
{currentDatabaseType === "mariadb" && (
<>
<FormField
control={form.control}
name="metadata.mariadb.databaseUser"
render={({ field }) => (
<FormItem>
<FormLabel>Database User</FormLabel>
<FormControl>
<Input
placeholder="Enter database user"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="metadata.mariadb.databasePassword"
render={({ field }) => (
<FormItem>
<FormLabel>Database Password</FormLabel>
<FormControl>
<Input
type="password"
placeholder="Enter database password"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</>
)}
{currentDatabaseType === "mongo" && (
<>
<FormField
control={form.control}
name="metadata.mongo.databaseUser"
render={({ field }) => (
<FormItem>
<FormLabel>Database User</FormLabel>
<FormControl>
<Input
placeholder="Enter database user"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="metadata.mongo.databasePassword"
render={({ field }) => (
<FormItem>
<FormLabel>Database Password</FormLabel>
<FormControl>
<Input
type="password"
placeholder="Enter database password"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</>
)}
{currentDatabaseType === "mysql" && (
<FormField
control={form.control}
name="metadata.mysql.databaseRootPassword"
render={({ field }) => (
<FormItem>
<FormLabel>Root Password</FormLabel>
<FormControl>
<Input
type="password"
placeholder="Enter root password"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
</>
)}
<DialogFooter>
<Button
isLoading={isDeploying}
form="hook-form-restore-backup"
type="submit"
disabled={!form.watch("backupFile")}
// disabled={
// !form.watch("backupFile") ||
// (backupType === "compose" && !form.watch("databaseType"))
// }
>
Restore
</Button>

View File

@@ -14,49 +14,83 @@ import {
TooltipTrigger,
} from "@/components/ui/tooltip";
import { api } from "@/utils/api";
import { Database, DatabaseBackup, Play, Trash2 } from "lucide-react";
import {
ClipboardList,
Database,
DatabaseBackup,
Play,
Trash2,
} from "lucide-react";
import Link from "next/link";
import { useState } from "react";
import { toast } from "sonner";
import type { ServiceType } from "../../application/advanced/show-resources";
import { AddBackup } from "./add-backup";
import { RestoreBackup } from "./restore-backup";
import { UpdateBackup } from "./update-backup";
import { HandleBackup } from "./handle-backup";
import { cn } from "@/lib/utils";
import {
MariadbIcon,
MongodbIcon,
MysqlIcon,
PostgresqlIcon,
} from "@/components/icons/data-tools-icons";
import { AlertBlock } from "@/components/shared/alert-block";
import { ShowDeploymentsModal } from "../../application/deployments/show-deployments-modal";
interface Props {
id: string;
type: Exclude<ServiceType, "application" | "redis"> | "web-server";
databaseType?: Exclude<ServiceType, "application" | "redis"> | "web-server";
backupType?: "database" | "compose";
}
export const ShowBackups = ({ id, type }: Props) => {
export const ShowBackups = ({
id,
databaseType,
backupType = "database",
}: Props) => {
const [activeManualBackup, setActiveManualBackup] = useState<
string | undefined
>();
const queryMap = {
postgres: () =>
api.postgres.one.useQuery({ postgresId: id }, { enabled: !!id }),
mysql: () => api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }),
mariadb: () =>
api.mariadb.one.useQuery({ mariadbId: id }, { enabled: !!id }),
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
"web-server": () => api.user.getBackups.useQuery(),
};
const queryMap =
backupType === "database"
? {
postgres: () =>
api.postgres.one.useQuery({ postgresId: id }, { enabled: !!id }),
mysql: () =>
api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }),
mariadb: () =>
api.mariadb.one.useQuery({ mariadbId: id }, { enabled: !!id }),
mongo: () =>
api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
"web-server": () => api.user.getBackups.useQuery(),
}
: {
compose: () =>
api.compose.one.useQuery({ composeId: id }, { enabled: !!id }),
};
const { data } = api.destination.all.useQuery();
const { data: postgres, refetch } = queryMap[type]
? queryMap[type]()
const key = backupType === "database" ? databaseType : "compose";
const query = queryMap[key as keyof typeof queryMap];
const { data: postgres, refetch } = query
? query()
: api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id });
const mutationMap = {
postgres: () => api.backup.manualBackupPostgres.useMutation(),
mysql: () => api.backup.manualBackupMySql.useMutation(),
mariadb: () => api.backup.manualBackupMariadb.useMutation(),
mongo: () => api.backup.manualBackupMongo.useMutation(),
"web-server": () => api.backup.manualBackupWebServer.useMutation(),
};
const mutationMap =
backupType === "database"
? {
postgres: api.backup.manualBackupPostgres.useMutation(),
mysql: api.backup.manualBackupMySql.useMutation(),
mariadb: api.backup.manualBackupMariadb.useMutation(),
mongo: api.backup.manualBackupMongo.useMutation(),
"web-server": api.backup.manualBackupWebServer.useMutation(),
}
: {
compose: api.backup.manualBackupCompose.useMutation(),
};
const { mutateAsync: manualBackup, isLoading: isManualBackup } = mutationMap[
type
]
? mutationMap[type]()
const mutation = mutationMap[key as keyof typeof mutationMap];
const { mutateAsync: manualBackup, isLoading: isManualBackup } = mutation
? mutation
: api.backup.manualBackupMongo.useMutation();
const { mutateAsync: deleteBackup, isLoading: isRemoving } =
@@ -78,16 +112,18 @@ export const ShowBackups = ({ id, type }: Props) => {
{postgres && postgres?.backups?.length > 0 && (
<div className="flex flex-col lg:flex-row gap-4 w-full lg:w-auto">
{type !== "web-server" && (
<AddBackup
databaseId={id}
databaseType={type}
{databaseType !== "web-server" && (
<HandleBackup
id={id}
databaseType={databaseType}
backupType={backupType}
refetch={refetch}
/>
)}
<RestoreBackup
databaseId={id}
databaseType={type}
id={id}
databaseType={databaseType}
backupType={backupType}
serverId={"serverId" in postgres ? postgres.serverId : undefined}
/>
</div>
@@ -95,7 +131,7 @@ export const ShowBackups = ({ id, type }: Props) => {
</CardHeader>
<CardContent className="flex flex-col gap-4">
{data?.length === 0 ? (
<div className="flex flex-col items-center gap-3">
<div className="flex flex-col items-center gap-3 min-h-[35vh] justify-center">
<DatabaseBackup className="size-8 text-muted-foreground" />
<span className="text-base text-muted-foreground text-center">
To create a backup it is required to set at least 1 provider.
@@ -110,7 +146,7 @@ export const ShowBackups = ({ id, type }: Props) => {
</span>
</div>
) : (
<div>
<div className="flex flex-col gap-4 w-full">
{postgres?.backups.length === 0 ? (
<div className="flex w-full flex-col items-center justify-center gap-3 pt-10">
<DatabaseBackup className="size-8 text-muted-foreground" />
@@ -118,14 +154,16 @@ export const ShowBackups = ({ id, type }: Props) => {
No backups configured
</span>
<div className="flex flex-col sm:flex-row gap-4 w-full sm:w-auto">
<AddBackup
databaseId={id}
databaseType={type}
<HandleBackup
id={id}
databaseType={databaseType}
backupType={backupType}
refetch={refetch}
/>
<RestoreBackup
databaseId={id}
databaseType={type}
id={id}
databaseType={databaseType}
backupType={backupType}
serverId={
"serverId" in postgres ? postgres.serverId : undefined
}
@@ -133,119 +171,204 @@ export const ShowBackups = ({ id, type }: Props) => {
</div>
</div>
) : (
<div className="flex flex-col pt-2">
<div className="flex flex-col pt-2 gap-4">
{backupType === "compose" && (
<AlertBlock title="Compose Backups">
Make sure the compose is running before creating a backup.
</AlertBlock>
)}
<div className="flex flex-col gap-6">
{postgres?.backups.map((backup) => (
<div key={backup.backupId}>
<div className="flex w-full flex-col md:flex-row md:items-center justify-between gap-4 md:gap-10 border rounded-lg p-4">
<div className="grid grid-cols-1 md:grid-cols-3 xl:grid-cols-6 flex-col gap-8">
<div className="flex flex-col gap-1">
<span className="font-medium">Destination</span>
<span className="text-sm text-muted-foreground">
{backup.destination.name}
</span>
</div>
<div className="flex flex-col gap-1">
<span className="font-medium">Database</span>
<span className="text-sm text-muted-foreground">
{backup.database}
</span>
</div>
<div className="flex flex-col gap-1">
<span className="font-medium">Scheduled</span>
<span className="text-sm text-muted-foreground">
{backup.schedule}
</span>
</div>
<div className="flex flex-col gap-1">
<span className="font-medium">Prefix Storage</span>
<span className="text-sm text-muted-foreground">
{backup.prefix}
</span>
</div>
<div className="flex flex-col gap-1">
<span className="font-medium">Enabled</span>
<span className="text-sm text-muted-foreground">
{backup.enabled ? "Yes" : "No"}
</span>
</div>
<div className="flex flex-col gap-1">
<span className="font-medium">Keep Latest</span>
<span className="text-sm text-muted-foreground">
{backup.keepLatestCount || "All"}
</span>
</div>
</div>
<div className="flex flex-row gap-4">
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
isLoading={
isManualBackup &&
activeManualBackup === backup.backupId
}
onClick={async () => {
setActiveManualBackup(backup.backupId);
await manualBackup({
backupId: backup.backupId as string,
})
.then(async () => {
toast.success(
"Manual Backup Successful",
);
})
.catch(() => {
toast.error(
"Error creating the manual backup",
);
});
setActiveManualBackup(undefined);
}}
>
<Play className="size-5 text-muted-foreground" />
</Button>
</TooltipTrigger>
<TooltipContent>Run Manual Backup</TooltipContent>
</Tooltip>
</TooltipProvider>
{postgres?.backups.map((backup) => {
const serverId =
"serverId" in postgres ? postgres.serverId : undefined;
<UpdateBackup
backupId={backup.backupId}
refetch={refetch}
/>
<DialogAction
title="Delete Backup"
description="Are you sure you want to delete this backup?"
type="destructive"
onClick={async () => {
await deleteBackup({
backupId: backup.backupId,
})
.then(() => {
refetch();
toast.success("Backup deleted successfully");
})
.catch(() => {
toast.error("Error deleting backup");
});
}}
>
<Button
variant="ghost"
size="icon"
className="group hover:bg-red-500/10"
isLoading={isRemoving}
return (
<div key={backup.backupId}>
<div className="flex w-full flex-col md:flex-row md:items-start justify-between gap-4 border rounded-lg p-4 hover:bg-muted/50 transition-colors">
<div className="flex flex-col w-full gap-4">
<div className="flex items-center gap-3">
{backup.backupType === "compose" && (
<div className="flex items-center justify-center size-10 rounded-lg">
{backup.databaseType === "postgres" && (
<PostgresqlIcon className="size-7" />
)}
{backup.databaseType === "mysql" && (
<MysqlIcon className="size-7" />
)}
{backup.databaseType === "mariadb" && (
<MariadbIcon className="size-7" />
)}
{backup.databaseType === "mongo" && (
<MongodbIcon className="size-7" />
)}
</div>
)}
<div className="flex flex-col gap-1">
{backup.backupType === "compose" && (
<div className="flex items-center gap-2">
<h3 className="font-medium">
{backup.serviceName}
</h3>
<span className="px-1.5 py-0.5 rounded-full bg-muted text-xs font-medium capitalize">
{backup.databaseType}
</span>
</div>
)}
<div className="flex items-center gap-2">
<div
className={cn(
"size-1.5 rounded-full",
backup.enabled
? "bg-green-500"
: "bg-red-500",
)}
/>
<span className="text-xs text-muted-foreground">
{backup.enabled ? "Active" : "Inactive"}
</span>
</div>
</div>
</div>
<div className="flex flex-wrap gap-x-8 gap-y-2">
<div className="min-w-[200px]">
<span className="text-sm font-medium text-muted-foreground">
Destination
</span>
<p className="font-medium text-sm mt-0.5">
{backup.destination.name}
</p>
</div>
<div className="min-w-[150px]">
<span className="text-sm font-medium text-muted-foreground">
Database
</span>
<p className="font-medium text-sm mt-0.5">
{backup.database}
</p>
</div>
<div className="min-w-[120px]">
<span className="text-sm font-medium text-muted-foreground">
Schedule
</span>
<p className="font-medium text-sm mt-0.5">
{backup.schedule}
</p>
</div>
<div className="min-w-[150px]">
<span className="text-sm font-medium text-muted-foreground">
Prefix Storage
</span>
<p className="font-medium text-sm mt-0.5">
{backup.prefix}
</p>
</div>
<div className="min-w-[100px]">
<span className="text-sm font-medium text-muted-foreground">
Keep Latest
</span>
<p className="font-medium text-sm mt-0.5">
{backup.keepLatestCount || "All"}
</p>
</div>
</div>
</div>
<div className="flex flex-row md:flex-col gap-1.5">
<ShowDeploymentsModal
id={backup.backupId}
type="backup"
serverId={serverId || undefined}
>
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
</Button>
</DialogAction>
<Button
variant="ghost"
size="icon"
className="size-8"
>
<ClipboardList className="size-4 transition-colors " />
</Button>
</ShowDeploymentsModal>
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="size-8"
isLoading={
isManualBackup &&
activeManualBackup === backup.backupId
}
onClick={async () => {
setActiveManualBackup(backup.backupId);
await manualBackup({
backupId: backup.backupId as string,
})
.then(async () => {
toast.success(
"Manual Backup Successful",
);
})
.catch(() => {
toast.error(
"Error creating the manual backup",
);
});
setActiveManualBackup(undefined);
}}
>
<Play className="size-4 " />
</Button>
</TooltipTrigger>
<TooltipContent>
Run Manual Backup
</TooltipContent>
</Tooltip>
</TooltipProvider>
<HandleBackup
backupType={backup.backupType}
backupId={backup.backupId}
refetch={refetch}
/>
<DialogAction
title="Delete Backup"
description="Are you sure you want to delete this backup?"
type="destructive"
onClick={async () => {
await deleteBackup({
backupId: backup.backupId,
})
.then(() => {
refetch();
toast.success(
"Backup deleted successfully",
);
})
.catch(() => {
toast.error("Error deleting backup");
});
}}
>
<Button
variant="ghost"
size="icon"
className="group hover:bg-red-500/10 size-8"
isLoading={isRemoving}
>
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
</Button>
</DialogAction>
</div>
</div>
</div>
</div>
))}
);
})}
</div>
</div>
)}

View File

@@ -1,329 +0,0 @@
import { Button } from "@/components/ui/button";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
} from "@/components/ui/command";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Switch } from "@/components/ui/switch";
import { cn } from "@/lib/utils";
import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod";
import { CheckIcon, ChevronsUpDown, PenBoxIcon } from "lucide-react";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
const UpdateBackupSchema = z.object({
destinationId: z.string().min(1, "Destination required"),
schedule: z.string().min(1, "Schedule (Cron) required"),
prefix: z.string().min(1, "Prefix required"),
enabled: z.boolean(),
database: z.string().min(1, "Database required"),
keepLatestCount: z.coerce.number().optional(),
});
type UpdateBackup = z.infer<typeof UpdateBackupSchema>;
interface Props {
backupId: string;
refetch: () => void;
}
export const UpdateBackup = ({ backupId, refetch }: Props) => {
const [isOpen, setIsOpen] = useState(false);
const { data, isLoading } = api.destination.all.useQuery();
const { data: backup } = api.backup.one.useQuery(
{
backupId,
},
{
enabled: !!backupId,
},
);
const { mutateAsync, isLoading: isLoadingUpdate } =
api.backup.update.useMutation();
const form = useForm<UpdateBackup>({
defaultValues: {
database: "",
destinationId: "",
enabled: true,
prefix: "/",
schedule: "",
keepLatestCount: undefined,
},
resolver: zodResolver(UpdateBackupSchema),
});
useEffect(() => {
if (backup) {
form.reset({
database: backup.database,
destinationId: backup.destinationId,
enabled: backup.enabled || false,
prefix: backup.prefix,
schedule: backup.schedule,
keepLatestCount: backup.keepLatestCount
? Number(backup.keepLatestCount)
: undefined,
});
}
}, [form, form.reset, backup]);
const onSubmit = async (data: UpdateBackup) => {
await mutateAsync({
backupId,
destinationId: data.destinationId,
prefix: data.prefix,
schedule: data.schedule,
enabled: data.enabled,
database: data.database,
keepLatestCount: data.keepLatestCount as number | null,
})
.then(async () => {
toast.success("Backup Updated");
refetch();
setIsOpen(false);
})
.catch(() => {
toast.error("Error updating the Backup");
});
};
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
<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>
</DialogTrigger>
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-lg">
<DialogHeader>
<DialogTitle>Update Backup</DialogTitle>
<DialogDescription>Update the backup</DialogDescription>
</DialogHeader>
<Form {...form}>
<form
id="hook-form-update-backup"
onSubmit={form.handleSubmit(onSubmit)}
className="grid w-full gap-4"
>
<div className="grid grid-cols-1 gap-4">
<FormField
control={form.control}
name="destinationId"
render={({ field }) => (
<FormItem className="">
<FormLabel>Destination</FormLabel>
<Popover>
<PopoverTrigger asChild>
<FormControl>
<Button
variant="outline"
className={cn(
"w-full justify-between !bg-input",
!field.value && "text-muted-foreground",
)}
>
{isLoading
? "Loading...."
: field.value
? data?.find(
(destination) =>
destination.destinationId === field.value,
)?.name
: "Select Destination"}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent className="p-0" align="start">
<Command>
<CommandInput
placeholder="Search Destination..."
className="h-9"
/>
{isLoading && (
<span className="py-6 text-center text-sm">
Loading Destinations....
</span>
)}
<CommandEmpty>No destinations found.</CommandEmpty>
<ScrollArea className="h-64">
<CommandGroup>
{data?.map((destination) => (
<CommandItem
value={destination.destinationId}
key={destination.destinationId}
onSelect={() => {
form.setValue(
"destinationId",
destination.destinationId,
);
}}
>
{destination.name}
<CheckIcon
className={cn(
"ml-auto h-4 w-4",
destination.destinationId === field.value
? "opacity-100"
: "opacity-0",
)}
/>
</CommandItem>
))}
</CommandGroup>
</ScrollArea>
</Command>
</PopoverContent>
</Popover>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="database"
render={({ field }) => {
return (
<FormItem>
<FormLabel>Database</FormLabel>
<FormControl>
<Input placeholder={"dokploy"} {...field} />
</FormControl>
<FormMessage />
</FormItem>
);
}}
/>
<FormField
control={form.control}
name="schedule"
render={({ field }) => {
return (
<FormItem>
<FormLabel>Schedule (Cron)</FormLabel>
<FormControl>
<Input placeholder={"0 0 * * *"} {...field} />
</FormControl>
<FormMessage />
</FormItem>
);
}}
/>
<FormField
control={form.control}
name="prefix"
render={({ field }) => {
return (
<FormItem>
<FormLabel>Prefix Destination</FormLabel>
<FormControl>
<Input placeholder={"dokploy/"} {...field} />
</FormControl>
<FormDescription>
Use if you want to back up in a specific path of your
destination/bucket
</FormDescription>
<FormMessage />
</FormItem>
);
}}
/>
<FormField
control={form.control}
name="keepLatestCount"
render={({ field }) => {
return (
<FormItem>
<FormLabel>Keep the latest</FormLabel>
<FormControl>
<Input
type="number"
placeholder={"keeps all the backups if left empty"}
{...field}
/>
</FormControl>
<FormDescription>
Optional. If provided, only keeps the latest N backups
in the cloud.
</FormDescription>
<FormMessage />
</FormItem>
);
}}
/>
<FormField
control={form.control}
name="enabled"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3 ">
<div className="space-y-0.5">
<FormLabel>Enabled</FormLabel>
<FormDescription>
Enable or disable the backup
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
</div>
<DialogFooter>
<Button
isLoading={isLoadingUpdate}
form="hook-form-update-backup"
type="submit"
>
Update
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
);
};

View File

@@ -36,6 +36,9 @@ export const ShowInvitations = () => {
const { data, isLoading, refetch } =
api.organization.allInvitations.useQuery();
const { mutateAsync: removeInvitation } =
api.organization.removeInvitation.useMutation();
return (
<div className="w-full">
<Card className="h-full bg-sidebar p-2.5 rounded-xl max-w-5xl mx-auto">
@@ -182,6 +185,22 @@ export const ShowInvitations = () => {
Cancel Invitation
</DropdownMenuItem>
)}
<DropdownMenuItem
className="w-full cursor-pointer"
onSelect={async (_e) => {
await removeInvitation({
invitationId: invitation.id,
}).then(() => {
refetch();
toast.success(
"Invitation removed",
);
});
}}
>
Remove Invitation
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>

View File

@@ -0,0 +1,10 @@
CREATE TYPE "public"."backupType" AS ENUM('database', 'compose');--> statement-breakpoint
ALTER TABLE "backup" ADD COLUMN "appName" text NOT NULL;--> statement-breakpoint
ALTER TABLE "backup" ADD COLUMN "serviceName" text;--> statement-breakpoint
ALTER TABLE "backup" ADD COLUMN "backupType" "backupType" DEFAULT 'database' NOT NULL;--> statement-breakpoint
ALTER TABLE "backup" ADD COLUMN "composeId" text;--> statement-breakpoint
ALTER TABLE "backup" ADD COLUMN "metadata" jsonb;--> statement-breakpoint
ALTER TABLE "deployment" ADD COLUMN "backupId" text;--> statement-breakpoint
ALTER TABLE "backup" ADD CONSTRAINT "backup_composeId_compose_composeId_fk" FOREIGN KEY ("composeId") REFERENCES "public"."compose"("composeId") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "deployment" ADD CONSTRAINT "deployment_backupId_backup_backupId_fk" FOREIGN KEY ("backupId") REFERENCES "public"."backup"("backupId") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "backup" ADD CONSTRAINT "backup_appName_unique" UNIQUE("appName");

File diff suppressed because it is too large Load Diff

View File

@@ -624,6 +624,13 @@
"when": 1746256928101,
"tag": "0088_illegal_ma_gnuci",
"breakpoints": true
},
{
"idx": 89,
"version": "7",
"when": 1746392564463,
"tag": "0089_noisy_sandman",
"breakpoints": true
}
]
}

View File

@@ -80,6 +80,7 @@ import {
Loader2,
PlusIcon,
Search,
ServerIcon,
Trash2,
X,
} from "lucide-react";
@@ -968,6 +969,11 @@ const Project = (
}}
className="flex flex-col group relative cursor-pointer bg-transparent transition-colors hover:bg-border"
>
{service.serverId && (
<div className="absolute -left-1 -top-2">
<ServerIcon className="size-4 text-muted-foreground" />
</div>
)}
<div className="absolute -right-1 -top-2">
<StatusTooltip status={service.status} />
</div>

View File

@@ -318,9 +318,14 @@ const Service = (
/>
</div>
</TabsContent>
<TabsContent value="deployments" className="w-full">
<div className="flex flex-col gap-4 pt-2.5">
<ShowDeployments applicationId={applicationId} />
<TabsContent value="deployments" className="w-full pt-2.5">
<div className="flex flex-col gap-4 border rounded-lg">
<ShowDeployments
id={applicationId}
type="application"
serverId={data?.serverId || ""}
refreshToken={data?.refreshToken || ""}
/>
</div>
</TabsContent>
<TabsContent value="preview-deployments" className="w-full">
@@ -330,7 +335,7 @@ const Service = (
</TabsContent>
<TabsContent value="domains" className="w-full">
<div className="flex flex-col gap-4 pt-2.5">
<ShowDomains applicationId={applicationId} />
<ShowDomains id={applicationId} type="application" />
</div>
</TabsContent>
<TabsContent value="advanced">

View File

@@ -1,15 +1,16 @@
import { ShowImport } from "@/components/dashboard/application/advanced/import/show-import";
import { ShowVolumes } from "@/components/dashboard/application/advanced/volumes/show-volumes";
import { ShowDeployments } from "@/components/dashboard/application/deployments/show-deployments";
import { ShowDomains } from "@/components/dashboard/application/domains/show-domains";
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 { DeleteService } from "@/components/dashboard/compose/delete-service";
import { ShowDeploymentsCompose } from "@/components/dashboard/compose/deployments/show-deployments-compose";
import { ShowDomainsCompose } from "@/components/dashboard/compose/domains/show-domains";
import { ShowGeneralCompose } from "@/components/dashboard/compose/general/show";
import { ShowDockerLogsCompose } from "@/components/dashboard/compose/logs/show";
import { ShowDockerLogsStack } from "@/components/dashboard/compose/logs/show-stack";
import { UpdateCompose } from "@/components/dashboard/compose/update-compose";
import { ShowBackups } from "@/components/dashboard/database/backups/show-backups";
import { ComposeFreeMonitoring } from "@/components/dashboard/monitoring/free/container/show-free-compose-monitoring";
import { ComposePaidMonitoring } from "@/components/dashboard/monitoring/paid/container/show-paid-compose-monitoring";
import { ProjectLayout } from "@/components/layouts/project-layout";
@@ -218,18 +219,19 @@ const Service = (
className={cn(
"lg:grid lg:w-fit max-md:overflow-y-scroll justify-start",
isCloud && data?.serverId
? "lg:grid-cols-8"
? "lg:grid-cols-9"
: data?.serverId
? "lg:grid-cols-7"
: "lg:grid-cols-8",
? "lg:grid-cols-8"
: "lg:grid-cols-9",
)}
>
<TabsTrigger value="general">General</TabsTrigger>
<TabsTrigger value="environment">Environment</TabsTrigger>
<TabsTrigger value="domains">Domains</TabsTrigger>
<TabsTrigger value="deployments">Deployments</TabsTrigger>
<TabsTrigger value="logs">Logs</TabsTrigger>
<TabsTrigger value="backups">Backups</TabsTrigger>
<TabsTrigger value="schedules">Schedules</TabsTrigger>
<TabsTrigger value="logs">Logs</TabsTrigger>
{((data?.serverId && isCloud) || !data?.server) && (
<TabsTrigger value="monitoring">Monitoring</TabsTrigger>
)}
@@ -247,6 +249,11 @@ const Service = (
<ShowEnvironment id={composeId} type="compose" />
</div>
</TabsContent>
<TabsContent value="backups">
<div className="flex flex-col gap-4 pt-2.5">
<ShowBackups id={composeId} backupType="compose" />
</div>
</TabsContent>
<TabsContent value="schedules">
<div className="flex flex-col gap-4 pt-2.5">
@@ -326,13 +333,18 @@ const Service = (
<TabsContent value="deployments">
<div className="flex flex-col gap-4 pt-2.5">
<ShowDeploymentsCompose composeId={composeId} />
<ShowDeployments
id={composeId}
type="compose"
serverId={data?.serverId || ""}
refreshToken={data?.refreshToken || ""}
/>
</div>
</TabsContent>
<TabsContent value="domains">
<div className="flex flex-col gap-4 pt-2.5">
<ShowDomainsCompose composeId={composeId} />
<ShowDomains id={composeId} type="compose" />
</div>
</TabsContent>
<TabsContent value="advanced">

View File

@@ -271,7 +271,7 @@ const Mariadb = (
</TabsContent>
<TabsContent value="backups">
<div className="flex flex-col gap-4 pt-2.5">
<ShowBackups id={mariadbId} type="mariadb" />
<ShowBackups id={mariadbId} databaseType="mariadb" />
</div>
</TabsContent>
<TabsContent value="advanced">

View File

@@ -272,7 +272,11 @@ const Mongo = (
</TabsContent>
<TabsContent value="backups">
<div className="flex flex-col gap-4 pt-2.5">
<ShowBackups id={mongoId} type="mongo" />
<ShowBackups
id={mongoId}
databaseType="mongo"
backupType="database"
/>
</div>
</TabsContent>
<TabsContent value="advanced">

View File

@@ -252,7 +252,11 @@ const MySql = (
</TabsContent>
<TabsContent value="backups">
<div className="flex flex-col gap-4 pt-2.5">
<ShowBackups id={mysqlId} type="mysql" />
<ShowBackups
id={mysqlId}
databaseType="mysql"
backupType="database"
/>
</div>
</TabsContent>
<TabsContent value="advanced">

View File

@@ -251,7 +251,11 @@ const Postgresql = (
</TabsContent>
<TabsContent value="backups">
<div className="flex flex-col gap-4 pt-2.5">
<ShowBackups id={postgresId} type="postgres" />
<ShowBackups
id={postgresId}
databaseType="postgres"
backupType="database"
/>
</div>
</TabsContent>
<TabsContent value="advanced">

View File

@@ -20,7 +20,11 @@ const Page = () => {
<WebServer />
<div className="w-full flex flex-col gap-4">
<Card className="h-full bg-sidebar p-2.5 rounded-xl mx-auto w-full">
<ShowBackups id={user?.userId ?? ""} type="web-server" />
<ShowBackups
id={user?.userId ?? ""}
databaseType="web-server"
backupType="database"
/>
</Card>
</div>
</div>

View File

@@ -3,6 +3,7 @@ import {
apiCreateBackup,
apiFindOneBackup,
apiRemoveBackup,
apiRestoreBackup,
apiUpdateBackup,
} from "@/server/db/schema";
import { removeJob, schedule, updateJob } from "@/server/utils/backup";
@@ -10,6 +11,8 @@ import {
IS_CLOUD,
createBackup,
findBackupById,
findComposeByBackupId,
findComposeById,
findMariadbByBackupId,
findMariadbById,
findMongoByBackupId,
@@ -31,6 +34,7 @@ import {
} from "@dokploy/server";
import { findDestinationById } from "@dokploy/server/services/destination";
import { runComposeBackup } from "@dokploy/server/utils/backups/compose";
import {
getS3Credentials,
normalizeS3Path,
@@ -40,6 +44,7 @@ import {
execAsyncRemote,
} from "@dokploy/server/utils/process/execAsync";
import {
restoreComposeBackup,
restoreMariadbBackup,
restoreMongoBackup,
restoreMySqlBackup,
@@ -82,6 +87,11 @@ export const backupRouter = createTRPCRouter({
serverId = backup.mongo.serverId;
} else if (databaseType === "mariadb" && backup.mariadb?.serverId) {
serverId = backup.mariadb.serverId;
} else if (
backup.backupType === "compose" &&
backup.compose?.serverId
) {
serverId = backup.compose.serverId;
}
const server = await findServerById(serverId);
@@ -232,6 +242,22 @@ export const backupRouter = createTRPCRouter({
});
}
}),
manualBackupCompose: protectedProcedure
.input(apiFindOneBackup)
.mutation(async ({ input }) => {
try {
const backup = await findBackupById(input.backupId);
const compose = await findComposeByBackupId(backup.backupId);
await runComposeBackup(compose, backup);
return true;
} catch (error) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Error running manual Compose backup ",
cause: error,
});
}
}),
manualBackupMongo: protectedProcedure
.input(apiFindOneBackup)
.mutation(async ({ input }) => {
@@ -341,88 +367,59 @@ export const backupRouter = createTRPCRouter({
override: true,
},
})
.input(
z.object({
databaseId: z.string(),
databaseType: z.enum([
"postgres",
"mysql",
"mariadb",
"mongo",
"web-server",
]),
databaseName: z.string().min(1),
backupFile: z.string().min(1),
destinationId: z.string().min(1),
}),
)
.input(apiRestoreBackup)
.subscription(async ({ input }) => {
const destination = await findDestinationById(input.destinationId);
if (input.databaseType === "postgres") {
const postgres = await findPostgresById(input.databaseId);
if (input.backupType === "database") {
if (input.databaseType === "postgres") {
const postgres = await findPostgresById(input.databaseId);
return observable<string>((emit) => {
restorePostgresBackup(
postgres,
destination,
input.databaseName,
input.backupFile,
(log) => {
return observable<string>((emit) => {
restorePostgresBackup(postgres, destination, input, (log) => {
emit.next(log);
},
);
});
}
if (input.databaseType === "mysql") {
const mysql = await findMySqlById(input.databaseId);
return observable<string>((emit) => {
restoreMySqlBackup(
mysql,
destination,
input.databaseName,
input.backupFile,
(log) => {
});
});
}
if (input.databaseType === "mysql") {
const mysql = await findMySqlById(input.databaseId);
return observable<string>((emit) => {
restoreMySqlBackup(mysql, destination, input, (log) => {
emit.next(log);
},
);
});
}
if (input.databaseType === "mariadb") {
const mariadb = await findMariadbById(input.databaseId);
return observable<string>((emit) => {
restoreMariadbBackup(
mariadb,
destination,
input.databaseName,
input.backupFile,
(log) => {
});
});
}
if (input.databaseType === "mariadb") {
const mariadb = await findMariadbById(input.databaseId);
return observable<string>((emit) => {
restoreMariadbBackup(mariadb, destination, input, (log) => {
emit.next(log);
},
);
});
}
if (input.databaseType === "mongo") {
const mongo = await findMongoById(input.databaseId);
return observable<string>((emit) => {
restoreMongoBackup(
mongo,
destination,
input.databaseName,
input.backupFile,
(log) => {
});
});
}
if (input.databaseType === "mongo") {
const mongo = await findMongoById(input.databaseId);
return observable<string>((emit) => {
restoreMongoBackup(mongo, destination, input, (log) => {
emit.next(log);
},
);
});
});
});
}
if (input.databaseType === "web-server") {
return observable<string>((emit) => {
restoreWebServerBackup(destination, input.backupFile, (log) => {
emit.next(log);
});
});
}
}
if (input.databaseType === "web-server") {
if (input.backupType === "compose") {
const compose = await findComposeById(input.databaseId);
return observable<string>((emit) => {
restoreWebServerBackup(destination, input.backupFile, (log) => {
restoreComposeBackup(compose, destination, input, (log) => {
emit.next(log);
});
});
}
return true;
}),
});

View File

@@ -2,6 +2,8 @@ import {
apiFindAllByApplication,
apiFindAllByCompose,
apiFindAllByServer,
apiFindAllByType,
deployments,
} from "@/server/db/schema";
import {
findAllDeploymentsByApplicationId,
@@ -12,7 +14,9 @@ import {
findServerById,
} from "@dokploy/server";
import { TRPCError } from "@trpc/server";
import { desc, eq } from "drizzle-orm";
import { createTRPCRouter, protectedProcedure } from "../trpc";
import { db } from "@/server/db";
export const deploymentRouter = createTRPCRouter({
all: protectedProcedure
@@ -54,4 +58,14 @@ export const deploymentRouter = createTRPCRouter({
}
return await findAllDeploymentsByServerId(input.serverId);
}),
allByType: protectedProcedure
.input(apiFindAllByType)
.query(async ({ input }) => {
const deploymentsList = await db.query.deployments.findMany({
where: eq(deployments[`${input.type}Id`], input.id),
orderBy: desc(deployments.createdAt),
});
return deploymentsList;
}),
});

View File

@@ -21,6 +21,7 @@ import {
removeDomain,
removeDomainById,
updateDomainById,
validateDomain,
} from "@dokploy/server";
import { TRPCError } from "@trpc/server";
import { z } from "zod";
@@ -56,7 +57,10 @@ export const domainRouter = createTRPCRouter({
} catch (error) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Error creating the domain",
message:
error instanceof Error
? error.message
: "Error creating the domain",
cause: error,
});
}
@@ -224,4 +228,15 @@ export const domainRouter = createTRPCRouter({
return result;
}),
validateDomain: protectedProcedure
.input(
z.object({
domain: z.string(),
serverIp: z.string().optional(),
}),
)
.mutation(async ({ input }) => {
return validateDomain(input.domain, input.serverIp);
}),
});

View File

@@ -157,4 +157,31 @@ export const organizationRouter = createTRPCRouter({
orderBy: [desc(invitation.status), desc(invitation.expiresAt)],
});
}),
removeInvitation: adminProcedure
.input(z.object({ invitationId: z.string() }))
.mutation(async ({ ctx, input }) => {
const invitationResult = await db.query.invitation.findFirst({
where: eq(invitation.id, input.invitationId),
});
if (!invitationResult) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Invitation not found",
});
}
if (
invitationResult?.organizationId !== ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "FORBIDDEN",
message: "You are not allowed to remove this invitation",
});
}
return await db
.delete(invitation)
.where(eq(invitation.id, input.invitationId));
}),
});

View File

@@ -103,6 +103,7 @@ export const userRouter = createTRPCRouter({
backups: {
with: {
destination: true,
deployments: true,
},
},
apiKeys: true,

View File

@@ -11,10 +11,11 @@ import {
runMongoBackup,
runMySqlBackup,
runPostgresBackup,
runComposeBackup,
} from "@dokploy/server";
import { db } from "@dokploy/server/dist/db";
import { backups, schedules, server } from "@dokploy/server/dist/db/schema";
import { eq } from "drizzle-orm";
import { and, eq } from "drizzle-orm";
import { logger } from "./logger.js";
import { scheduleJob } from "./queue.js";
import type { QueueJob } from "./schema.js";
@@ -24,40 +25,57 @@ export const runJobs = async (job: QueueJob) => {
if (job.type === "backup") {
const { backupId } = job;
const backup = await findBackupById(backupId);
const { databaseType, postgres, mysql, mongo, mariadb } = backup;
const {
databaseType,
postgres,
mysql,
mongo,
mariadb,
compose,
backupType,
} = backup;
if (databaseType === "postgres" && postgres) {
const server = await findServerById(postgres.serverId as string);
if (backupType === "database") {
if (databaseType === "postgres" && postgres) {
const server = await findServerById(postgres.serverId as string);
if (server.serverStatus === "inactive") {
logger.info("Server is inactive");
return;
}
await runPostgresBackup(postgres, backup);
await keepLatestNBackups(backup, server.serverId);
} else if (databaseType === "mysql" && mysql) {
const server = await findServerById(mysql.serverId as string);
if (server.serverStatus === "inactive") {
logger.info("Server is inactive");
return;
}
await runMySqlBackup(mysql, backup);
await keepLatestNBackups(backup, server.serverId);
} else if (databaseType === "mongo" && mongo) {
const server = await findServerById(mongo.serverId as string);
if (server.serverStatus === "inactive") {
logger.info("Server is inactive");
return;
}
await runMongoBackup(mongo, backup);
await keepLatestNBackups(backup, server.serverId);
} else if (databaseType === "mariadb" && mariadb) {
const server = await findServerById(mariadb.serverId as string);
if (server.serverStatus === "inactive") {
logger.info("Server is inactive");
return;
}
await runMariadbBackup(mariadb, backup);
await keepLatestNBackups(backup, server.serverId);
}
} else if (backupType === "compose" && compose) {
const server = await findServerById(compose.serverId as string);
if (server.serverStatus === "inactive") {
logger.info("Server is inactive");
return;
}
await runPostgresBackup(postgres, backup);
await keepLatestNBackups(backup, server.serverId);
} else if (databaseType === "mysql" && mysql) {
const server = await findServerById(mysql.serverId as string);
if (server.serverStatus === "inactive") {
logger.info("Server is inactive");
return;
}
await runMySqlBackup(mysql, backup);
await keepLatestNBackups(backup, server.serverId);
} else if (databaseType === "mongo" && mongo) {
const server = await findServerById(mongo.serverId as string);
if (server.serverStatus === "inactive") {
logger.info("Server is inactive");
return;
}
await runMongoBackup(mongo, backup);
await keepLatestNBackups(backup, server.serverId);
} else if (databaseType === "mariadb" && mariadb) {
const server = await findServerById(mariadb.serverId as string);
if (server.serverStatus === "inactive") {
logger.info("Server is inactive");
return;
}
await runMariadbBackup(mariadb, backup);
await keepLatestNBackups(backup, server.serverId);
await runComposeBackup(compose, backup);
}
} else if (job.type === "server") {
const { serverId } = job;
@@ -87,7 +105,10 @@ export const initializeJobs = async () => {
logger.info("Setting up Jobs....");
const servers = await db.query.server.findMany({
where: eq(server.enableDockerCleanup, true),
where: and(
eq(server.enableDockerCleanup, true),
eq(server.serverStatus, "active"),
),
});
for (const server of servers) {
@@ -99,7 +120,7 @@ export const initializeJobs = async () => {
});
}
logger.info({ Quantity: servers.length }, "Servers Initialized");
logger.info({ Quantity: servers.length }, "Active Servers Initialized");
const backupsResult = await db.query.backups.findMany({
where: eq(backups.enabled, true),
@@ -108,6 +129,7 @@ export const initializeJobs = async () => {
mysql: true,
postgres: true,
mongo: true,
compose: true,
},
});
@@ -122,14 +144,44 @@ export const initializeJobs = async () => {
const schedulesResult = await db.query.schedules.findMany({
where: eq(schedules.enabled, true),
with: {
application: {
with: {
server: true,
},
},
compose: {
with: {
server: true,
},
},
server: true,
},
});
for (const schedule of schedulesResult) {
const filteredSchedulesBasedOnServerStatus = schedulesResult.filter(
(schedule) => {
if (schedule.server) {
return schedule.server.serverStatus === "active";
}
if (schedule.application) {
return schedule.application.server?.serverStatus === "active";
}
if (schedule.compose) {
return schedule.compose.server?.serverStatus === "active";
}
},
);
for (const schedule of filteredSchedulesBasedOnServerStatus) {
scheduleJob({
scheduleId: schedule.scheduleId,
type: "schedule",
cronSchedule: schedule.cronExpression,
});
}
logger.info({ Quantity: schedulesResult.length }, "Schedules Initialized");
logger.info(
{ Quantity: filteredSchedulesBasedOnServerStatus.length },
"Schedules Initialized",
);
};

View File

@@ -7,7 +7,7 @@ import { runJobs } from "./utils.js";
export const firstWorker = new Worker(
"backupQueue",
async (job: Job<QueueJob>) => {
logger.info({ data: job.data }, "Job received");
logger.info({ data: job.data }, "Running job");
await runJobs(job.data);
},
{
@@ -18,7 +18,7 @@ export const firstWorker = new Worker(
export const secondWorker = new Worker(
"backupQueue",
async (job: Job<QueueJob>) => {
logger.info({ data: job.data }, "Job received");
logger.info({ data: job.data }, "Running job");
await runJobs(job.data);
},
{

View File

@@ -3,6 +3,7 @@ import {
type AnyPgColumn,
boolean,
integer,
jsonb,
pgEnum,
pgTable,
text,
@@ -16,6 +17,9 @@ import { mongo } from "./mongo";
import { mysql } from "./mysql";
import { postgres } from "./postgres";
import { users_temp } from "./user";
import { compose } from "./compose";
import { deployments } from "./deployment";
import { generateAppName } from ".";
export const databaseType = pgEnum("databaseType", [
"postgres",
"mariadb",
@@ -24,23 +28,34 @@ export const databaseType = pgEnum("databaseType", [
"web-server",
]);
export const backupType = pgEnum("backupType", ["database", "compose"]);
export const backups = pgTable("backup", {
backupId: text("backupId")
.notNull()
.primaryKey()
.$defaultFn(() => nanoid()),
appName: text("appName")
.notNull()
.$defaultFn(() => generateAppName("backup"))
.unique(),
schedule: text("schedule").notNull(),
enabled: boolean("enabled"),
database: text("database").notNull(),
prefix: text("prefix").notNull(),
serviceName: text("serviceName"),
destinationId: text("destinationId")
.notNull()
.references(() => destinations.destinationId, { onDelete: "cascade" }),
keepLatestCount: integer("keepLatestCount"),
backupType: backupType("backupType").notNull().default("database"),
databaseType: databaseType("databaseType").notNull(),
composeId: text("composeId").references(
(): AnyPgColumn => compose.composeId,
{
onDelete: "cascade",
},
),
postgresId: text("postgresId").references(
(): AnyPgColumn => postgres.postgresId,
{
@@ -60,9 +75,29 @@ export const backups = pgTable("backup", {
onDelete: "cascade",
}),
userId: text("userId").references(() => users_temp.id),
// Only for compose backups
metadata: jsonb("metadata").$type<
| {
postgres?: {
databaseUser: string;
};
mariadb?: {
databaseUser: string;
databasePassword: string;
};
mongo?: {
databaseUser: string;
databasePassword: string;
};
mysql?: {
databaseRootPassword: string;
};
}
| undefined
>(),
});
export const backupsRelations = relations(backups, ({ one }) => ({
export const backupsRelations = relations(backups, ({ one, many }) => ({
destination: one(destinations, {
fields: [backups.destinationId],
references: [destinations.destinationId],
@@ -87,6 +122,11 @@ export const backupsRelations = relations(backups, ({ one }) => ({
fields: [backups.userId],
references: [users_temp.id],
}),
compose: one(compose, {
fields: [backups.composeId],
references: [compose.composeId],
}),
deployments: many(deployments),
}));
const createSchema = createInsertSchema(backups, {
@@ -103,6 +143,7 @@ const createSchema = createInsertSchema(backups, {
mysqlId: z.string().optional(),
mongoId: z.string().optional(),
userId: z.string().optional(),
metadata: z.any().optional(),
});
export const apiCreateBackup = createSchema.pick({
@@ -118,6 +159,10 @@ export const apiCreateBackup = createSchema.pick({
mongoId: true,
databaseType: true,
userId: true,
backupType: true,
composeId: true,
serviceName: true,
metadata: true,
});
export const apiFindOneBackup = createSchema
@@ -141,5 +186,44 @@ export const apiUpdateBackup = createSchema
destinationId: true,
database: true,
keepLatestCount: true,
serviceName: true,
metadata: true,
databaseType: true,
})
.required();
export const apiRestoreBackup = z.object({
databaseId: z.string(),
databaseType: z.enum(["postgres", "mysql", "mariadb", "mongo", "web-server"]),
backupType: z.enum(["database", "compose"]),
databaseName: z.string().min(1),
backupFile: z.string().min(1),
destinationId: z.string().min(1),
metadata: z
.object({
serviceName: z.string().optional(),
postgres: z
.object({
databaseUser: z.string(),
})
.optional(),
mariadb: z
.object({
databaseUser: z.string(),
databasePassword: z.string(),
})
.optional(),
mongo: z
.object({
databaseUser: z.string(),
databasePassword: z.string(),
})
.optional(),
mysql: z
.object({
databaseRootPassword: z.string(),
})
.optional(),
})
.optional(),
});

View File

@@ -15,6 +15,8 @@ import { server } from "./server";
import { applicationStatus, triggerType } from "./shared";
import { sshKeys } from "./ssh-key";
import { generateAppName } from "./utils";
import { backups } from "./backups";
import { schedules } from "./schedule";
export const sourceTypeCompose = pgEnum("sourceTypeCompose", [
"git",
@@ -135,6 +137,7 @@ export const composeRelations = relations(compose, ({ one, many }) => ({
fields: [compose.serverId],
references: [server.serverId],
}),
backups: many(backups),
schedules: many(schedules),
}));

View File

@@ -14,6 +14,7 @@ import { compose } from "./compose";
import { previewDeployments } from "./preview-deployments";
import { server } from "./server";
import { schedules } from "./schedule";
import { backups } from "./backups";
export const deploymentStatus = pgEnum("deploymentStatus", [
"running",
"done",
@@ -54,6 +55,9 @@ export const deployments = pgTable("deployment", {
(): AnyPgColumn => schedules.scheduleId,
{ onDelete: "cascade" },
),
backupId: text("backupId").references((): AnyPgColumn => backups.backupId, {
onDelete: "cascade",
}),
});
export const deploymentsRelations = relations(deployments, ({ one }) => ({
@@ -77,6 +81,10 @@ export const deploymentsRelations = relations(deployments, ({ one }) => ({
fields: [deployments.scheduleId],
references: [schedules.scheduleId],
}),
backup: one(backups, {
fields: [deployments.backupId],
references: [backups.backupId],
}),
}));
const schema = createInsertSchema(deployments, {
@@ -126,6 +134,18 @@ export const apiCreateDeploymentCompose = schema
composeId: z.string().min(1),
});
export const apiCreateDeploymentBackup = schema
.pick({
title: true,
status: true,
logPath: true,
backupId: true,
description: true,
})
.extend({
backupId: z.string().min(1),
});
export const apiCreateDeploymentServer = schema
.pick({
title: true,
@@ -175,3 +195,17 @@ export const apiFindAllByServer = schema
serverId: z.string().min(1),
})
.required();
export const apiFindAllByType = z
.object({
id: z.string().min(1),
type: z.enum([
"application",
"compose",
"server",
"schedule",
"previewDeployment",
"backup",
]),
})
.required();

View File

@@ -50,6 +50,7 @@ export * from "./utils/backups/mysql";
export * from "./utils/backups/postgres";
export * from "./utils/backups/utils";
export * from "./utils/backups/web-server";
export * from "./utils/backups/compose";
export * from "./templates/processors";
export * from "./utils/notifications/build-error";

View File

@@ -35,6 +35,7 @@ export const findBackupById = async (backupId: string) => {
mariadb: true,
mongo: true,
destination: true,
compose: true,
},
});
if (!backup) {

View File

@@ -131,6 +131,12 @@ export const findComposeById = async (composeId: string) => {
bitbucket: true,
gitea: true,
server: true,
backups: {
with: {
destination: true,
deployments: true,
},
},
},
});
if (!result) {

View File

@@ -4,6 +4,7 @@ import { paths } from "@dokploy/server/constants";
import { db } from "@dokploy/server/db";
import {
type apiCreateDeployment,
type apiCreateDeploymentBackup,
type apiCreateDeploymentCompose,
type apiCreateDeploymentPreview,
type apiCreateDeploymentSchedule,
@@ -29,6 +30,7 @@ import {
updatePreviewDeployment,
} from "./preview-deployment";
import { findScheduleById } from "./schedule";
import { findBackupById } from "./backup";
export type Deployment = typeof deployments.$inferSelect;
@@ -284,6 +286,86 @@ echo "Initializing deployment" >> ${logFilePath};
}
};
export const createDeploymentBackup = async (
deployment: Omit<
typeof apiCreateDeploymentBackup._type,
"deploymentId" | "createdAt" | "status" | "logPath"
>,
) => {
const backup = await findBackupById(deployment.backupId);
let serverId: string | null | undefined;
if (backup.backupType === "database") {
serverId =
backup.postgres?.serverId ||
backup.mariadb?.serverId ||
backup.mysql?.serverId ||
backup.mongo?.serverId;
} else if (backup.backupType === "compose") {
serverId = backup.compose?.serverId;
}
try {
await removeLastTenDeployments(deployment.backupId, "backup", serverId);
const { LOGS_PATH } = paths(!!serverId);
const formattedDateTime = format(new Date(), "yyyy-MM-dd:HH:mm:ss");
const fileName = `${backup.appName}-${formattedDateTime}.log`;
const logFilePath = path.join(LOGS_PATH, backup.appName, fileName);
if (serverId) {
const server = await findServerById(serverId);
const command = `
mkdir -p ${LOGS_PATH}/${backup.appName};
echo "Initializing backup\n" >> ${logFilePath};
`;
await execAsyncRemote(server.serverId, command);
} else {
await fsPromises.mkdir(path.join(LOGS_PATH, backup.appName), {
recursive: true,
});
await fsPromises.writeFile(logFilePath, "Initializing backup\n");
}
const deploymentCreate = await db
.insert(deployments)
.values({
backupId: deployment.backupId,
title: deployment.title || "Backup",
description: deployment.description || "",
status: "running",
logPath: logFilePath,
startedAt: new Date().toISOString(),
})
.returning();
if (deploymentCreate.length === 0 || !deploymentCreate[0]) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Error creating the backup",
});
}
return deploymentCreate[0];
} catch (error) {
await db
.insert(deployments)
.values({
backupId: deployment.backupId,
title: deployment.title || "Backup",
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 backup",
});
}
};
export const createDeploymentSchedule = async (
deployment: Omit<
typeof apiCreateDeploymentSchedule._type,
@@ -388,7 +470,13 @@ export const removeDeploymentsByApplicationId = async (
const getDeploymentsByType = async (
id: string,
type: "application" | "compose" | "server" | "schedule" | "previewDeployment",
type:
| "application"
| "compose"
| "server"
| "schedule"
| "previewDeployment"
| "backup",
) => {
const deploymentList = await db.query.deployments.findMany({
where: eq(deployments[`${type}Id`], id),
@@ -411,7 +499,13 @@ export const removeDeployments = async (application: Application) => {
const removeLastTenDeployments = async (
id: string,
type: "application" | "compose" | "server" | "schedule" | "previewDeployment",
type:
| "application"
| "compose"
| "server"
| "schedule"
| "previewDeployment"
| "backup",
serverId?: string | null,
) => {
const deploymentList = await getDeploymentsByType(id, type);

View File

@@ -7,6 +7,8 @@ import { type apiCreateDomain, domains } from "../db/schema";
import { findUserById } from "./admin";
import { findApplicationById } from "./application";
import { findServerById } from "./server";
import dns from "node:dns";
import { promisify } from "node:util";
export type Domain = typeof domains.$inferSelect;
@@ -137,3 +139,84 @@ export const removeDomainById = async (domainId: string) => {
export const getDomainHost = (domain: Domain) => {
return `${domain.https ? "https" : "http"}://${domain.host}`;
};
const resolveDns = promisify(dns.resolve4);
// Cloudflare IP ranges (simplified - these are some common ones)
const CLOUDFLARE_IPS = [
"172.67.",
"104.21.",
"104.16.",
"104.17.",
"104.18.",
"104.19.",
"104.20.",
"104.22.",
"104.23.",
"104.24.",
"104.25.",
"104.26.",
"104.27.",
"104.28.",
];
const isCloudflareIp = (ip: string) => {
return CLOUDFLARE_IPS.some((range) => ip.startsWith(range));
};
export const validateDomain = async (
domain: string,
expectedIp?: string,
): Promise<{
isValid: boolean;
resolvedIp?: string;
error?: string;
isCloudflare?: boolean;
}> => {
try {
// Remove protocol and path if present
const cleanDomain = domain.replace(/^https?:\/\//, "").split("/")[0];
// Resolve the domain to get its IP
const ips = await resolveDns(cleanDomain || "");
const resolvedIps = ips.map((ip) => ip.toString());
// Check if it's a Cloudflare IP
const behindCloudflare = ips.some((ip) => isCloudflareIp(ip));
// If behind Cloudflare, we consider it valid but inform the user
if (behindCloudflare) {
return {
isValid: true,
resolvedIp: resolvedIps.join(", "),
isCloudflare: true,
error:
"Domain is behind Cloudflare - actual IP is masked by Cloudflare proxy",
};
}
// If we have an expected IP, validate against it
if (expectedIp) {
return {
isValid: resolvedIps.includes(expectedIp),
resolvedIp: resolvedIps.join(", "),
error: !resolvedIps.includes(expectedIp)
? `Domain resolves to ${resolvedIps.join(", ")} but should point to ${expectedIp}`
: undefined,
};
}
// If no expected IP, just return the resolved IP
return {
isValid: true,
resolvedIp: resolvedIps.join(", "),
};
} catch (error) {
return {
isValid: false,
error:
error instanceof Error ? error.message : "Failed to resolve domain",
};
}
};

View File

@@ -63,6 +63,7 @@ export const findMariadbById = async (mariadbId: string) => {
backups: {
with: {
destination: true,
deployments: true,
},
},
},

View File

@@ -1,5 +1,10 @@
import { db } from "@dokploy/server/db";
import { type apiCreateMongo, backups, mongo } from "@dokploy/server/db/schema";
import {
type apiCreateMongo,
backups,
compose,
mongo,
} from "@dokploy/server/db/schema";
import { buildAppName } from "@dokploy/server/db/schema";
import { generatePassword } from "@dokploy/server/templates";
import { buildMongo } from "@dokploy/server/utils/databases/mongo";
@@ -55,6 +60,7 @@ export const findMongoById = async (mongoId: string) => {
backups: {
with: {
destination: true,
deployments: true,
},
},
},
@@ -103,6 +109,25 @@ export const findMongoByBackupId = async (backupId: string) => {
return result[0];
};
export const findComposeByBackupId = async (backupId: string) => {
const result = await db
.select({
...getTableColumns(compose),
})
.from(compose)
.innerJoin(backups, eq(compose.composeId, backups.composeId))
.where(eq(backups.backupId, backupId))
.limit(1);
if (!result || !result[0]) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Compose not found",
});
}
return result[0];
};
export const removeMongoById = async (mongoId: string) => {
const result = await db
.delete(mongo)

View File

@@ -59,6 +59,7 @@ export const findMySqlById = async (mysqlId: string) => {
backups: {
with: {
destination: true,
deployments: true,
},
},
},

View File

@@ -58,6 +58,7 @@ export const findPostgresById = async (postgresId: string) => {
backups: {
with: {
destination: true,
deployments: true,
},
},
},

View File

@@ -40,6 +40,8 @@ export const getServiceImageDigest = async () => {
"docker service inspect dokploy --format '{{.Spec.TaskTemplate.ContainerSpec.Image}}'",
);
console.log("stdout", stdout);
const currentDigest = stdout.trim().split("@")[1];
if (!currentDigest) {

View File

@@ -0,0 +1,68 @@
import type { BackupSchedule } from "@dokploy/server/services/backup";
import type { Compose } from "@dokploy/server/services/compose";
import { findProjectById } from "@dokploy/server/services/project";
import { sendDatabaseBackupNotifications } from "../notifications/database-backup";
import { execAsync, execAsyncRemote } from "../process/execAsync";
import { getS3Credentials, normalizeS3Path, getBackupCommand } from "./utils";
import {
createDeploymentBackup,
updateDeploymentStatus,
} from "@dokploy/server/services/deployment";
export const runComposeBackup = async (
compose: Compose,
backup: BackupSchedule,
) => {
const { projectId, name } = compose;
const project = await findProjectById(projectId);
const { prefix } = backup;
const destination = backup.destination;
const backupFileName = `${new Date().toISOString()}.dump.gz`;
const bucketDestination = `${normalizeS3Path(prefix)}${backupFileName}`;
const deployment = await createDeploymentBackup({
backupId: backup.backupId,
title: "Compose Backup",
description: "Compose Backup",
});
try {
const rcloneFlags = getS3Credentials(destination);
const rcloneDestination = `:s3:${destination.bucket}/${bucketDestination}`;
const rcloneCommand = `rclone rcat ${rcloneFlags.join(" ")} "${rcloneDestination}"`;
const backupCommand = getBackupCommand(
backup,
rcloneCommand,
deployment.logPath,
);
if (compose.serverId) {
await execAsyncRemote(compose.serverId, backupCommand);
} else {
await execAsync(backupCommand);
}
await sendDatabaseBackupNotifications({
applicationName: name,
projectName: project.name,
databaseType: "mongodb",
type: "success",
organizationId: project.organizationId,
});
await updateDeploymentStatus(deployment.deploymentId, "done");
} catch (error) {
console.log(error);
await sendDatabaseBackupNotifications({
applicationName: name,
projectName: project.name,
databaseType: "mongodb",
type: "error",
// @ts-ignore
errorMessage: error?.message || "Error message not provided",
organizationId: project.organizationId,
});
await updateDeploymentStatus(deployment.deploymentId, "error");
throw error;
}
};

View File

@@ -70,6 +70,7 @@ export const initCronJobs = async () => {
mysql: true,
mongo: true,
user: true,
compose: true,
},
});
@@ -77,10 +78,10 @@ export const initCronJobs = async () => {
try {
if (backup.enabled) {
scheduleBackup(backup);
console.log(
`[Backup] ${backup.databaseType} Enabled with cron: [${backup.schedule}]`,
);
}
console.log(
`[Backup] ${backup.databaseType} Enabled with cron: [${backup.schedule}]`,
);
} catch (error) {
console.error(`[Backup] ${backup.databaseType} Error`, error);
}

View File

@@ -1,46 +1,43 @@
import type { BackupSchedule } from "@dokploy/server/services/backup";
import type { Mariadb } from "@dokploy/server/services/mariadb";
import { findProjectById } from "@dokploy/server/services/project";
import {
getRemoteServiceContainer,
getServiceContainer,
} from "../docker/utils";
import { sendDatabaseBackupNotifications } from "../notifications/database-backup";
import { execAsync, execAsyncRemote } from "../process/execAsync";
import { getS3Credentials, normalizeS3Path } from "./utils";
import { getBackupCommand, getS3Credentials, normalizeS3Path } from "./utils";
import {
createDeploymentBackup,
updateDeploymentStatus,
} from "@dokploy/server/services/deployment";
export const runMariadbBackup = async (
mariadb: Mariadb,
backup: BackupSchedule,
) => {
const { appName, databasePassword, databaseUser, projectId, name } = mariadb;
const { projectId, name } = mariadb;
const project = await findProjectById(projectId);
const { prefix, database } = backup;
const { prefix } = backup;
const destination = backup.destination;
const backupFileName = `${new Date().toISOString()}.sql.gz`;
const bucketDestination = `${normalizeS3Path(prefix)}${backupFileName}`;
const deployment = await createDeploymentBackup({
backupId: backup.backupId,
title: "MariaDB Backup",
description: "MariaDB Backup",
});
try {
const rcloneFlags = getS3Credentials(destination);
const rcloneDestination = `:s3:${destination.bucket}/${bucketDestination}`;
const rcloneCommand = `rclone rcat ${rcloneFlags.join(" ")} "${rcloneDestination}"`;
const backupCommand = getBackupCommand(
backup,
rcloneCommand,
deployment.logPath,
);
if (mariadb.serverId) {
const { Id: containerId } = await getRemoteServiceContainer(
mariadb.serverId,
appName,
);
const mariadbDumpCommand = `docker exec ${containerId} sh -c "mariadb-dump --user='${databaseUser}' --password='${databasePassword}' --databases ${database} | gzip"`;
await execAsyncRemote(
mariadb.serverId,
`${mariadbDumpCommand} | ${rcloneCommand}`,
);
await execAsyncRemote(mariadb.serverId, backupCommand);
} else {
const { Id: containerId } = await getServiceContainer(appName);
const mariadbDumpCommand = `docker exec ${containerId} sh -c "mariadb-dump --user='${databaseUser}' --password='${databasePassword}' --databases ${database} | gzip"`;
await execAsync(`${mariadbDumpCommand} | ${rcloneCommand}`);
await execAsync(backupCommand);
}
await sendDatabaseBackupNotifications({
@@ -50,6 +47,7 @@ export const runMariadbBackup = async (
type: "success",
organizationId: project.organizationId,
});
await updateDeploymentStatus(deployment.deploymentId, "done");
} catch (error) {
console.log(error);
await sendDatabaseBackupNotifications({
@@ -61,6 +59,7 @@ export const runMariadbBackup = async (
errorMessage: error?.message || "Error message not provided",
organizationId: project.organizationId,
});
await updateDeploymentStatus(deployment.deploymentId, "error");
throw error;
}
};

View File

@@ -1,43 +1,41 @@
import type { BackupSchedule } from "@dokploy/server/services/backup";
import type { Mongo } from "@dokploy/server/services/mongo";
import { findProjectById } from "@dokploy/server/services/project";
import {
getRemoteServiceContainer,
getServiceContainer,
} from "../docker/utils";
import { sendDatabaseBackupNotifications } from "../notifications/database-backup";
import { execAsync, execAsyncRemote } from "../process/execAsync";
import { getS3Credentials, normalizeS3Path } from "./utils";
import { getBackupCommand, getS3Credentials, normalizeS3Path } from "./utils";
import {
createDeploymentBackup,
updateDeploymentStatus,
} from "@dokploy/server/services/deployment";
// mongodb://mongo:Bqh7AQl-PRbnBu@localhost:27017/?tls=false&directConnection=true
export const runMongoBackup = async (mongo: Mongo, backup: BackupSchedule) => {
const { appName, databasePassword, databaseUser, projectId, name } = mongo;
const { projectId, name } = mongo;
const project = await findProjectById(projectId);
const { prefix, database } = backup;
const { prefix } = backup;
const destination = backup.destination;
const backupFileName = `${new Date().toISOString()}.dump.gz`;
const bucketDestination = `${normalizeS3Path(prefix)}${backupFileName}`;
const deployment = await createDeploymentBackup({
backupId: backup.backupId,
title: "MongoDB Backup",
description: "MongoDB Backup",
});
try {
const rcloneFlags = getS3Credentials(destination);
const rcloneDestination = `:s3:${destination.bucket}/${bucketDestination}`;
const rcloneCommand = `rclone rcat ${rcloneFlags.join(" ")} "${rcloneDestination}"`;
if (mongo.serverId) {
const { Id: containerId } = await getRemoteServiceContainer(
mongo.serverId,
appName,
);
const mongoDumpCommand = `docker exec ${containerId} sh -c "mongodump -d '${database}' -u '${databaseUser}' -p '${databasePassword}' --archive --authenticationDatabase=admin --gzip"`;
await execAsyncRemote(
mongo.serverId,
`${mongoDumpCommand} | ${rcloneCommand}`,
);
const backupCommand = getBackupCommand(
backup,
rcloneCommand,
deployment.logPath,
);
if (mongo.serverId) {
await execAsyncRemote(mongo.serverId, backupCommand);
} else {
const { Id: containerId } = await getServiceContainer(appName);
const mongoDumpCommand = `docker exec ${containerId} sh -c "mongodump -d '${database}' -u '${databaseUser}' -p '${databasePassword}' --archive --authenticationDatabase=admin --gzip"`;
await execAsync(`${mongoDumpCommand} | ${rcloneCommand}`);
await execAsync(backupCommand);
}
await sendDatabaseBackupNotifications({
@@ -47,6 +45,7 @@ export const runMongoBackup = async (mongo: Mongo, backup: BackupSchedule) => {
type: "success",
organizationId: project.organizationId,
});
await updateDeploymentStatus(deployment.deploymentId, "done");
} catch (error) {
console.log(error);
await sendDatabaseBackupNotifications({
@@ -58,7 +57,7 @@ export const runMongoBackup = async (mongo: Mongo, backup: BackupSchedule) => {
errorMessage: error?.message || "Error message not provided",
organizationId: project.organizationId,
});
await updateDeploymentStatus(deployment.deploymentId, "error");
throw error;
}
};
// mongorestore -d monguito -u mongo -p Bqh7AQl-PRbnBu --authenticationDatabase admin --gzip --archive=2024-04-13T05:03:58.937Z.dump.gz

View File

@@ -1,43 +1,43 @@
import type { BackupSchedule } from "@dokploy/server/services/backup";
import type { MySql } from "@dokploy/server/services/mysql";
import { findProjectById } from "@dokploy/server/services/project";
import {
getRemoteServiceContainer,
getServiceContainer,
} from "../docker/utils";
import { sendDatabaseBackupNotifications } from "../notifications/database-backup";
import { execAsync, execAsyncRemote } from "../process/execAsync";
import { getS3Credentials, normalizeS3Path } from "./utils";
import { getBackupCommand, getS3Credentials, normalizeS3Path } from "./utils";
import {
createDeploymentBackup,
updateDeploymentStatus,
} from "@dokploy/server/services/deployment";
export const runMySqlBackup = async (mysql: MySql, backup: BackupSchedule) => {
const { appName, databaseRootPassword, projectId, name } = mysql;
const { projectId, name } = mysql;
const project = await findProjectById(projectId);
const { prefix, database } = backup;
const { prefix } = backup;
const destination = backup.destination;
const backupFileName = `${new Date().toISOString()}.sql.gz`;
const bucketDestination = `${normalizeS3Path(prefix)}${backupFileName}`;
const deployment = await createDeploymentBackup({
backupId: backup.backupId,
title: "MySQL Backup",
description: "MySQL Backup",
});
try {
const rcloneFlags = getS3Credentials(destination);
const rcloneDestination = `:s3:${destination.bucket}/${bucketDestination}`;
const rcloneCommand = `rclone rcat ${rcloneFlags.join(" ")} "${rcloneDestination}"`;
const backupCommand = getBackupCommand(
backup,
rcloneCommand,
deployment.logPath,
);
if (mysql.serverId) {
const { Id: containerId } = await getRemoteServiceContainer(
mysql.serverId,
appName,
);
const mysqlDumpCommand = `docker exec ${containerId} sh -c "mysqldump --default-character-set=utf8mb4 -u 'root' --password='${databaseRootPassword}' --single-transaction --no-tablespaces --quick '${database}' | gzip"`;
await execAsyncRemote(
mysql.serverId,
`${mysqlDumpCommand} | ${rcloneCommand}`,
);
await execAsyncRemote(mysql.serverId, backupCommand);
} else {
const { Id: containerId } = await getServiceContainer(appName);
const mysqlDumpCommand = `docker exec ${containerId} sh -c "mysqldump --default-character-set=utf8mb4 -u 'root' --password='${databaseRootPassword}' --single-transaction --no-tablespaces --quick '${database}' | gzip"`;
await execAsync(`${mysqlDumpCommand} | ${rcloneCommand}`);
await execAsync(backupCommand);
}
await sendDatabaseBackupNotifications({
applicationName: name,
@@ -46,6 +46,7 @@ export const runMySqlBackup = async (mysql: MySql, backup: BackupSchedule) => {
type: "success",
organizationId: project.organizationId,
});
await updateDeploymentStatus(deployment.deploymentId, "done");
} catch (error) {
console.log(error);
await sendDatabaseBackupNotifications({
@@ -57,6 +58,7 @@ export const runMySqlBackup = async (mysql: MySql, backup: BackupSchedule) => {
errorMessage: error?.message || "Error message not provided",
organizationId: project.organizationId,
});
await updateDeploymentStatus(deployment.deploymentId, "error");
throw error;
}
};

View File

@@ -1,22 +1,27 @@
import type { BackupSchedule } from "@dokploy/server/services/backup";
import type { Postgres } from "@dokploy/server/services/postgres";
import { findProjectById } from "@dokploy/server/services/project";
import {
getRemoteServiceContainer,
getServiceContainer,
} from "../docker/utils";
import { sendDatabaseBackupNotifications } from "../notifications/database-backup";
import { execAsync, execAsyncRemote } from "../process/execAsync";
import { getS3Credentials, normalizeS3Path } from "./utils";
import { getBackupCommand, getS3Credentials, normalizeS3Path } from "./utils";
import {
createDeploymentBackup,
updateDeploymentStatus,
} from "@dokploy/server/services/deployment";
export const runPostgresBackup = async (
postgres: Postgres,
backup: BackupSchedule,
) => {
const { appName, databaseUser, name, projectId } = postgres;
const { name, projectId } = postgres;
const project = await findProjectById(projectId);
const { prefix, database } = backup;
const deployment = await createDeploymentBackup({
backupId: backup.backupId,
title: "Initializing Backup",
description: "Initializing Backup",
});
const { prefix } = backup;
const destination = backup.destination;
const backupFileName = `${new Date().toISOString()}.sql.gz`;
const bucketDestination = `${normalizeS3Path(prefix)}${backupFileName}`;
@@ -25,22 +30,16 @@ export const runPostgresBackup = async (
const rcloneDestination = `:s3:${destination.bucket}/${bucketDestination}`;
const rcloneCommand = `rclone rcat ${rcloneFlags.join(" ")} "${rcloneDestination}"`;
const backupCommand = getBackupCommand(
backup,
rcloneCommand,
deployment.logPath,
);
if (postgres.serverId) {
const { Id: containerId } = await getRemoteServiceContainer(
postgres.serverId,
appName,
);
const pgDumpCommand = `docker exec ${containerId} sh -c "pg_dump -Fc --no-acl --no-owner -h localhost -U ${databaseUser} --no-password '${database}' | gzip"`;
await execAsyncRemote(
postgres.serverId,
`${pgDumpCommand} | ${rcloneCommand}`,
);
await execAsyncRemote(postgres.serverId, backupCommand);
} else {
const { Id: containerId } = await getServiceContainer(appName);
const pgDumpCommand = `docker exec ${containerId} sh -c "pg_dump -Fc --no-acl --no-owner -h localhost -U ${databaseUser} --no-password '${database}' | gzip"`;
await execAsync(`${pgDumpCommand} | ${rcloneCommand}`);
await execAsync(backupCommand);
}
await sendDatabaseBackupNotifications({
@@ -50,6 +49,8 @@ export const runPostgresBackup = async (
type: "success",
organizationId: project.organizationId,
});
await updateDeploymentStatus(deployment.deploymentId, "done");
} catch (error) {
await sendDatabaseBackupNotifications({
applicationName: name,
@@ -61,10 +62,9 @@ export const runPostgresBackup = async (
organizationId: project.organizationId,
});
await updateDeploymentStatus(deployment.deploymentId, "error");
throw error;
} finally {
}
};
// Restore
// /Applications/pgAdmin 4.app/Contents/SharedSupport/pg_restore --host "localhost" --port "5432" --username "mauricio" --no-password --dbname "postgres" --verbose "/Users/mauricio/Downloads/_databases_2024-04-12T07_02_05.234Z.sql"

View File

@@ -7,26 +7,40 @@ import { runMongoBackup } from "./mongo";
import { runMySqlBackup } from "./mysql";
import { runPostgresBackup } from "./postgres";
import { runWebServerBackup } from "./web-server";
import { runComposeBackup } from "./compose";
export const scheduleBackup = (backup: BackupSchedule) => {
const { schedule, backupId, databaseType, postgres, mysql, mongo, mariadb } =
backup;
const {
schedule,
backupId,
databaseType,
postgres,
mysql,
mongo,
mariadb,
compose,
} = backup;
scheduleJob(backupId, schedule, async () => {
if (databaseType === "postgres" && postgres) {
await runPostgresBackup(postgres, backup);
await keepLatestNBackups(backup, postgres.serverId);
} else if (databaseType === "mysql" && mysql) {
await runMySqlBackup(mysql, backup);
await keepLatestNBackups(backup, mysql.serverId);
} else if (databaseType === "mongo" && mongo) {
await runMongoBackup(mongo, backup);
await keepLatestNBackups(backup, mongo.serverId);
} else if (databaseType === "mariadb" && mariadb) {
await runMariadbBackup(mariadb, backup);
await keepLatestNBackups(backup, mariadb.serverId);
} else if (databaseType === "web-server") {
await runWebServerBackup(backup);
await keepLatestNBackups(backup);
if (backup.backupType === "database") {
if (databaseType === "postgres" && postgres) {
await runPostgresBackup(postgres, backup);
await keepLatestNBackups(backup, postgres.serverId);
} else if (databaseType === "mysql" && mysql) {
await runMySqlBackup(mysql, backup);
await keepLatestNBackups(backup, mysql.serverId);
} else if (databaseType === "mongo" && mongo) {
await runMongoBackup(mongo, backup);
await keepLatestNBackups(backup, mongo.serverId);
} else if (databaseType === "mariadb" && mariadb) {
await runMariadbBackup(mariadb, backup);
await keepLatestNBackups(backup, mariadb.serverId);
} else if (databaseType === "web-server") {
await runWebServerBackup(backup);
await keepLatestNBackups(backup);
}
} else if (backup.backupType === "compose" && compose) {
await runComposeBackup(compose, backup);
await keepLatestNBackups(backup, compose.serverId);
}
});
};
@@ -61,3 +75,181 @@ export const getS3Credentials = (destination: Destination) => {
return rcloneFlags;
};
export const getPostgresBackupCommand = (
database: string,
databaseUser: string,
) => {
return `docker exec -i $CONTAINER_ID bash -c "set -o pipefail; pg_dump -Fc --no-acl --no-owner -h localhost -U ${databaseUser} --no-password '${database}' | gzip"`;
};
export const getMariadbBackupCommand = (
database: string,
databaseUser: string,
databasePassword: string,
) => {
return `docker exec -i $CONTAINER_ID bash -c "set -o pipefail; mariadb-dump --user='${databaseUser}' --password='${databasePassword}' --databases ${database} | gzip"`;
};
export const getMysqlBackupCommand = (
database: string,
databasePassword: string,
) => {
return `docker exec -i $CONTAINER_ID bash -c "set -o pipefail; mysqldump --default-character-set=utf8mb4 -u 'root' --password='${databasePassword}' --single-transaction --no-tablespaces --quick '${database}' | gzip"`;
};
export const getMongoBackupCommand = (
database: string,
databaseUser: string,
databasePassword: string,
) => {
return `docker exec -i $CONTAINER_ID bash -c "set -o pipefail; mongodump -d '${database}' -u '${databaseUser}' -p '${databasePassword}' --archive --authenticationDatabase admin --gzip"`;
};
export const getServiceContainerCommand = (appName: string) => {
return `docker ps -q --filter "status=running" --filter "label=com.docker.swarm.service.name=${appName}" | head -n 1`;
};
export const getComposeContainerCommand = (
appName: string,
serviceName: string,
composeType: "stack" | "docker-compose" | undefined,
) => {
if (composeType === "stack") {
return `docker ps -q --filter "status=running" --filter "label=com.docker.stack.namespace=${appName}" --filter "label=com.docker.swarm.service.name=${serviceName}" | head -n 1`;
}
return `docker ps -q --filter "status=running" --filter "label=com.docker.compose.project=${appName}" --filter "label=com.docker.compose.service=${serviceName}" | head -n 1`;
};
const getContainerSearchCommand = (backup: BackupSchedule) => {
const { backupType, postgres, mysql, mariadb, mongo, compose, serviceName } =
backup;
if (backupType === "database") {
const appName =
postgres?.appName || mysql?.appName || mariadb?.appName || mongo?.appName;
return getServiceContainerCommand(appName || "");
}
if (backupType === "compose") {
const { appName, composeType } = compose || {};
return getComposeContainerCommand(
appName || "",
serviceName || "",
composeType,
);
}
};
export const generateBackupCommand = (backup: BackupSchedule) => {
const { backupType, databaseType } = backup;
switch (databaseType) {
case "postgres": {
const postgres = backup.postgres;
if (backupType === "database" && postgres) {
return getPostgresBackupCommand(backup.database, postgres.databaseUser);
}
if (backupType === "compose" && backup.metadata?.postgres) {
return getPostgresBackupCommand(
backup.database,
backup.metadata.postgres.databaseUser,
);
}
break;
}
case "mysql": {
const mysql = backup.mysql;
if (backupType === "database" && mysql) {
return getMysqlBackupCommand(backup.database, mysql.databasePassword);
}
if (backupType === "compose" && backup.metadata?.mysql) {
return getMysqlBackupCommand(
backup.database,
backup.metadata?.mysql?.databaseRootPassword || "",
);
}
break;
}
case "mariadb": {
const mariadb = backup.mariadb;
if (backupType === "database" && mariadb) {
return getMariadbBackupCommand(
backup.database,
mariadb.databaseUser,
mariadb.databasePassword,
);
}
if (backupType === "compose" && backup.metadata?.mariadb) {
return getMariadbBackupCommand(
backup.database,
backup.metadata.mariadb.databaseUser,
backup.metadata.mariadb.databasePassword,
);
}
break;
}
case "mongo": {
const mongo = backup.mongo;
if (backupType === "database" && mongo) {
return getMongoBackupCommand(
backup.database,
mongo.databaseUser,
mongo.databasePassword,
);
}
if (backupType === "compose" && backup.metadata?.mongo) {
return getMongoBackupCommand(
backup.database,
backup.metadata.mongo.databaseUser,
backup.metadata.mongo.databasePassword,
);
}
break;
}
default:
throw new Error(`Database type not supported: ${databaseType}`);
}
return null;
};
export const getBackupCommand = (
backup: BackupSchedule,
rcloneCommand: string,
logPath: string,
) => {
const containerSearch = getContainerSearchCommand(backup);
const backupCommand = generateBackupCommand(backup);
return `
set -eo pipefail;
echo "[$(date)] Starting backup process..." >> ${logPath};
echo "[$(date)] Executing backup command..." >> ${logPath};
CONTAINER_ID=$(${containerSearch})
if [ -z "$CONTAINER_ID" ]; then
echo "[$(date)] ❌ Error: Container not found" >> ${logPath};
exit 1;
fi
echo "[$(date)] Container Up: $CONTAINER_ID" >> ${logPath};
# Run the backup command and capture the exit status
BACKUP_OUTPUT=$(${backupCommand} 2>&1 >/dev/null) || {
echo "[$(date)] ❌ Error: Backup failed" >> ${logPath};
echo "Error: $BACKUP_OUTPUT" >> ${logPath};
exit 1;
}
echo "[$(date)] ✅ backup completed successfully" >> ${logPath};
echo "[$(date)] Starting upload to S3..." >> ${logPath};
# Run the upload command and capture the exit status
UPLOAD_OUTPUT=$(${backupCommand} | ${rcloneCommand} 2>&1 >/dev/null) || {
echo "[$(date)] ❌ Error: Upload to S3 failed" >> ${logPath};
echo "Error: $UPLOAD_OUTPUT" >> ${logPath};
exit 1;
}
echo "[$(date)] ✅ Upload to S3 completed successfully" >> ${logPath};
echo "Backup done ✅" >> ${logPath};
`;
};

View File

@@ -6,12 +6,25 @@ import { IS_CLOUD, paths } from "@dokploy/server/constants";
import { mkdtemp } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
import {
createDeploymentBackup,
updateDeploymentStatus,
} from "@dokploy/server/services/deployment";
import { createWriteStream } from "node:fs";
export const runWebServerBackup = async (backup: BackupSchedule) => {
if (IS_CLOUD) {
return;
}
const deployment = await createDeploymentBackup({
backupId: backup.backupId,
title: "Web Server Backup",
description: "Web Server Backup",
});
const writeStream = createWriteStream(deployment.logPath, { flags: "a" });
try {
if (IS_CLOUD) {
return;
}
const destination = await findDestinationById(backup.destinationId);
const rcloneFlags = getS3Credentials(destination);
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
@@ -29,29 +42,49 @@ export const runWebServerBackup = async (backup: BackupSchedule) => {
);
if (!containerId) {
throw new Error("PostgreSQL container not found");
writeStream.write("Dokploy postgres container not found❌\n");
writeStream.end();
throw new Error("Dokploy postgres container not found");
}
writeStream.write(`Dokploy postgres container ID: ${containerId}\n`);
const postgresContainerId = containerId.trim();
const postgresCommand = `docker exec ${postgresContainerId} pg_dump -v -Fc -U dokploy -d dokploy > '${tempDir}/database.sql'`;
writeStream.write(`Running command: ${postgresCommand}\n`);
await execAsync(postgresCommand);
await execAsync(`cp -r ${BASE_PATH}/* ${tempDir}/filesystem/`);
writeStream.write("Copied filesystem to temp directory\n");
await execAsync(
// Zip all .sql files since we created more than one
`cd ${tempDir} && zip -r ${backupFileName} *.sql filesystem/ > /dev/null 2>&1`,
);
writeStream.write("Zipped database and filesystem\n");
const uploadCommand = `rclone copyto ${rcloneFlags.join(" ")} "${tempDir}/${backupFileName}" "${s3Path}"`;
writeStream.write(`Running command: ${uploadCommand}\n`);
await execAsync(uploadCommand);
writeStream.write("Uploaded backup to S3 ✅\n");
writeStream.end();
await updateDeploymentStatus(deployment.deploymentId, "done");
return true;
} finally {
await execAsync(`rm -rf ${tempDir}`);
}
} catch (error) {
console.error("Backup error:", error);
writeStream.write("Backup error❌\n");
writeStream.write(
error instanceof Error ? error.message : "Unknown error\n",
);
writeStream.end();
await updateDeploymentStatus(deployment.deploymentId, "error");
throw error;
}
};

View File

@@ -494,56 +494,7 @@ export const getCreateFileCommand = (
`;
};
export const getServiceContainer = async (appName: string) => {
try {
const filter = {
status: ["running"],
label: [`com.docker.swarm.service.name=${appName}`],
};
const containers = await docker.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 getRemoteServiceContainer = async (
serverId: string,
appName: string,
) => {
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 getServiceContainerIV2 = async (
export const getServiceContainer = async (
appName: string,
serverId?: string | null,
) => {
@@ -558,10 +509,11 @@ export const getServiceContainerIV2 = async (
});
if (containers.length === 0 || !containers[0]) {
throw new Error(`No container found with name: ${appName}`);
return null;
}
const container = containers[0];
return container;
} catch (error) {
throw error;
@@ -597,7 +549,7 @@ export const getComposeContainer = async (
});
if (containers.length === 0 || !containers[0]) {
throw new Error(`No container found with name: ${appName}`);
return null;
}
const container = containers[0];

View File

@@ -5,6 +5,51 @@ import { Client } from "ssh2";
export const execAsync = util.promisify(exec);
interface ExecOptions {
cwd?: string;
env?: NodeJS.ProcessEnv;
}
export const execAsyncStream = (
command: string,
onData?: (data: string) => void,
options: ExecOptions = {},
): Promise<{ stdout: string; stderr: string }> => {
return new Promise((resolve, reject) => {
let stdoutComplete = "";
let stderrComplete = "";
const childProcess = exec(command, options, (error) => {
if (error) {
reject(error);
return;
}
resolve({ stdout: stdoutComplete, stderr: stderrComplete });
});
childProcess.stdout?.on("data", (data: Buffer | string) => {
const stringData = data.toString();
stdoutComplete += stringData;
if (onData) {
onData(stringData);
}
});
childProcess.stderr?.on("data", (data: Buffer | string) => {
const stringData = data.toString();
stderrComplete += stringData;
if (onData) {
onData(stringData);
}
});
childProcess.on("error", (error) => {
console.log(error);
reject(error);
});
});
};
export const execFileAsync = async (
command: string,
args: string[],

View File

@@ -0,0 +1,97 @@
import type { Destination } from "@dokploy/server/services/destination";
import type { Compose } from "@dokploy/server/services/compose";
import { getS3Credentials } from "../backups/utils";
import { execAsync, execAsyncRemote } from "../process/execAsync";
import { getRestoreCommand } from "./utils";
import type { apiRestoreBackup } from "@dokploy/server/db/schema";
import type { z } from "zod";
interface DatabaseCredentials {
databaseUser?: string;
databasePassword?: string;
}
export const restoreComposeBackup = async (
compose: Compose,
destination: Destination,
backupInput: z.infer<typeof apiRestoreBackup>,
emit: (log: string) => void,
) => {
try {
if (backupInput.databaseType === "web-server") {
return;
}
const { serverId, appName, composeType } = compose;
const rcloneFlags = getS3Credentials(destination);
const bucketPath = `:s3:${destination.bucket}`;
const backupPath = `${bucketPath}/${backupInput.backupFile}`;
let rcloneCommand = `rclone cat ${rcloneFlags.join(" ")} "${backupPath}" | gunzip`;
if (backupInput.metadata?.mongo) {
rcloneCommand = `rclone copy ${rcloneFlags.join(" ")} "${backupPath}"`;
}
let credentials: DatabaseCredentials;
switch (backupInput.databaseType) {
case "postgres":
credentials = {
databaseUser: backupInput.metadata?.postgres?.databaseUser,
};
break;
case "mariadb":
credentials = {
databaseUser: backupInput.metadata?.mariadb?.databaseUser,
databasePassword: backupInput.metadata?.mariadb?.databasePassword,
};
break;
case "mysql":
credentials = {
databasePassword: backupInput.metadata?.mysql?.databaseRootPassword,
};
break;
case "mongo":
credentials = {
databaseUser: backupInput.metadata?.mongo?.databaseUser,
databasePassword: backupInput.metadata?.mongo?.databasePassword,
};
break;
}
const restoreCommand = getRestoreCommand({
appName: appName,
serviceName: backupInput.metadata?.serviceName,
type: backupInput.databaseType,
credentials: {
database: backupInput.databaseName,
...credentials,
},
restoreType: composeType,
rcloneCommand,
});
emit("Starting restore...");
emit(`Backup path: ${backupPath}`);
emit(`Executing command: ${restoreCommand}`);
if (serverId) {
await execAsyncRemote(serverId, restoreCommand);
} else {
await execAsync(restoreCommand);
}
emit("Restore completed successfully!");
} catch (error) {
console.error(error);
emit(
`Error: ${
error instanceof Error ? error.message : "Error restoring mongo backup"
}`,
);
throw new Error(
error instanceof Error ? error.message : "Error restoring mongo backup",
);
}
};

View File

@@ -3,3 +3,4 @@ export { restoreMySqlBackup } from "./mysql";
export { restoreMariadbBackup } from "./mariadb";
export { restoreMongoBackup } from "./mongo";
export { restoreWebServerBackup } from "./web-server";
export { restoreComposeBackup } from "./compose";

View File

@@ -1,42 +1,46 @@
import type { Destination } from "@dokploy/server/services/destination";
import type { Mariadb } from "@dokploy/server/services/mariadb";
import { getS3Credentials } from "../backups/utils";
import {
getRemoteServiceContainer,
getServiceContainer,
} from "../docker/utils";
import { execAsync, execAsyncRemote } from "../process/execAsync";
import { getRestoreCommand } from "./utils";
import type { apiRestoreBackup } from "@dokploy/server/db/schema";
import type { z } from "zod";
export const restoreMariadbBackup = async (
mariadb: Mariadb,
destination: Destination,
database: string,
backupFile: string,
backupInput: z.infer<typeof apiRestoreBackup>,
emit: (log: string) => void,
) => {
try {
const { appName, databasePassword, databaseUser, serverId } = mariadb;
const { appName, serverId, databaseUser, databasePassword } = mariadb;
const rcloneFlags = getS3Credentials(destination);
const bucketPath = `:s3:${destination.bucket}`;
const backupPath = `${bucketPath}/${backupFile}`;
const backupPath = `${bucketPath}/${backupInput.backupFile}`;
const { Id: containerName } = serverId
? await getRemoteServiceContainer(serverId, appName)
: await getServiceContainer(appName);
const rcloneCommand = `rclone cat ${rcloneFlags.join(" ")} "${backupPath}" | gunzip`;
const restoreCommand = `
rclone cat ${rcloneFlags.join(" ")} "${backupPath}" | gunzip | docker exec -i ${containerName} mariadb -u ${databaseUser} -p${databasePassword} ${database}
`;
const command = getRestoreCommand({
appName,
credentials: {
database: backupInput.databaseName,
databaseUser,
databasePassword,
},
type: "mariadb",
rcloneCommand,
restoreType: "database",
});
emit("Starting restore...");
emit(`Executing command: ${restoreCommand}`);
emit(`Executing command: ${command}`);
if (serverId) {
await execAsyncRemote(serverId, restoreCommand);
await execAsyncRemote(serverId, command);
} else {
await execAsync(restoreCommand);
await execAsync(command);
}
emit("Restore completed successfully!");

View File

@@ -1,17 +1,15 @@
import type { Destination } from "@dokploy/server/services/destination";
import type { Mongo } from "@dokploy/server/services/mongo";
import { getS3Credentials } from "../backups/utils";
import {
getRemoteServiceContainer,
getServiceContainer,
} from "../docker/utils";
import { execAsync, execAsyncRemote } from "../process/execAsync";
import { getRestoreCommand } from "./utils";
import type { apiRestoreBackup } from "@dokploy/server/db/schema";
import type { z } from "zod";
export const restoreMongoBackup = async (
mongo: Mongo,
destination: Destination,
database: string,
backupFile: string,
backupInput: z.infer<typeof apiRestoreBackup>,
emit: (log: string) => void,
) => {
try {
@@ -19,34 +17,30 @@ export const restoreMongoBackup = async (
const rcloneFlags = getS3Credentials(destination);
const bucketPath = `:s3:${destination.bucket}`;
const backupPath = `${bucketPath}/${backupFile}`;
const backupPath = `${bucketPath}/${backupInput.backupFile}`;
const rcloneCommand = `rclone copy ${rcloneFlags.join(" ")} "${backupPath}"`;
const { Id: containerName } = serverId
? await getRemoteServiceContainer(serverId, appName)
: await getServiceContainer(appName);
// For MongoDB, we need to first download the backup file since mongorestore expects a directory
const tempDir = "/tmp/dokploy-restore";
const fileName = backupFile.split("/").pop() || "backup.dump.gz";
const decompressedName = fileName.replace(".gz", "");
const downloadCommand = `\
rm -rf ${tempDir} && \
mkdir -p ${tempDir} && \
rclone copy ${rcloneFlags.join(" ")} "${backupPath}" ${tempDir} && \
cd ${tempDir} && \
gunzip -f "${fileName}" && \
docker exec -i ${containerName} mongorestore --username ${databaseUser} --password ${databasePassword} --authenticationDatabase admin --db ${database} --archive < "${decompressedName}" && \
rm -rf ${tempDir}`;
const command = getRestoreCommand({
appName,
type: "mongo",
credentials: {
database: backupInput.databaseName,
databaseUser,
databasePassword,
},
restoreType: "database",
rcloneCommand,
backupFile: backupInput.backupFile,
});
emit("Starting restore...");
emit(`Executing command: ${downloadCommand}`);
emit(`Executing command: ${command}`);
if (serverId) {
await execAsyncRemote(serverId, downloadCommand);
await execAsyncRemote(serverId, command);
} else {
await execAsync(downloadCommand);
await execAsync(command);
}
emit("Restore completed successfully!");

View File

@@ -1,17 +1,15 @@
import type { Destination } from "@dokploy/server/services/destination";
import type { MySql } from "@dokploy/server/services/mysql";
import { getS3Credentials } from "../backups/utils";
import {
getRemoteServiceContainer,
getServiceContainer,
} from "../docker/utils";
import { execAsync, execAsyncRemote } from "../process/execAsync";
import { getRestoreCommand } from "./utils";
import type { apiRestoreBackup } from "@dokploy/server/db/schema";
import type { z } from "zod";
export const restoreMySqlBackup = async (
mysql: MySql,
destination: Destination,
database: string,
backupFile: string,
backupInput: z.infer<typeof apiRestoreBackup>,
emit: (log: string) => void,
) => {
try {
@@ -19,24 +17,29 @@ export const restoreMySqlBackup = async (
const rcloneFlags = getS3Credentials(destination);
const bucketPath = `:s3:${destination.bucket}`;
const backupPath = `${bucketPath}/${backupFile}`;
const backupPath = `${bucketPath}/${backupInput.backupFile}`;
const { Id: containerName } = serverId
? await getRemoteServiceContainer(serverId, appName)
: await getServiceContainer(appName);
const rcloneCommand = `rclone cat ${rcloneFlags.join(" ")} "${backupPath}" | gunzip`;
const restoreCommand = `
rclone cat ${rcloneFlags.join(" ")} "${backupPath}" | gunzip | docker exec -i ${containerName} mysql -u root -p${databaseRootPassword} ${database}
`;
const command = getRestoreCommand({
appName,
type: "mysql",
credentials: {
database: backupInput.databaseName,
databasePassword: databaseRootPassword,
},
restoreType: "database",
rcloneCommand,
});
emit("Starting restore...");
emit(`Executing command: ${restoreCommand}`);
emit(`Executing command: ${command}`);
if (serverId) {
await execAsyncRemote(serverId, restoreCommand);
await execAsyncRemote(serverId, command);
} else {
await execAsync(restoreCommand);
await execAsync(command);
}
emit("Restore completed successfully!");

View File

@@ -1,17 +1,15 @@
import type { Destination } from "@dokploy/server/services/destination";
import type { Postgres } from "@dokploy/server/services/postgres";
import { getS3Credentials } from "../backups/utils";
import {
getRemoteServiceContainer,
getServiceContainer,
} from "../docker/utils";
import { execAsync, execAsyncRemote } from "../process/execAsync";
import { getRestoreCommand } from "./utils";
import type { apiRestoreBackup } from "@dokploy/server/db/schema";
import type { z } from "zod";
export const restorePostgresBackup = async (
postgres: Postgres,
destination: Destination,
database: string,
backupFile: string,
backupInput: z.infer<typeof apiRestoreBackup>,
emit: (log: string) => void,
) => {
try {
@@ -20,30 +18,30 @@ export const restorePostgresBackup = async (
const rcloneFlags = getS3Credentials(destination);
const bucketPath = `:s3:${destination.bucket}`;
const backupPath = `${bucketPath}/${backupFile}`;
const backupPath = `${bucketPath}/${backupInput.backupFile}`;
const { Id: containerName } = serverId
? await getRemoteServiceContainer(serverId, appName)
: await getServiceContainer(appName);
const rcloneCommand = `rclone cat ${rcloneFlags.join(" ")} "${backupPath}" | gunzip`;
emit("Starting restore...");
emit(`Backup path: ${backupPath}`);
const command = `\
rclone cat ${rcloneFlags.join(" ")} "${backupPath}" | gunzip | docker exec -i ${containerName} pg_restore -U ${databaseUser} -d ${database} --clean --if-exists`;
const command = getRestoreCommand({
appName,
credentials: {
database: backupInput.databaseName,
databaseUser,
},
type: "postgres",
rcloneCommand,
restoreType: "database",
});
emit(`Executing command: ${command}`);
if (serverId) {
const { stdout, stderr } = await execAsyncRemote(serverId, command);
emit(stdout);
emit(stderr);
await execAsyncRemote(serverId, command);
} else {
const { stdout, stderr } = await execAsync(command);
console.log("stdout", stdout);
console.log("stderr", stderr);
emit(stdout);
emit(stderr);
await execAsync(command);
}
emit("Restore completed successfully!");

View File

@@ -0,0 +1,131 @@
import {
getComposeContainerCommand,
getServiceContainerCommand,
} from "../backups/utils";
export const getPostgresRestoreCommand = (
database: string,
databaseUser: string,
) => {
return `docker exec -i $CONTAINER_ID sh -c "pg_restore -U ${databaseUser} -d ${database} --clean --if-exists"`;
};
export const getMariadbRestoreCommand = (
database: string,
databaseUser: string,
databasePassword: string,
) => {
return `docker exec -i $CONTAINER_ID sh -c "mariadb -u ${databaseUser} -p${databasePassword} ${database}"`;
};
export const getMysqlRestoreCommand = (
database: string,
databasePassword: string,
) => {
return `docker exec -i $CONTAINER_ID sh -c "mysql -u root -p${databasePassword} ${database}"`;
};
export const getMongoRestoreCommand = (
database: string,
databaseUser: string,
databasePassword: string,
) => {
return `docker exec -i $CONTAINER_ID sh -c "mongorestore --username ${databaseUser} --password ${databasePassword} --authenticationDatabase admin --db ${database} --archive"`;
};
export const getComposeSearchCommand = (
appName: string,
type: "stack" | "docker-compose" | "database",
serviceName?: string,
) => {
if (type === "database") {
return getServiceContainerCommand(appName || "");
}
return getComposeContainerCommand(appName || "", serviceName || "", type);
};
interface DatabaseCredentials {
database: string;
databaseUser?: string;
databasePassword?: string;
}
const generateRestoreCommand = (
type: "postgres" | "mariadb" | "mysql" | "mongo",
credentials: DatabaseCredentials,
) => {
const { database, databaseUser, databasePassword } = credentials;
switch (type) {
case "postgres":
return getPostgresRestoreCommand(database, databaseUser || "");
case "mariadb":
return getMariadbRestoreCommand(
database,
databaseUser || "",
databasePassword || "",
);
case "mysql":
return getMysqlRestoreCommand(database, databasePassword || "");
case "mongo":
return getMongoRestoreCommand(
database,
databaseUser || "",
databasePassword || "",
);
}
};
const getMongoSpecificCommand = (
rcloneCommand: string,
restoreCommand: string,
backupFile: string,
): string => {
const tempDir = "/tmp/dokploy-restore";
const fileName = backupFile.split("/").pop() || "backup.dump.gz";
const decompressedName = fileName.replace(".gz", "");
return `
rm -rf ${tempDir} && \
mkdir -p ${tempDir} && \
${rcloneCommand} ${tempDir} && \
cd ${tempDir} && \
gunzip -f "${fileName}" && \
${restoreCommand} < "${decompressedName}" && \
rm -rf ${tempDir}
`;
};
interface RestoreOptions {
appName: string;
type: "postgres" | "mariadb" | "mysql" | "mongo";
restoreType: "stack" | "docker-compose" | "database";
credentials: DatabaseCredentials;
serviceName?: string;
rcloneCommand: string;
backupFile?: string;
}
export const getRestoreCommand = ({
appName,
type,
restoreType,
credentials,
serviceName,
rcloneCommand,
backupFile,
}: RestoreOptions) => {
const containerSearch = getComposeSearchCommand(
appName,
restoreType,
serviceName,
);
const restoreCommand = generateRestoreCommand(type, credentials);
let cmd = `CONTAINER_ID=$(${containerSearch})`;
if (type !== "mongo") {
cmd += ` && ${rcloneCommand} | ${restoreCommand}`;
} else {
cmd += ` && ${getMongoSpecificCommand(rcloneCommand, restoreCommand, backupFile || "")}`;
}
return cmd;
};

View File

@@ -1,7 +1,7 @@
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 { getComposeContainer, getServiceContainer } from "../docker/utils";
import { execAsyncRemote } from "../process/execAsync";
import { spawnAsync } from "../process/spawnAsync";
import { createDeploymentSchedule } from "@dokploy/server/services/deployment";
@@ -45,16 +45,16 @@ export const runCommand = async (scheduleId: string) => {
let containerId = "";
let serverId = "";
if (scheduleType === "application" && application) {
const container = await getServiceContainerIV2(
const container = await getServiceContainer(
application.appName,
application.serverId,
);
containerId = container.Id;
containerId = container?.Id || "";
serverId = application.serverId || "";
}
if (scheduleType === "compose" && compose) {
const container = await getComposeContainer(compose, serviceName || "");
containerId = container.Id;
containerId = container?.Id || "";
serverId = compose.serverId || "";
}
@@ -64,8 +64,8 @@ export const runCommand = async (scheduleId: string) => {
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 "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;
}
@@ -81,7 +81,7 @@ export const runCommand = async (scheduleId: string) => {
try {
writeStream.write(
`docker exec ${containerId} ${shellType} -c "${command}"\n`,
`docker exec ${containerId} ${shellType} -c ${command}\n`,
);
await spawnAsync(
"docker",