mirror of
https://github.com/Dokploy/dokploy
synced 2025-06-26 18:27:59 +00:00
Merge branch '187-backups-for-docker-compose' into feat/introduce-monitoring-self-hosted-pay
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
|
||||
5
apps/dokploy/drizzle/0088_same_ezekiel.sql
Normal file
5
apps/dokploy/drizzle/0088_same_ezekiel.sql
Normal 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;
|
||||
1
apps/dokploy/drizzle/0089_dazzling_marrow.sql
Normal file
1
apps/dokploy/drizzle/0089_dazzling_marrow.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE "backup" ADD COLUMN "metadata" jsonb;
|
||||
5448
apps/dokploy/drizzle/meta/0088_snapshot.json
Normal file
5448
apps/dokploy/drizzle/meta/0088_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
5454
apps/dokploy/drizzle/meta/0089_snapshot.json
Normal file
5454
apps/dokploy/drizzle/meta/0089_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -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));
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -35,6 +35,7 @@ export const findBackupById = async (backupId: string) => {
|
||||
mariadb: true,
|
||||
mongo: true,
|
||||
destination: true,
|
||||
compose: true,
|
||||
},
|
||||
});
|
||||
if (!backup) {
|
||||
|
||||
@@ -131,6 +131,11 @@ export const findComposeById = async (composeId: string) => {
|
||||
bitbucket: true,
|
||||
gitea: true,
|
||||
server: true,
|
||||
backups: {
|
||||
with: {
|
||||
destination: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!result) {
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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)
|
||||
|
||||
113
packages/server/src/utils/backups/compose.ts
Normal file
113
packages/server/src/utils/backups/compose.ts
Normal 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();
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
4
packages/server/src/utils/docker/backup.ts
Normal file
4
packages/server/src/utils/docker/backup.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export const createBackupLabels = (backupId: string) => {
|
||||
const labels = [`dokploy.backup.id=${backupId}`];
|
||||
return labels;
|
||||
};
|
||||
93
packages/server/src/utils/restore/compose.ts
Normal file
93
packages/server/src/utils/restore/compose.ts
Normal 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",
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -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";
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user