Merge branch '187-backups-for-docker-compose' into feat/introduce-monitoring-self-hosted-pay

This commit is contained in:
Mauricio Siu
2025-05-01 21:19:59 -06:00
41 changed files with 13655 additions and 1070 deletions

View File

@@ -89,8 +89,6 @@ export const AddDomain = ({
serverId: application?.serverId || "",
});
console.log("canGenerateTraefikMeDomains", canGenerateTraefikMeDomains);
const form = useForm<Domain>({
resolver: zodResolver(domain),
defaultValues: {
@@ -276,6 +274,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,16 +8,49 @@ 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,
Trash2,
XCircle,
} from "lucide-react";
import Link from "next/link";
import { toast } from "sonner";
import { AddDomain } from "./add-domain";
import { useState } from "react";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import type { ValidationStates } from "../../compose/domains/show-domains";
import { DnsHelperModal } from "../../compose/domains/dns-helper-modal";
import { Badge } from "@/components/ui/badge";
interface Props {
applicationId: string;
}
export const ShowDomains = ({ applicationId }: Props) => {
const { data: application } = api.application.one.useQuery(
{
applicationId,
},
{
enabled: !!applicationId,
},
);
const [validationStates, setValidationStates] = useState<ValidationStates>(
{},
);
const { data: ip } = api.settings.getIp.useQuery();
const { data, refetch } = api.domain.byApplicationId.useQuery(
{
applicationId,
@@ -26,10 +59,47 @@ export const ShowDomains = ({ applicationId }: Props) => {
enabled: !!applicationId,
},
);
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">
@@ -68,73 +138,208 @@ 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">
{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"
>
<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">
<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
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((_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-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>
{/* 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>
<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>
<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

@@ -401,6 +401,11 @@ export const AddDomainCompose = ({
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

@@ -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

@@ -7,17 +7,55 @@ import {
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { api } from "@/utils/api";
import { ExternalLink, GlobeIcon, PenBoxIcon, Trash2 } from "lucide-react";
import {
ExternalLink,
GlobeIcon,
PenBoxIcon,
Trash2,
InfoIcon,
Server,
CheckCircle2,
XCircle,
Loader2,
RefreshCw,
} from "lucide-react";
import Link from "next/link";
import { toast } from "sonner";
import { AddDomainCompose } from "./add-domain";
import { Badge } from "@/components/ui/badge";
import { DnsHelperModal } from "./dns-helper-modal";
import { useState } from "react";
interface Props {
composeId: string;
}
export type ValidationState = {
isLoading: boolean;
isValid?: boolean;
error?: string;
resolvedIp?: string;
message?: string;
};
export type ValidationStates = {
[key: string]: ValidationState;
};
export const ShowDomainsCompose = ({ composeId }: Props) => {
const [validationStates, setValidationStates] = useState<ValidationStates>(
{},
);
const { data: ip } = api.settings.getIp.useQuery();
const { data, refetch } = api.domain.byComposeId.useQuery(
{
composeId,
@@ -27,11 +65,58 @@ export const ShowDomainsCompose = ({ composeId }: Props) => {
},
);
const { data: compose } = api.compose.one.useQuery(
{
composeId,
},
{
enabled: !!composeId,
},
);
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:
compose?.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 ">
<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">
@@ -45,100 +130,248 @@ export const ShowDomainsCompose = ({ composeId }: Props) => {
{data && data?.length > 0 && (
<AddDomainCompose composeId={composeId}>
<Button>
<GlobeIcon className="size-4" /> Add Domain
<GlobeIcon className="size-4 mr-2" /> Add Domain
</Button>
</AddDomainCompose>
)}
</div>
</CardHeader>
<CardContent className="flex w-full flex-row gap-4">
<CardContent>
{data?.length === 0 ? (
<div className="flex w-full flex-col items-center justify-center gap-3">
<div className="flex w-full flex-col items-center justify-center gap-3 py-8">
<GlobeIcon className="size-8 text-muted-foreground" />
<span className="text-base text-muted-foreground">
<span className="text-base text-muted-foreground text-center">
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
<GlobeIcon className="size-4 mr-2" /> Add Domain
</Button>
</AddDomainCompose>
</div>
</div>
) : (
<div className="flex w-full flex-col gap-4">
<div className="grid grid-cols-1 gap-4 xl:grid-cols-2">
{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 border bg-card transition-all hover:shadow-md bg-transparent"
>
<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>
<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">
<Badge variant="outline" className="w-fit">
<Server className="size-3 mr-1" />
{item.serviceName}
</Badge>
<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={
compose?.server?.ipAddress?.toString() ||
ip?.toString()
}
/>
)}
<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>
<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>
{/* 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>
<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>
<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 &&
!validationState.isValid ? (
<div className="flex flex-col gap-1">
<p className="font-medium text-red-500">
Error:
</p>
<p>{validationState.error}</p>
</div>
) : validationState?.isValid ? (
<div className="flex flex-col gap-1">
<p className="font-medium text-green-500">
{validationState.message
? "Info:"
: "Valid Configuration:"}
</p>
<p>
{validationState.message ||
`Domain points to ${validationState.resolvedIp}`}
</p>
</div>
) : (
"Click to validate DNS configuration"
)}
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</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>
</CardContent>
</Card>
);
})}
</div>

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,767 @@
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, 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";
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.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>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>
)}
/>
{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,163 @@ 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",
}),
});
type RestoreBackup = z.infer<typeof RestoreBackupSchema>;
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"),
serviceName: z.string().nullable().optional(),
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.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 +199,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 +210,21 @@ 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,
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 +250,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 +280,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 +314,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 +517,270 @@ 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"
/>
<Input placeholder="Enter database name" {...field} />
</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="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

@@ -19,44 +19,71 @@ 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";
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 +105,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>
@@ -110,7 +139,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 +147,15 @@ 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}
serverId={
"serverId" in postgres ? postgres.serverId : undefined
}
@@ -133,56 +163,118 @@ 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 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-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 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 gap-4">
<div className="flex flex-row md:flex-col gap-1.5">
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="size-8"
isLoading={
isManualBackup &&
activeManualBackup === backup.backupId
@@ -205,14 +297,15 @@ export const ShowBackups = ({ id, type }: Props) => {
setActiveManualBackup(undefined);
}}
>
<Play className="size-5 text-muted-foreground" />
<Play className="size-4 " />
</Button>
</TooltipTrigger>
<TooltipContent>Run Manual Backup</TooltipContent>
</Tooltip>
</TooltipProvider>
<UpdateBackup
<HandleBackup
backupType={backup.backupType}
backupId={backup.backupId}
refetch={refetch}
/>
@@ -236,7 +329,7 @@ export const ShowBackups = ({ id, type }: Props) => {
<Button
variant="ghost"
size="icon"
className="group hover:bg-red-500/10"
className="group hover:bg-red-500/10 size-8"
isLoading={isRemoving}
>
<Trash2 className="size-4 text-primary group-hover:text-red-500" />

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,5 @@
CREATE TYPE "public"."backupType" AS ENUM('database', 'compose');--> 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 CONSTRAINT "backup_composeId_compose_composeId_fk" FOREIGN KEY ("composeId") REFERENCES "public"."compose"("composeId") ON DELETE cascade ON UPDATE no action;

View File

@@ -0,0 +1 @@
ALTER TABLE "backup" ADD COLUMN "metadata" jsonb;

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -617,6 +617,20 @@
"when": 1745723563822,
"tag": "0087_lively_risque",
"breakpoints": true
},
{
"idx": 88,
"version": "7",
"when": 1745801614194,
"tag": "0088_same_ezekiel",
"breakpoints": true
},
{
"idx": 89,
"version": "7",
"when": 1745812150155,
"tag": "0089_dazzling_marrow",
"breakpoints": true
}
]
}

View File

@@ -9,6 +9,7 @@ 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";
@@ -217,16 +218,17 @@ const Service = (
className={cn(
"lg:grid lg:w-fit max-md:overflow-y-scroll justify-start",
isCloud && data?.serverId
? "lg:grid-cols-7"
? "lg:grid-cols-8"
: data?.serverId
? "lg:grid-cols-6"
: "lg:grid-cols-7",
? "lg:grid-cols-7"
: "lg:grid-cols-8",
)}
>
<TabsTrigger value="general">General</TabsTrigger>
<TabsTrigger value="environment">Environment</TabsTrigger>
<TabsTrigger value="domains">Domains</TabsTrigger>
<TabsTrigger value="deployments">Deployments</TabsTrigger>
<TabsTrigger value="backups">Backups</TabsTrigger>
<TabsTrigger value="logs">Logs</TabsTrigger>
{((data?.serverId && isCloud) || !data?.server) && (
<TabsTrigger value="monitoring">Monitoring</TabsTrigger>
@@ -245,6 +247,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="monitoring">
<div className="pt-2.5">

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

@@ -23,7 +23,11 @@ const Page = () => {
{!isCloud && <EnablePaidFeatures />}
<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

@@ -10,6 +10,8 @@ import {
IS_CLOUD,
createBackup,
findBackupById,
findComposeByBackupId,
findComposeById,
findMariadbByBackupId,
findMariadbById,
findMongoByBackupId,
@@ -31,6 +33,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 +43,7 @@ import {
execAsyncRemote,
} from "@dokploy/server/utils/process/execAsync";
import {
restoreComposeBackup,
restoreMariadbBackup,
restoreMongoBackup,
restoreMySqlBackup,
@@ -82,6 +86,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 +241,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 }) => {
@@ -351,78 +376,96 @@ export const backupRouter = createTRPCRouter({
"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.any(),
}),
)
.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) => {
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) => {
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) => {
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) => {
emit.next(log);
},
);
});
}
if (input.databaseType === "web-server") {
return observable<string>((emit) => {
restoreWebServerBackup(destination, input.backupFile, (log) => {
emit.next(log);
return observable<string>((emit) => {
restorePostgresBackup(
postgres,
destination,
input.databaseName,
input.backupFile,
(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) => {
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) => {
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) => {
emit.next(log);
},
);
});
}
if (input.databaseType === "web-server") {
return observable<string>((emit) => {
restoreWebServerBackup(destination, input.backupFile, (log) => {
emit.next(log);
});
});
}
}
if (input.backupType === "compose") {
const compose = await findComposeById(input.databaseId);
return observable<string>((emit) => {
restoreComposeBackup(
compose,
destination,
input.databaseName,
input.backupFile,
input.metadata,
(log) => {
emit.next(log);
},
);
});
}
return true;
}),
});

View File

@@ -21,6 +21,7 @@ import {
removeDomain,
removeDomainById,
updateDomainById,
validateDomain,
} from "@dokploy/server";
import { TRPCError } from "@trpc/server";
import { z } from "zod";
@@ -224,4 +225,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

@@ -9,10 +9,11 @@ import {
runMongoBackup,
runMySqlBackup,
runPostgresBackup,
runComposeBackup,
} from "@dokploy/server";
import { db } from "@dokploy/server/dist/db";
import { backups, 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";
@@ -22,40 +23,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);
}
}
if (job.type === "server") {
@@ -80,7 +98,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) {
@@ -92,7 +113,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),
@@ -101,6 +122,7 @@ export const initializeJobs = async () => {
mysql: true,
postgres: true,
mongo: true,
compose: true,
},
});

View File

@@ -1,21 +1,32 @@
{
"name": "@dokploy/server",
"version": "1.0.0",
"main": "./src/index.ts",
"main": "./dist/index.js",
"type": "module",
"exports": {
".": "./src/index.ts",
".": {
"import": "./dist/index.js",
"require": "./dist/index.cjs.js"
},
"./db": {
"import": "./src/db/index.ts",
"import": "./dist/db/index.js",
"require": "./dist/db/index.cjs.js"
},
"./setup/*": {
"import": "./src/setup/*.ts",
"require": "./dist/setup/index.cjs.js"
"./*": {
"import": "./dist/*",
"require": "./dist/*.cjs"
},
"./constants": {
"import": "./src/constants/index.ts",
"require": "./dist/constants.cjs.js"
"./dist": {
"import": "./dist/index.js",
"require": "./dist/index.cjs.js"
},
"./dist/db": {
"import": "./dist/db/index.js",
"require": "./dist/db/index.cjs.js"
},
"./dist/db/schema": {
"import": "./dist/db/schema/index.js",
"require": "./dist/db/schema/index.cjs.js"
}
},
"scripts": {

View File

@@ -3,6 +3,7 @@ import {
type AnyPgColumn,
boolean,
integer,
jsonb,
pgEnum,
pgTable,
text,
@@ -16,6 +17,8 @@ import { mongo } from "./mongo";
import { mysql } from "./mysql";
import { postgres } from "./postgres";
import { users_temp } from "./user";
import { compose } from "./compose";
export const databaseType = pgEnum("databaseType", [
"postgres",
"mariadb",
@@ -24,6 +27,8 @@ export const databaseType = pgEnum("databaseType", [
"web-server",
]);
export const backupType = pgEnum("backupType", ["database", "compose"]);
export const backups = pgTable("backup", {
backupId: text("backupId")
.notNull()
@@ -33,14 +38,19 @@ export const backups = pgTable("backup", {
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,6 +70,26 @@ 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 }) => ({
@@ -87,6 +117,10 @@ export const backupsRelations = relations(backups, ({ one }) => ({
fields: [backups.userId],
references: [users_temp.id],
}),
compose: one(compose, {
fields: [backups.composeId],
references: [compose.composeId],
}),
}));
const createSchema = createInsertSchema(backups, {
@@ -103,6 +137,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 +153,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 +180,8 @@ export const apiUpdateBackup = createSchema
destinationId: true,
database: true,
keepLatestCount: true,
serviceName: true,
metadata: true,
databaseType: true,
})
.required();

View File

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

View File

@@ -49,6 +49,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,11 @@ export const findComposeById = async (composeId: string) => {
bitbucket: true,
gitea: true,
server: true,
backups: {
with: {
destination: true,
},
},
},
});
if (!result) {

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

@@ -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";
@@ -103,6 +108,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

@@ -0,0 +1,113 @@
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 } from "./utils";
export const runComposeBackup = async (
compose: Compose,
backup: BackupSchedule,
) => {
const { projectId, name } = compose;
const project = await findProjectById(projectId);
const { prefix, database } = backup;
const destination = backup.destination;
const backupFileName = `${new Date().toISOString()}.dump.gz`;
const bucketDestination = `${normalizeS3Path(prefix)}${backupFileName}`;
try {
const rcloneFlags = getS3Credentials(destination);
const rcloneDestination = `:s3:${destination.bucket}/${bucketDestination}`;
const rcloneCommand = `rclone rcat ${rcloneFlags.join(" ")} "${rcloneDestination}"`;
const command = getFindContainerCommand(compose, backup.serviceName || "");
if (compose.serverId) {
const { stdout } = await execAsyncRemote(compose.serverId, command);
if (!stdout) {
throw new Error("Container not found");
}
const containerId = stdout.trim();
let backupCommand = "";
if (backup.databaseType === "postgres") {
backupCommand = `docker exec ${containerId} sh -c "pg_dump -Fc --no-acl --no-owner -h localhost -U ${backup.metadata?.postgres?.databaseUser} --no-password '${database}' | gzip"`;
} else if (backup.databaseType === "mariadb") {
backupCommand = `docker exec ${containerId} sh -c "mariadb-dump --user='${backup.metadata?.mariadb?.databaseUser}' --password='${backup.metadata?.mariadb?.databasePassword}' --databases ${database} | gzip"`;
} else if (backup.databaseType === "mysql") {
backupCommand = `docker exec ${containerId} sh -c "mysqldump --default-character-set=utf8mb4 -u 'root' --password='${backup.metadata?.mysql?.databaseRootPassword}' --single-transaction --no-tablespaces --quick '${database}' | gzip"`;
} else if (backup.databaseType === "mongo") {
backupCommand = `docker exec ${containerId} sh -c "mongodump -d '${database}' -u '${backup.metadata?.mongo?.databaseUser}' -p '${backup.metadata?.mongo?.databasePassword}' --archive --authenticationDatabase admin --gzip"`;
}
await execAsyncRemote(
compose.serverId,
`${backupCommand} | ${rcloneCommand}`,
);
} else {
const { stdout } = await execAsync(command);
if (!stdout) {
throw new Error("Container not found");
}
const containerId = stdout.trim();
let backupCommand = "";
if (backup.databaseType === "postgres") {
backupCommand = `docker exec ${containerId} sh -c "pg_dump -Fc --no-acl --no-owner -h localhost -U ${backup.metadata?.postgres?.databaseUser} --no-password '${database}' | gzip"`;
} else if (backup.databaseType === "mariadb") {
backupCommand = `docker exec ${containerId} sh -c "mariadb-dump --user='${backup.metadata?.mariadb?.databaseUser}' --password='${backup.metadata?.mariadb?.databasePassword}' --databases ${database} | gzip"`;
} else if (backup.databaseType === "mysql") {
backupCommand = `docker exec ${containerId} sh -c "mysqldump --default-character-set=utf8mb4 -u 'root' --password='${backup.metadata?.mysql?.databaseRootPassword}' --single-transaction --no-tablespaces --quick '${database}' | gzip"`;
} else if (backup.databaseType === "mongo") {
backupCommand = `docker exec ${containerId} sh -c "mongodump -d '${database}' -u '${backup.metadata?.mongo?.databaseUser}' -p '${backup.metadata?.mongo?.databasePassword}' --archive --authenticationDatabase admin --gzip"`;
}
await execAsync(`${backupCommand} | ${rcloneCommand}`);
}
await sendDatabaseBackupNotifications({
applicationName: name,
projectName: project.name,
databaseType: "mongodb",
type: "success",
organizationId: project.organizationId,
});
} 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,
});
throw error;
}
};
export const getFindContainerCommand = (
compose: Compose,
serviceName: string,
) => {
const { appName, composeType } = compose;
const labels =
composeType === "stack"
? {
namespace: `label=com.docker.stack.namespace=${appName}`,
service: `label=com.docker.swarm.service.name=${appName}_${serviceName}`,
}
: {
project: `label=com.docker.compose.project=${appName}`,
service: `label=com.docker.compose.service=${serviceName}`,
};
const command = `docker ps --filter "status=running" \
--filter "${Object.values(labels).join('" --filter "')}" \
--format "{{.ID}}" | head -n 1`;
return command.trim();
};

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

@@ -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);
}
});
};

View File

@@ -0,0 +1,4 @@
export const createBackupLabels = (backupId: string) => {
const labels = [`dokploy.backup.id=${backupId}`];
return labels;
};

View File

@@ -0,0 +1,93 @@
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 type { Backup } from "@dokploy/server/services/backup";
import { getFindContainerCommand } from "../backups/compose";
export const restoreComposeBackup = async (
compose: Compose,
destination: Destination,
database: string,
backupFile: string,
metadata: Backup["metadata"] & { serviceName: string },
emit: (log: string) => void,
) => {
try {
const { serverId } = compose;
const rcloneFlags = getS3Credentials(destination);
const bucketPath = `:s3:${destination.bucket}`;
const backupPath = `${bucketPath}/${backupFile}`;
const command = getFindContainerCommand(compose, metadata.serviceName);
let containerId = "";
if (serverId) {
const { stdout, stderr } = await execAsyncRemote(serverId, command);
emit(stdout);
emit(stderr);
containerId = stdout.trim();
} else {
const { stdout, stderr } = await execAsync(command);
emit(stdout);
emit(stderr);
containerId = stdout.trim();
}
let restoreCommand = "";
if (metadata.postgres) {
restoreCommand = `rclone cat ${rcloneFlags.join(" ")} "${backupPath}" | gunzip | docker exec -i ${containerId} pg_restore -U ${metadata.postgres.databaseUser} -d ${database} --clean --if-exists`;
} else if (metadata.mariadb) {
restoreCommand = `
rclone cat ${rcloneFlags.join(" ")} "${backupPath}" | gunzip | docker exec -i ${containerId} mariadb -u ${metadata.mariadb.databaseUser} -p${metadata.mariadb.databasePassword} ${database}
`;
} else if (metadata.mysql) {
restoreCommand = `
rclone cat ${rcloneFlags.join(" ")} "${backupPath}" | gunzip | docker exec -i ${containerId} mysql -u root -p${metadata.mysql.databaseRootPassword} ${database}
`;
} else if (metadata.mongo) {
const tempDir = "/tmp/dokploy-restore";
const fileName = backupFile.split("/").pop() || "backup.dump.gz";
const decompressedName = fileName.replace(".gz", "");
restoreCommand = `\
rm -rf ${tempDir} && \
mkdir -p ${tempDir} && \
rclone copy ${rcloneFlags.join(" ")} "${backupPath}" ${tempDir} && \
cd ${tempDir} && \
gunzip -f "${fileName}" && \
docker exec -i ${containerId} mongorestore --username ${metadata.mongo.databaseUser} --password ${metadata.mongo.databasePassword} --authenticationDatabase admin --db ${database} --archive < "${decompressedName}" && \
rm -rf ${tempDir}`;
}
emit("Starting restore...");
emit(`Backup path: ${backupPath}`);
emit(`Executing command: ${restoreCommand}`);
if (serverId) {
const { stdout, stderr } = await execAsyncRemote(
serverId,
restoreCommand,
);
emit(stdout);
emit(stderr);
} else {
const { stdout, stderr } = await execAsync(restoreCommand);
emit(stdout);
emit(stderr);
}
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

@@ -40,8 +40,6 @@ rclone cat ${rcloneFlags.join(" ")} "${backupPath}" | gunzip | docker exec -i ${
emit(stderr);
} else {
const { stdout, stderr } = await execAsync(command);
console.log("stdout", stdout);
console.log("stderr", stderr);
emit(stdout);
emit(stderr);
}