mirror of
https://github.com/Dokploy/dokploy
synced 2025-06-26 18:27:59 +00:00
Merge pull request #1801 from Dokploy/187-backups-for-docker-compose
187 backups for docker compose for cloud version
This commit is contained in:
@@ -15,11 +15,15 @@ import { Paintbrush } from "lucide-react";
|
|||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
applicationId: string;
|
id: string;
|
||||||
|
type: "application" | "compose";
|
||||||
}
|
}
|
||||||
|
|
||||||
export const CancelQueues = ({ applicationId }: Props) => {
|
export const CancelQueues = ({ id, type }: Props) => {
|
||||||
const { mutateAsync, isLoading } = api.application.cleanQueues.useMutation();
|
const { mutateAsync, isLoading } =
|
||||||
|
type === "application"
|
||||||
|
? api.application.cleanQueues.useMutation()
|
||||||
|
: api.compose.cleanQueues.useMutation();
|
||||||
const { data: isCloud } = api.settings.isCloud.useQuery();
|
const { data: isCloud } = api.settings.isCloud.useQuery();
|
||||||
|
|
||||||
if (isCloud) {
|
if (isCloud) {
|
||||||
@@ -48,7 +52,8 @@ export const CancelQueues = ({ applicationId }: Props) => {
|
|||||||
<AlertDialogAction
|
<AlertDialogAction
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
await mutateAsync({
|
await mutateAsync({
|
||||||
applicationId,
|
applicationId: id || "",
|
||||||
|
composeId: id || "",
|
||||||
})
|
})
|
||||||
.then(() => {
|
.then(() => {
|
||||||
toast.success("Queues are being cleaned");
|
toast.success("Queues are being cleaned");
|
||||||
|
|||||||
@@ -14,10 +14,14 @@ import { RefreshCcw } from "lucide-react";
|
|||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
applicationId: string;
|
id: string;
|
||||||
|
type: "application" | "compose";
|
||||||
}
|
}
|
||||||
export const RefreshToken = ({ applicationId }: Props) => {
|
export const RefreshToken = ({ id, type }: Props) => {
|
||||||
const { mutateAsync } = api.application.refreshToken.useMutation();
|
const { mutateAsync } =
|
||||||
|
type === "application"
|
||||||
|
? api.application.refreshToken.useMutation()
|
||||||
|
: api.compose.refreshToken.useMutation();
|
||||||
const utils = api.useUtils();
|
const utils = api.useUtils();
|
||||||
return (
|
return (
|
||||||
<AlertDialog>
|
<AlertDialog>
|
||||||
@@ -37,12 +41,19 @@ export const RefreshToken = ({ applicationId }: Props) => {
|
|||||||
<AlertDialogAction
|
<AlertDialogAction
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
await mutateAsync({
|
await mutateAsync({
|
||||||
applicationId,
|
applicationId: id || "",
|
||||||
|
composeId: id || "",
|
||||||
})
|
})
|
||||||
.then(() => {
|
.then(() => {
|
||||||
utils.application.one.invalidate({
|
if (type === "application") {
|
||||||
applicationId,
|
utils.application.one.invalidate({
|
||||||
});
|
applicationId: id,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
utils.compose.one.invalidate({
|
||||||
|
composeId: id,
|
||||||
|
});
|
||||||
|
}
|
||||||
toast.success("Refresh updated");
|
toast.success("Refresh updated");
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Dialog, DialogContent, DialogTrigger } from "@/components/ui/dialog";
|
||||||
|
|
||||||
|
import type { RouterOutputs } from "@/utils/api";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { ShowDeployment } from "../deployments/show-deployment";
|
||||||
|
import { ShowDeployments } from "./show-deployments";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
id: string;
|
||||||
|
type:
|
||||||
|
| "application"
|
||||||
|
| "compose"
|
||||||
|
| "schedule"
|
||||||
|
| "server"
|
||||||
|
| "backup"
|
||||||
|
| "previewDeployment";
|
||||||
|
serverId?: string;
|
||||||
|
refreshToken?: string;
|
||||||
|
children?: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const formatDuration = (seconds: number) => {
|
||||||
|
if (seconds < 60) return `${seconds}s`;
|
||||||
|
const minutes = Math.floor(seconds / 60);
|
||||||
|
const remainingSeconds = seconds % 60;
|
||||||
|
return `${minutes}m ${remainingSeconds}s`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ShowDeploymentsModal = ({
|
||||||
|
id,
|
||||||
|
type,
|
||||||
|
serverId,
|
||||||
|
refreshToken,
|
||||||
|
children,
|
||||||
|
}: Props) => {
|
||||||
|
const [activeLog, setActiveLog] = useState<
|
||||||
|
RouterOutputs["deployment"]["all"][number] | null
|
||||||
|
>(null);
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
return (
|
||||||
|
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
{children ? (
|
||||||
|
children
|
||||||
|
) : (
|
||||||
|
<Button className="sm:w-auto w-full" size="sm" variant="outline">
|
||||||
|
View Logs
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-5xl p-0">
|
||||||
|
<ShowDeployments
|
||||||
|
id={id}
|
||||||
|
type={type}
|
||||||
|
serverId={serverId}
|
||||||
|
refreshToken={refreshToken}
|
||||||
|
/>
|
||||||
|
</DialogContent>
|
||||||
|
<ShowDeployment
|
||||||
|
serverId={serverId || ""}
|
||||||
|
open={Boolean(activeLog && activeLog.logPath !== null)}
|
||||||
|
onClose={() => setActiveLog(null)}
|
||||||
|
logPath={activeLog?.logPath || ""}
|
||||||
|
errorMessage={activeLog?.errorMessage || ""}
|
||||||
|
/>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -9,29 +9,53 @@ import {
|
|||||||
CardTitle,
|
CardTitle,
|
||||||
} from "@/components/ui/card";
|
} from "@/components/ui/card";
|
||||||
import { type RouterOutputs, api } from "@/utils/api";
|
import { type RouterOutputs, api } from "@/utils/api";
|
||||||
import { RocketIcon, Clock } from "lucide-react";
|
import { RocketIcon, Clock, Loader2 } from "lucide-react";
|
||||||
import React, { useEffect, useState } from "react";
|
import React, { useEffect, useState } from "react";
|
||||||
import { CancelQueues } from "./cancel-queues";
|
import { CancelQueues } from "./cancel-queues";
|
||||||
import { RefreshToken } from "./refresh-token";
|
import { RefreshToken } from "./refresh-token";
|
||||||
import { ShowDeployment } from "./show-deployment";
|
import { ShowDeployment } from "./show-deployment";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { formatDuration } from "../schedules/show-schedules-logs";
|
|
||||||
interface Props {
|
interface Props {
|
||||||
applicationId: string;
|
id: string;
|
||||||
|
type:
|
||||||
|
| "application"
|
||||||
|
| "compose"
|
||||||
|
| "schedule"
|
||||||
|
| "server"
|
||||||
|
| "backup"
|
||||||
|
| "previewDeployment";
|
||||||
|
refreshToken?: string;
|
||||||
|
serverId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ShowDeployments = ({ applicationId }: Props) => {
|
export const formatDuration = (seconds: number) => {
|
||||||
|
if (seconds < 60) return `${seconds}s`;
|
||||||
|
const minutes = Math.floor(seconds / 60);
|
||||||
|
const remainingSeconds = seconds % 60;
|
||||||
|
return `${minutes}m ${remainingSeconds}s`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ShowDeployments = ({
|
||||||
|
id,
|
||||||
|
type,
|
||||||
|
refreshToken,
|
||||||
|
serverId,
|
||||||
|
}: Props) => {
|
||||||
const [activeLog, setActiveLog] = useState<
|
const [activeLog, setActiveLog] = useState<
|
||||||
RouterOutputs["deployment"]["all"][number] | null
|
RouterOutputs["deployment"]["all"][number] | null
|
||||||
>(null);
|
>(null);
|
||||||
const { data } = api.application.one.useQuery({ applicationId });
|
const { data: deployments, isLoading: isLoadingDeployments } =
|
||||||
const { data: deployments } = api.deployment.all.useQuery(
|
api.deployment.allByType.useQuery(
|
||||||
{ applicationId },
|
{
|
||||||
{
|
id,
|
||||||
enabled: !!applicationId,
|
type,
|
||||||
refetchInterval: 1000,
|
},
|
||||||
},
|
{
|
||||||
);
|
enabled: !!id,
|
||||||
|
refetchInterval: 1000,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
const [url, setUrl] = React.useState("");
|
const [url, setUrl] = React.useState("");
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -39,34 +63,48 @@ export const ShowDeployments = ({ applicationId }: Props) => {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="bg-background">
|
<Card className="bg-background border-none">
|
||||||
<CardHeader className="flex flex-row items-center justify-between flex-wrap gap-2">
|
<CardHeader className="flex flex-row items-center justify-between flex-wrap gap-2">
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<CardTitle className="text-xl">Deployments</CardTitle>
|
<CardTitle className="text-xl">Deployments</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
See all the 10 last deployments for this application
|
See all the 10 last deployments for this {type}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
<CancelQueues applicationId={applicationId} />
|
{(type === "application" || type === "compose") && (
|
||||||
|
<CancelQueues id={id} type={type} />
|
||||||
|
)}
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="flex flex-col gap-4">
|
<CardContent className="flex flex-col gap-4">
|
||||||
<div className="flex flex-col gap-2 text-sm">
|
{refreshToken && (
|
||||||
<span>
|
<div className="flex flex-col gap-2 text-sm">
|
||||||
If you want to re-deploy this application use this URL in the config
|
<span>
|
||||||
of your git provider or docker
|
If you want to re-deploy this application use this URL in the
|
||||||
</span>
|
config of your git provider or docker
|
||||||
<div className="flex flex-row items-center gap-2 flex-wrap">
|
</span>
|
||||||
<span>Webhook URL: </span>
|
<div className="flex flex-row items-center gap-2 flex-wrap">
|
||||||
<div className="flex flex-row items-center gap-2">
|
<span>Webhook URL: </span>
|
||||||
<span className="break-all text-muted-foreground">
|
<div className="flex flex-row items-center gap-2">
|
||||||
{`${url}/api/deploy/${data?.refreshToken}`}
|
<span className="break-all text-muted-foreground">
|
||||||
</span>
|
{`${url}/api/deploy/${refreshToken}`}
|
||||||
<RefreshToken applicationId={applicationId} />
|
</span>
|
||||||
|
{(type === "application" || type === "compose") && (
|
||||||
|
<RefreshToken id={id} type={type} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
{data?.deployments?.length === 0 ? (
|
|
||||||
<div className="flex w-full flex-col items-center justify-center gap-3 pt-10">
|
{isLoadingDeployments ? (
|
||||||
|
<div className="flex w-full flex-row items-center justify-center gap-3 pt-10 min-h-[25vh]">
|
||||||
|
<Loader2 className="size-6 text-muted-foreground animate-spin" />
|
||||||
|
<span className="text-base text-muted-foreground">
|
||||||
|
Loading deployments...
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : deployments?.length === 0 ? (
|
||||||
|
<div className="flex w-full flex-col items-center justify-center gap-3 pt-10 min-h-[25vh]">
|
||||||
<RocketIcon className="size-8 text-muted-foreground" />
|
<RocketIcon className="size-8 text-muted-foreground" />
|
||||||
<span className="text-base text-muted-foreground">
|
<span className="text-base text-muted-foreground">
|
||||||
No deployments found
|
No deployments found
|
||||||
@@ -129,7 +167,7 @@ export const ShowDeployments = ({ applicationId }: Props) => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<ShowDeployment
|
<ShowDeployment
|
||||||
serverId={data?.serverId || ""}
|
serverId={serverId}
|
||||||
open={Boolean(activeLog && activeLog.logPath !== null)}
|
open={Boolean(activeLog && activeLog.logPath !== null)}
|
||||||
onClose={() => setActiveLog(null)}
|
onClose={() => setActiveLog(null)}
|
||||||
logPath={activeLog?.logPath || ""}
|
logPath={activeLog?.logPath || ""}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -38,26 +38,67 @@ import { useEffect, useState } from "react";
|
|||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
import { domain } from "@/server/db/validations/domain";
|
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { Dices } from "lucide-react";
|
import { DatabaseZap, Dices, RefreshCw } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import type z from "zod";
|
import z from "zod";
|
||||||
|
|
||||||
|
export type CacheType = "fetch" | "cache";
|
||||||
|
|
||||||
|
export const domain = z
|
||||||
|
.object({
|
||||||
|
host: z.string().min(1, { message: "Add a hostname" }),
|
||||||
|
path: z.string().min(1).optional(),
|
||||||
|
port: z
|
||||||
|
.number()
|
||||||
|
.min(1, { message: "Port must be at least 1" })
|
||||||
|
.max(65535, { message: "Port must be 65535 or below" })
|
||||||
|
.optional(),
|
||||||
|
https: z.boolean().optional(),
|
||||||
|
certificateType: z.enum(["letsencrypt", "none", "custom"]).optional(),
|
||||||
|
customCertResolver: z.string().optional(),
|
||||||
|
serviceName: z.string().optional(),
|
||||||
|
domainType: z.enum(["application", "compose", "preview"]).optional(),
|
||||||
|
})
|
||||||
|
.superRefine((input, ctx) => {
|
||||||
|
if (input.https && !input.certificateType) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
path: ["certificateType"],
|
||||||
|
message: "Required",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.certificateType === "custom" && !input.customCertResolver) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
path: ["customCertResolver"],
|
||||||
|
message: "Required",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.domainType === "compose" && !input.serviceName) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
path: ["serviceName"],
|
||||||
|
message: "Required",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
type Domain = z.infer<typeof domain>;
|
type Domain = z.infer<typeof domain>;
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
applicationId: string;
|
id: string;
|
||||||
|
type: "application" | "compose";
|
||||||
domainId?: string;
|
domainId?: string;
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const AddDomain = ({
|
export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
|
||||||
applicationId,
|
|
||||||
domainId = "",
|
|
||||||
children,
|
|
||||||
}: Props) => {
|
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const [cacheType, setCacheType] = useState<CacheType>("cache");
|
||||||
|
|
||||||
const utils = api.useUtils();
|
const utils = api.useUtils();
|
||||||
const { data, refetch } = api.domain.one.useQuery(
|
const { data, refetch } = api.domain.one.useQuery(
|
||||||
{
|
{
|
||||||
@@ -68,14 +109,24 @@ export const AddDomain = ({
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
const { data: application } = api.application.one.useQuery(
|
const { data: application } =
|
||||||
{
|
type === "application"
|
||||||
applicationId,
|
? api.application.one.useQuery(
|
||||||
},
|
{
|
||||||
{
|
applicationId: id,
|
||||||
enabled: !!applicationId,
|
},
|
||||||
},
|
{
|
||||||
);
|
enabled: !!id,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
: api.compose.one.useQuery(
|
||||||
|
{
|
||||||
|
composeId: id,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
enabled: !!id,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
const { mutateAsync, isError, error, isLoading } = domainId
|
const { mutateAsync, isError, error, isLoading } = domainId
|
||||||
? api.domain.update.useMutation()
|
? api.domain.update.useMutation()
|
||||||
@@ -89,7 +140,22 @@ export const AddDomain = ({
|
|||||||
serverId: application?.serverId || "",
|
serverId: application?.serverId || "",
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log("canGenerateTraefikMeDomains", canGenerateTraefikMeDomains);
|
const {
|
||||||
|
data: services,
|
||||||
|
isFetching: isLoadingServices,
|
||||||
|
error: errorServices,
|
||||||
|
refetch: refetchServices,
|
||||||
|
} = api.compose.loadServices.useQuery(
|
||||||
|
{
|
||||||
|
composeId: id,
|
||||||
|
type: cacheType,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
retry: false,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
enabled: type === "compose" && !!id,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
const form = useForm<Domain>({
|
const form = useForm<Domain>({
|
||||||
resolver: zodResolver(domain),
|
resolver: zodResolver(domain),
|
||||||
@@ -100,12 +166,15 @@ export const AddDomain = ({
|
|||||||
https: false,
|
https: false,
|
||||||
certificateType: undefined,
|
certificateType: undefined,
|
||||||
customCertResolver: undefined,
|
customCertResolver: undefined,
|
||||||
|
serviceName: undefined,
|
||||||
|
domainType: type,
|
||||||
},
|
},
|
||||||
mode: "onChange",
|
mode: "onChange",
|
||||||
});
|
});
|
||||||
|
|
||||||
const certificateType = form.watch("certificateType");
|
const certificateType = form.watch("certificateType");
|
||||||
const https = form.watch("https");
|
const https = form.watch("https");
|
||||||
|
const domainType = form.watch("domainType");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (data) {
|
if (data) {
|
||||||
@@ -116,6 +185,8 @@ export const AddDomain = ({
|
|||||||
port: data?.port || undefined,
|
port: data?.port || undefined,
|
||||||
certificateType: data?.certificateType || undefined,
|
certificateType: data?.certificateType || undefined,
|
||||||
customCertResolver: data?.customCertResolver || undefined,
|
customCertResolver: data?.customCertResolver || undefined,
|
||||||
|
serviceName: data?.serviceName || undefined,
|
||||||
|
domainType: data?.domainType || type,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -127,6 +198,7 @@ export const AddDomain = ({
|
|||||||
https: false,
|
https: false,
|
||||||
certificateType: undefined,
|
certificateType: undefined,
|
||||||
customCertResolver: undefined,
|
customCertResolver: undefined,
|
||||||
|
domainType: type,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [form, data, isLoading, domainId]);
|
}, [form, data, isLoading, domainId]);
|
||||||
@@ -150,22 +222,37 @@ export const AddDomain = ({
|
|||||||
const onSubmit = async (data: Domain) => {
|
const onSubmit = async (data: Domain) => {
|
||||||
await mutateAsync({
|
await mutateAsync({
|
||||||
domainId,
|
domainId,
|
||||||
applicationId,
|
...(data.domainType === "application" && {
|
||||||
|
applicationId: id,
|
||||||
|
}),
|
||||||
|
...(data.domainType === "compose" && {
|
||||||
|
composeId: id,
|
||||||
|
}),
|
||||||
...data,
|
...data,
|
||||||
})
|
})
|
||||||
.then(async () => {
|
.then(async () => {
|
||||||
toast.success(dictionary.success);
|
toast.success(dictionary.success);
|
||||||
await utils.domain.byApplicationId.invalidate({
|
|
||||||
applicationId,
|
if (data.domainType === "application") {
|
||||||
});
|
await utils.domain.byApplicationId.invalidate({
|
||||||
await utils.application.readTraefikConfig.invalidate({ applicationId });
|
applicationId: id,
|
||||||
|
});
|
||||||
|
await utils.application.readTraefikConfig.invalidate({
|
||||||
|
applicationId: id,
|
||||||
|
});
|
||||||
|
} else if (data.domainType === "compose") {
|
||||||
|
await utils.domain.byComposeId.invalidate({
|
||||||
|
composeId: id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (domainId) {
|
if (domainId) {
|
||||||
refetch();
|
refetch();
|
||||||
}
|
}
|
||||||
setIsOpen(false);
|
setIsOpen(false);
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch((e) => {
|
||||||
|
console.log(e);
|
||||||
toast.error(dictionary.error);
|
toast.error(dictionary.error);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -189,6 +276,119 @@ export const AddDomain = ({
|
|||||||
>
|
>
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
|
<div className="flex flex-row items-end w-full gap-4">
|
||||||
|
{domainType === "compose" && (
|
||||||
|
<div className="flex flex-col gap-2 w-full">
|
||||||
|
{errorServices && (
|
||||||
|
<AlertBlock
|
||||||
|
type="warning"
|
||||||
|
className="[overflow-wrap:anywhere]"
|
||||||
|
>
|
||||||
|
{errorServices?.message}
|
||||||
|
</AlertBlock>
|
||||||
|
)}
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="serviceName"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="w-full">
|
||||||
|
<FormLabel>Service Name</FormLabel>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Select
|
||||||
|
onValueChange={field.onChange}
|
||||||
|
defaultValue={field.value || ""}
|
||||||
|
>
|
||||||
|
<FormControl>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select a service name" />
|
||||||
|
</SelectTrigger>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
<SelectContent>
|
||||||
|
{services?.map((service, index) => (
|
||||||
|
<SelectItem
|
||||||
|
value={service}
|
||||||
|
key={`${service}-${index}`}
|
||||||
|
>
|
||||||
|
{service}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
<SelectItem value="none" disabled>
|
||||||
|
Empty
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<TooltipProvider delayDuration={0}>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
type="button"
|
||||||
|
isLoading={isLoadingServices}
|
||||||
|
onClick={() => {
|
||||||
|
if (cacheType === "fetch") {
|
||||||
|
refetchServices();
|
||||||
|
} else {
|
||||||
|
setCacheType("fetch");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<RefreshCw className="size-4 text-muted-foreground" />
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent
|
||||||
|
side="left"
|
||||||
|
sideOffset={5}
|
||||||
|
className="max-w-[10rem]"
|
||||||
|
>
|
||||||
|
<p>
|
||||||
|
Fetch: Will clone the repository and load
|
||||||
|
the services
|
||||||
|
</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
<TooltipProvider delayDuration={0}>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
type="button"
|
||||||
|
isLoading={isLoadingServices}
|
||||||
|
onClick={() => {
|
||||||
|
if (cacheType === "cache") {
|
||||||
|
refetchServices();
|
||||||
|
} else {
|
||||||
|
setCacheType("cache");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DatabaseZap className="size-4 text-muted-foreground" />
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent
|
||||||
|
side="left"
|
||||||
|
sideOffset={5}
|
||||||
|
className="max-w-[10rem]"
|
||||||
|
>
|
||||||
|
<p>
|
||||||
|
Cache: If you previously deployed this
|
||||||
|
compose, it will read the services from
|
||||||
|
the last deployment/fetch from the
|
||||||
|
repository
|
||||||
|
</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="host"
|
name="host"
|
||||||
@@ -276,6 +476,11 @@ export const AddDomain = ({
|
|||||||
return (
|
return (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Container Port</FormLabel>
|
<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>
|
<FormControl>
|
||||||
<NumberInput placeholder={"3000"} {...field} />
|
<NumberInput placeholder={"3000"} {...field} />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
@@ -8,28 +8,133 @@ import {
|
|||||||
CardTitle,
|
CardTitle,
|
||||||
} from "@/components/ui/card";
|
} from "@/components/ui/card";
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
import { ExternalLink, GlobeIcon, PenBoxIcon, Trash2 } from "lucide-react";
|
import {
|
||||||
|
CheckCircle2,
|
||||||
|
ExternalLink,
|
||||||
|
GlobeIcon,
|
||||||
|
InfoIcon,
|
||||||
|
Loader2,
|
||||||
|
PenBoxIcon,
|
||||||
|
RefreshCw,
|
||||||
|
Server,
|
||||||
|
Trash2,
|
||||||
|
XCircle,
|
||||||
|
} from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { AddDomain } from "./add-domain";
|
import { AddDomain } from "./handle-domain";
|
||||||
|
import { useState } from "react";
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipProvider,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from "@/components/ui/tooltip";
|
||||||
|
import { DnsHelperModal } from "./dns-helper-modal";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
|
||||||
|
export type ValidationState = {
|
||||||
|
isLoading: boolean;
|
||||||
|
isValid?: boolean;
|
||||||
|
error?: string;
|
||||||
|
resolvedIp?: string;
|
||||||
|
message?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ValidationStates = Record<string, ValidationState>;
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
applicationId: string;
|
id: string;
|
||||||
|
type: "application" | "compose";
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ShowDomains = ({ applicationId }: Props) => {
|
export const ShowDomains = ({ id, type }: Props) => {
|
||||||
const { data, refetch } = api.domain.byApplicationId.useQuery(
|
const { data: application } =
|
||||||
{
|
type === "application"
|
||||||
applicationId,
|
? api.application.one.useQuery(
|
||||||
},
|
{
|
||||||
{
|
applicationId: id,
|
||||||
enabled: !!applicationId,
|
},
|
||||||
},
|
{
|
||||||
|
enabled: !!id,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
: api.compose.one.useQuery(
|
||||||
|
{
|
||||||
|
composeId: id,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
enabled: !!id,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const [validationStates, setValidationStates] = useState<ValidationStates>(
|
||||||
|
{},
|
||||||
);
|
);
|
||||||
|
const { data: ip } = api.settings.getIp.useQuery();
|
||||||
|
|
||||||
|
const {
|
||||||
|
data,
|
||||||
|
refetch,
|
||||||
|
isLoading: isLoadingDomains,
|
||||||
|
} = type === "application"
|
||||||
|
? api.domain.byApplicationId.useQuery(
|
||||||
|
{
|
||||||
|
applicationId: id,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
enabled: !!id,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
: api.domain.byComposeId.useQuery(
|
||||||
|
{
|
||||||
|
composeId: id,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
enabled: !!id,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const { mutateAsync: validateDomain } =
|
||||||
|
api.domain.validateDomain.useMutation();
|
||||||
const { mutateAsync: deleteDomain, isLoading: isRemoving } =
|
const { mutateAsync: deleteDomain, isLoading: isRemoving } =
|
||||||
api.domain.delete.useMutation();
|
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 (
|
return (
|
||||||
<div className="flex w-full flex-col gap-5 ">
|
<div className="flex w-full flex-col gap-5 ">
|
||||||
<Card className="bg-background">
|
<Card className="bg-background">
|
||||||
@@ -43,7 +148,7 @@ export const ShowDomains = ({ applicationId }: Props) => {
|
|||||||
|
|
||||||
<div className="flex flex-row gap-4 flex-wrap">
|
<div className="flex flex-row gap-4 flex-wrap">
|
||||||
{data && data?.length > 0 && (
|
{data && data?.length > 0 && (
|
||||||
<AddDomain applicationId={applicationId}>
|
<AddDomain id={id} type={type}>
|
||||||
<Button>
|
<Button>
|
||||||
<GlobeIcon className="size-4" /> Add Domain
|
<GlobeIcon className="size-4" /> Add Domain
|
||||||
</Button>
|
</Button>
|
||||||
@@ -52,15 +157,22 @@ export const ShowDomains = ({ applicationId }: Props) => {
|
|||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="flex w-full flex-row gap-4">
|
<CardContent className="flex w-full flex-row gap-4">
|
||||||
{data?.length === 0 ? (
|
{isLoadingDomains ? (
|
||||||
<div className="flex w-full flex-col items-center justify-center gap-3">
|
<div className="flex w-full flex-row gap-4 min-h-[40vh] justify-center items-center">
|
||||||
|
<Loader2 className="size-5 animate-spin text-muted-foreground" />
|
||||||
|
<span className="text-base text-muted-foreground">
|
||||||
|
Loading domains...
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : data?.length === 0 ? (
|
||||||
|
<div className="flex w-full flex-col items-center justify-center gap-3 min-h-[40vh]">
|
||||||
<GlobeIcon className="size-8 text-muted-foreground" />
|
<GlobeIcon className="size-8 text-muted-foreground" />
|
||||||
<span className="text-base text-muted-foreground">
|
<span className="text-base text-muted-foreground">
|
||||||
To access the application it is required to set at least 1
|
To access the application it is required to set at least 1
|
||||||
domain
|
domain
|
||||||
</span>
|
</span>
|
||||||
<div className="flex flex-row gap-4 flex-wrap">
|
<div className="flex flex-row gap-4 flex-wrap">
|
||||||
<AddDomain applicationId={applicationId}>
|
<AddDomain id={id} type={type}>
|
||||||
<Button>
|
<Button>
|
||||||
<GlobeIcon className="size-4" /> Add Domain
|
<GlobeIcon className="size-4" /> Add Domain
|
||||||
</Button>
|
</Button>
|
||||||
@@ -68,73 +180,216 @@ export const ShowDomains = ({ applicationId }: Props) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex w-full flex-col gap-4">
|
<div className="grid grid-cols-1 gap-4 xl:grid-cols-2 w-full min-h-[40vh] ">
|
||||||
{data?.map((item) => {
|
{data?.map((item) => {
|
||||||
|
const validationState = validationStates[item.host];
|
||||||
return (
|
return (
|
||||||
<div
|
<Card
|
||||||
key={item.domainId}
|
key={item.domainId}
|
||||||
className="flex w-full items-center justify-between gap-4 border p-4 md:px-6 rounded-lg flex-wrap"
|
className="relative overflow-hidden w-full border bg-card transition-all hover:shadow-md bg-transparent h-fit"
|
||||||
>
|
>
|
||||||
<Link
|
<CardContent className="p-6">
|
||||||
className="md:basis-1/2 flex gap-2 items-center hover:underline transition-all w-full"
|
<div className="flex flex-col gap-4">
|
||||||
target="_blank"
|
{/* Service & Domain Info */}
|
||||||
href={`${item.https ? "https" : "http"}://${item.host}${item.path}`}
|
<div className="flex items-start justify-between">
|
||||||
>
|
<div className="flex flex-col gap-2">
|
||||||
<span className="truncate max-w-full text-sm">
|
{item.serviceName && (
|
||||||
{item.host}
|
<Badge variant="outline" className="w-fit">
|
||||||
</span>
|
<Server className="size-3 mr-1" />
|
||||||
<ExternalLink className="size-4 min-w-4" />
|
{item.serviceName}
|
||||||
</Link>
|
</Badge>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="flex gap-8">
|
<Link
|
||||||
<div className="flex gap-8 opacity-50 items-center h-10 text-center text-sm font-medium">
|
className="flex items-center gap-2 text-base font-medium hover:underline"
|
||||||
<span>{item.path}</span>
|
target="_blank"
|
||||||
<span>{item.port}</span>
|
href={`${item.https ? "https" : "http"}://${item.host}${item.path}`}
|
||||||
<span>{item.https ? "HTTPS" : "HTTP"}</span>
|
>
|
||||||
</div>
|
{item.host}
|
||||||
|
<ExternalLink className="size-4" />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{!item.host.includes("traefik.me") && (
|
||||||
|
<DnsHelperModal
|
||||||
|
domain={{
|
||||||
|
host: item.host,
|
||||||
|
https: item.https,
|
||||||
|
path: item.path || undefined,
|
||||||
|
}}
|
||||||
|
serverIp={
|
||||||
|
application?.server?.ipAddress?.toString() ||
|
||||||
|
ip?.toString()
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<AddDomain
|
||||||
|
id={id}
|
||||||
|
type={type}
|
||||||
|
domainId={item.domainId}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="group hover:bg-blue-500/10"
|
||||||
|
>
|
||||||
|
<PenBoxIcon className="size-3.5 text-primary group-hover:text-blue-500" />
|
||||||
|
</Button>
|
||||||
|
</AddDomain>
|
||||||
|
<DialogAction
|
||||||
|
title="Delete Domain"
|
||||||
|
description="Are you sure you want to delete this domain?"
|
||||||
|
type="destructive"
|
||||||
|
onClick={async () => {
|
||||||
|
await deleteDomain({
|
||||||
|
domainId: item.domainId,
|
||||||
|
})
|
||||||
|
.then((_data) => {
|
||||||
|
refetch();
|
||||||
|
toast.success(
|
||||||
|
"Domain deleted successfully",
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
toast.error("Error deleting domain");
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="group hover:bg-red-500/10"
|
||||||
|
isLoading={isRemoving}
|
||||||
|
>
|
||||||
|
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
|
||||||
|
</Button>
|
||||||
|
</DialogAction>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-2">
|
{/* Domain Details */}
|
||||||
<AddDomain
|
<div className="flex flex-wrap gap-3">
|
||||||
applicationId={applicationId}
|
<TooltipProvider>
|
||||||
domainId={item.domainId}
|
<Tooltip>
|
||||||
>
|
<TooltipTrigger asChild>
|
||||||
<Button
|
<Badge variant="secondary">
|
||||||
variant="ghost"
|
<InfoIcon className="size-3 mr-1" />
|
||||||
size="icon"
|
Path: {item.path || "/"}
|
||||||
className="group hover:bg-blue-500/10 "
|
</Badge>
|
||||||
>
|
</TooltipTrigger>
|
||||||
<PenBoxIcon className="size-3.5 text-primary group-hover:text-blue-500" />
|
<TooltipContent>
|
||||||
</Button>
|
<p>URL path for this service</p>
|
||||||
</AddDomain>
|
</TooltipContent>
|
||||||
<DialogAction
|
</Tooltip>
|
||||||
title="Delete Domain"
|
</TooltipProvider>
|
||||||
description="Are you sure you want to delete this domain?"
|
|
||||||
type="destructive"
|
<TooltipProvider>
|
||||||
onClick={async () => {
|
<Tooltip>
|
||||||
await deleteDomain({
|
<TooltipTrigger asChild>
|
||||||
domainId: item.domainId,
|
<Badge variant="secondary">
|
||||||
})
|
<InfoIcon className="size-3 mr-1" />
|
||||||
.then(() => {
|
Port: {item.port}
|
||||||
refetch();
|
</Badge>
|
||||||
toast.success("Domain deleted successfully");
|
</TooltipTrigger>
|
||||||
})
|
<TooltipContent>
|
||||||
.catch(() => {
|
<p>Container port exposed</p>
|
||||||
toast.error("Error deleting domain");
|
</TooltipContent>
|
||||||
});
|
</Tooltip>
|
||||||
}}
|
</TooltipProvider>
|
||||||
>
|
|
||||||
<Button
|
<TooltipProvider>
|
||||||
variant="ghost"
|
<Tooltip>
|
||||||
size="icon"
|
<TooltipTrigger asChild>
|
||||||
className="group hover:bg-red-500/10"
|
<Badge
|
||||||
isLoading={isRemoving}
|
variant={item.https ? "outline" : "secondary"}
|
||||||
>
|
>
|
||||||
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
|
{item.https ? "HTTPS" : "HTTP"}
|
||||||
</Button>
|
</Badge>
|
||||||
</DialogAction>
|
</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>
|
||||||
</div>
|
</Card>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import {
|
|||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
import { GitBranch, UploadCloud } from "lucide-react";
|
import { GitBranch, Loader2, UploadCloud } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { SaveBitbucketProvider } from "./save-bitbucket-provider";
|
import { SaveBitbucketProvider } from "./save-bitbucket-provider";
|
||||||
@@ -34,14 +34,49 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const ShowProviderForm = ({ applicationId }: Props) => {
|
export const ShowProviderForm = ({ applicationId }: Props) => {
|
||||||
const { data: githubProviders } = api.github.githubProviders.useQuery();
|
const { data: githubProviders, isLoading: isLoadingGithub } =
|
||||||
const { data: gitlabProviders } = api.gitlab.gitlabProviders.useQuery();
|
api.github.githubProviders.useQuery();
|
||||||
const { data: bitbucketProviders } =
|
const { data: gitlabProviders, isLoading: isLoadingGitlab } =
|
||||||
|
api.gitlab.gitlabProviders.useQuery();
|
||||||
|
const { data: bitbucketProviders, isLoading: isLoadingBitbucket } =
|
||||||
api.bitbucket.bitbucketProviders.useQuery();
|
api.bitbucket.bitbucketProviders.useQuery();
|
||||||
const { data: giteaProviders } = api.gitea.giteaProviders.useQuery();
|
const { data: giteaProviders, isLoading: isLoadingGitea } =
|
||||||
|
api.gitea.giteaProviders.useQuery();
|
||||||
|
|
||||||
const { data: application } = api.application.one.useQuery({ applicationId });
|
const { data: application } = api.application.one.useQuery({ applicationId });
|
||||||
const [tab, setSab] = useState<TabState>(application?.sourceType || "github");
|
const [tab, setSab] = useState<TabState>(application?.sourceType || "github");
|
||||||
|
|
||||||
|
const isLoading =
|
||||||
|
isLoadingGithub || isLoadingGitlab || isLoadingBitbucket || isLoadingGitea;
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<Card className="group relative w-full bg-transparent">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-start justify-between">
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<span className="flex flex-col space-y-0.5">Provider</span>
|
||||||
|
<p className="flex items-center text-sm font-normal text-muted-foreground">
|
||||||
|
Select the source of your code
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="hidden space-y-1 text-sm font-normal md:block">
|
||||||
|
<GitBranch className="size-6 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="flex min-h-[25vh] items-center justify-center">
|
||||||
|
<div className="flex items-center gap-2 text-muted-foreground">
|
||||||
|
<Loader2 className="size-4 animate-spin" />
|
||||||
|
<span>Loading providers...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="group relative w-full bg-transparent">
|
<Card className="group relative w-full bg-transparent">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
@@ -123,7 +158,7 @@ export const ShowProviderForm = ({ applicationId }: Props) => {
|
|||||||
{githubProviders && githubProviders?.length > 0 ? (
|
{githubProviders && githubProviders?.length > 0 ? (
|
||||||
<SaveGithubProvider applicationId={applicationId} />
|
<SaveGithubProvider applicationId={applicationId} />
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col items-center gap-3 min-h-[15vh] justify-center">
|
<div className="flex flex-col items-center gap-3 min-h-[25vh] justify-center">
|
||||||
<GithubIcon className="size-8 text-muted-foreground" />
|
<GithubIcon className="size-8 text-muted-foreground" />
|
||||||
<span className="text-base text-muted-foreground">
|
<span className="text-base text-muted-foreground">
|
||||||
To deploy using GitHub, you need to configure your account
|
To deploy using GitHub, you need to configure your account
|
||||||
@@ -143,7 +178,7 @@ export const ShowProviderForm = ({ applicationId }: Props) => {
|
|||||||
{gitlabProviders && gitlabProviders?.length > 0 ? (
|
{gitlabProviders && gitlabProviders?.length > 0 ? (
|
||||||
<SaveGitlabProvider applicationId={applicationId} />
|
<SaveGitlabProvider applicationId={applicationId} />
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col items-center gap-3 min-h-[15vh] justify-center">
|
<div className="flex flex-col items-center gap-3 min-h-[25vh] justify-center">
|
||||||
<GitlabIcon className="size-8 text-muted-foreground" />
|
<GitlabIcon className="size-8 text-muted-foreground" />
|
||||||
<span className="text-base text-muted-foreground">
|
<span className="text-base text-muted-foreground">
|
||||||
To deploy using GitLab, you need to configure your account
|
To deploy using GitLab, you need to configure your account
|
||||||
@@ -163,7 +198,7 @@ export const ShowProviderForm = ({ applicationId }: Props) => {
|
|||||||
{bitbucketProviders && bitbucketProviders?.length > 0 ? (
|
{bitbucketProviders && bitbucketProviders?.length > 0 ? (
|
||||||
<SaveBitbucketProvider applicationId={applicationId} />
|
<SaveBitbucketProvider applicationId={applicationId} />
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col items-center gap-3 min-h-[15vh] justify-center">
|
<div className="flex flex-col items-center gap-3 min-h-[25vh] justify-center">
|
||||||
<BitbucketIcon className="size-8 text-muted-foreground" />
|
<BitbucketIcon className="size-8 text-muted-foreground" />
|
||||||
<span className="text-base text-muted-foreground">
|
<span className="text-base text-muted-foreground">
|
||||||
To deploy using Bitbucket, you need to configure your account
|
To deploy using Bitbucket, you need to configure your account
|
||||||
@@ -183,7 +218,7 @@ export const ShowProviderForm = ({ applicationId }: Props) => {
|
|||||||
{giteaProviders && giteaProviders?.length > 0 ? (
|
{giteaProviders && giteaProviders?.length > 0 ? (
|
||||||
<SaveGiteaProvider applicationId={applicationId} />
|
<SaveGiteaProvider applicationId={applicationId} />
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col items-center gap-3 min-h-[15vh] justify-center">
|
<div className="flex flex-col items-center gap-3 min-h-[25vh] justify-center">
|
||||||
<GiteaIcon className="size-8 text-muted-foreground" />
|
<GiteaIcon className="size-8 text-muted-foreground" />
|
||||||
<span className="text-base text-muted-foreground">
|
<span className="text-base text-muted-foreground">
|
||||||
To deploy using Gitea, you need to configure your account
|
To deploy using Gitea, you need to configure your account
|
||||||
|
|||||||
@@ -1,100 +0,0 @@
|
|||||||
import { DateTooltip } from "@/components/shared/date-tooltip";
|
|
||||||
import { StatusTooltip } from "@/components/shared/status-tooltip";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
DialogTrigger,
|
|
||||||
} from "@/components/ui/dialog";
|
|
||||||
|
|
||||||
import type { RouterOutputs } from "@/utils/api";
|
|
||||||
import { useState } from "react";
|
|
||||||
import { ShowDeployment } from "../deployments/show-deployment";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
deployments: RouterOutputs["deployment"]["all"];
|
|
||||||
serverId?: string;
|
|
||||||
trigger?: React.ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ShowPreviewBuilds = ({
|
|
||||||
deployments,
|
|
||||||
serverId,
|
|
||||||
trigger,
|
|
||||||
}: Props) => {
|
|
||||||
const [activeLog, setActiveLog] = useState<
|
|
||||||
RouterOutputs["deployment"]["all"][number] | null
|
|
||||||
>(null);
|
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
|
||||||
return (
|
|
||||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
|
||||||
<DialogTrigger asChild>
|
|
||||||
{trigger ? (
|
|
||||||
trigger
|
|
||||||
) : (
|
|
||||||
<Button className="sm:w-auto w-full" size="sm" variant="outline">
|
|
||||||
View Builds
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</DialogTrigger>
|
|
||||||
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-5xl">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Preview Builds</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
See all the preview builds for this application on this Pull Request
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
<div className="grid gap-4">
|
|
||||||
{deployments?.map((deployment) => (
|
|
||||||
<div
|
|
||||||
key={deployment.deploymentId}
|
|
||||||
className="flex items-center justify-between rounded-lg border p-4 gap-2"
|
|
||||||
>
|
|
||||||
<div className="flex flex-col">
|
|
||||||
<span className="flex items-center gap-4 font-medium capitalize text-foreground">
|
|
||||||
{deployment.status}
|
|
||||||
|
|
||||||
<StatusTooltip
|
|
||||||
status={deployment?.status}
|
|
||||||
className="size-2.5"
|
|
||||||
/>
|
|
||||||
</span>
|
|
||||||
<span className="text-sm text-muted-foreground">
|
|
||||||
{deployment.title}
|
|
||||||
</span>
|
|
||||||
{deployment.description && (
|
|
||||||
<span className="break-all text-sm text-muted-foreground">
|
|
||||||
{deployment.description}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col items-end gap-2">
|
|
||||||
<div className="text-sm capitalize text-muted-foreground">
|
|
||||||
<DateTooltip date={deployment.createdAt} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
onClick={() => {
|
|
||||||
setActiveLog(deployment);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
View
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</DialogContent>
|
|
||||||
<ShowDeployment
|
|
||||||
serverId={serverId || ""}
|
|
||||||
open={Boolean(activeLog && activeLog.logPath !== null)}
|
|
||||||
onClose={() => setActiveLog(null)}
|
|
||||||
logPath={activeLog?.logPath || ""}
|
|
||||||
errorMessage={activeLog?.errorMessage || ""}
|
|
||||||
/>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -17,7 +17,7 @@ import {
|
|||||||
ExternalLink,
|
ExternalLink,
|
||||||
FileText,
|
FileText,
|
||||||
GitPullRequest,
|
GitPullRequest,
|
||||||
Layers,
|
Loader2,
|
||||||
PenSquare,
|
PenSquare,
|
||||||
RocketIcon,
|
RocketIcon,
|
||||||
Trash2,
|
Trash2,
|
||||||
@@ -25,8 +25,8 @@ import {
|
|||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { ShowModalLogs } from "../../settings/web-server/show-modal-logs";
|
import { ShowModalLogs } from "../../settings/web-server/show-modal-logs";
|
||||||
import { AddPreviewDomain } from "./add-preview-domain";
|
import { AddPreviewDomain } from "./add-preview-domain";
|
||||||
import { ShowPreviewBuilds } from "./show-preview-builds";
|
|
||||||
import { ShowPreviewSettings } from "./show-preview-settings";
|
import { ShowPreviewSettings } from "./show-preview-settings";
|
||||||
|
import { ShowDeploymentsModal } from "../deployments/show-deployments-modal";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
applicationId: string;
|
applicationId: string;
|
||||||
@@ -38,13 +38,16 @@ export const ShowPreviewDeployments = ({ applicationId }: Props) => {
|
|||||||
const { mutateAsync: deletePreviewDeployment, isLoading } =
|
const { mutateAsync: deletePreviewDeployment, isLoading } =
|
||||||
api.previewDeployment.delete.useMutation();
|
api.previewDeployment.delete.useMutation();
|
||||||
|
|
||||||
const { data: previewDeployments, refetch: refetchPreviewDeployments } =
|
const {
|
||||||
api.previewDeployment.all.useQuery(
|
data: previewDeployments,
|
||||||
{ applicationId },
|
refetch: refetchPreviewDeployments,
|
||||||
{
|
isLoading: isLoadingPreviewDeployments,
|
||||||
enabled: !!applicationId,
|
} = api.previewDeployment.all.useQuery(
|
||||||
},
|
{ applicationId },
|
||||||
);
|
{
|
||||||
|
enabled: !!applicationId,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
const handleDeletePreviewDeployment = async (previewDeploymentId: string) => {
|
const handleDeletePreviewDeployment = async (previewDeploymentId: string) => {
|
||||||
deletePreviewDeployment({
|
deletePreviewDeployment({
|
||||||
@@ -80,8 +83,15 @@ export const ShowPreviewDeployments = ({ applicationId }: Props) => {
|
|||||||
each pull request you create.
|
each pull request you create.
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{!previewDeployments?.length ? (
|
{isLoadingPreviewDeployments ? (
|
||||||
<div className="flex w-full flex-col items-center justify-center gap-3 pt-10">
|
<div className="flex w-full flex-row items-center justify-center gap-3 min-h-[35vh]">
|
||||||
|
<Loader2 className="size-5 text-muted-foreground animate-spin" />
|
||||||
|
<span className="text-base text-muted-foreground">
|
||||||
|
Loading preview deployments...
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : !previewDeployments?.length ? (
|
||||||
|
<div className="flex w-full flex-col items-center justify-center gap-3 min-h-[35vh]">
|
||||||
<RocketIcon className="size-8 text-muted-foreground" />
|
<RocketIcon className="size-8 text-muted-foreground" />
|
||||||
<span className="text-base text-muted-foreground">
|
<span className="text-base text-muted-foreground">
|
||||||
No preview deployments found
|
No preview deployments found
|
||||||
@@ -168,19 +178,10 @@ export const ShowPreviewDeployments = ({ applicationId }: Props) => {
|
|||||||
</Button>
|
</Button>
|
||||||
</ShowModalLogs>
|
</ShowModalLogs>
|
||||||
|
|
||||||
<ShowPreviewBuilds
|
<ShowDeploymentsModal
|
||||||
deployments={deployment.deployments || []}
|
id={deployment.previewDeploymentId}
|
||||||
|
type="previewDeployment"
|
||||||
serverId={data?.serverId || ""}
|
serverId={data?.serverId || ""}
|
||||||
trigger={
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="gap-2"
|
|
||||||
>
|
|
||||||
<Layers className="size-4" />
|
|
||||||
Builds
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<AddPreviewDomain
|
<AddPreviewDomain
|
||||||
|
|||||||
@@ -43,10 +43,10 @@ import {
|
|||||||
DialogTrigger,
|
DialogTrigger,
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import type { CacheType } from "../../compose/domains/add-domain";
|
|
||||||
import { AlertBlock } from "@/components/shared/alert-block";
|
import { AlertBlock } from "@/components/shared/alert-block";
|
||||||
import { CodeEditor } from "@/components/shared/code-editor";
|
import { CodeEditor } from "@/components/shared/code-editor";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import type { CacheType } from "../domains/handle-domain";
|
||||||
|
|
||||||
export const commonCronExpressions = [
|
export const commonCronExpressions = [
|
||||||
{ label: "Every minute", value: "* * * * *" },
|
{ label: "Every minute", value: "* * * * *" },
|
||||||
|
|||||||
@@ -1,131 +0,0 @@
|
|||||||
import { DateTooltip } from "@/components/shared/date-tooltip";
|
|
||||||
import { StatusTooltip } from "@/components/shared/status-tooltip";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
DialogTrigger,
|
|
||||||
} from "@/components/ui/dialog";
|
|
||||||
|
|
||||||
import type { RouterOutputs } from "@/utils/api";
|
|
||||||
import { useState } from "react";
|
|
||||||
import { ShowDeployment } from "../deployments/show-deployment";
|
|
||||||
import { ClipboardList, Clock } from "lucide-react";
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
deployments: RouterOutputs["deployment"]["all"];
|
|
||||||
serverId?: string;
|
|
||||||
children?: React.ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const formatDuration = (seconds: number) => {
|
|
||||||
if (seconds < 60) return `${seconds}s`;
|
|
||||||
const minutes = Math.floor(seconds / 60);
|
|
||||||
const remainingSeconds = seconds % 60;
|
|
||||||
return `${minutes}m ${remainingSeconds}s`;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const ShowSchedulesLogs = ({
|
|
||||||
deployments,
|
|
||||||
serverId,
|
|
||||||
children,
|
|
||||||
}: Props) => {
|
|
||||||
const [activeLog, setActiveLog] = useState<
|
|
||||||
RouterOutputs["deployment"]["all"][number] | null
|
|
||||||
>(null);
|
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
|
||||||
return (
|
|
||||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
|
||||||
<DialogTrigger asChild>
|
|
||||||
{children ? (
|
|
||||||
children
|
|
||||||
) : (
|
|
||||||
<Button className="sm:w-auto w-full" size="sm" variant="outline">
|
|
||||||
View Logs
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</DialogTrigger>
|
|
||||||
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-5xl">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Logs</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
See all the logs for this schedule
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
{deployments.length > 0 ? (
|
|
||||||
<div className="grid gap-4">
|
|
||||||
{deployments.map((deployment, index) => (
|
|
||||||
<div
|
|
||||||
key={deployment.deploymentId}
|
|
||||||
className="flex items-center justify-between rounded-lg border p-4 gap-2"
|
|
||||||
>
|
|
||||||
<div className="flex flex-col">
|
|
||||||
<span className="flex items-center gap-4 font-medium capitalize text-foreground">
|
|
||||||
{index + 1} {deployment.status}
|
|
||||||
<StatusTooltip
|
|
||||||
status={deployment?.status}
|
|
||||||
className="size-2.5"
|
|
||||||
/>
|
|
||||||
</span>
|
|
||||||
<span className="text-sm text-muted-foreground">
|
|
||||||
{deployment.title}
|
|
||||||
</span>
|
|
||||||
{deployment.description && (
|
|
||||||
<span className="break-all text-sm text-muted-foreground">
|
|
||||||
{deployment.description}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col items-end gap-2">
|
|
||||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
|
||||||
<DateTooltip date={deployment.createdAt} />
|
|
||||||
{deployment.startedAt && deployment.finishedAt && (
|
|
||||||
<Badge
|
|
||||||
variant="outline"
|
|
||||||
className="text-[10px] gap-1 flex items-center"
|
|
||||||
>
|
|
||||||
<Clock className="size-3" />
|
|
||||||
{formatDuration(
|
|
||||||
Math.floor(
|
|
||||||
(new Date(deployment.finishedAt).getTime() -
|
|
||||||
new Date(deployment.startedAt).getTime()) /
|
|
||||||
1000,
|
|
||||||
),
|
|
||||||
)}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
onClick={() => {
|
|
||||||
setActiveLog(deployment);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
View
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground">
|
|
||||||
<ClipboardList className="size-12 mb-4" />
|
|
||||||
<p className="text-lg font-medium">No logs found</p>
|
|
||||||
<p className="text-sm">This schedule hasn't been executed yet</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</DialogContent>
|
|
||||||
<ShowDeployment
|
|
||||||
serverId={serverId || ""}
|
|
||||||
open={Boolean(activeLog && activeLog.logPath !== null)}
|
|
||||||
onClose={() => setActiveLog(null)}
|
|
||||||
logPath={activeLog?.logPath || ""}
|
|
||||||
errorMessage={activeLog?.errorMessage || ""}
|
|
||||||
/>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -18,7 +18,6 @@ import {
|
|||||||
} from "@/components/ui/card";
|
} from "@/components/ui/card";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { ShowSchedulesLogs } from "./show-schedules-logs";
|
|
||||||
import {
|
import {
|
||||||
Tooltip,
|
Tooltip,
|
||||||
TooltipContent,
|
TooltipContent,
|
||||||
@@ -26,6 +25,7 @@ import {
|
|||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
} from "@/components/ui/tooltip";
|
} from "@/components/ui/tooltip";
|
||||||
import { DialogAction } from "@/components/shared/dialog-action";
|
import { DialogAction } from "@/components/shared/dialog-action";
|
||||||
|
import { ShowDeploymentsModal } from "../deployments/show-deployments-modal";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -88,7 +88,6 @@ export const ShowSchedules = ({ id, scheduleType = "application" }: Props) => {
|
|||||||
schedule.serverId ||
|
schedule.serverId ||
|
||||||
schedule.application?.serverId ||
|
schedule.application?.serverId ||
|
||||||
schedule.compose?.serverId;
|
schedule.compose?.serverId;
|
||||||
const deployments = schedule.deployments;
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={schedule.scheduleId}
|
key={schedule.scheduleId}
|
||||||
@@ -144,14 +143,15 @@ export const ShowSchedules = ({ id, scheduleType = "application" }: Props) => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<ShowSchedulesLogs
|
<ShowDeploymentsModal
|
||||||
deployments={deployments || []}
|
id={schedule.scheduleId}
|
||||||
|
type="schedule"
|
||||||
serverId={serverId || undefined}
|
serverId={serverId || undefined}
|
||||||
>
|
>
|
||||||
<Button variant="ghost" size="icon">
|
<Button variant="ghost" size="icon">
|
||||||
<ClipboardList className="size-4 transition-colors " />
|
<ClipboardList className="size-4 transition-colors " />
|
||||||
</Button>
|
</Button>
|
||||||
</ShowSchedulesLogs>
|
</ShowDeploymentsModal>
|
||||||
|
|
||||||
<TooltipProvider delayDuration={0}>
|
<TooltipProvider delayDuration={0}>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
|
|||||||
@@ -1,66 +0,0 @@
|
|||||||
import {
|
|
||||||
AlertDialog,
|
|
||||||
AlertDialogAction,
|
|
||||||
AlertDialogCancel,
|
|
||||||
AlertDialogContent,
|
|
||||||
AlertDialogDescription,
|
|
||||||
AlertDialogFooter,
|
|
||||||
AlertDialogHeader,
|
|
||||||
AlertDialogTitle,
|
|
||||||
AlertDialogTrigger,
|
|
||||||
} from "@/components/ui/alert-dialog";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { api } from "@/utils/api";
|
|
||||||
import { Paintbrush } from "lucide-react";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
composeId: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const CancelQueuesCompose = ({ composeId }: Props) => {
|
|
||||||
const { mutateAsync, isLoading } = api.compose.cleanQueues.useMutation();
|
|
||||||
const { data: isCloud } = api.settings.isCloud.useQuery();
|
|
||||||
|
|
||||||
if (isCloud) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<AlertDialog>
|
|
||||||
<AlertDialogTrigger asChild>
|
|
||||||
<Button variant="destructive" className="w-fit" isLoading={isLoading}>
|
|
||||||
Cancel Queues
|
|
||||||
<Paintbrush className="size-4" />
|
|
||||||
</Button>
|
|
||||||
</AlertDialogTrigger>
|
|
||||||
<AlertDialogContent>
|
|
||||||
<AlertDialogHeader>
|
|
||||||
<AlertDialogTitle>
|
|
||||||
Are you sure to cancel the incoming deployments?
|
|
||||||
</AlertDialogTitle>
|
|
||||||
<AlertDialogDescription>
|
|
||||||
This will cancel all the incoming deployments
|
|
||||||
</AlertDialogDescription>
|
|
||||||
</AlertDialogHeader>
|
|
||||||
<AlertDialogFooter>
|
|
||||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
|
||||||
<AlertDialogAction
|
|
||||||
onClick={async () => {
|
|
||||||
await mutateAsync({
|
|
||||||
composeId,
|
|
||||||
})
|
|
||||||
.then(() => {
|
|
||||||
toast.success("Queues are being cleaned");
|
|
||||||
})
|
|
||||||
.catch((err) => {
|
|
||||||
toast.error(err.message);
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Confirm
|
|
||||||
</AlertDialogAction>
|
|
||||||
</AlertDialogFooter>
|
|
||||||
</AlertDialogContent>
|
|
||||||
</AlertDialog>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
import {
|
|
||||||
AlertDialog,
|
|
||||||
AlertDialogAction,
|
|
||||||
AlertDialogCancel,
|
|
||||||
AlertDialogContent,
|
|
||||||
AlertDialogDescription,
|
|
||||||
AlertDialogFooter,
|
|
||||||
AlertDialogHeader,
|
|
||||||
AlertDialogTitle,
|
|
||||||
AlertDialogTrigger,
|
|
||||||
} from "@/components/ui/alert-dialog";
|
|
||||||
import { api } from "@/utils/api";
|
|
||||||
import { RefreshCcw } from "lucide-react";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
composeId: string;
|
|
||||||
}
|
|
||||||
export const RefreshTokenCompose = ({ composeId }: Props) => {
|
|
||||||
const { mutateAsync } = api.compose.refreshToken.useMutation();
|
|
||||||
const utils = api.useUtils();
|
|
||||||
return (
|
|
||||||
<AlertDialog>
|
|
||||||
<AlertDialogTrigger>
|
|
||||||
<RefreshCcw className="h-4 w-4 cursor-pointer text-muted-foreground" />
|
|
||||||
</AlertDialogTrigger>
|
|
||||||
<AlertDialogContent>
|
|
||||||
<AlertDialogHeader>
|
|
||||||
<AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
|
|
||||||
<AlertDialogDescription>
|
|
||||||
This action cannot be undone. This will permanently change the token
|
|
||||||
and all the previous tokens will be invalidated
|
|
||||||
</AlertDialogDescription>
|
|
||||||
</AlertDialogHeader>
|
|
||||||
<AlertDialogFooter>
|
|
||||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
|
||||||
<AlertDialogAction
|
|
||||||
onClick={async () => {
|
|
||||||
await mutateAsync({
|
|
||||||
composeId,
|
|
||||||
})
|
|
||||||
.then(() => {
|
|
||||||
utils.compose.one.invalidate({
|
|
||||||
composeId,
|
|
||||||
});
|
|
||||||
toast.success("Refresh Token updated");
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
toast.error("Error updating the refresh token");
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Confirm
|
|
||||||
</AlertDialogAction>
|
|
||||||
</AlertDialogFooter>
|
|
||||||
</AlertDialogContent>
|
|
||||||
</AlertDialog>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,184 +0,0 @@
|
|||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
import { Checkbox } from "@/components/ui/checkbox";
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from "@/components/ui/dialog";
|
|
||||||
import { Loader2 } from "lucide-react";
|
|
||||||
import { useEffect, useRef, useState } from "react";
|
|
||||||
import { TerminalLine } from "../../docker/logs/terminal-line";
|
|
||||||
import { type LogLine, parseLogs } from "../../docker/logs/utils";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
logPath: string | null;
|
|
||||||
serverId?: string;
|
|
||||||
open: boolean;
|
|
||||||
onClose: () => void;
|
|
||||||
errorMessage?: string;
|
|
||||||
}
|
|
||||||
export const ShowDeploymentCompose = ({
|
|
||||||
logPath,
|
|
||||||
open,
|
|
||||||
onClose,
|
|
||||||
serverId,
|
|
||||||
errorMessage,
|
|
||||||
}: Props) => {
|
|
||||||
const [data, setData] = useState("");
|
|
||||||
const [filteredLogs, setFilteredLogs] = useState<LogLine[]>([]);
|
|
||||||
const [showExtraLogs, setShowExtraLogs] = useState(false);
|
|
||||||
const wsRef = useRef<WebSocket | null>(null); // Ref to hold WebSocket instance
|
|
||||||
const [autoScroll, setAutoScroll] = useState(true);
|
|
||||||
const scrollRef = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
const scrollToBottom = () => {
|
|
||||||
if (autoScroll && scrollRef.current) {
|
|
||||||
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleScroll = () => {
|
|
||||||
if (!scrollRef.current) return;
|
|
||||||
|
|
||||||
const { scrollTop, scrollHeight, clientHeight } = scrollRef.current;
|
|
||||||
const isAtBottom = Math.abs(scrollHeight - scrollTop - clientHeight) < 10;
|
|
||||||
setAutoScroll(isAtBottom);
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!open || !logPath) return;
|
|
||||||
|
|
||||||
setData("");
|
|
||||||
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
|
||||||
|
|
||||||
const wsUrl = `${protocol}//${window.location.host}/listen-deployment?logPath=${logPath}&serverId=${serverId}`;
|
|
||||||
const ws = new WebSocket(wsUrl);
|
|
||||||
|
|
||||||
wsRef.current = ws; // Store WebSocket instance in ref
|
|
||||||
|
|
||||||
ws.onmessage = (e) => {
|
|
||||||
setData((currentData) => currentData + e.data);
|
|
||||||
};
|
|
||||||
|
|
||||||
ws.onerror = (error) => {
|
|
||||||
console.error("WebSocket error: ", error);
|
|
||||||
};
|
|
||||||
|
|
||||||
ws.onclose = () => {
|
|
||||||
wsRef.current = null;
|
|
||||||
};
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
|
||||||
ws.close();
|
|
||||||
wsRef.current = null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, [logPath, open]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const logs = parseLogs(data);
|
|
||||||
let filteredLogsResult = logs;
|
|
||||||
if (serverId) {
|
|
||||||
let hideSubsequentLogs = false;
|
|
||||||
filteredLogsResult = logs.filter((log) => {
|
|
||||||
if (
|
|
||||||
log.message.includes(
|
|
||||||
"===================================EXTRA LOGS============================================",
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
hideSubsequentLogs = true;
|
|
||||||
return showExtraLogs;
|
|
||||||
}
|
|
||||||
return showExtraLogs ? true : !hideSubsequentLogs;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
setFilteredLogs(filteredLogsResult);
|
|
||||||
}, [data, showExtraLogs]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
scrollToBottom();
|
|
||||||
|
|
||||||
if (autoScroll && scrollRef.current) {
|
|
||||||
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
|
||||||
}
|
|
||||||
}, [filteredLogs, autoScroll]);
|
|
||||||
|
|
||||||
const optionalErrors = parseLogs(errorMessage || "");
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog
|
|
||||||
open={open}
|
|
||||||
onOpenChange={(e) => {
|
|
||||||
onClose();
|
|
||||||
if (!e) {
|
|
||||||
setData("");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (wsRef.current) {
|
|
||||||
if (wsRef.current.readyState === WebSocket.OPEN) {
|
|
||||||
wsRef.current.close();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DialogContent className={"sm:max-w-5xl max-h-screen"}>
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Deployment</DialogTitle>
|
|
||||||
<DialogDescription className="flex items-center gap-2">
|
|
||||||
<span>
|
|
||||||
See all the details of this deployment |{" "}
|
|
||||||
<Badge variant="blank" className="text-xs">
|
|
||||||
{filteredLogs.length} lines
|
|
||||||
</Badge>
|
|
||||||
</span>
|
|
||||||
{serverId && (
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<Checkbox
|
|
||||||
id="show-extra-logs"
|
|
||||||
checked={showExtraLogs}
|
|
||||||
onCheckedChange={(checked) =>
|
|
||||||
setShowExtraLogs(checked as boolean)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<label
|
|
||||||
htmlFor="show-extra-logs"
|
|
||||||
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
|
||||||
>
|
|
||||||
Show Extra Logs
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<div
|
|
||||||
ref={scrollRef}
|
|
||||||
onScroll={handleScroll}
|
|
||||||
className="h-[720px] overflow-y-auto space-y-0 border p-4 bg-[#fafafa] dark:bg-[#050506] rounded custom-logs-scrollbar"
|
|
||||||
>
|
|
||||||
{filteredLogs.length > 0 ? (
|
|
||||||
filteredLogs.map((log: LogLine, index: number) => (
|
|
||||||
<TerminalLine key={index} log={log} noTimestamp />
|
|
||||||
))
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
{optionalErrors.length > 0 ? (
|
|
||||||
optionalErrors.map((log: LogLine, index: number) => (
|
|
||||||
<TerminalLine key={`extra-${index}`} log={log} noTimestamp />
|
|
||||||
))
|
|
||||||
) : (
|
|
||||||
<div className="flex justify-center items-center h-full text-muted-foreground">
|
|
||||||
<Loader2 className="h-6 w-6 animate-spin" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,141 +0,0 @@
|
|||||||
import { DateTooltip } from "@/components/shared/date-tooltip";
|
|
||||||
import { StatusTooltip } from "@/components/shared/status-tooltip";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import {
|
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardDescription,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
} from "@/components/ui/card";
|
|
||||||
import { type RouterOutputs, api } from "@/utils/api";
|
|
||||||
import { RocketIcon, Clock } from "lucide-react";
|
|
||||||
import React, { useEffect, useState } from "react";
|
|
||||||
import { CancelQueuesCompose } from "./cancel-queues-compose";
|
|
||||||
import { RefreshTokenCompose } from "./refresh-token-compose";
|
|
||||||
import { ShowDeploymentCompose } from "./show-deployment-compose";
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
import { formatDuration } from "@/components/dashboard/application/schedules/show-schedules-logs";
|
|
||||||
interface Props {
|
|
||||||
composeId: string;
|
|
||||||
}
|
|
||||||
export const ShowDeploymentsCompose = ({ composeId }: Props) => {
|
|
||||||
const [activeLog, setActiveLog] = useState<
|
|
||||||
RouterOutputs["deployment"]["all"][number] | null
|
|
||||||
>(null);
|
|
||||||
const { data } = api.compose.one.useQuery({ composeId });
|
|
||||||
const { data: deployments } = api.deployment.allByCompose.useQuery(
|
|
||||||
{ composeId },
|
|
||||||
{
|
|
||||||
enabled: !!composeId,
|
|
||||||
refetchInterval: 5000,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
const [url, setUrl] = React.useState("");
|
|
||||||
useEffect(() => {
|
|
||||||
setUrl(document.location.origin);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card className="bg-background">
|
|
||||||
<CardHeader className="flex flex-row items-center justify-between flex-wrap gap-2">
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<CardTitle className="text-xl">Deployments</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
See all the 10 last deployments for this compose
|
|
||||||
</CardDescription>
|
|
||||||
</div>
|
|
||||||
<CancelQueuesCompose composeId={composeId} />
|
|
||||||
{/* <CancelQueues applicationId={applicationId} /> */}
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="flex flex-col gap-4">
|
|
||||||
<div className="flex flex-col gap-2 text-sm">
|
|
||||||
<span>
|
|
||||||
If you want to re-deploy this application use this URL in the config
|
|
||||||
of your git provider or docker
|
|
||||||
</span>
|
|
||||||
<div className="flex flex-row items-center gap-2 flex-wrap">
|
|
||||||
<span>Webhook URL: </span>
|
|
||||||
<div className="flex flex-row items-center gap-2">
|
|
||||||
<span className="text-muted-foreground">
|
|
||||||
{`${url}/api/deploy/compose/${data?.refreshToken}`}
|
|
||||||
</span>
|
|
||||||
<RefreshTokenCompose composeId={composeId} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{data?.deployments?.length === 0 ? (
|
|
||||||
<div className="flex w-full flex-col items-center justify-center gap-3 pt-10">
|
|
||||||
<RocketIcon className="size-8 text-muted-foreground" />
|
|
||||||
<span className="text-base text-muted-foreground">
|
|
||||||
No deployments found
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="flex flex-col gap-4">
|
|
||||||
{deployments?.map((deployment) => (
|
|
||||||
<div
|
|
||||||
key={deployment.deploymentId}
|
|
||||||
className="flex items-center justify-between rounded-lg border p-4"
|
|
||||||
>
|
|
||||||
<div className="flex flex-col">
|
|
||||||
<span className="flex items-center gap-4 font-medium capitalize text-foreground">
|
|
||||||
{deployment.status}
|
|
||||||
|
|
||||||
<StatusTooltip
|
|
||||||
status={deployment?.status}
|
|
||||||
className="size-2.5"
|
|
||||||
/>
|
|
||||||
</span>
|
|
||||||
<span className="text-sm text-muted-foreground">
|
|
||||||
{deployment.title}
|
|
||||||
</span>
|
|
||||||
{deployment.description && (
|
|
||||||
<span className="text-sm text-muted-foreground">
|
|
||||||
{deployment.description}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col items-end gap-2">
|
|
||||||
<div className="text-sm capitalize text-muted-foreground flex items-center gap-2">
|
|
||||||
<DateTooltip date={deployment.createdAt} />
|
|
||||||
{deployment.startedAt && deployment.finishedAt && (
|
|
||||||
<Badge
|
|
||||||
variant="outline"
|
|
||||||
className="text-[10px] gap-1 flex items-center"
|
|
||||||
>
|
|
||||||
<Clock className="size-3" />
|
|
||||||
{formatDuration(
|
|
||||||
Math.floor(
|
|
||||||
(new Date(deployment.finishedAt).getTime() -
|
|
||||||
new Date(deployment.startedAt).getTime()) /
|
|
||||||
1000,
|
|
||||||
),
|
|
||||||
)}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
onClick={() => {
|
|
||||||
setActiveLog(deployment);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
View
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<ShowDeploymentCompose
|
|
||||||
serverId={data?.serverId || ""}
|
|
||||||
open={Boolean(activeLog && activeLog.logPath !== null)}
|
|
||||||
onClose={() => setActiveLog(null)}
|
|
||||||
logPath={activeLog?.logPath || ""}
|
|
||||||
errorMessage={activeLog?.errorMessage || ""}
|
|
||||||
/>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,503 +0,0 @@
|
|||||||
import { AlertBlock } from "@/components/shared/alert-block";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
DialogTrigger,
|
|
||||||
} from "@/components/ui/dialog";
|
|
||||||
import {
|
|
||||||
Form,
|
|
||||||
FormControl,
|
|
||||||
FormDescription,
|
|
||||||
FormField,
|
|
||||||
FormItem,
|
|
||||||
FormLabel,
|
|
||||||
FormMessage,
|
|
||||||
} from "@/components/ui/form";
|
|
||||||
import { Input, NumberInput } from "@/components/ui/input";
|
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from "@/components/ui/select";
|
|
||||||
import { Switch } from "@/components/ui/switch";
|
|
||||||
import { api } from "@/utils/api";
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import { useForm } from "react-hook-form";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
|
|
||||||
import {
|
|
||||||
Tooltip,
|
|
||||||
TooltipContent,
|
|
||||||
TooltipProvider,
|
|
||||||
TooltipTrigger,
|
|
||||||
} from "@/components/ui/tooltip";
|
|
||||||
import { domainCompose } from "@/server/db/validations/domain";
|
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
|
||||||
import { DatabaseZap, Dices, RefreshCw } from "lucide-react";
|
|
||||||
import Link from "next/link";
|
|
||||||
import type z from "zod";
|
|
||||||
|
|
||||||
type Domain = z.infer<typeof domainCompose>;
|
|
||||||
|
|
||||||
export type CacheType = "fetch" | "cache";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
composeId: string;
|
|
||||||
domainId?: string;
|
|
||||||
children: React.ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const AddDomainCompose = ({
|
|
||||||
composeId,
|
|
||||||
domainId = "",
|
|
||||||
children,
|
|
||||||
}: Props) => {
|
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
|
||||||
const [cacheType, setCacheType] = useState<CacheType>("cache");
|
|
||||||
const utils = api.useUtils();
|
|
||||||
const { data, refetch } = api.domain.one.useQuery(
|
|
||||||
{
|
|
||||||
domainId,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
enabled: !!domainId,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const { data: compose } = api.compose.one.useQuery(
|
|
||||||
{
|
|
||||||
composeId,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
enabled: !!composeId,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const {
|
|
||||||
data: services,
|
|
||||||
isFetching: isLoadingServices,
|
|
||||||
error: errorServices,
|
|
||||||
refetch: refetchServices,
|
|
||||||
} = api.compose.loadServices.useQuery(
|
|
||||||
{
|
|
||||||
composeId,
|
|
||||||
type: cacheType,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
retry: false,
|
|
||||||
refetchOnWindowFocus: false,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const { mutateAsync: generateDomain, isLoading: isLoadingGenerate } =
|
|
||||||
api.domain.generateDomain.useMutation();
|
|
||||||
|
|
||||||
const { mutateAsync, isError, error, isLoading } = domainId
|
|
||||||
? api.domain.update.useMutation()
|
|
||||||
: api.domain.create.useMutation();
|
|
||||||
|
|
||||||
const { data: canGenerateTraefikMeDomains } =
|
|
||||||
api.domain.canGenerateTraefikMeDomains.useQuery({
|
|
||||||
serverId: compose?.serverId || "",
|
|
||||||
});
|
|
||||||
|
|
||||||
const form = useForm<Domain>({
|
|
||||||
resolver: zodResolver(domainCompose),
|
|
||||||
defaultValues: {
|
|
||||||
host: "",
|
|
||||||
path: undefined,
|
|
||||||
port: undefined,
|
|
||||||
https: false,
|
|
||||||
certificateType: undefined,
|
|
||||||
customCertResolver: undefined,
|
|
||||||
serviceName: "",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const https = form.watch("https");
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (data) {
|
|
||||||
form.reset({
|
|
||||||
...data,
|
|
||||||
/* Convert null to undefined */
|
|
||||||
path: data?.path || undefined,
|
|
||||||
port: data?.port || undefined,
|
|
||||||
serviceName: data?.serviceName || undefined,
|
|
||||||
certificateType: data?.certificateType || undefined,
|
|
||||||
customCertResolver: data?.customCertResolver || undefined,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!domainId) {
|
|
||||||
form.reset({
|
|
||||||
host: "",
|
|
||||||
path: undefined,
|
|
||||||
port: undefined,
|
|
||||||
https: false,
|
|
||||||
certificateType: undefined,
|
|
||||||
customCertResolver: undefined,
|
|
||||||
serviceName: "",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [form, form.reset, data, isLoading]);
|
|
||||||
|
|
||||||
const dictionary = {
|
|
||||||
success: domainId ? "Domain Updated" : "Domain Created",
|
|
||||||
error: domainId ? "Error updating the domain" : "Error creating the domain",
|
|
||||||
submit: domainId ? "Update" : "Create",
|
|
||||||
dialogDescription: domainId
|
|
||||||
? "In this section you can edit a domain"
|
|
||||||
: "In this section you can add domains",
|
|
||||||
};
|
|
||||||
|
|
||||||
const onSubmit = async (data: Domain) => {
|
|
||||||
await mutateAsync({
|
|
||||||
domainId,
|
|
||||||
composeId,
|
|
||||||
domainType: "compose",
|
|
||||||
...data,
|
|
||||||
})
|
|
||||||
.then(async () => {
|
|
||||||
await utils.domain.byComposeId.invalidate({
|
|
||||||
composeId,
|
|
||||||
});
|
|
||||||
toast.success(dictionary.success);
|
|
||||||
if (domainId) {
|
|
||||||
refetch();
|
|
||||||
}
|
|
||||||
setIsOpen(false);
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
toast.error(dictionary.error);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
return (
|
|
||||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
|
||||||
<DialogTrigger className="" asChild>
|
|
||||||
{children}
|
|
||||||
</DialogTrigger>
|
|
||||||
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-2xl">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Domain</DialogTitle>
|
|
||||||
<DialogDescription>{dictionary.dialogDescription}</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
<div className="flex flex-col gap-4">
|
|
||||||
<AlertBlock type="info">
|
|
||||||
Deploy is required to apply changes after creating or updating a
|
|
||||||
domain.
|
|
||||||
</AlertBlock>
|
|
||||||
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Form {...form}>
|
|
||||||
<form
|
|
||||||
id="hook-form"
|
|
||||||
onSubmit={form.handleSubmit(onSubmit)}
|
|
||||||
className="grid w-full gap-8 "
|
|
||||||
>
|
|
||||||
<div className="flex flex-col gap-4">
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
{errorServices && (
|
|
||||||
<AlertBlock
|
|
||||||
type="warning"
|
|
||||||
className="[overflow-wrap:anywhere]"
|
|
||||||
>
|
|
||||||
{errorServices?.message}
|
|
||||||
</AlertBlock>
|
|
||||||
)}
|
|
||||||
<div className="flex flex-row items-end w-full gap-4">
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="serviceName"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem className="w-full">
|
|
||||||
<FormLabel>Service Name</FormLabel>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Select
|
|
||||||
onValueChange={field.onChange}
|
|
||||||
defaultValue={field.value || ""}
|
|
||||||
>
|
|
||||||
<FormControl>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue placeholder="Select a service name" />
|
|
||||||
</SelectTrigger>
|
|
||||||
</FormControl>
|
|
||||||
|
|
||||||
<SelectContent>
|
|
||||||
{services?.map((service, index) => (
|
|
||||||
<SelectItem
|
|
||||||
value={service}
|
|
||||||
key={`${service}-${index}`}
|
|
||||||
>
|
|
||||||
{service}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
<SelectItem value="none" disabled>
|
|
||||||
Empty
|
|
||||||
</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
<TooltipProvider delayDuration={0}>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
type="button"
|
|
||||||
isLoading={isLoadingServices}
|
|
||||||
onClick={() => {
|
|
||||||
if (cacheType === "fetch") {
|
|
||||||
refetchServices();
|
|
||||||
} else {
|
|
||||||
setCacheType("fetch");
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<RefreshCw className="size-4 text-muted-foreground" />
|
|
||||||
</Button>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent
|
|
||||||
side="left"
|
|
||||||
sideOffset={5}
|
|
||||||
className="max-w-[10rem]"
|
|
||||||
>
|
|
||||||
<p>
|
|
||||||
Fetch: Will clone the repository and load the
|
|
||||||
services
|
|
||||||
</p>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</TooltipProvider>
|
|
||||||
<TooltipProvider delayDuration={0}>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
type="button"
|
|
||||||
isLoading={isLoadingServices}
|
|
||||||
onClick={() => {
|
|
||||||
if (cacheType === "cache") {
|
|
||||||
refetchServices();
|
|
||||||
} else {
|
|
||||||
setCacheType("cache");
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DatabaseZap className="size-4 text-muted-foreground" />
|
|
||||||
</Button>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent
|
|
||||||
side="left"
|
|
||||||
sideOffset={5}
|
|
||||||
className="max-w-[10rem]"
|
|
||||||
>
|
|
||||||
<p>
|
|
||||||
Cache: If you previously deployed this
|
|
||||||
compose, it will read the services from the
|
|
||||||
last deployment/fetch from the repository
|
|
||||||
</p>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</TooltipProvider>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="host"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
{!canGenerateTraefikMeDomains &&
|
|
||||||
field.value.includes("traefik.me") && (
|
|
||||||
<AlertBlock type="warning">
|
|
||||||
You need to set an IP address in your{" "}
|
|
||||||
<Link
|
|
||||||
href="/dashboard/settings/server"
|
|
||||||
className="text-primary"
|
|
||||||
>
|
|
||||||
{compose?.serverId
|
|
||||||
? "Remote Servers -> Server -> Edit Server -> Update IP Address"
|
|
||||||
: "Web Server -> Server -> Update Server IP"}
|
|
||||||
</Link>{" "}
|
|
||||||
to make your traefik.me domain work.
|
|
||||||
</AlertBlock>
|
|
||||||
)}
|
|
||||||
<FormLabel>Host</FormLabel>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<FormControl>
|
|
||||||
<Input placeholder="api.dokploy.com" {...field} />
|
|
||||||
</FormControl>
|
|
||||||
<TooltipProvider delayDuration={0}>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
type="button"
|
|
||||||
isLoading={isLoadingGenerate}
|
|
||||||
onClick={() => {
|
|
||||||
generateDomain({
|
|
||||||
serverId: compose?.serverId || "",
|
|
||||||
appName: compose?.appName || "",
|
|
||||||
})
|
|
||||||
.then((domain) => {
|
|
||||||
field.onChange(domain);
|
|
||||||
})
|
|
||||||
.catch((err) => {
|
|
||||||
toast.error(err.message);
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Dices className="size-4 text-muted-foreground" />
|
|
||||||
</Button>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent
|
|
||||||
side="left"
|
|
||||||
sideOffset={5}
|
|
||||||
className="max-w-[10rem]"
|
|
||||||
>
|
|
||||||
<p>Generate traefik.me domain</p>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</TooltipProvider>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="path"
|
|
||||||
render={({ field }) => {
|
|
||||||
return (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Path</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input placeholder={"/"} {...field} />
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="port"
|
|
||||||
render={({ field }) => {
|
|
||||||
return (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Container Port</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<NumberInput placeholder={"3000"} {...field} />
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="https"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem className="flex flex-row items-center justify-between p-3 mt-4 border rounded-lg shadow-sm">
|
|
||||||
<div className="space-y-0.5">
|
|
||||||
<FormLabel>HTTPS</FormLabel>
|
|
||||||
<FormDescription>
|
|
||||||
Automatically provision SSL Certificate.
|
|
||||||
</FormDescription>
|
|
||||||
<FormMessage />
|
|
||||||
</div>
|
|
||||||
<FormControl>
|
|
||||||
<Switch
|
|
||||||
checked={field.value}
|
|
||||||
onCheckedChange={field.onChange}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{https && (
|
|
||||||
<>
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="certificateType"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem className="col-span-2">
|
|
||||||
<FormLabel>Certificate Provider</FormLabel>
|
|
||||||
<Select
|
|
||||||
onValueChange={field.onChange}
|
|
||||||
defaultValue={field.value || ""}
|
|
||||||
>
|
|
||||||
<FormControl>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue placeholder="Select a certificate provider" />
|
|
||||||
</SelectTrigger>
|
|
||||||
</FormControl>
|
|
||||||
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="none">None</SelectItem>
|
|
||||||
<SelectItem value={"letsencrypt"}>
|
|
||||||
Let's Encrypt
|
|
||||||
</SelectItem>
|
|
||||||
<SelectItem value={"custom"}>Custom</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{form.getValues().certificateType === "custom" && (
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="customCertResolver"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem className="col-span-2">
|
|
||||||
<FormLabel>Custom Certificate Resolver</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input
|
|
||||||
placeholder="Enter your custom certificate resolver"
|
|
||||||
{...field}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<DialogFooter>
|
|
||||||
<Button
|
|
||||||
isLoading={form.formState.isSubmitting}
|
|
||||||
form="hook-form"
|
|
||||||
type="submit"
|
|
||||||
>
|
|
||||||
{dictionary.submit}
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</Form>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,150 +0,0 @@
|
|||||||
import { DialogAction } from "@/components/shared/dialog-action";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import {
|
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardDescription,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
} from "@/components/ui/card";
|
|
||||||
import { api } from "@/utils/api";
|
|
||||||
import { ExternalLink, GlobeIcon, PenBoxIcon, Trash2 } from "lucide-react";
|
|
||||||
import Link from "next/link";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
import { AddDomainCompose } from "./add-domain";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
composeId: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ShowDomainsCompose = ({ composeId }: Props) => {
|
|
||||||
const { data, refetch } = api.domain.byComposeId.useQuery(
|
|
||||||
{
|
|
||||||
composeId,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
enabled: !!composeId,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const { mutateAsync: deleteDomain, isLoading: isRemoving } =
|
|
||||||
api.domain.delete.useMutation();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex w-full flex-col gap-5 ">
|
|
||||||
<Card className="bg-background">
|
|
||||||
<CardHeader className="flex flex-row items-center flex-wrap gap-4 justify-between">
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
<CardTitle className="text-xl">Domains</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
Domains are used to access to the application
|
|
||||||
</CardDescription>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-row gap-4 flex-wrap">
|
|
||||||
{data && data?.length > 0 && (
|
|
||||||
<AddDomainCompose composeId={composeId}>
|
|
||||||
<Button>
|
|
||||||
<GlobeIcon className="size-4" /> Add Domain
|
|
||||||
</Button>
|
|
||||||
</AddDomainCompose>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="flex w-full flex-row gap-4">
|
|
||||||
{data?.length === 0 ? (
|
|
||||||
<div className="flex w-full flex-col items-center justify-center gap-3">
|
|
||||||
<GlobeIcon className="size-8 text-muted-foreground" />
|
|
||||||
<span className="text-base text-muted-foreground">
|
|
||||||
To access to the application it is required to set at least 1
|
|
||||||
domain
|
|
||||||
</span>
|
|
||||||
<div className="flex flex-row gap-4 flex-wrap">
|
|
||||||
<AddDomainCompose composeId={composeId}>
|
|
||||||
<Button>
|
|
||||||
<GlobeIcon className="size-4" /> Add Domain
|
|
||||||
</Button>
|
|
||||||
</AddDomainCompose>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="flex w-full flex-col gap-4">
|
|
||||||
{data?.map((item) => {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={item.domainId}
|
|
||||||
className="flex w-full items-center justify-between gap-4 border p-4 md:px-6 rounded-lg flex-wrap"
|
|
||||||
>
|
|
||||||
<div className="md:basis-1/2 flex gap-6 w-full items-center">
|
|
||||||
<span className="opacity-50 text-center font-medium text-sm whitespace-nowrap">
|
|
||||||
{item.serviceName}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<Link
|
|
||||||
className="flex gap-2 items-center hover:underline transition-all w-full max-w-[calc(100%-4rem)]"
|
|
||||||
target="_blank"
|
|
||||||
href={`${item.https ? "https" : "http"}://${item.host}${item.path}`}
|
|
||||||
>
|
|
||||||
<span className="truncate text-sm">{item.host}</span>
|
|
||||||
<ExternalLink className="size-4 min-w-4" />
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex gap-8">
|
|
||||||
<div className="flex gap-8 opacity-50 items-center h-10 text-center text-sm font-medium">
|
|
||||||
<span>{item.path}</span>
|
|
||||||
<span>{item.port}</span>
|
|
||||||
<span>{item.https ? "HTTPS" : "HTTP"}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<AddDomainCompose
|
|
||||||
composeId={composeId}
|
|
||||||
domainId={item.domainId}
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="group hover:bg-blue-500/10 "
|
|
||||||
>
|
|
||||||
<PenBoxIcon className="size-3.5 text-primary group-hover:text-blue-500" />
|
|
||||||
</Button>
|
|
||||||
</AddDomainCompose>
|
|
||||||
<DialogAction
|
|
||||||
title="Delete Domain"
|
|
||||||
description="Are you sure you want to delete this domain?"
|
|
||||||
type="destructive"
|
|
||||||
onClick={async () => {
|
|
||||||
await deleteDomain({
|
|
||||||
domainId: item.domainId,
|
|
||||||
})
|
|
||||||
.then((_data) => {
|
|
||||||
refetch();
|
|
||||||
toast.success("Domain deleted successfully");
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
toast.error("Error deleting domain");
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="group hover:bg-red-500/10"
|
|
||||||
isLoading={isRemoving}
|
|
||||||
>
|
|
||||||
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
|
|
||||||
</Button>
|
|
||||||
</DialogAction>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
import { CodeIcon, GitBranch } from "lucide-react";
|
import { CodeIcon, GitBranch, Loader2 } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { ComposeFileEditor } from "../compose-file-editor";
|
import { ComposeFileEditor } from "../compose-file-editor";
|
||||||
@@ -25,15 +25,49 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const ShowProviderFormCompose = ({ composeId }: Props) => {
|
export const ShowProviderFormCompose = ({ composeId }: Props) => {
|
||||||
const { data: githubProviders } = api.github.githubProviders.useQuery();
|
const { data: githubProviders, isLoading: isLoadingGithub } =
|
||||||
const { data: gitlabProviders } = api.gitlab.gitlabProviders.useQuery();
|
api.github.githubProviders.useQuery();
|
||||||
const { data: bitbucketProviders } =
|
const { data: gitlabProviders, isLoading: isLoadingGitlab } =
|
||||||
|
api.gitlab.gitlabProviders.useQuery();
|
||||||
|
const { data: bitbucketProviders, isLoading: isLoadingBitbucket } =
|
||||||
api.bitbucket.bitbucketProviders.useQuery();
|
api.bitbucket.bitbucketProviders.useQuery();
|
||||||
const { data: giteaProviders } = api.gitea.giteaProviders.useQuery();
|
const { data: giteaProviders, isLoading: isLoadingGitea } =
|
||||||
|
api.gitea.giteaProviders.useQuery();
|
||||||
|
|
||||||
const { data: compose } = api.compose.one.useQuery({ composeId });
|
const { data: compose } = api.compose.one.useQuery({ composeId });
|
||||||
const [tab, setSab] = useState<TabState>(compose?.sourceType || "github");
|
const [tab, setSab] = useState<TabState>(compose?.sourceType || "github");
|
||||||
|
|
||||||
|
const isLoading =
|
||||||
|
isLoadingGithub || isLoadingGitlab || isLoadingBitbucket || isLoadingGitea;
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<Card className="group relative w-full bg-transparent">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-start justify-between">
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<span className="flex flex-col space-y-0.5">Provider</span>
|
||||||
|
<p className="flex items-center text-sm font-normal text-muted-foreground">
|
||||||
|
Select the source of your code
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="hidden space-y-1 text-sm font-normal md:block">
|
||||||
|
<GitBranch className="size-6 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="flex min-h-[25vh] items-center justify-center">
|
||||||
|
<div className="flex items-center gap-2 text-muted-foreground">
|
||||||
|
<Loader2 className="size-4 animate-spin" />
|
||||||
|
<span>Loading providers...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="group relative w-full bg-transparent">
|
<Card className="group relative w-full bg-transparent">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
@@ -108,7 +142,7 @@ export const ShowProviderFormCompose = ({ composeId }: Props) => {
|
|||||||
{githubProviders && githubProviders?.length > 0 ? (
|
{githubProviders && githubProviders?.length > 0 ? (
|
||||||
<SaveGithubProviderCompose composeId={composeId} />
|
<SaveGithubProviderCompose composeId={composeId} />
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col items-center gap-3 min-h-[15vh] justify-center">
|
<div className="flex flex-col items-center gap-3 min-h-[25vh] justify-center">
|
||||||
<GithubIcon className="size-8 text-muted-foreground" />
|
<GithubIcon className="size-8 text-muted-foreground" />
|
||||||
<span className="text-base text-muted-foreground">
|
<span className="text-base text-muted-foreground">
|
||||||
To deploy using GitHub, you need to configure your account
|
To deploy using GitHub, you need to configure your account
|
||||||
@@ -128,7 +162,7 @@ export const ShowProviderFormCompose = ({ composeId }: Props) => {
|
|||||||
{gitlabProviders && gitlabProviders?.length > 0 ? (
|
{gitlabProviders && gitlabProviders?.length > 0 ? (
|
||||||
<SaveGitlabProviderCompose composeId={composeId} />
|
<SaveGitlabProviderCompose composeId={composeId} />
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col items-center gap-3 min-h-[15vh] justify-center">
|
<div className="flex flex-col items-center gap-3 min-h-[25vh] justify-center">
|
||||||
<GitlabIcon className="size-8 text-muted-foreground" />
|
<GitlabIcon className="size-8 text-muted-foreground" />
|
||||||
<span className="text-base text-muted-foreground">
|
<span className="text-base text-muted-foreground">
|
||||||
To deploy using GitLab, you need to configure your account
|
To deploy using GitLab, you need to configure your account
|
||||||
@@ -148,7 +182,7 @@ export const ShowProviderFormCompose = ({ composeId }: Props) => {
|
|||||||
{bitbucketProviders && bitbucketProviders?.length > 0 ? (
|
{bitbucketProviders && bitbucketProviders?.length > 0 ? (
|
||||||
<SaveBitbucketProviderCompose composeId={composeId} />
|
<SaveBitbucketProviderCompose composeId={composeId} />
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col items-center gap-3 min-h-[15vh] justify-center">
|
<div className="flex flex-col items-center gap-3 min-h-[25vh] justify-center">
|
||||||
<BitbucketIcon className="size-8 text-muted-foreground" />
|
<BitbucketIcon className="size-8 text-muted-foreground" />
|
||||||
<span className="text-base text-muted-foreground">
|
<span className="text-base text-muted-foreground">
|
||||||
To deploy using Bitbucket, you need to configure your account
|
To deploy using Bitbucket, you need to configure your account
|
||||||
@@ -168,7 +202,7 @@ export const ShowProviderFormCompose = ({ composeId }: Props) => {
|
|||||||
{giteaProviders && giteaProviders?.length > 0 ? (
|
{giteaProviders && giteaProviders?.length > 0 ? (
|
||||||
<SaveGiteaProviderCompose composeId={composeId} />
|
<SaveGiteaProviderCompose composeId={composeId} />
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col items-center gap-3 min-h-[15vh] justify-center">
|
<div className="flex flex-col items-center gap-3 min-h-[25vh] justify-center">
|
||||||
<GiteaIcon className="size-8 text-muted-foreground" />
|
<GiteaIcon className="size-8 text-muted-foreground" />
|
||||||
<span className="text-base text-muted-foreground">
|
<span className="text-base text-muted-foreground">
|
||||||
To deploy using Gitea, you need to configure your account
|
To deploy using Gitea, you need to configure your account
|
||||||
|
|||||||
@@ -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,828 @@
|
|||||||
|
import { AlertBlock } from "@/components/shared/alert-block";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Command,
|
||||||
|
CommandEmpty,
|
||||||
|
CommandGroup,
|
||||||
|
CommandInput,
|
||||||
|
CommandItem,
|
||||||
|
} from "@/components/ui/command";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormDescription,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from "@/components/ui/form";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import {
|
||||||
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverTrigger,
|
||||||
|
} from "@/components/ui/popover";
|
||||||
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipProvider,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from "@/components/ui/tooltip";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { api } from "@/utils/api";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import {
|
||||||
|
DatabaseZap,
|
||||||
|
Info,
|
||||||
|
PenBoxIcon,
|
||||||
|
PlusIcon,
|
||||||
|
RefreshCw,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { CheckIcon, ChevronsUpDown } from "lucide-react";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { commonCronExpressions } from "../../application/schedules/handle-schedules";
|
||||||
|
|
||||||
|
type CacheType = "cache" | "fetch";
|
||||||
|
|
||||||
|
type DatabaseType = "postgres" | "mariadb" | "mysql" | "mongo" | "web-server";
|
||||||
|
|
||||||
|
const Schema = z
|
||||||
|
.object({
|
||||||
|
destinationId: z.string().min(1, "Destination required"),
|
||||||
|
schedule: z.string().min(1, "Schedule (Cron) required"),
|
||||||
|
prefix: z.string().min(1, "Prefix required"),
|
||||||
|
enabled: z.boolean(),
|
||||||
|
database: z.string().min(1, "Database required"),
|
||||||
|
keepLatestCount: z.coerce.number().optional(),
|
||||||
|
serviceName: z.string().nullable(),
|
||||||
|
databaseType: z
|
||||||
|
.enum(["postgres", "mariadb", "mysql", "mongo", "web-server"])
|
||||||
|
.optional(),
|
||||||
|
backupType: z.enum(["database", "compose"]),
|
||||||
|
metadata: z
|
||||||
|
.object({
|
||||||
|
postgres: z
|
||||||
|
.object({
|
||||||
|
databaseUser: z.string(),
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
|
mariadb: z
|
||||||
|
.object({
|
||||||
|
databaseUser: z.string(),
|
||||||
|
databasePassword: z.string(),
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
|
mongo: z
|
||||||
|
.object({
|
||||||
|
databaseUser: z.string(),
|
||||||
|
databasePassword: z.string(),
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
|
mysql: z
|
||||||
|
.object({
|
||||||
|
databaseRootPassword: z.string(),
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
|
})
|
||||||
|
.superRefine((data, ctx) => {
|
||||||
|
if (data.backupType === "compose" && !data.databaseType) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message: "Database type is required for compose backups",
|
||||||
|
path: ["databaseType"],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.backupType === "compose" && !data.serviceName) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message: "Service name is required for compose backups",
|
||||||
|
path: ["serviceName"],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.backupType === "compose" && data.databaseType) {
|
||||||
|
if (data.databaseType === "postgres") {
|
||||||
|
if (!data.metadata?.postgres?.databaseUser) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message: "Database user is required for PostgreSQL",
|
||||||
|
path: ["metadata", "postgres", "databaseUser"],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else if (data.databaseType === "mariadb") {
|
||||||
|
if (!data.metadata?.mariadb?.databaseUser) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message: "Database user is required for MariaDB",
|
||||||
|
path: ["metadata", "mariadb", "databaseUser"],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!data.metadata?.mariadb?.databasePassword) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message: "Database password is required for MariaDB",
|
||||||
|
path: ["metadata", "mariadb", "databasePassword"],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else if (data.databaseType === "mongo") {
|
||||||
|
if (!data.metadata?.mongo?.databaseUser) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message: "Database user is required for MongoDB",
|
||||||
|
path: ["metadata", "mongo", "databaseUser"],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!data.metadata?.mongo?.databasePassword) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message: "Database password is required for MongoDB",
|
||||||
|
path: ["metadata", "mongo", "databasePassword"],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else if (data.databaseType === "mysql") {
|
||||||
|
if (!data.metadata?.mysql?.databaseRootPassword) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message: "Root password is required for MySQL",
|
||||||
|
path: ["metadata", "mysql", "databaseRootPassword"],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
id?: string;
|
||||||
|
backupId?: string;
|
||||||
|
databaseType?: DatabaseType;
|
||||||
|
refetch: () => void;
|
||||||
|
backupType: "database" | "compose";
|
||||||
|
}
|
||||||
|
|
||||||
|
export const HandleBackup = ({
|
||||||
|
id,
|
||||||
|
backupId,
|
||||||
|
databaseType = "postgres",
|
||||||
|
refetch,
|
||||||
|
backupType = "database",
|
||||||
|
}: Props) => {
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
|
||||||
|
const { data, isLoading } = api.destination.all.useQuery();
|
||||||
|
const { data: backup } = api.backup.one.useQuery(
|
||||||
|
{
|
||||||
|
backupId: backupId ?? "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
enabled: !!backupId,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const [cacheType, setCacheType] = useState<CacheType>("cache");
|
||||||
|
const { mutateAsync: createBackup, isLoading: isCreatingPostgresBackup } =
|
||||||
|
backupId
|
||||||
|
? api.backup.update.useMutation()
|
||||||
|
: api.backup.create.useMutation();
|
||||||
|
|
||||||
|
const form = useForm<z.infer<typeof Schema>>({
|
||||||
|
defaultValues: {
|
||||||
|
database: databaseType === "web-server" ? "dokploy" : "",
|
||||||
|
destinationId: "",
|
||||||
|
enabled: true,
|
||||||
|
prefix: "/",
|
||||||
|
schedule: "",
|
||||||
|
keepLatestCount: undefined,
|
||||||
|
serviceName: null,
|
||||||
|
databaseType: backupType === "compose" ? undefined : databaseType,
|
||||||
|
backupType: backupType,
|
||||||
|
metadata: {},
|
||||||
|
},
|
||||||
|
resolver: zodResolver(Schema),
|
||||||
|
});
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: services,
|
||||||
|
isFetching: isLoadingServices,
|
||||||
|
error: errorServices,
|
||||||
|
refetch: refetchServices,
|
||||||
|
} = api.compose.loadServices.useQuery(
|
||||||
|
{
|
||||||
|
composeId: backup?.composeId ?? id ?? "",
|
||||||
|
type: cacheType,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
retry: false,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
enabled: backupType === "compose" && !!backup?.composeId && !!id,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
form.reset({
|
||||||
|
database: backup?.database
|
||||||
|
? backup?.database
|
||||||
|
: databaseType === "web-server"
|
||||||
|
? "dokploy"
|
||||||
|
: "",
|
||||||
|
destinationId: backup?.destinationId ?? "",
|
||||||
|
enabled: backup?.enabled ?? true,
|
||||||
|
prefix: backup?.prefix ?? "/",
|
||||||
|
schedule: backup?.schedule ?? "",
|
||||||
|
keepLatestCount: backup?.keepLatestCount ?? undefined,
|
||||||
|
serviceName: backup?.serviceName ?? null,
|
||||||
|
databaseType: backup?.databaseType ?? databaseType,
|
||||||
|
backupType: backup?.backupType ?? backupType,
|
||||||
|
metadata: backup?.metadata ?? {},
|
||||||
|
});
|
||||||
|
}, [form, form.reset, backupId, backup]);
|
||||||
|
|
||||||
|
const onSubmit = async (data: z.infer<typeof Schema>) => {
|
||||||
|
const getDatabaseId =
|
||||||
|
backupType === "compose"
|
||||||
|
? {
|
||||||
|
composeId: id,
|
||||||
|
}
|
||||||
|
: databaseType === "postgres"
|
||||||
|
? {
|
||||||
|
postgresId: id,
|
||||||
|
}
|
||||||
|
: databaseType === "mariadb"
|
||||||
|
? {
|
||||||
|
mariadbId: id,
|
||||||
|
}
|
||||||
|
: databaseType === "mysql"
|
||||||
|
? {
|
||||||
|
mysqlId: id,
|
||||||
|
}
|
||||||
|
: databaseType === "mongo"
|
||||||
|
? {
|
||||||
|
mongoId: id,
|
||||||
|
}
|
||||||
|
: databaseType === "web-server"
|
||||||
|
? {
|
||||||
|
userId: id,
|
||||||
|
}
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
await createBackup({
|
||||||
|
destinationId: data.destinationId,
|
||||||
|
prefix: data.prefix,
|
||||||
|
schedule: data.schedule,
|
||||||
|
enabled: data.enabled,
|
||||||
|
database: data.database,
|
||||||
|
keepLatestCount: data.keepLatestCount ?? null,
|
||||||
|
databaseType: data.databaseType || databaseType,
|
||||||
|
serviceName: data.serviceName,
|
||||||
|
...getDatabaseId,
|
||||||
|
backupId: backupId ?? "",
|
||||||
|
backupType,
|
||||||
|
metadata: data.metadata,
|
||||||
|
})
|
||||||
|
.then(async () => {
|
||||||
|
toast.success(`Backup ${backupId ? "Updated" : "Created"}`);
|
||||||
|
refetch();
|
||||||
|
setIsOpen(false);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
toast.error(`Error ${backupId ? "updating" : "creating"} a backup`);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
{backupId ? (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="group hover:bg-blue-500/10 size-8"
|
||||||
|
>
|
||||||
|
<PenBoxIcon className="size-3.5 text-primary group-hover:text-blue-500" />
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button>
|
||||||
|
<PlusIcon className="h-4 w-4" />
|
||||||
|
{backupId ? "Update Backup" : "Create Backup"}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent className="sm:max-w-2xl max-h-screen overflow-y-auto">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
{backupId ? "Update Backup" : "Create Backup"}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{backupId ? "Update a backup" : "Add a new backup"}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<Form {...form}>
|
||||||
|
<form
|
||||||
|
id="hook-form-add-backup"
|
||||||
|
onSubmit={form.handleSubmit(onSubmit)}
|
||||||
|
className="grid w-full gap-4"
|
||||||
|
>
|
||||||
|
<div className="grid grid-cols-1 gap-4">
|
||||||
|
{errorServices && (
|
||||||
|
<AlertBlock type="warning" className="[overflow-wrap:anywhere]">
|
||||||
|
{errorServices?.message}
|
||||||
|
</AlertBlock>
|
||||||
|
)}
|
||||||
|
{backupType === "compose" && (
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="databaseType"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Database Type</FormLabel>
|
||||||
|
<Select
|
||||||
|
value={field.value}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
field.onChange(value as DatabaseType);
|
||||||
|
form.setValue("metadata", {});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-full">
|
||||||
|
<SelectValue placeholder="Select a database type" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="postgres">PostgreSQL</SelectItem>
|
||||||
|
<SelectItem value="mariadb">MariaDB</SelectItem>
|
||||||
|
<SelectItem value="mysql">MySQL</SelectItem>
|
||||||
|
<SelectItem value="mongo">MongoDB</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="destinationId"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="">
|
||||||
|
<FormLabel>Destination</FormLabel>
|
||||||
|
<Popover>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<FormControl>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className={cn(
|
||||||
|
"w-full justify-between !bg-input",
|
||||||
|
!field.value && "text-muted-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isLoading
|
||||||
|
? "Loading...."
|
||||||
|
: field.value
|
||||||
|
? data?.find(
|
||||||
|
(destination) =>
|
||||||
|
destination.destinationId === field.value,
|
||||||
|
)?.name
|
||||||
|
: "Select Destination"}
|
||||||
|
|
||||||
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
|
</Button>
|
||||||
|
</FormControl>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="p-0" align="start">
|
||||||
|
<Command>
|
||||||
|
<CommandInput
|
||||||
|
placeholder="Search Destination..."
|
||||||
|
className="h-9"
|
||||||
|
/>
|
||||||
|
{isLoading && (
|
||||||
|
<span className="py-6 text-center text-sm">
|
||||||
|
Loading Destinations....
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<CommandEmpty>No destinations found.</CommandEmpty>
|
||||||
|
<ScrollArea className="h-64">
|
||||||
|
<CommandGroup>
|
||||||
|
{data?.map((destination) => (
|
||||||
|
<CommandItem
|
||||||
|
value={destination.destinationId}
|
||||||
|
key={destination.destinationId}
|
||||||
|
onSelect={() => {
|
||||||
|
form.setValue(
|
||||||
|
"destinationId",
|
||||||
|
destination.destinationId,
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{destination.name}
|
||||||
|
<CheckIcon
|
||||||
|
className={cn(
|
||||||
|
"ml-auto h-4 w-4",
|
||||||
|
destination.destinationId === field.value
|
||||||
|
? "opacity-100"
|
||||||
|
: "opacity-0",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
</ScrollArea>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{backupType === "compose" && (
|
||||||
|
<div className="flex flex-row items-end w-full gap-4">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="serviceName"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="w-full">
|
||||||
|
<FormLabel>Service Name</FormLabel>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Select
|
||||||
|
onValueChange={field.onChange}
|
||||||
|
value={field.value || undefined}
|
||||||
|
>
|
||||||
|
<FormControl>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select a service name" />
|
||||||
|
</SelectTrigger>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
<SelectContent>
|
||||||
|
{services?.map((service, index) => (
|
||||||
|
<SelectItem
|
||||||
|
value={service}
|
||||||
|
key={`${service}-${index}`}
|
||||||
|
>
|
||||||
|
{service}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
{(!services || services.length === 0) && (
|
||||||
|
<SelectItem value="none" disabled>
|
||||||
|
Empty
|
||||||
|
</SelectItem>
|
||||||
|
)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<TooltipProvider delayDuration={0}>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
type="button"
|
||||||
|
isLoading={isLoadingServices}
|
||||||
|
onClick={() => {
|
||||||
|
if (cacheType === "fetch") {
|
||||||
|
refetchServices();
|
||||||
|
} else {
|
||||||
|
setCacheType("fetch");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<RefreshCw className="size-4 text-muted-foreground" />
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent
|
||||||
|
side="left"
|
||||||
|
sideOffset={5}
|
||||||
|
className="max-w-[10rem]"
|
||||||
|
>
|
||||||
|
<p>
|
||||||
|
Fetch: Will clone the repository and load the
|
||||||
|
services
|
||||||
|
</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
<TooltipProvider delayDuration={0}>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
type="button"
|
||||||
|
isLoading={isLoadingServices}
|
||||||
|
onClick={() => {
|
||||||
|
if (cacheType === "cache") {
|
||||||
|
refetchServices();
|
||||||
|
} else {
|
||||||
|
setCacheType("cache");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DatabaseZap className="size-4 text-muted-foreground" />
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent
|
||||||
|
side="left"
|
||||||
|
sideOffset={5}
|
||||||
|
className="max-w-[10rem]"
|
||||||
|
>
|
||||||
|
<p>
|
||||||
|
Cache: If you previously deployed this
|
||||||
|
compose, it will read the services from the
|
||||||
|
last deployment/fetch from the repository
|
||||||
|
</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="database"
|
||||||
|
render={({ field }) => {
|
||||||
|
return (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Database</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
disabled={databaseType === "web-server"}
|
||||||
|
placeholder={"dokploy"}
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="schedule"
|
||||||
|
render={({ field }) => {
|
||||||
|
return (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel className="flex items-center gap-2">
|
||||||
|
Schedule
|
||||||
|
<TooltipProvider>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Info className="w-4 h-4 text-muted-foreground cursor-help" />
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>
|
||||||
|
Cron expression format: minute hour day month
|
||||||
|
weekday
|
||||||
|
</p>
|
||||||
|
<p>Example: 0 0 * * * (daily at midnight)</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
</FormLabel>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Select
|
||||||
|
onValueChange={(value) => {
|
||||||
|
field.onChange(value);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FormControl>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select a predefined schedule" />
|
||||||
|
</SelectTrigger>
|
||||||
|
</FormControl>
|
||||||
|
<SelectContent>
|
||||||
|
{commonCronExpressions.map((expr) => (
|
||||||
|
<SelectItem key={expr.value} value={expr.value}>
|
||||||
|
{expr.label} ({expr.value})
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<div className="relative">
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
placeholder="Custom cron expression (e.g., 0 0 * * *)"
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<FormDescription>
|
||||||
|
Choose a predefined schedule or enter a custom cron
|
||||||
|
expression
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="prefix"
|
||||||
|
render={({ field }) => {
|
||||||
|
return (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Prefix Destination</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder={"dokploy/"} {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
Use if you want to back up in a specific path of your
|
||||||
|
destination/bucket
|
||||||
|
</FormDescription>
|
||||||
|
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="keepLatestCount"
|
||||||
|
render={({ field }) => {
|
||||||
|
return (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Keep the latest</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
placeholder={"keeps all the backups if left empty"}
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
Optional. If provided, only keeps the latest N backups
|
||||||
|
in the cloud.
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="enabled"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3 ">
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<FormLabel>Enabled</FormLabel>
|
||||||
|
<FormDescription>
|
||||||
|
Enable or disable the backup
|
||||||
|
</FormDescription>
|
||||||
|
</div>
|
||||||
|
<FormControl>
|
||||||
|
<Switch
|
||||||
|
checked={field.value}
|
||||||
|
onCheckedChange={field.onChange}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{backupType === "compose" && (
|
||||||
|
<>
|
||||||
|
{form.watch("databaseType") === "postgres" && (
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="metadata.postgres.databaseUser"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Database User</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="postgres" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{form.watch("databaseType") === "mariadb" && (
|
||||||
|
<>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="metadata.mariadb.databaseUser"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Database User</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="mariadb" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="metadata.mariadb.databasePassword"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Database Password</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
placeholder="••••••••"
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{form.watch("databaseType") === "mongo" && (
|
||||||
|
<>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="metadata.mongo.databaseUser"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Database User</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="mongo" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="metadata.mongo.databasePassword"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Database Password</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
placeholder="••••••••"
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{form.watch("databaseType") === "mysql" && (
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="metadata.mysql.databaseRootPassword"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Root Password</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
placeholder="••••••••"
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
isLoading={isCreatingPostgresBackup}
|
||||||
|
form="hook-form-add-backup"
|
||||||
|
type="submit"
|
||||||
|
>
|
||||||
|
{backupId ? "Update" : "Create"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -32,50 +32,172 @@ import {
|
|||||||
PopoverTrigger,
|
PopoverTrigger,
|
||||||
} from "@/components/ui/popover";
|
} from "@/components/ui/popover";
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import copy from "copy-to-clipboard";
|
import copy from "copy-to-clipboard";
|
||||||
import { debounce } from "lodash";
|
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 { useState } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import type { ServiceType } from "../../application/advanced/show-resources";
|
import type { ServiceType } from "../../application/advanced/show-resources";
|
||||||
import { type LogLine, parseLogs } from "../../docker/logs/utils";
|
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 {
|
interface Props {
|
||||||
databaseId: string;
|
id: string;
|
||||||
databaseType: Exclude<ServiceType, "application" | "redis"> | "web-server";
|
databaseType?: DatabaseType;
|
||||||
serverId?: string | null;
|
serverId?: string | null;
|
||||||
|
backupType?: "database" | "compose";
|
||||||
}
|
}
|
||||||
|
|
||||||
const RestoreBackupSchema = z.object({
|
const RestoreBackupSchema = z
|
||||||
destinationId: z
|
.object({
|
||||||
.string({
|
destinationId: z
|
||||||
required_error: "Please select a destination",
|
.string({
|
||||||
})
|
required_error: "Please select a destination",
|
||||||
.min(1, {
|
})
|
||||||
message: "Destination is required",
|
.min(1, {
|
||||||
}),
|
message: "Destination is required",
|
||||||
backupFile: z
|
}),
|
||||||
.string({
|
backupFile: z
|
||||||
required_error: "Please select a backup file",
|
.string({
|
||||||
})
|
required_error: "Please select a backup file",
|
||||||
.min(1, {
|
})
|
||||||
message: "Backup file is required",
|
.min(1, {
|
||||||
}),
|
message: "Backup file is required",
|
||||||
databaseName: z
|
}),
|
||||||
.string({
|
databaseName: z
|
||||||
required_error: "Please enter a database name",
|
.string({
|
||||||
})
|
required_error: "Please enter a database name",
|
||||||
.min(1, {
|
})
|
||||||
message: "Database name is required",
|
.min(1, {
|
||||||
}),
|
message: "Database name is required",
|
||||||
});
|
}),
|
||||||
|
databaseType: z
|
||||||
|
.enum(["postgres", "mariadb", "mysql", "mongo", "web-server"])
|
||||||
|
.optional(),
|
||||||
|
backupType: z.enum(["database", "compose"]).default("database"),
|
||||||
|
metadata: z
|
||||||
|
.object({
|
||||||
|
postgres: z
|
||||||
|
.object({
|
||||||
|
databaseUser: z.string(),
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
|
mariadb: z
|
||||||
|
.object({
|
||||||
|
databaseUser: z.string(),
|
||||||
|
databasePassword: z.string(),
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
|
mongo: z
|
||||||
|
.object({
|
||||||
|
databaseUser: z.string(),
|
||||||
|
databasePassword: z.string(),
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
|
mysql: z
|
||||||
|
.object({
|
||||||
|
databaseRootPassword: z.string(),
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
|
serviceName: z.string().optional(),
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
|
})
|
||||||
|
.superRefine((data, ctx) => {
|
||||||
|
if (data.backupType === "compose" && !data.databaseType) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message: "Database type is required for compose backups",
|
||||||
|
path: ["databaseType"],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
type RestoreBackup = z.infer<typeof RestoreBackupSchema>;
|
if (data.backupType === "compose" && !data.metadata?.serviceName) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message: "Service name is required for compose backups",
|
||||||
|
path: ["metadata", "serviceName"],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.backupType === "compose" && data.databaseType) {
|
||||||
|
if (data.databaseType === "postgres") {
|
||||||
|
if (!data.metadata?.postgres?.databaseUser) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message: "Database user is required for PostgreSQL",
|
||||||
|
path: ["metadata", "postgres", "databaseUser"],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else if (data.databaseType === "mariadb") {
|
||||||
|
if (!data.metadata?.mariadb?.databaseUser) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message: "Database user is required for MariaDB",
|
||||||
|
path: ["metadata", "mariadb", "databaseUser"],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!data.metadata?.mariadb?.databasePassword) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message: "Database password is required for MariaDB",
|
||||||
|
path: ["metadata", "mariadb", "databasePassword"],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else if (data.databaseType === "mongo") {
|
||||||
|
if (!data.metadata?.mongo?.databaseUser) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message: "Database user is required for MongoDB",
|
||||||
|
path: ["metadata", "mongo", "databaseUser"],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!data.metadata?.mongo?.databasePassword) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message: "Database password is required for MongoDB",
|
||||||
|
path: ["metadata", "mongo", "databasePassword"],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else if (data.databaseType === "mysql") {
|
||||||
|
if (!data.metadata?.mysql?.databaseRootPassword) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message: "Root password is required for MySQL",
|
||||||
|
path: ["metadata", "mysql", "databaseRootPassword"],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const formatBytes = (bytes: number): string => {
|
const formatBytes = (bytes: number): string => {
|
||||||
if (bytes === 0) return "0 Bytes";
|
if (bytes === 0) return "0 Bytes";
|
||||||
@@ -86,9 +208,10 @@ const formatBytes = (bytes: number): string => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const RestoreBackup = ({
|
export const RestoreBackup = ({
|
||||||
databaseId,
|
id,
|
||||||
databaseType,
|
databaseType,
|
||||||
serverId,
|
serverId,
|
||||||
|
backupType = "database",
|
||||||
}: Props) => {
|
}: Props) => {
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
@@ -96,16 +219,22 @@ export const RestoreBackup = ({
|
|||||||
|
|
||||||
const { data: destinations = [] } = api.destination.all.useQuery();
|
const { data: destinations = [] } = api.destination.all.useQuery();
|
||||||
|
|
||||||
const form = useForm<RestoreBackup>({
|
const form = useForm<z.infer<typeof RestoreBackupSchema>>({
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
destinationId: "",
|
destinationId: "",
|
||||||
backupFile: "",
|
backupFile: "",
|
||||||
databaseName: databaseType === "web-server" ? "dokploy" : "",
|
databaseName: databaseType === "web-server" ? "dokploy" : "",
|
||||||
|
databaseType:
|
||||||
|
backupType === "compose" ? ("postgres" as DatabaseType) : databaseType,
|
||||||
|
backupType: backupType,
|
||||||
|
metadata: {},
|
||||||
},
|
},
|
||||||
resolver: zodResolver(RestoreBackupSchema),
|
resolver: zodResolver(RestoreBackupSchema),
|
||||||
});
|
});
|
||||||
|
|
||||||
const destionationId = form.watch("destinationId");
|
const destionationId = form.watch("destinationId");
|
||||||
|
const currentDatabaseType = form.watch("databaseType");
|
||||||
|
const metadata = form.watch("metadata");
|
||||||
|
|
||||||
const debouncedSetSearch = debounce((value: string) => {
|
const debouncedSetSearch = debounce((value: string) => {
|
||||||
setDebouncedSearchTerm(value);
|
setDebouncedSearchTerm(value);
|
||||||
@@ -131,16 +260,15 @@ export const RestoreBackup = ({
|
|||||||
const [filteredLogs, setFilteredLogs] = useState<LogLine[]>([]);
|
const [filteredLogs, setFilteredLogs] = useState<LogLine[]>([]);
|
||||||
const [isDeploying, setIsDeploying] = useState(false);
|
const [isDeploying, setIsDeploying] = useState(false);
|
||||||
|
|
||||||
// const { mutateAsync: restore, isLoading: isRestoring } =
|
|
||||||
// api.backup.restoreBackup.useMutation();
|
|
||||||
|
|
||||||
api.backup.restoreBackupWithLogs.useSubscription(
|
api.backup.restoreBackupWithLogs.useSubscription(
|
||||||
{
|
{
|
||||||
databaseId,
|
databaseId: id,
|
||||||
databaseType,
|
databaseType: currentDatabaseType as DatabaseType,
|
||||||
databaseName: form.watch("databaseName"),
|
databaseName: form.watch("databaseName"),
|
||||||
backupFile: form.watch("backupFile"),
|
backupFile: form.watch("backupFile"),
|
||||||
destinationId: form.watch("destinationId"),
|
destinationId: form.watch("destinationId"),
|
||||||
|
backupType: backupType,
|
||||||
|
metadata: metadata,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
enabled: isDeploying,
|
enabled: isDeploying,
|
||||||
@@ -162,10 +290,32 @@ export const RestoreBackup = ({
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
const onSubmit = async (_data: RestoreBackup) => {
|
const onSubmit = async (data: z.infer<typeof RestoreBackupSchema>) => {
|
||||||
|
if (backupType === "compose" && !data.databaseType) {
|
||||||
|
toast.error("Please select a database type");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.log({ data });
|
||||||
setIsDeploying(true);
|
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 (
|
return (
|
||||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
@@ -174,7 +324,7 @@ export const RestoreBackup = ({
|
|||||||
Restore Backup
|
Restore Backup
|
||||||
</Button>
|
</Button>
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
<DialogContent className="sm:max-w-lg">
|
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-lg">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle className="flex items-center">
|
<DialogTitle className="flex items-center">
|
||||||
<RotateCcw className="mr-2 size-4" />
|
<RotateCcw className="mr-2 size-4" />
|
||||||
@@ -377,25 +527,274 @@ export const RestoreBackup = ({
|
|||||||
control={form.control}
|
control={form.control}
|
||||||
name="databaseName"
|
name="databaseName"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem className="">
|
<FormItem>
|
||||||
<FormLabel>Database Name</FormLabel>
|
<FormLabel>Database Name</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
disabled={databaseType === "web-server"}
|
|
||||||
{...field}
|
|
||||||
placeholder="Enter database name"
|
placeholder="Enter database name"
|
||||||
|
{...field}
|
||||||
|
disabled={databaseType === "web-server"}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{backupType === "compose" && (
|
||||||
|
<>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="databaseType"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Database Type</FormLabel>
|
||||||
|
<Select
|
||||||
|
value={field.value}
|
||||||
|
onValueChange={(value: DatabaseType) => {
|
||||||
|
field.onChange(value);
|
||||||
|
form.setValue("metadata", {});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select database type" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="postgres">PostgreSQL</SelectItem>
|
||||||
|
<SelectItem value="mariadb">MariaDB</SelectItem>
|
||||||
|
<SelectItem value="mongo">MongoDB</SelectItem>
|
||||||
|
<SelectItem value="mysql">MySQL</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="metadata.serviceName"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="w-full">
|
||||||
|
<FormLabel>Service Name</FormLabel>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Select
|
||||||
|
onValueChange={field.onChange}
|
||||||
|
value={field.value || undefined}
|
||||||
|
>
|
||||||
|
<FormControl>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select a service name" />
|
||||||
|
</SelectTrigger>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
<SelectContent>
|
||||||
|
{services?.map((service, index) => (
|
||||||
|
<SelectItem
|
||||||
|
value={service}
|
||||||
|
key={`${service}-${index}`}
|
||||||
|
>
|
||||||
|
{service}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
{(!services || services.length === 0) && (
|
||||||
|
<SelectItem value="none" disabled>
|
||||||
|
Empty
|
||||||
|
</SelectItem>
|
||||||
|
)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<TooltipProvider delayDuration={0}>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
type="button"
|
||||||
|
isLoading={isLoadingServices}
|
||||||
|
onClick={() => {
|
||||||
|
if (cacheType === "fetch") {
|
||||||
|
refetchServices();
|
||||||
|
} else {
|
||||||
|
setCacheType("fetch");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<RefreshCw className="size-4 text-muted-foreground" />
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent
|
||||||
|
side="left"
|
||||||
|
sideOffset={5}
|
||||||
|
className="max-w-[10rem]"
|
||||||
|
>
|
||||||
|
<p>
|
||||||
|
Fetch: Will clone the repository and load the
|
||||||
|
services
|
||||||
|
</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
<TooltipProvider delayDuration={0}>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
type="button"
|
||||||
|
isLoading={isLoadingServices}
|
||||||
|
onClick={() => {
|
||||||
|
if (cacheType === "cache") {
|
||||||
|
refetchServices();
|
||||||
|
} else {
|
||||||
|
setCacheType("cache");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DatabaseZap className="size-4 text-muted-foreground" />
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent
|
||||||
|
side="left"
|
||||||
|
sideOffset={5}
|
||||||
|
className="max-w-[10rem]"
|
||||||
|
>
|
||||||
|
<p>
|
||||||
|
Cache: If you previously deployed this compose,
|
||||||
|
it will read the services from the last
|
||||||
|
deployment/fetch from the repository
|
||||||
|
</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{currentDatabaseType === "postgres" && (
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="metadata.postgres.databaseUser"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Database User</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="Enter database user" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{currentDatabaseType === "mariadb" && (
|
||||||
|
<>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="metadata.mariadb.databaseUser"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Database User</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
placeholder="Enter database user"
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="metadata.mariadb.databasePassword"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Database Password</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
placeholder="Enter database password"
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{currentDatabaseType === "mongo" && (
|
||||||
|
<>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="metadata.mongo.databaseUser"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Database User</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
placeholder="Enter database user"
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="metadata.mongo.databasePassword"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Database Password</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
placeholder="Enter database password"
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{currentDatabaseType === "mysql" && (
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="metadata.mysql.databaseRootPassword"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Root Password</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
placeholder="Enter root password"
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button
|
<Button
|
||||||
isLoading={isDeploying}
|
isLoading={isDeploying}
|
||||||
form="hook-form-restore-backup"
|
form="hook-form-restore-backup"
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={!form.watch("backupFile")}
|
// disabled={
|
||||||
|
// !form.watch("backupFile") ||
|
||||||
|
// (backupType === "compose" && !form.watch("databaseType"))
|
||||||
|
// }
|
||||||
>
|
>
|
||||||
Restore
|
Restore
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -14,49 +14,83 @@ import {
|
|||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
} from "@/components/ui/tooltip";
|
} from "@/components/ui/tooltip";
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
import { Database, DatabaseBackup, Play, Trash2 } from "lucide-react";
|
import {
|
||||||
|
ClipboardList,
|
||||||
|
Database,
|
||||||
|
DatabaseBackup,
|
||||||
|
Play,
|
||||||
|
Trash2,
|
||||||
|
} from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import type { ServiceType } from "../../application/advanced/show-resources";
|
import type { ServiceType } from "../../application/advanced/show-resources";
|
||||||
import { AddBackup } from "./add-backup";
|
|
||||||
import { RestoreBackup } from "./restore-backup";
|
import { RestoreBackup } from "./restore-backup";
|
||||||
import { UpdateBackup } from "./update-backup";
|
import { HandleBackup } from "./handle-backup";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import {
|
||||||
|
MariadbIcon,
|
||||||
|
MongodbIcon,
|
||||||
|
MysqlIcon,
|
||||||
|
PostgresqlIcon,
|
||||||
|
} from "@/components/icons/data-tools-icons";
|
||||||
|
import { AlertBlock } from "@/components/shared/alert-block";
|
||||||
|
import { ShowDeploymentsModal } from "../../application/deployments/show-deployments-modal";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
id: string;
|
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<
|
const [activeManualBackup, setActiveManualBackup] = useState<
|
||||||
string | undefined
|
string | undefined
|
||||||
>();
|
>();
|
||||||
const queryMap = {
|
const queryMap =
|
||||||
postgres: () =>
|
backupType === "database"
|
||||||
api.postgres.one.useQuery({ postgresId: id }, { enabled: !!id }),
|
? {
|
||||||
mysql: () => api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }),
|
postgres: () =>
|
||||||
mariadb: () =>
|
api.postgres.one.useQuery({ postgresId: id }, { enabled: !!id }),
|
||||||
api.mariadb.one.useQuery({ mariadbId: id }, { enabled: !!id }),
|
mysql: () =>
|
||||||
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
|
api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }),
|
||||||
"web-server": () => api.user.getBackups.useQuery(),
|
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 } = api.destination.all.useQuery();
|
||||||
const { data: postgres, refetch } = queryMap[type]
|
const key = backupType === "database" ? databaseType : "compose";
|
||||||
? queryMap[type]()
|
const query = queryMap[key as keyof typeof queryMap];
|
||||||
|
const { data: postgres, refetch } = query
|
||||||
|
? query()
|
||||||
: api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id });
|
: api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id });
|
||||||
|
|
||||||
const mutationMap = {
|
const mutationMap =
|
||||||
postgres: () => api.backup.manualBackupPostgres.useMutation(),
|
backupType === "database"
|
||||||
mysql: () => api.backup.manualBackupMySql.useMutation(),
|
? {
|
||||||
mariadb: () => api.backup.manualBackupMariadb.useMutation(),
|
postgres: api.backup.manualBackupPostgres.useMutation(),
|
||||||
mongo: () => api.backup.manualBackupMongo.useMutation(),
|
mysql: api.backup.manualBackupMySql.useMutation(),
|
||||||
"web-server": () => api.backup.manualBackupWebServer.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[
|
const mutation = mutationMap[key as keyof typeof mutationMap];
|
||||||
type
|
|
||||||
]
|
const { mutateAsync: manualBackup, isLoading: isManualBackup } = mutation
|
||||||
? mutationMap[type]()
|
? mutation
|
||||||
: api.backup.manualBackupMongo.useMutation();
|
: api.backup.manualBackupMongo.useMutation();
|
||||||
|
|
||||||
const { mutateAsync: deleteBackup, isLoading: isRemoving } =
|
const { mutateAsync: deleteBackup, isLoading: isRemoving } =
|
||||||
@@ -78,16 +112,18 @@ export const ShowBackups = ({ id, type }: Props) => {
|
|||||||
|
|
||||||
{postgres && postgres?.backups?.length > 0 && (
|
{postgres && postgres?.backups?.length > 0 && (
|
||||||
<div className="flex flex-col lg:flex-row gap-4 w-full lg:w-auto">
|
<div className="flex flex-col lg:flex-row gap-4 w-full lg:w-auto">
|
||||||
{type !== "web-server" && (
|
{databaseType !== "web-server" && (
|
||||||
<AddBackup
|
<HandleBackup
|
||||||
databaseId={id}
|
id={id}
|
||||||
databaseType={type}
|
databaseType={databaseType}
|
||||||
|
backupType={backupType}
|
||||||
refetch={refetch}
|
refetch={refetch}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<RestoreBackup
|
<RestoreBackup
|
||||||
databaseId={id}
|
id={id}
|
||||||
databaseType={type}
|
databaseType={databaseType}
|
||||||
|
backupType={backupType}
|
||||||
serverId={"serverId" in postgres ? postgres.serverId : undefined}
|
serverId={"serverId" in postgres ? postgres.serverId : undefined}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -95,7 +131,7 @@ export const ShowBackups = ({ id, type }: Props) => {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="flex flex-col gap-4">
|
<CardContent className="flex flex-col gap-4">
|
||||||
{data?.length === 0 ? (
|
{data?.length === 0 ? (
|
||||||
<div className="flex flex-col items-center gap-3">
|
<div className="flex flex-col items-center gap-3 min-h-[35vh] justify-center">
|
||||||
<DatabaseBackup className="size-8 text-muted-foreground" />
|
<DatabaseBackup className="size-8 text-muted-foreground" />
|
||||||
<span className="text-base text-muted-foreground text-center">
|
<span className="text-base text-muted-foreground text-center">
|
||||||
To create a backup it is required to set at least 1 provider.
|
To create a backup it is required to set at least 1 provider.
|
||||||
@@ -110,7 +146,7 @@ export const ShowBackups = ({ id, type }: Props) => {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div>
|
<div className="flex flex-col gap-4 w-full">
|
||||||
{postgres?.backups.length === 0 ? (
|
{postgres?.backups.length === 0 ? (
|
||||||
<div className="flex w-full flex-col items-center justify-center gap-3 pt-10">
|
<div className="flex w-full flex-col items-center justify-center gap-3 pt-10">
|
||||||
<DatabaseBackup className="size-8 text-muted-foreground" />
|
<DatabaseBackup className="size-8 text-muted-foreground" />
|
||||||
@@ -118,14 +154,16 @@ export const ShowBackups = ({ id, type }: Props) => {
|
|||||||
No backups configured
|
No backups configured
|
||||||
</span>
|
</span>
|
||||||
<div className="flex flex-col sm:flex-row gap-4 w-full sm:w-auto">
|
<div className="flex flex-col sm:flex-row gap-4 w-full sm:w-auto">
|
||||||
<AddBackup
|
<HandleBackup
|
||||||
databaseId={id}
|
id={id}
|
||||||
databaseType={type}
|
databaseType={databaseType}
|
||||||
|
backupType={backupType}
|
||||||
refetch={refetch}
|
refetch={refetch}
|
||||||
/>
|
/>
|
||||||
<RestoreBackup
|
<RestoreBackup
|
||||||
databaseId={id}
|
id={id}
|
||||||
databaseType={type}
|
databaseType={databaseType}
|
||||||
|
backupType={backupType}
|
||||||
serverId={
|
serverId={
|
||||||
"serverId" in postgres ? postgres.serverId : undefined
|
"serverId" in postgres ? postgres.serverId : undefined
|
||||||
}
|
}
|
||||||
@@ -133,119 +171,204 @@ export const ShowBackups = ({ id, type }: Props) => {
|
|||||||
</div>
|
</div>
|
||||||
</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">
|
<div className="flex flex-col gap-6">
|
||||||
{postgres?.backups.map((backup) => (
|
{postgres?.backups.map((backup) => {
|
||||||
<div key={backup.backupId}>
|
const serverId =
|
||||||
<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">
|
"serverId" in postgres ? postgres.serverId : undefined;
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 xl:grid-cols-6 flex-col gap-8">
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
<span className="font-medium">Destination</span>
|
|
||||||
<span className="text-sm text-muted-foreground">
|
|
||||||
{backup.destination.name}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
<span className="font-medium">Database</span>
|
|
||||||
<span className="text-sm text-muted-foreground">
|
|
||||||
{backup.database}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
<span className="font-medium">Scheduled</span>
|
|
||||||
<span className="text-sm text-muted-foreground">
|
|
||||||
{backup.schedule}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
<span className="font-medium">Prefix Storage</span>
|
|
||||||
<span className="text-sm text-muted-foreground">
|
|
||||||
{backup.prefix}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
<span className="font-medium">Enabled</span>
|
|
||||||
<span className="text-sm text-muted-foreground">
|
|
||||||
{backup.enabled ? "Yes" : "No"}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
<span className="font-medium">Keep Latest</span>
|
|
||||||
<span className="text-sm text-muted-foreground">
|
|
||||||
{backup.keepLatestCount || "All"}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-row gap-4">
|
|
||||||
<TooltipProvider delayDuration={0}>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
isLoading={
|
|
||||||
isManualBackup &&
|
|
||||||
activeManualBackup === backup.backupId
|
|
||||||
}
|
|
||||||
onClick={async () => {
|
|
||||||
setActiveManualBackup(backup.backupId);
|
|
||||||
await manualBackup({
|
|
||||||
backupId: backup.backupId as string,
|
|
||||||
})
|
|
||||||
.then(async () => {
|
|
||||||
toast.success(
|
|
||||||
"Manual Backup Successful",
|
|
||||||
);
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
toast.error(
|
|
||||||
"Error creating the manual backup",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
setActiveManualBackup(undefined);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Play className="size-5 text-muted-foreground" />
|
|
||||||
</Button>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>Run Manual Backup</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</TooltipProvider>
|
|
||||||
|
|
||||||
<UpdateBackup
|
return (
|
||||||
backupId={backup.backupId}
|
<div key={backup.backupId}>
|
||||||
refetch={refetch}
|
<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">
|
||||||
<DialogAction
|
<div className="flex items-center gap-3">
|
||||||
title="Delete Backup"
|
{backup.backupType === "compose" && (
|
||||||
description="Are you sure you want to delete this backup?"
|
<div className="flex items-center justify-center size-10 rounded-lg">
|
||||||
type="destructive"
|
{backup.databaseType === "postgres" && (
|
||||||
onClick={async () => {
|
<PostgresqlIcon className="size-7" />
|
||||||
await deleteBackup({
|
)}
|
||||||
backupId: backup.backupId,
|
{backup.databaseType === "mysql" && (
|
||||||
})
|
<MysqlIcon className="size-7" />
|
||||||
.then(() => {
|
)}
|
||||||
refetch();
|
{backup.databaseType === "mariadb" && (
|
||||||
toast.success("Backup deleted successfully");
|
<MariadbIcon className="size-7" />
|
||||||
})
|
)}
|
||||||
.catch(() => {
|
{backup.databaseType === "mongo" && (
|
||||||
toast.error("Error deleting backup");
|
<MongodbIcon className="size-7" />
|
||||||
});
|
)}
|
||||||
}}
|
</div>
|
||||||
>
|
)}
|
||||||
<Button
|
<div className="flex flex-col gap-1">
|
||||||
variant="ghost"
|
{backup.backupType === "compose" && (
|
||||||
size="icon"
|
<div className="flex items-center gap-2">
|
||||||
className="group hover:bg-red-500/10"
|
<h3 className="font-medium">
|
||||||
isLoading={isRemoving}
|
{backup.serviceName}
|
||||||
|
</h3>
|
||||||
|
<span className="px-1.5 py-0.5 rounded-full bg-muted text-xs font-medium capitalize">
|
||||||
|
{backup.databaseType}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"size-1.5 rounded-full",
|
||||||
|
backup.enabled
|
||||||
|
? "bg-green-500"
|
||||||
|
: "bg-red-500",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{backup.enabled ? "Active" : "Inactive"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-x-8 gap-y-2">
|
||||||
|
<div className="min-w-[200px]">
|
||||||
|
<span className="text-sm font-medium text-muted-foreground">
|
||||||
|
Destination
|
||||||
|
</span>
|
||||||
|
<p className="font-medium text-sm mt-0.5">
|
||||||
|
{backup.destination.name}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="min-w-[150px]">
|
||||||
|
<span className="text-sm font-medium text-muted-foreground">
|
||||||
|
Database
|
||||||
|
</span>
|
||||||
|
<p className="font-medium text-sm mt-0.5">
|
||||||
|
{backup.database}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="min-w-[120px]">
|
||||||
|
<span className="text-sm font-medium text-muted-foreground">
|
||||||
|
Schedule
|
||||||
|
</span>
|
||||||
|
<p className="font-medium text-sm mt-0.5">
|
||||||
|
{backup.schedule}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="min-w-[150px]">
|
||||||
|
<span className="text-sm font-medium text-muted-foreground">
|
||||||
|
Prefix Storage
|
||||||
|
</span>
|
||||||
|
<p className="font-medium text-sm mt-0.5">
|
||||||
|
{backup.prefix}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="min-w-[100px]">
|
||||||
|
<span className="text-sm font-medium text-muted-foreground">
|
||||||
|
Keep Latest
|
||||||
|
</span>
|
||||||
|
<p className="font-medium text-sm mt-0.5">
|
||||||
|
{backup.keepLatestCount || "All"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-row md:flex-col gap-1.5">
|
||||||
|
<ShowDeploymentsModal
|
||||||
|
id={backup.backupId}
|
||||||
|
type="backup"
|
||||||
|
serverId={serverId || undefined}
|
||||||
>
|
>
|
||||||
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
|
<Button
|
||||||
</Button>
|
variant="ghost"
|
||||||
</DialogAction>
|
size="icon"
|
||||||
|
className="size-8"
|
||||||
|
>
|
||||||
|
<ClipboardList className="size-4 transition-colors " />
|
||||||
|
</Button>
|
||||||
|
</ShowDeploymentsModal>
|
||||||
|
<TooltipProvider delayDuration={0}>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="size-8"
|
||||||
|
isLoading={
|
||||||
|
isManualBackup &&
|
||||||
|
activeManualBackup === backup.backupId
|
||||||
|
}
|
||||||
|
onClick={async () => {
|
||||||
|
setActiveManualBackup(backup.backupId);
|
||||||
|
await manualBackup({
|
||||||
|
backupId: backup.backupId as string,
|
||||||
|
})
|
||||||
|
.then(async () => {
|
||||||
|
toast.success(
|
||||||
|
"Manual Backup Successful",
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
toast.error(
|
||||||
|
"Error creating the manual backup",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
setActiveManualBackup(undefined);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Play className="size-4 " />
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
Run Manual Backup
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
|
||||||
|
<HandleBackup
|
||||||
|
backupType={backup.backupType}
|
||||||
|
backupId={backup.backupId}
|
||||||
|
refetch={refetch}
|
||||||
|
/>
|
||||||
|
<DialogAction
|
||||||
|
title="Delete Backup"
|
||||||
|
description="Are you sure you want to delete this backup?"
|
||||||
|
type="destructive"
|
||||||
|
onClick={async () => {
|
||||||
|
await deleteBackup({
|
||||||
|
backupId: backup.backupId,
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
refetch();
|
||||||
|
toast.success(
|
||||||
|
"Backup deleted successfully",
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
toast.error("Error deleting backup");
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="group hover:bg-red-500/10 size-8"
|
||||||
|
isLoading={isRemoving}
|
||||||
|
>
|
||||||
|
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
|
||||||
|
</Button>
|
||||||
|
</DialogAction>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
);
|
||||||
))}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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 } =
|
const { data, isLoading, refetch } =
|
||||||
api.organization.allInvitations.useQuery();
|
api.organization.allInvitations.useQuery();
|
||||||
|
|
||||||
|
const { mutateAsync: removeInvitation } =
|
||||||
|
api.organization.removeInvitation.useMutation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
<Card className="h-full bg-sidebar p-2.5 rounded-xl max-w-5xl mx-auto">
|
<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
|
Cancel Invitation
|
||||||
</DropdownMenuItem>
|
</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>
|
</DropdownMenuContent>
|
||||||
|
|||||||
10
apps/dokploy/drizzle/0089_noisy_sandman.sql
Normal file
10
apps/dokploy/drizzle/0089_noisy_sandman.sql
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
CREATE TYPE "public"."backupType" AS ENUM('database', 'compose');--> statement-breakpoint
|
||||||
|
ALTER TABLE "backup" ADD COLUMN "appName" text NOT NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE "backup" ADD COLUMN "serviceName" text;--> statement-breakpoint
|
||||||
|
ALTER TABLE "backup" ADD COLUMN "backupType" "backupType" DEFAULT 'database' NOT NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE "backup" ADD COLUMN "composeId" text;--> statement-breakpoint
|
||||||
|
ALTER TABLE "backup" ADD COLUMN "metadata" jsonb;--> statement-breakpoint
|
||||||
|
ALTER TABLE "deployment" ADD COLUMN "backupId" text;--> statement-breakpoint
|
||||||
|
ALTER TABLE "backup" ADD CONSTRAINT "backup_composeId_compose_composeId_fk" FOREIGN KEY ("composeId") REFERENCES "public"."compose"("composeId") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "deployment" ADD CONSTRAINT "deployment_backupId_backup_backupId_fk" FOREIGN KEY ("backupId") REFERENCES "public"."backup"("backupId") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "backup" ADD CONSTRAINT "backup_appName_unique" UNIQUE("appName");
|
||||||
5697
apps/dokploy/drizzle/meta/0089_snapshot.json
Normal file
5697
apps/dokploy/drizzle/meta/0089_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -624,6 +624,13 @@
|
|||||||
"when": 1746256928101,
|
"when": 1746256928101,
|
||||||
"tag": "0088_illegal_ma_gnuci",
|
"tag": "0088_illegal_ma_gnuci",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 89,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1746392564463,
|
||||||
|
"tag": "0089_noisy_sandman",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -80,6 +80,7 @@ import {
|
|||||||
Loader2,
|
Loader2,
|
||||||
PlusIcon,
|
PlusIcon,
|
||||||
Search,
|
Search,
|
||||||
|
ServerIcon,
|
||||||
Trash2,
|
Trash2,
|
||||||
X,
|
X,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
@@ -968,6 +969,11 @@ const Project = (
|
|||||||
}}
|
}}
|
||||||
className="flex flex-col group relative cursor-pointer bg-transparent transition-colors hover:bg-border"
|
className="flex flex-col group relative cursor-pointer bg-transparent transition-colors hover:bg-border"
|
||||||
>
|
>
|
||||||
|
{service.serverId && (
|
||||||
|
<div className="absolute -left-1 -top-2">
|
||||||
|
<ServerIcon className="size-4 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="absolute -right-1 -top-2">
|
<div className="absolute -right-1 -top-2">
|
||||||
<StatusTooltip status={service.status} />
|
<StatusTooltip status={service.status} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -318,9 +318,14 @@ const Service = (
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
<TabsContent value="deployments" className="w-full">
|
<TabsContent value="deployments" className="w-full pt-2.5">
|
||||||
<div className="flex flex-col gap-4 pt-2.5">
|
<div className="flex flex-col gap-4 border rounded-lg">
|
||||||
<ShowDeployments applicationId={applicationId} />
|
<ShowDeployments
|
||||||
|
id={applicationId}
|
||||||
|
type="application"
|
||||||
|
serverId={data?.serverId || ""}
|
||||||
|
refreshToken={data?.refreshToken || ""}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
<TabsContent value="preview-deployments" className="w-full">
|
<TabsContent value="preview-deployments" className="w-full">
|
||||||
@@ -330,7 +335,7 @@ const Service = (
|
|||||||
</TabsContent>
|
</TabsContent>
|
||||||
<TabsContent value="domains" className="w-full">
|
<TabsContent value="domains" className="w-full">
|
||||||
<div className="flex flex-col gap-4 pt-2.5">
|
<div className="flex flex-col gap-4 pt-2.5">
|
||||||
<ShowDomains applicationId={applicationId} />
|
<ShowDomains id={applicationId} type="application" />
|
||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
<TabsContent value="advanced">
|
<TabsContent value="advanced">
|
||||||
|
|||||||
@@ -1,15 +1,16 @@
|
|||||||
import { ShowImport } from "@/components/dashboard/application/advanced/import/show-import";
|
import { ShowImport } from "@/components/dashboard/application/advanced/import/show-import";
|
||||||
import { ShowVolumes } from "@/components/dashboard/application/advanced/volumes/show-volumes";
|
import { ShowVolumes } from "@/components/dashboard/application/advanced/volumes/show-volumes";
|
||||||
|
import { ShowDeployments } from "@/components/dashboard/application/deployments/show-deployments";
|
||||||
|
import { ShowDomains } from "@/components/dashboard/application/domains/show-domains";
|
||||||
import { ShowEnvironment } from "@/components/dashboard/application/environment/show-enviroment";
|
import { ShowEnvironment } from "@/components/dashboard/application/environment/show-enviroment";
|
||||||
import { ShowSchedules } from "@/components/dashboard/application/schedules/show-schedules";
|
import { ShowSchedules } from "@/components/dashboard/application/schedules/show-schedules";
|
||||||
import { AddCommandCompose } from "@/components/dashboard/compose/advanced/add-command";
|
import { AddCommandCompose } from "@/components/dashboard/compose/advanced/add-command";
|
||||||
import { DeleteService } from "@/components/dashboard/compose/delete-service";
|
import { DeleteService } from "@/components/dashboard/compose/delete-service";
|
||||||
import { ShowDeploymentsCompose } from "@/components/dashboard/compose/deployments/show-deployments-compose";
|
|
||||||
import { ShowDomainsCompose } from "@/components/dashboard/compose/domains/show-domains";
|
|
||||||
import { ShowGeneralCompose } from "@/components/dashboard/compose/general/show";
|
import { ShowGeneralCompose } from "@/components/dashboard/compose/general/show";
|
||||||
import { ShowDockerLogsCompose } from "@/components/dashboard/compose/logs/show";
|
import { ShowDockerLogsCompose } from "@/components/dashboard/compose/logs/show";
|
||||||
import { ShowDockerLogsStack } from "@/components/dashboard/compose/logs/show-stack";
|
import { ShowDockerLogsStack } from "@/components/dashboard/compose/logs/show-stack";
|
||||||
import { UpdateCompose } from "@/components/dashboard/compose/update-compose";
|
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 { ComposeFreeMonitoring } from "@/components/dashboard/monitoring/free/container/show-free-compose-monitoring";
|
||||||
import { ComposePaidMonitoring } from "@/components/dashboard/monitoring/paid/container/show-paid-compose-monitoring";
|
import { ComposePaidMonitoring } from "@/components/dashboard/monitoring/paid/container/show-paid-compose-monitoring";
|
||||||
import { ProjectLayout } from "@/components/layouts/project-layout";
|
import { ProjectLayout } from "@/components/layouts/project-layout";
|
||||||
@@ -218,18 +219,19 @@ const Service = (
|
|||||||
className={cn(
|
className={cn(
|
||||||
"lg:grid lg:w-fit max-md:overflow-y-scroll justify-start",
|
"lg:grid lg:w-fit max-md:overflow-y-scroll justify-start",
|
||||||
isCloud && data?.serverId
|
isCloud && data?.serverId
|
||||||
? "lg:grid-cols-8"
|
? "lg:grid-cols-9"
|
||||||
: data?.serverId
|
: data?.serverId
|
||||||
? "lg:grid-cols-7"
|
? "lg:grid-cols-8"
|
||||||
: "lg:grid-cols-8",
|
: "lg:grid-cols-9",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<TabsTrigger value="general">General</TabsTrigger>
|
<TabsTrigger value="general">General</TabsTrigger>
|
||||||
<TabsTrigger value="environment">Environment</TabsTrigger>
|
<TabsTrigger value="environment">Environment</TabsTrigger>
|
||||||
<TabsTrigger value="domains">Domains</TabsTrigger>
|
<TabsTrigger value="domains">Domains</TabsTrigger>
|
||||||
<TabsTrigger value="deployments">Deployments</TabsTrigger>
|
<TabsTrigger value="deployments">Deployments</TabsTrigger>
|
||||||
<TabsTrigger value="logs">Logs</TabsTrigger>
|
<TabsTrigger value="backups">Backups</TabsTrigger>
|
||||||
<TabsTrigger value="schedules">Schedules</TabsTrigger>
|
<TabsTrigger value="schedules">Schedules</TabsTrigger>
|
||||||
|
<TabsTrigger value="logs">Logs</TabsTrigger>
|
||||||
{((data?.serverId && isCloud) || !data?.server) && (
|
{((data?.serverId && isCloud) || !data?.server) && (
|
||||||
<TabsTrigger value="monitoring">Monitoring</TabsTrigger>
|
<TabsTrigger value="monitoring">Monitoring</TabsTrigger>
|
||||||
)}
|
)}
|
||||||
@@ -247,6 +249,11 @@ const Service = (
|
|||||||
<ShowEnvironment id={composeId} type="compose" />
|
<ShowEnvironment id={composeId} type="compose" />
|
||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
<TabsContent value="backups">
|
||||||
|
<div className="flex flex-col gap-4 pt-2.5">
|
||||||
|
<ShowBackups id={composeId} backupType="compose" />
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="schedules">
|
<TabsContent value="schedules">
|
||||||
<div className="flex flex-col gap-4 pt-2.5">
|
<div className="flex flex-col gap-4 pt-2.5">
|
||||||
@@ -326,13 +333,18 @@ const Service = (
|
|||||||
|
|
||||||
<TabsContent value="deployments">
|
<TabsContent value="deployments">
|
||||||
<div className="flex flex-col gap-4 pt-2.5">
|
<div className="flex flex-col gap-4 pt-2.5">
|
||||||
<ShowDeploymentsCompose composeId={composeId} />
|
<ShowDeployments
|
||||||
|
id={composeId}
|
||||||
|
type="compose"
|
||||||
|
serverId={data?.serverId || ""}
|
||||||
|
refreshToken={data?.refreshToken || ""}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="domains">
|
<TabsContent value="domains">
|
||||||
<div className="flex flex-col gap-4 pt-2.5">
|
<div className="flex flex-col gap-4 pt-2.5">
|
||||||
<ShowDomainsCompose composeId={composeId} />
|
<ShowDomains id={composeId} type="compose" />
|
||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
<TabsContent value="advanced">
|
<TabsContent value="advanced">
|
||||||
|
|||||||
@@ -271,7 +271,7 @@ const Mariadb = (
|
|||||||
</TabsContent>
|
</TabsContent>
|
||||||
<TabsContent value="backups">
|
<TabsContent value="backups">
|
||||||
<div className="flex flex-col gap-4 pt-2.5">
|
<div className="flex flex-col gap-4 pt-2.5">
|
||||||
<ShowBackups id={mariadbId} type="mariadb" />
|
<ShowBackups id={mariadbId} databaseType="mariadb" />
|
||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
<TabsContent value="advanced">
|
<TabsContent value="advanced">
|
||||||
|
|||||||
@@ -272,7 +272,11 @@ const Mongo = (
|
|||||||
</TabsContent>
|
</TabsContent>
|
||||||
<TabsContent value="backups">
|
<TabsContent value="backups">
|
||||||
<div className="flex flex-col gap-4 pt-2.5">
|
<div className="flex flex-col gap-4 pt-2.5">
|
||||||
<ShowBackups id={mongoId} type="mongo" />
|
<ShowBackups
|
||||||
|
id={mongoId}
|
||||||
|
databaseType="mongo"
|
||||||
|
backupType="database"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
<TabsContent value="advanced">
|
<TabsContent value="advanced">
|
||||||
|
|||||||
@@ -252,7 +252,11 @@ const MySql = (
|
|||||||
</TabsContent>
|
</TabsContent>
|
||||||
<TabsContent value="backups">
|
<TabsContent value="backups">
|
||||||
<div className="flex flex-col gap-4 pt-2.5">
|
<div className="flex flex-col gap-4 pt-2.5">
|
||||||
<ShowBackups id={mysqlId} type="mysql" />
|
<ShowBackups
|
||||||
|
id={mysqlId}
|
||||||
|
databaseType="mysql"
|
||||||
|
backupType="database"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
<TabsContent value="advanced">
|
<TabsContent value="advanced">
|
||||||
|
|||||||
@@ -251,7 +251,11 @@ const Postgresql = (
|
|||||||
</TabsContent>
|
</TabsContent>
|
||||||
<TabsContent value="backups">
|
<TabsContent value="backups">
|
||||||
<div className="flex flex-col gap-4 pt-2.5">
|
<div className="flex flex-col gap-4 pt-2.5">
|
||||||
<ShowBackups id={postgresId} type="postgres" />
|
<ShowBackups
|
||||||
|
id={postgresId}
|
||||||
|
databaseType="postgres"
|
||||||
|
backupType="database"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
<TabsContent value="advanced">
|
<TabsContent value="advanced">
|
||||||
|
|||||||
@@ -20,7 +20,11 @@ const Page = () => {
|
|||||||
<WebServer />
|
<WebServer />
|
||||||
<div className="w-full flex flex-col gap-4">
|
<div className="w-full flex flex-col gap-4">
|
||||||
<Card className="h-full bg-sidebar p-2.5 rounded-xl mx-auto w-full">
|
<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>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
apiCreateBackup,
|
apiCreateBackup,
|
||||||
apiFindOneBackup,
|
apiFindOneBackup,
|
||||||
apiRemoveBackup,
|
apiRemoveBackup,
|
||||||
|
apiRestoreBackup,
|
||||||
apiUpdateBackup,
|
apiUpdateBackup,
|
||||||
} from "@/server/db/schema";
|
} from "@/server/db/schema";
|
||||||
import { removeJob, schedule, updateJob } from "@/server/utils/backup";
|
import { removeJob, schedule, updateJob } from "@/server/utils/backup";
|
||||||
@@ -10,6 +11,8 @@ import {
|
|||||||
IS_CLOUD,
|
IS_CLOUD,
|
||||||
createBackup,
|
createBackup,
|
||||||
findBackupById,
|
findBackupById,
|
||||||
|
findComposeByBackupId,
|
||||||
|
findComposeById,
|
||||||
findMariadbByBackupId,
|
findMariadbByBackupId,
|
||||||
findMariadbById,
|
findMariadbById,
|
||||||
findMongoByBackupId,
|
findMongoByBackupId,
|
||||||
@@ -31,6 +34,7 @@ import {
|
|||||||
} from "@dokploy/server";
|
} from "@dokploy/server";
|
||||||
|
|
||||||
import { findDestinationById } from "@dokploy/server/services/destination";
|
import { findDestinationById } from "@dokploy/server/services/destination";
|
||||||
|
import { runComposeBackup } from "@dokploy/server/utils/backups/compose";
|
||||||
import {
|
import {
|
||||||
getS3Credentials,
|
getS3Credentials,
|
||||||
normalizeS3Path,
|
normalizeS3Path,
|
||||||
@@ -40,6 +44,7 @@ import {
|
|||||||
execAsyncRemote,
|
execAsyncRemote,
|
||||||
} from "@dokploy/server/utils/process/execAsync";
|
} from "@dokploy/server/utils/process/execAsync";
|
||||||
import {
|
import {
|
||||||
|
restoreComposeBackup,
|
||||||
restoreMariadbBackup,
|
restoreMariadbBackup,
|
||||||
restoreMongoBackup,
|
restoreMongoBackup,
|
||||||
restoreMySqlBackup,
|
restoreMySqlBackup,
|
||||||
@@ -82,6 +87,11 @@ export const backupRouter = createTRPCRouter({
|
|||||||
serverId = backup.mongo.serverId;
|
serverId = backup.mongo.serverId;
|
||||||
} else if (databaseType === "mariadb" && backup.mariadb?.serverId) {
|
} else if (databaseType === "mariadb" && backup.mariadb?.serverId) {
|
||||||
serverId = backup.mariadb.serverId;
|
serverId = backup.mariadb.serverId;
|
||||||
|
} else if (
|
||||||
|
backup.backupType === "compose" &&
|
||||||
|
backup.compose?.serverId
|
||||||
|
) {
|
||||||
|
serverId = backup.compose.serverId;
|
||||||
}
|
}
|
||||||
const server = await findServerById(serverId);
|
const server = await findServerById(serverId);
|
||||||
|
|
||||||
@@ -232,6 +242,22 @@ export const backupRouter = createTRPCRouter({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
|
manualBackupCompose: protectedProcedure
|
||||||
|
.input(apiFindOneBackup)
|
||||||
|
.mutation(async ({ input }) => {
|
||||||
|
try {
|
||||||
|
const backup = await findBackupById(input.backupId);
|
||||||
|
const compose = await findComposeByBackupId(backup.backupId);
|
||||||
|
await runComposeBackup(compose, backup);
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "BAD_REQUEST",
|
||||||
|
message: "Error running manual Compose backup ",
|
||||||
|
cause: error,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}),
|
||||||
manualBackupMongo: protectedProcedure
|
manualBackupMongo: protectedProcedure
|
||||||
.input(apiFindOneBackup)
|
.input(apiFindOneBackup)
|
||||||
.mutation(async ({ input }) => {
|
.mutation(async ({ input }) => {
|
||||||
@@ -341,88 +367,59 @@ export const backupRouter = createTRPCRouter({
|
|||||||
override: true,
|
override: true,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
.input(
|
.input(apiRestoreBackup)
|
||||||
z.object({
|
|
||||||
databaseId: z.string(),
|
|
||||||
databaseType: z.enum([
|
|
||||||
"postgres",
|
|
||||||
"mysql",
|
|
||||||
"mariadb",
|
|
||||||
"mongo",
|
|
||||||
"web-server",
|
|
||||||
]),
|
|
||||||
databaseName: z.string().min(1),
|
|
||||||
backupFile: z.string().min(1),
|
|
||||||
destinationId: z.string().min(1),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.subscription(async ({ input }) => {
|
.subscription(async ({ input }) => {
|
||||||
const destination = await findDestinationById(input.destinationId);
|
const destination = await findDestinationById(input.destinationId);
|
||||||
if (input.databaseType === "postgres") {
|
if (input.backupType === "database") {
|
||||||
const postgres = await findPostgresById(input.databaseId);
|
if (input.databaseType === "postgres") {
|
||||||
|
const postgres = await findPostgresById(input.databaseId);
|
||||||
|
|
||||||
return observable<string>((emit) => {
|
return observable<string>((emit) => {
|
||||||
restorePostgresBackup(
|
restorePostgresBackup(postgres, destination, input, (log) => {
|
||||||
postgres,
|
|
||||||
destination,
|
|
||||||
input.databaseName,
|
|
||||||
input.backupFile,
|
|
||||||
(log) => {
|
|
||||||
emit.next(log);
|
emit.next(log);
|
||||||
},
|
});
|
||||||
);
|
});
|
||||||
});
|
}
|
||||||
}
|
if (input.databaseType === "mysql") {
|
||||||
if (input.databaseType === "mysql") {
|
const mysql = await findMySqlById(input.databaseId);
|
||||||
const mysql = await findMySqlById(input.databaseId);
|
return observable<string>((emit) => {
|
||||||
return observable<string>((emit) => {
|
restoreMySqlBackup(mysql, destination, input, (log) => {
|
||||||
restoreMySqlBackup(
|
|
||||||
mysql,
|
|
||||||
destination,
|
|
||||||
input.databaseName,
|
|
||||||
input.backupFile,
|
|
||||||
(log) => {
|
|
||||||
emit.next(log);
|
emit.next(log);
|
||||||
},
|
});
|
||||||
);
|
});
|
||||||
});
|
}
|
||||||
}
|
if (input.databaseType === "mariadb") {
|
||||||
if (input.databaseType === "mariadb") {
|
const mariadb = await findMariadbById(input.databaseId);
|
||||||
const mariadb = await findMariadbById(input.databaseId);
|
return observable<string>((emit) => {
|
||||||
return observable<string>((emit) => {
|
restoreMariadbBackup(mariadb, destination, input, (log) => {
|
||||||
restoreMariadbBackup(
|
|
||||||
mariadb,
|
|
||||||
destination,
|
|
||||||
input.databaseName,
|
|
||||||
input.backupFile,
|
|
||||||
(log) => {
|
|
||||||
emit.next(log);
|
emit.next(log);
|
||||||
},
|
});
|
||||||
);
|
});
|
||||||
});
|
}
|
||||||
}
|
if (input.databaseType === "mongo") {
|
||||||
if (input.databaseType === "mongo") {
|
const mongo = await findMongoById(input.databaseId);
|
||||||
const mongo = await findMongoById(input.databaseId);
|
return observable<string>((emit) => {
|
||||||
return observable<string>((emit) => {
|
restoreMongoBackup(mongo, destination, input, (log) => {
|
||||||
restoreMongoBackup(
|
|
||||||
mongo,
|
|
||||||
destination,
|
|
||||||
input.databaseName,
|
|
||||||
input.backupFile,
|
|
||||||
(log) => {
|
|
||||||
emit.next(log);
|
emit.next(log);
|
||||||
},
|
});
|
||||||
);
|
});
|
||||||
});
|
}
|
||||||
|
if (input.databaseType === "web-server") {
|
||||||
|
return observable<string>((emit) => {
|
||||||
|
restoreWebServerBackup(destination, input.backupFile, (log) => {
|
||||||
|
emit.next(log);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (input.databaseType === "web-server") {
|
if (input.backupType === "compose") {
|
||||||
|
const compose = await findComposeById(input.databaseId);
|
||||||
return observable<string>((emit) => {
|
return observable<string>((emit) => {
|
||||||
restoreWebServerBackup(destination, input.backupFile, (log) => {
|
restoreComposeBackup(compose, destination, input, (log) => {
|
||||||
emit.next(log);
|
emit.next(log);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import {
|
|||||||
apiFindAllByApplication,
|
apiFindAllByApplication,
|
||||||
apiFindAllByCompose,
|
apiFindAllByCompose,
|
||||||
apiFindAllByServer,
|
apiFindAllByServer,
|
||||||
|
apiFindAllByType,
|
||||||
|
deployments,
|
||||||
} from "@/server/db/schema";
|
} from "@/server/db/schema";
|
||||||
import {
|
import {
|
||||||
findAllDeploymentsByApplicationId,
|
findAllDeploymentsByApplicationId,
|
||||||
@@ -12,7 +14,9 @@ import {
|
|||||||
findServerById,
|
findServerById,
|
||||||
} from "@dokploy/server";
|
} from "@dokploy/server";
|
||||||
import { TRPCError } from "@trpc/server";
|
import { TRPCError } from "@trpc/server";
|
||||||
|
import { desc, eq } from "drizzle-orm";
|
||||||
import { createTRPCRouter, protectedProcedure } from "../trpc";
|
import { createTRPCRouter, protectedProcedure } from "../trpc";
|
||||||
|
import { db } from "@/server/db";
|
||||||
|
|
||||||
export const deploymentRouter = createTRPCRouter({
|
export const deploymentRouter = createTRPCRouter({
|
||||||
all: protectedProcedure
|
all: protectedProcedure
|
||||||
@@ -54,4 +58,14 @@ export const deploymentRouter = createTRPCRouter({
|
|||||||
}
|
}
|
||||||
return await findAllDeploymentsByServerId(input.serverId);
|
return await findAllDeploymentsByServerId(input.serverId);
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
allByType: protectedProcedure
|
||||||
|
.input(apiFindAllByType)
|
||||||
|
.query(async ({ input }) => {
|
||||||
|
const deploymentsList = await db.query.deployments.findMany({
|
||||||
|
where: eq(deployments[`${input.type}Id`], input.id),
|
||||||
|
orderBy: desc(deployments.createdAt),
|
||||||
|
});
|
||||||
|
return deploymentsList;
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
removeDomain,
|
removeDomain,
|
||||||
removeDomainById,
|
removeDomainById,
|
||||||
updateDomainById,
|
updateDomainById,
|
||||||
|
validateDomain,
|
||||||
} from "@dokploy/server";
|
} from "@dokploy/server";
|
||||||
import { TRPCError } from "@trpc/server";
|
import { TRPCError } from "@trpc/server";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
@@ -56,7 +57,10 @@ export const domainRouter = createTRPCRouter({
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: "BAD_REQUEST",
|
code: "BAD_REQUEST",
|
||||||
message: "Error creating the domain",
|
message:
|
||||||
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: "Error creating the domain",
|
||||||
cause: error,
|
cause: error,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -224,4 +228,15 @@ export const domainRouter = createTRPCRouter({
|
|||||||
|
|
||||||
return result;
|
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)],
|
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));
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -103,6 +103,7 @@ export const userRouter = createTRPCRouter({
|
|||||||
backups: {
|
backups: {
|
||||||
with: {
|
with: {
|
||||||
destination: true,
|
destination: true,
|
||||||
|
deployments: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
apiKeys: true,
|
apiKeys: true,
|
||||||
|
|||||||
@@ -11,10 +11,11 @@ import {
|
|||||||
runMongoBackup,
|
runMongoBackup,
|
||||||
runMySqlBackup,
|
runMySqlBackup,
|
||||||
runPostgresBackup,
|
runPostgresBackup,
|
||||||
|
runComposeBackup,
|
||||||
} from "@dokploy/server";
|
} from "@dokploy/server";
|
||||||
import { db } from "@dokploy/server/dist/db";
|
import { db } from "@dokploy/server/dist/db";
|
||||||
import { backups, schedules, server } from "@dokploy/server/dist/db/schema";
|
import { backups, schedules, server } from "@dokploy/server/dist/db/schema";
|
||||||
import { eq } from "drizzle-orm";
|
import { and, eq } from "drizzle-orm";
|
||||||
import { logger } from "./logger.js";
|
import { logger } from "./logger.js";
|
||||||
import { scheduleJob } from "./queue.js";
|
import { scheduleJob } from "./queue.js";
|
||||||
import type { QueueJob } from "./schema.js";
|
import type { QueueJob } from "./schema.js";
|
||||||
@@ -24,40 +25,57 @@ export const runJobs = async (job: QueueJob) => {
|
|||||||
if (job.type === "backup") {
|
if (job.type === "backup") {
|
||||||
const { backupId } = job;
|
const { backupId } = job;
|
||||||
const backup = await findBackupById(backupId);
|
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) {
|
if (backupType === "database") {
|
||||||
const server = await findServerById(postgres.serverId as string);
|
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") {
|
if (server.serverStatus === "inactive") {
|
||||||
logger.info("Server is inactive");
|
logger.info("Server is inactive");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await runPostgresBackup(postgres, backup);
|
await runComposeBackup(compose, 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 (job.type === "server") {
|
} else if (job.type === "server") {
|
||||||
const { serverId } = job;
|
const { serverId } = job;
|
||||||
@@ -87,7 +105,10 @@ export const initializeJobs = async () => {
|
|||||||
logger.info("Setting up Jobs....");
|
logger.info("Setting up Jobs....");
|
||||||
|
|
||||||
const servers = await db.query.server.findMany({
|
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) {
|
for (const server of servers) {
|
||||||
@@ -99,7 +120,7 @@ export const initializeJobs = async () => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info({ Quantity: servers.length }, "Servers Initialized");
|
logger.info({ Quantity: servers.length }, "Active Servers Initialized");
|
||||||
|
|
||||||
const backupsResult = await db.query.backups.findMany({
|
const backupsResult = await db.query.backups.findMany({
|
||||||
where: eq(backups.enabled, true),
|
where: eq(backups.enabled, true),
|
||||||
@@ -108,6 +129,7 @@ export const initializeJobs = async () => {
|
|||||||
mysql: true,
|
mysql: true,
|
||||||
postgres: true,
|
postgres: true,
|
||||||
mongo: true,
|
mongo: true,
|
||||||
|
compose: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -122,14 +144,44 @@ export const initializeJobs = async () => {
|
|||||||
|
|
||||||
const schedulesResult = await db.query.schedules.findMany({
|
const schedulesResult = await db.query.schedules.findMany({
|
||||||
where: eq(schedules.enabled, true),
|
where: eq(schedules.enabled, true),
|
||||||
|
with: {
|
||||||
|
application: {
|
||||||
|
with: {
|
||||||
|
server: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
compose: {
|
||||||
|
with: {
|
||||||
|
server: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
server: true,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
for (const schedule of schedulesResult) {
|
const filteredSchedulesBasedOnServerStatus = schedulesResult.filter(
|
||||||
|
(schedule) => {
|
||||||
|
if (schedule.server) {
|
||||||
|
return schedule.server.serverStatus === "active";
|
||||||
|
}
|
||||||
|
if (schedule.application) {
|
||||||
|
return schedule.application.server?.serverStatus === "active";
|
||||||
|
}
|
||||||
|
if (schedule.compose) {
|
||||||
|
return schedule.compose.server?.serverStatus === "active";
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const schedule of filteredSchedulesBasedOnServerStatus) {
|
||||||
scheduleJob({
|
scheduleJob({
|
||||||
scheduleId: schedule.scheduleId,
|
scheduleId: schedule.scheduleId,
|
||||||
type: "schedule",
|
type: "schedule",
|
||||||
cronSchedule: schedule.cronExpression,
|
cronSchedule: schedule.cronExpression,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
logger.info({ Quantity: schedulesResult.length }, "Schedules Initialized");
|
logger.info(
|
||||||
|
{ Quantity: filteredSchedulesBasedOnServerStatus.length },
|
||||||
|
"Schedules Initialized",
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { runJobs } from "./utils.js";
|
|||||||
export const firstWorker = new Worker(
|
export const firstWorker = new Worker(
|
||||||
"backupQueue",
|
"backupQueue",
|
||||||
async (job: Job<QueueJob>) => {
|
async (job: Job<QueueJob>) => {
|
||||||
logger.info({ data: job.data }, "Job received");
|
logger.info({ data: job.data }, "Running job");
|
||||||
await runJobs(job.data);
|
await runJobs(job.data);
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -18,7 +18,7 @@ export const firstWorker = new Worker(
|
|||||||
export const secondWorker = new Worker(
|
export const secondWorker = new Worker(
|
||||||
"backupQueue",
|
"backupQueue",
|
||||||
async (job: Job<QueueJob>) => {
|
async (job: Job<QueueJob>) => {
|
||||||
logger.info({ data: job.data }, "Job received");
|
logger.info({ data: job.data }, "Running job");
|
||||||
await runJobs(job.data);
|
await runJobs(job.data);
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
type AnyPgColumn,
|
type AnyPgColumn,
|
||||||
boolean,
|
boolean,
|
||||||
integer,
|
integer,
|
||||||
|
jsonb,
|
||||||
pgEnum,
|
pgEnum,
|
||||||
pgTable,
|
pgTable,
|
||||||
text,
|
text,
|
||||||
@@ -16,6 +17,9 @@ import { mongo } from "./mongo";
|
|||||||
import { mysql } from "./mysql";
|
import { mysql } from "./mysql";
|
||||||
import { postgres } from "./postgres";
|
import { postgres } from "./postgres";
|
||||||
import { users_temp } from "./user";
|
import { users_temp } from "./user";
|
||||||
|
import { compose } from "./compose";
|
||||||
|
import { deployments } from "./deployment";
|
||||||
|
import { generateAppName } from ".";
|
||||||
export const databaseType = pgEnum("databaseType", [
|
export const databaseType = pgEnum("databaseType", [
|
||||||
"postgres",
|
"postgres",
|
||||||
"mariadb",
|
"mariadb",
|
||||||
@@ -24,23 +28,34 @@ export const databaseType = pgEnum("databaseType", [
|
|||||||
"web-server",
|
"web-server",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
export const backupType = pgEnum("backupType", ["database", "compose"]);
|
||||||
|
|
||||||
export const backups = pgTable("backup", {
|
export const backups = pgTable("backup", {
|
||||||
backupId: text("backupId")
|
backupId: text("backupId")
|
||||||
.notNull()
|
.notNull()
|
||||||
.primaryKey()
|
.primaryKey()
|
||||||
.$defaultFn(() => nanoid()),
|
.$defaultFn(() => nanoid()),
|
||||||
|
appName: text("appName")
|
||||||
|
.notNull()
|
||||||
|
.$defaultFn(() => generateAppName("backup"))
|
||||||
|
.unique(),
|
||||||
schedule: text("schedule").notNull(),
|
schedule: text("schedule").notNull(),
|
||||||
enabled: boolean("enabled"),
|
enabled: boolean("enabled"),
|
||||||
database: text("database").notNull(),
|
database: text("database").notNull(),
|
||||||
prefix: text("prefix").notNull(),
|
prefix: text("prefix").notNull(),
|
||||||
|
serviceName: text("serviceName"),
|
||||||
destinationId: text("destinationId")
|
destinationId: text("destinationId")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => destinations.destinationId, { onDelete: "cascade" }),
|
.references(() => destinations.destinationId, { onDelete: "cascade" }),
|
||||||
|
|
||||||
keepLatestCount: integer("keepLatestCount"),
|
keepLatestCount: integer("keepLatestCount"),
|
||||||
|
backupType: backupType("backupType").notNull().default("database"),
|
||||||
databaseType: databaseType("databaseType").notNull(),
|
databaseType: databaseType("databaseType").notNull(),
|
||||||
|
composeId: text("composeId").references(
|
||||||
|
(): AnyPgColumn => compose.composeId,
|
||||||
|
{
|
||||||
|
onDelete: "cascade",
|
||||||
|
},
|
||||||
|
),
|
||||||
postgresId: text("postgresId").references(
|
postgresId: text("postgresId").references(
|
||||||
(): AnyPgColumn => postgres.postgresId,
|
(): AnyPgColumn => postgres.postgresId,
|
||||||
{
|
{
|
||||||
@@ -60,9 +75,29 @@ export const backups = pgTable("backup", {
|
|||||||
onDelete: "cascade",
|
onDelete: "cascade",
|
||||||
}),
|
}),
|
||||||
userId: text("userId").references(() => users_temp.id),
|
userId: text("userId").references(() => users_temp.id),
|
||||||
|
// Only for compose backups
|
||||||
|
metadata: jsonb("metadata").$type<
|
||||||
|
| {
|
||||||
|
postgres?: {
|
||||||
|
databaseUser: string;
|
||||||
|
};
|
||||||
|
mariadb?: {
|
||||||
|
databaseUser: string;
|
||||||
|
databasePassword: string;
|
||||||
|
};
|
||||||
|
mongo?: {
|
||||||
|
databaseUser: string;
|
||||||
|
databasePassword: string;
|
||||||
|
};
|
||||||
|
mysql?: {
|
||||||
|
databaseRootPassword: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
| undefined
|
||||||
|
>(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const backupsRelations = relations(backups, ({ one }) => ({
|
export const backupsRelations = relations(backups, ({ one, many }) => ({
|
||||||
destination: one(destinations, {
|
destination: one(destinations, {
|
||||||
fields: [backups.destinationId],
|
fields: [backups.destinationId],
|
||||||
references: [destinations.destinationId],
|
references: [destinations.destinationId],
|
||||||
@@ -87,6 +122,11 @@ export const backupsRelations = relations(backups, ({ one }) => ({
|
|||||||
fields: [backups.userId],
|
fields: [backups.userId],
|
||||||
references: [users_temp.id],
|
references: [users_temp.id],
|
||||||
}),
|
}),
|
||||||
|
compose: one(compose, {
|
||||||
|
fields: [backups.composeId],
|
||||||
|
references: [compose.composeId],
|
||||||
|
}),
|
||||||
|
deployments: many(deployments),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const createSchema = createInsertSchema(backups, {
|
const createSchema = createInsertSchema(backups, {
|
||||||
@@ -103,6 +143,7 @@ const createSchema = createInsertSchema(backups, {
|
|||||||
mysqlId: z.string().optional(),
|
mysqlId: z.string().optional(),
|
||||||
mongoId: z.string().optional(),
|
mongoId: z.string().optional(),
|
||||||
userId: z.string().optional(),
|
userId: z.string().optional(),
|
||||||
|
metadata: z.any().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const apiCreateBackup = createSchema.pick({
|
export const apiCreateBackup = createSchema.pick({
|
||||||
@@ -118,6 +159,10 @@ export const apiCreateBackup = createSchema.pick({
|
|||||||
mongoId: true,
|
mongoId: true,
|
||||||
databaseType: true,
|
databaseType: true,
|
||||||
userId: true,
|
userId: true,
|
||||||
|
backupType: true,
|
||||||
|
composeId: true,
|
||||||
|
serviceName: true,
|
||||||
|
metadata: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const apiFindOneBackup = createSchema
|
export const apiFindOneBackup = createSchema
|
||||||
@@ -141,5 +186,44 @@ export const apiUpdateBackup = createSchema
|
|||||||
destinationId: true,
|
destinationId: true,
|
||||||
database: true,
|
database: true,
|
||||||
keepLatestCount: true,
|
keepLatestCount: true,
|
||||||
|
serviceName: true,
|
||||||
|
metadata: true,
|
||||||
|
databaseType: true,
|
||||||
})
|
})
|
||||||
.required();
|
.required();
|
||||||
|
|
||||||
|
export const apiRestoreBackup = z.object({
|
||||||
|
databaseId: z.string(),
|
||||||
|
databaseType: z.enum(["postgres", "mysql", "mariadb", "mongo", "web-server"]),
|
||||||
|
backupType: z.enum(["database", "compose"]),
|
||||||
|
databaseName: z.string().min(1),
|
||||||
|
backupFile: z.string().min(1),
|
||||||
|
destinationId: z.string().min(1),
|
||||||
|
metadata: z
|
||||||
|
.object({
|
||||||
|
serviceName: z.string().optional(),
|
||||||
|
postgres: z
|
||||||
|
.object({
|
||||||
|
databaseUser: z.string(),
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
|
mariadb: z
|
||||||
|
.object({
|
||||||
|
databaseUser: z.string(),
|
||||||
|
databasePassword: z.string(),
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
|
mongo: z
|
||||||
|
.object({
|
||||||
|
databaseUser: z.string(),
|
||||||
|
databasePassword: z.string(),
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
|
mysql: z
|
||||||
|
.object({
|
||||||
|
databaseRootPassword: z.string(),
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
|
});
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ import { server } from "./server";
|
|||||||
import { applicationStatus, triggerType } from "./shared";
|
import { applicationStatus, triggerType } from "./shared";
|
||||||
import { sshKeys } from "./ssh-key";
|
import { sshKeys } from "./ssh-key";
|
||||||
import { generateAppName } from "./utils";
|
import { generateAppName } from "./utils";
|
||||||
|
import { backups } from "./backups";
|
||||||
|
|
||||||
import { schedules } from "./schedule";
|
import { schedules } from "./schedule";
|
||||||
export const sourceTypeCompose = pgEnum("sourceTypeCompose", [
|
export const sourceTypeCompose = pgEnum("sourceTypeCompose", [
|
||||||
"git",
|
"git",
|
||||||
@@ -135,6 +137,7 @@ export const composeRelations = relations(compose, ({ one, many }) => ({
|
|||||||
fields: [compose.serverId],
|
fields: [compose.serverId],
|
||||||
references: [server.serverId],
|
references: [server.serverId],
|
||||||
}),
|
}),
|
||||||
|
backups: many(backups),
|
||||||
schedules: many(schedules),
|
schedules: many(schedules),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { compose } from "./compose";
|
|||||||
import { previewDeployments } from "./preview-deployments";
|
import { previewDeployments } from "./preview-deployments";
|
||||||
import { server } from "./server";
|
import { server } from "./server";
|
||||||
import { schedules } from "./schedule";
|
import { schedules } from "./schedule";
|
||||||
|
import { backups } from "./backups";
|
||||||
export const deploymentStatus = pgEnum("deploymentStatus", [
|
export const deploymentStatus = pgEnum("deploymentStatus", [
|
||||||
"running",
|
"running",
|
||||||
"done",
|
"done",
|
||||||
@@ -54,6 +55,9 @@ export const deployments = pgTable("deployment", {
|
|||||||
(): AnyPgColumn => schedules.scheduleId,
|
(): AnyPgColumn => schedules.scheduleId,
|
||||||
{ onDelete: "cascade" },
|
{ onDelete: "cascade" },
|
||||||
),
|
),
|
||||||
|
backupId: text("backupId").references((): AnyPgColumn => backups.backupId, {
|
||||||
|
onDelete: "cascade",
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const deploymentsRelations = relations(deployments, ({ one }) => ({
|
export const deploymentsRelations = relations(deployments, ({ one }) => ({
|
||||||
@@ -77,6 +81,10 @@ export const deploymentsRelations = relations(deployments, ({ one }) => ({
|
|||||||
fields: [deployments.scheduleId],
|
fields: [deployments.scheduleId],
|
||||||
references: [schedules.scheduleId],
|
references: [schedules.scheduleId],
|
||||||
}),
|
}),
|
||||||
|
backup: one(backups, {
|
||||||
|
fields: [deployments.backupId],
|
||||||
|
references: [backups.backupId],
|
||||||
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const schema = createInsertSchema(deployments, {
|
const schema = createInsertSchema(deployments, {
|
||||||
@@ -126,6 +134,18 @@ export const apiCreateDeploymentCompose = schema
|
|||||||
composeId: z.string().min(1),
|
composeId: z.string().min(1),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const apiCreateDeploymentBackup = schema
|
||||||
|
.pick({
|
||||||
|
title: true,
|
||||||
|
status: true,
|
||||||
|
logPath: true,
|
||||||
|
backupId: true,
|
||||||
|
description: true,
|
||||||
|
})
|
||||||
|
.extend({
|
||||||
|
backupId: z.string().min(1),
|
||||||
|
});
|
||||||
|
|
||||||
export const apiCreateDeploymentServer = schema
|
export const apiCreateDeploymentServer = schema
|
||||||
.pick({
|
.pick({
|
||||||
title: true,
|
title: true,
|
||||||
@@ -175,3 +195,17 @@ export const apiFindAllByServer = schema
|
|||||||
serverId: z.string().min(1),
|
serverId: z.string().min(1),
|
||||||
})
|
})
|
||||||
.required();
|
.required();
|
||||||
|
|
||||||
|
export const apiFindAllByType = z
|
||||||
|
.object({
|
||||||
|
id: z.string().min(1),
|
||||||
|
type: z.enum([
|
||||||
|
"application",
|
||||||
|
"compose",
|
||||||
|
"server",
|
||||||
|
"schedule",
|
||||||
|
"previewDeployment",
|
||||||
|
"backup",
|
||||||
|
]),
|
||||||
|
})
|
||||||
|
.required();
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ export * from "./utils/backups/mysql";
|
|||||||
export * from "./utils/backups/postgres";
|
export * from "./utils/backups/postgres";
|
||||||
export * from "./utils/backups/utils";
|
export * from "./utils/backups/utils";
|
||||||
export * from "./utils/backups/web-server";
|
export * from "./utils/backups/web-server";
|
||||||
|
export * from "./utils/backups/compose";
|
||||||
export * from "./templates/processors";
|
export * from "./templates/processors";
|
||||||
|
|
||||||
export * from "./utils/notifications/build-error";
|
export * from "./utils/notifications/build-error";
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ export const findBackupById = async (backupId: string) => {
|
|||||||
mariadb: true,
|
mariadb: true,
|
||||||
mongo: true,
|
mongo: true,
|
||||||
destination: true,
|
destination: true,
|
||||||
|
compose: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (!backup) {
|
if (!backup) {
|
||||||
|
|||||||
@@ -131,6 +131,12 @@ export const findComposeById = async (composeId: string) => {
|
|||||||
bitbucket: true,
|
bitbucket: true,
|
||||||
gitea: true,
|
gitea: true,
|
||||||
server: true,
|
server: true,
|
||||||
|
backups: {
|
||||||
|
with: {
|
||||||
|
destination: true,
|
||||||
|
deployments: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (!result) {
|
if (!result) {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { paths } from "@dokploy/server/constants";
|
|||||||
import { db } from "@dokploy/server/db";
|
import { db } from "@dokploy/server/db";
|
||||||
import {
|
import {
|
||||||
type apiCreateDeployment,
|
type apiCreateDeployment,
|
||||||
|
type apiCreateDeploymentBackup,
|
||||||
type apiCreateDeploymentCompose,
|
type apiCreateDeploymentCompose,
|
||||||
type apiCreateDeploymentPreview,
|
type apiCreateDeploymentPreview,
|
||||||
type apiCreateDeploymentSchedule,
|
type apiCreateDeploymentSchedule,
|
||||||
@@ -29,6 +30,7 @@ import {
|
|||||||
updatePreviewDeployment,
|
updatePreviewDeployment,
|
||||||
} from "./preview-deployment";
|
} from "./preview-deployment";
|
||||||
import { findScheduleById } from "./schedule";
|
import { findScheduleById } from "./schedule";
|
||||||
|
import { findBackupById } from "./backup";
|
||||||
|
|
||||||
export type Deployment = typeof deployments.$inferSelect;
|
export type Deployment = typeof deployments.$inferSelect;
|
||||||
|
|
||||||
@@ -284,6 +286,86 @@ echo "Initializing deployment" >> ${logFilePath};
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const createDeploymentBackup = async (
|
||||||
|
deployment: Omit<
|
||||||
|
typeof apiCreateDeploymentBackup._type,
|
||||||
|
"deploymentId" | "createdAt" | "status" | "logPath"
|
||||||
|
>,
|
||||||
|
) => {
|
||||||
|
const backup = await findBackupById(deployment.backupId);
|
||||||
|
|
||||||
|
let serverId: string | null | undefined;
|
||||||
|
if (backup.backupType === "database") {
|
||||||
|
serverId =
|
||||||
|
backup.postgres?.serverId ||
|
||||||
|
backup.mariadb?.serverId ||
|
||||||
|
backup.mysql?.serverId ||
|
||||||
|
backup.mongo?.serverId;
|
||||||
|
} else if (backup.backupType === "compose") {
|
||||||
|
serverId = backup.compose?.serverId;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await removeLastTenDeployments(deployment.backupId, "backup", serverId);
|
||||||
|
const { LOGS_PATH } = paths(!!serverId);
|
||||||
|
const formattedDateTime = format(new Date(), "yyyy-MM-dd:HH:mm:ss");
|
||||||
|
const fileName = `${backup.appName}-${formattedDateTime}.log`;
|
||||||
|
const logFilePath = path.join(LOGS_PATH, backup.appName, fileName);
|
||||||
|
|
||||||
|
if (serverId) {
|
||||||
|
const server = await findServerById(serverId);
|
||||||
|
|
||||||
|
const command = `
|
||||||
|
mkdir -p ${LOGS_PATH}/${backup.appName};
|
||||||
|
echo "Initializing backup\n" >> ${logFilePath};
|
||||||
|
`;
|
||||||
|
|
||||||
|
await execAsyncRemote(server.serverId, command);
|
||||||
|
} else {
|
||||||
|
await fsPromises.mkdir(path.join(LOGS_PATH, backup.appName), {
|
||||||
|
recursive: true,
|
||||||
|
});
|
||||||
|
await fsPromises.writeFile(logFilePath, "Initializing backup\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
const deploymentCreate = await db
|
||||||
|
.insert(deployments)
|
||||||
|
.values({
|
||||||
|
backupId: deployment.backupId,
|
||||||
|
title: deployment.title || "Backup",
|
||||||
|
description: deployment.description || "",
|
||||||
|
status: "running",
|
||||||
|
logPath: logFilePath,
|
||||||
|
startedAt: new Date().toISOString(),
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
if (deploymentCreate.length === 0 || !deploymentCreate[0]) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "BAD_REQUEST",
|
||||||
|
message: "Error creating the backup",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return deploymentCreate[0];
|
||||||
|
} catch (error) {
|
||||||
|
await db
|
||||||
|
.insert(deployments)
|
||||||
|
.values({
|
||||||
|
backupId: deployment.backupId,
|
||||||
|
title: deployment.title || "Backup",
|
||||||
|
status: "error",
|
||||||
|
logPath: "",
|
||||||
|
description: deployment.description || "",
|
||||||
|
errorMessage: `An error have occured: ${error instanceof Error ? error.message : error}`,
|
||||||
|
startedAt: new Date().toISOString(),
|
||||||
|
finishedAt: new Date().toISOString(),
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "BAD_REQUEST",
|
||||||
|
message: "Error creating the backup",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
export const createDeploymentSchedule = async (
|
export const createDeploymentSchedule = async (
|
||||||
deployment: Omit<
|
deployment: Omit<
|
||||||
typeof apiCreateDeploymentSchedule._type,
|
typeof apiCreateDeploymentSchedule._type,
|
||||||
@@ -388,7 +470,13 @@ export const removeDeploymentsByApplicationId = async (
|
|||||||
|
|
||||||
const getDeploymentsByType = async (
|
const getDeploymentsByType = async (
|
||||||
id: string,
|
id: string,
|
||||||
type: "application" | "compose" | "server" | "schedule" | "previewDeployment",
|
type:
|
||||||
|
| "application"
|
||||||
|
| "compose"
|
||||||
|
| "server"
|
||||||
|
| "schedule"
|
||||||
|
| "previewDeployment"
|
||||||
|
| "backup",
|
||||||
) => {
|
) => {
|
||||||
const deploymentList = await db.query.deployments.findMany({
|
const deploymentList = await db.query.deployments.findMany({
|
||||||
where: eq(deployments[`${type}Id`], id),
|
where: eq(deployments[`${type}Id`], id),
|
||||||
@@ -411,7 +499,13 @@ export const removeDeployments = async (application: Application) => {
|
|||||||
|
|
||||||
const removeLastTenDeployments = async (
|
const removeLastTenDeployments = async (
|
||||||
id: string,
|
id: string,
|
||||||
type: "application" | "compose" | "server" | "schedule" | "previewDeployment",
|
type:
|
||||||
|
| "application"
|
||||||
|
| "compose"
|
||||||
|
| "server"
|
||||||
|
| "schedule"
|
||||||
|
| "previewDeployment"
|
||||||
|
| "backup",
|
||||||
serverId?: string | null,
|
serverId?: string | null,
|
||||||
) => {
|
) => {
|
||||||
const deploymentList = await getDeploymentsByType(id, type);
|
const deploymentList = await getDeploymentsByType(id, type);
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import { type apiCreateDomain, domains } from "../db/schema";
|
|||||||
import { findUserById } from "./admin";
|
import { findUserById } from "./admin";
|
||||||
import { findApplicationById } from "./application";
|
import { findApplicationById } from "./application";
|
||||||
import { findServerById } from "./server";
|
import { findServerById } from "./server";
|
||||||
|
import dns from "node:dns";
|
||||||
|
import { promisify } from "node:util";
|
||||||
|
|
||||||
export type Domain = typeof domains.$inferSelect;
|
export type Domain = typeof domains.$inferSelect;
|
||||||
|
|
||||||
@@ -137,3 +139,84 @@ export const removeDomainById = async (domainId: string) => {
|
|||||||
export const getDomainHost = (domain: Domain) => {
|
export const getDomainHost = (domain: Domain) => {
|
||||||
return `${domain.https ? "https" : "http"}://${domain.host}`;
|
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",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ export const findMariadbById = async (mariadbId: string) => {
|
|||||||
backups: {
|
backups: {
|
||||||
with: {
|
with: {
|
||||||
destination: true,
|
destination: true,
|
||||||
|
deployments: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
import { db } from "@dokploy/server/db";
|
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 { buildAppName } from "@dokploy/server/db/schema";
|
||||||
import { generatePassword } from "@dokploy/server/templates";
|
import { generatePassword } from "@dokploy/server/templates";
|
||||||
import { buildMongo } from "@dokploy/server/utils/databases/mongo";
|
import { buildMongo } from "@dokploy/server/utils/databases/mongo";
|
||||||
@@ -55,6 +60,7 @@ export const findMongoById = async (mongoId: string) => {
|
|||||||
backups: {
|
backups: {
|
||||||
with: {
|
with: {
|
||||||
destination: true,
|
destination: true,
|
||||||
|
deployments: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -103,6 +109,25 @@ export const findMongoByBackupId = async (backupId: string) => {
|
|||||||
return result[0];
|
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) => {
|
export const removeMongoById = async (mongoId: string) => {
|
||||||
const result = await db
|
const result = await db
|
||||||
.delete(mongo)
|
.delete(mongo)
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ export const findMySqlById = async (mysqlId: string) => {
|
|||||||
backups: {
|
backups: {
|
||||||
with: {
|
with: {
|
||||||
destination: true,
|
destination: true,
|
||||||
|
deployments: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ export const findPostgresById = async (postgresId: string) => {
|
|||||||
backups: {
|
backups: {
|
||||||
with: {
|
with: {
|
||||||
destination: true,
|
destination: true,
|
||||||
|
deployments: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -40,6 +40,8 @@ export const getServiceImageDigest = async () => {
|
|||||||
"docker service inspect dokploy --format '{{.Spec.TaskTemplate.ContainerSpec.Image}}'",
|
"docker service inspect dokploy --format '{{.Spec.TaskTemplate.ContainerSpec.Image}}'",
|
||||||
);
|
);
|
||||||
|
|
||||||
|
console.log("stdout", stdout);
|
||||||
|
|
||||||
const currentDigest = stdout.trim().split("@")[1];
|
const currentDigest = stdout.trim().split("@")[1];
|
||||||
|
|
||||||
if (!currentDigest) {
|
if (!currentDigest) {
|
||||||
|
|||||||
68
packages/server/src/utils/backups/compose.ts
Normal file
68
packages/server/src/utils/backups/compose.ts
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import type { BackupSchedule } from "@dokploy/server/services/backup";
|
||||||
|
import type { Compose } from "@dokploy/server/services/compose";
|
||||||
|
import { findProjectById } from "@dokploy/server/services/project";
|
||||||
|
import { sendDatabaseBackupNotifications } from "../notifications/database-backup";
|
||||||
|
import { execAsync, execAsyncRemote } from "../process/execAsync";
|
||||||
|
import { getS3Credentials, normalizeS3Path, getBackupCommand } from "./utils";
|
||||||
|
import {
|
||||||
|
createDeploymentBackup,
|
||||||
|
updateDeploymentStatus,
|
||||||
|
} from "@dokploy/server/services/deployment";
|
||||||
|
|
||||||
|
export const runComposeBackup = async (
|
||||||
|
compose: Compose,
|
||||||
|
backup: BackupSchedule,
|
||||||
|
) => {
|
||||||
|
const { projectId, name } = compose;
|
||||||
|
const project = await findProjectById(projectId);
|
||||||
|
const { prefix } = backup;
|
||||||
|
const destination = backup.destination;
|
||||||
|
const backupFileName = `${new Date().toISOString()}.dump.gz`;
|
||||||
|
const bucketDestination = `${normalizeS3Path(prefix)}${backupFileName}`;
|
||||||
|
const deployment = await createDeploymentBackup({
|
||||||
|
backupId: backup.backupId,
|
||||||
|
title: "Compose Backup",
|
||||||
|
description: "Compose Backup",
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const rcloneFlags = getS3Credentials(destination);
|
||||||
|
const rcloneDestination = `:s3:${destination.bucket}/${bucketDestination}`;
|
||||||
|
const rcloneCommand = `rclone rcat ${rcloneFlags.join(" ")} "${rcloneDestination}"`;
|
||||||
|
|
||||||
|
const backupCommand = getBackupCommand(
|
||||||
|
backup,
|
||||||
|
rcloneCommand,
|
||||||
|
deployment.logPath,
|
||||||
|
);
|
||||||
|
if (compose.serverId) {
|
||||||
|
await execAsyncRemote(compose.serverId, backupCommand);
|
||||||
|
} else {
|
||||||
|
await execAsync(backupCommand);
|
||||||
|
}
|
||||||
|
|
||||||
|
await sendDatabaseBackupNotifications({
|
||||||
|
applicationName: name,
|
||||||
|
projectName: project.name,
|
||||||
|
databaseType: "mongodb",
|
||||||
|
type: "success",
|
||||||
|
organizationId: project.organizationId,
|
||||||
|
});
|
||||||
|
|
||||||
|
await updateDeploymentStatus(deployment.deploymentId, "done");
|
||||||
|
} catch (error) {
|
||||||
|
console.log(error);
|
||||||
|
await sendDatabaseBackupNotifications({
|
||||||
|
applicationName: name,
|
||||||
|
projectName: project.name,
|
||||||
|
databaseType: "mongodb",
|
||||||
|
type: "error",
|
||||||
|
// @ts-ignore
|
||||||
|
errorMessage: error?.message || "Error message not provided",
|
||||||
|
organizationId: project.organizationId,
|
||||||
|
});
|
||||||
|
|
||||||
|
await updateDeploymentStatus(deployment.deploymentId, "error");
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -70,6 +70,7 @@ export const initCronJobs = async () => {
|
|||||||
mysql: true,
|
mysql: true,
|
||||||
mongo: true,
|
mongo: true,
|
||||||
user: true,
|
user: true,
|
||||||
|
compose: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -77,10 +78,10 @@ export const initCronJobs = async () => {
|
|||||||
try {
|
try {
|
||||||
if (backup.enabled) {
|
if (backup.enabled) {
|
||||||
scheduleBackup(backup);
|
scheduleBackup(backup);
|
||||||
|
console.log(
|
||||||
|
`[Backup] ${backup.databaseType} Enabled with cron: [${backup.schedule}]`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
console.log(
|
|
||||||
`[Backup] ${backup.databaseType} Enabled with cron: [${backup.schedule}]`,
|
|
||||||
);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`[Backup] ${backup.databaseType} Error`, error);
|
console.error(`[Backup] ${backup.databaseType} Error`, error);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,46 +1,43 @@
|
|||||||
import type { BackupSchedule } from "@dokploy/server/services/backup";
|
import type { BackupSchedule } from "@dokploy/server/services/backup";
|
||||||
import type { Mariadb } from "@dokploy/server/services/mariadb";
|
import type { Mariadb } from "@dokploy/server/services/mariadb";
|
||||||
import { findProjectById } from "@dokploy/server/services/project";
|
import { findProjectById } from "@dokploy/server/services/project";
|
||||||
import {
|
|
||||||
getRemoteServiceContainer,
|
|
||||||
getServiceContainer,
|
|
||||||
} from "../docker/utils";
|
|
||||||
import { sendDatabaseBackupNotifications } from "../notifications/database-backup";
|
import { sendDatabaseBackupNotifications } from "../notifications/database-backup";
|
||||||
import { execAsync, execAsyncRemote } from "../process/execAsync";
|
import { execAsync, execAsyncRemote } from "../process/execAsync";
|
||||||
import { getS3Credentials, normalizeS3Path } from "./utils";
|
import { getBackupCommand, getS3Credentials, normalizeS3Path } from "./utils";
|
||||||
|
import {
|
||||||
|
createDeploymentBackup,
|
||||||
|
updateDeploymentStatus,
|
||||||
|
} from "@dokploy/server/services/deployment";
|
||||||
|
|
||||||
export const runMariadbBackup = async (
|
export const runMariadbBackup = async (
|
||||||
mariadb: Mariadb,
|
mariadb: Mariadb,
|
||||||
backup: BackupSchedule,
|
backup: BackupSchedule,
|
||||||
) => {
|
) => {
|
||||||
const { appName, databasePassword, databaseUser, projectId, name } = mariadb;
|
const { projectId, name } = mariadb;
|
||||||
const project = await findProjectById(projectId);
|
const project = await findProjectById(projectId);
|
||||||
const { prefix, database } = backup;
|
const { prefix } = backup;
|
||||||
const destination = backup.destination;
|
const destination = backup.destination;
|
||||||
const backupFileName = `${new Date().toISOString()}.sql.gz`;
|
const backupFileName = `${new Date().toISOString()}.sql.gz`;
|
||||||
const bucketDestination = `${normalizeS3Path(prefix)}${backupFileName}`;
|
const bucketDestination = `${normalizeS3Path(prefix)}${backupFileName}`;
|
||||||
|
const deployment = await createDeploymentBackup({
|
||||||
|
backupId: backup.backupId,
|
||||||
|
title: "MariaDB Backup",
|
||||||
|
description: "MariaDB Backup",
|
||||||
|
});
|
||||||
try {
|
try {
|
||||||
const rcloneFlags = getS3Credentials(destination);
|
const rcloneFlags = getS3Credentials(destination);
|
||||||
const rcloneDestination = `:s3:${destination.bucket}/${bucketDestination}`;
|
const rcloneDestination = `:s3:${destination.bucket}/${bucketDestination}`;
|
||||||
|
|
||||||
const rcloneCommand = `rclone rcat ${rcloneFlags.join(" ")} "${rcloneDestination}"`;
|
const rcloneCommand = `rclone rcat ${rcloneFlags.join(" ")} "${rcloneDestination}"`;
|
||||||
|
|
||||||
|
const backupCommand = getBackupCommand(
|
||||||
|
backup,
|
||||||
|
rcloneCommand,
|
||||||
|
deployment.logPath,
|
||||||
|
);
|
||||||
if (mariadb.serverId) {
|
if (mariadb.serverId) {
|
||||||
const { Id: containerId } = await getRemoteServiceContainer(
|
await execAsyncRemote(mariadb.serverId, backupCommand);
|
||||||
mariadb.serverId,
|
|
||||||
appName,
|
|
||||||
);
|
|
||||||
const mariadbDumpCommand = `docker exec ${containerId} sh -c "mariadb-dump --user='${databaseUser}' --password='${databasePassword}' --databases ${database} | gzip"`;
|
|
||||||
|
|
||||||
await execAsyncRemote(
|
|
||||||
mariadb.serverId,
|
|
||||||
`${mariadbDumpCommand} | ${rcloneCommand}`,
|
|
||||||
);
|
|
||||||
} else {
|
} else {
|
||||||
const { Id: containerId } = await getServiceContainer(appName);
|
await execAsync(backupCommand);
|
||||||
const mariadbDumpCommand = `docker exec ${containerId} sh -c "mariadb-dump --user='${databaseUser}' --password='${databasePassword}' --databases ${database} | gzip"`;
|
|
||||||
|
|
||||||
await execAsync(`${mariadbDumpCommand} | ${rcloneCommand}`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await sendDatabaseBackupNotifications({
|
await sendDatabaseBackupNotifications({
|
||||||
@@ -50,6 +47,7 @@ export const runMariadbBackup = async (
|
|||||||
type: "success",
|
type: "success",
|
||||||
organizationId: project.organizationId,
|
organizationId: project.organizationId,
|
||||||
});
|
});
|
||||||
|
await updateDeploymentStatus(deployment.deploymentId, "done");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log(error);
|
console.log(error);
|
||||||
await sendDatabaseBackupNotifications({
|
await sendDatabaseBackupNotifications({
|
||||||
@@ -61,6 +59,7 @@ export const runMariadbBackup = async (
|
|||||||
errorMessage: error?.message || "Error message not provided",
|
errorMessage: error?.message || "Error message not provided",
|
||||||
organizationId: project.organizationId,
|
organizationId: project.organizationId,
|
||||||
});
|
});
|
||||||
|
await updateDeploymentStatus(deployment.deploymentId, "error");
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,43 +1,41 @@
|
|||||||
import type { BackupSchedule } from "@dokploy/server/services/backup";
|
import type { BackupSchedule } from "@dokploy/server/services/backup";
|
||||||
import type { Mongo } from "@dokploy/server/services/mongo";
|
import type { Mongo } from "@dokploy/server/services/mongo";
|
||||||
import { findProjectById } from "@dokploy/server/services/project";
|
import { findProjectById } from "@dokploy/server/services/project";
|
||||||
import {
|
|
||||||
getRemoteServiceContainer,
|
|
||||||
getServiceContainer,
|
|
||||||
} from "../docker/utils";
|
|
||||||
import { sendDatabaseBackupNotifications } from "../notifications/database-backup";
|
import { sendDatabaseBackupNotifications } from "../notifications/database-backup";
|
||||||
import { execAsync, execAsyncRemote } from "../process/execAsync";
|
import { execAsync, execAsyncRemote } from "../process/execAsync";
|
||||||
import { getS3Credentials, normalizeS3Path } from "./utils";
|
import { getBackupCommand, getS3Credentials, normalizeS3Path } from "./utils";
|
||||||
|
import {
|
||||||
|
createDeploymentBackup,
|
||||||
|
updateDeploymentStatus,
|
||||||
|
} from "@dokploy/server/services/deployment";
|
||||||
|
|
||||||
// mongodb://mongo:Bqh7AQl-PRbnBu@localhost:27017/?tls=false&directConnection=true
|
|
||||||
export const runMongoBackup = async (mongo: Mongo, backup: BackupSchedule) => {
|
export const runMongoBackup = async (mongo: Mongo, backup: BackupSchedule) => {
|
||||||
const { appName, databasePassword, databaseUser, projectId, name } = mongo;
|
const { projectId, name } = mongo;
|
||||||
const project = await findProjectById(projectId);
|
const project = await findProjectById(projectId);
|
||||||
const { prefix, database } = backup;
|
const { prefix } = backup;
|
||||||
const destination = backup.destination;
|
const destination = backup.destination;
|
||||||
const backupFileName = `${new Date().toISOString()}.dump.gz`;
|
const backupFileName = `${new Date().toISOString()}.dump.gz`;
|
||||||
const bucketDestination = `${normalizeS3Path(prefix)}${backupFileName}`;
|
const bucketDestination = `${normalizeS3Path(prefix)}${backupFileName}`;
|
||||||
|
const deployment = await createDeploymentBackup({
|
||||||
|
backupId: backup.backupId,
|
||||||
|
title: "MongoDB Backup",
|
||||||
|
description: "MongoDB Backup",
|
||||||
|
});
|
||||||
try {
|
try {
|
||||||
const rcloneFlags = getS3Credentials(destination);
|
const rcloneFlags = getS3Credentials(destination);
|
||||||
const rcloneDestination = `:s3:${destination.bucket}/${bucketDestination}`;
|
const rcloneDestination = `:s3:${destination.bucket}/${bucketDestination}`;
|
||||||
|
|
||||||
const rcloneCommand = `rclone rcat ${rcloneFlags.join(" ")} "${rcloneDestination}"`;
|
const rcloneCommand = `rclone rcat ${rcloneFlags.join(" ")} "${rcloneDestination}"`;
|
||||||
if (mongo.serverId) {
|
|
||||||
const { Id: containerId } = await getRemoteServiceContainer(
|
|
||||||
mongo.serverId,
|
|
||||||
appName,
|
|
||||||
);
|
|
||||||
const mongoDumpCommand = `docker exec ${containerId} sh -c "mongodump -d '${database}' -u '${databaseUser}' -p '${databasePassword}' --archive --authenticationDatabase=admin --gzip"`;
|
|
||||||
|
|
||||||
await execAsyncRemote(
|
const backupCommand = getBackupCommand(
|
||||||
mongo.serverId,
|
backup,
|
||||||
`${mongoDumpCommand} | ${rcloneCommand}`,
|
rcloneCommand,
|
||||||
);
|
deployment.logPath,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (mongo.serverId) {
|
||||||
|
await execAsyncRemote(mongo.serverId, backupCommand);
|
||||||
} else {
|
} else {
|
||||||
const { Id: containerId } = await getServiceContainer(appName);
|
await execAsync(backupCommand);
|
||||||
const mongoDumpCommand = `docker exec ${containerId} sh -c "mongodump -d '${database}' -u '${databaseUser}' -p '${databasePassword}' --archive --authenticationDatabase=admin --gzip"`;
|
|
||||||
await execAsync(`${mongoDumpCommand} | ${rcloneCommand}`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await sendDatabaseBackupNotifications({
|
await sendDatabaseBackupNotifications({
|
||||||
@@ -47,6 +45,7 @@ export const runMongoBackup = async (mongo: Mongo, backup: BackupSchedule) => {
|
|||||||
type: "success",
|
type: "success",
|
||||||
organizationId: project.organizationId,
|
organizationId: project.organizationId,
|
||||||
});
|
});
|
||||||
|
await updateDeploymentStatus(deployment.deploymentId, "done");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log(error);
|
console.log(error);
|
||||||
await sendDatabaseBackupNotifications({
|
await sendDatabaseBackupNotifications({
|
||||||
@@ -58,7 +57,7 @@ export const runMongoBackup = async (mongo: Mongo, backup: BackupSchedule) => {
|
|||||||
errorMessage: error?.message || "Error message not provided",
|
errorMessage: error?.message || "Error message not provided",
|
||||||
organizationId: project.organizationId,
|
organizationId: project.organizationId,
|
||||||
});
|
});
|
||||||
|
await updateDeploymentStatus(deployment.deploymentId, "error");
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
// mongorestore -d monguito -u mongo -p Bqh7AQl-PRbnBu --authenticationDatabase admin --gzip --archive=2024-04-13T05:03:58.937Z.dump.gz
|
|
||||||
|
|||||||
@@ -1,43 +1,43 @@
|
|||||||
import type { BackupSchedule } from "@dokploy/server/services/backup";
|
import type { BackupSchedule } from "@dokploy/server/services/backup";
|
||||||
import type { MySql } from "@dokploy/server/services/mysql";
|
import type { MySql } from "@dokploy/server/services/mysql";
|
||||||
import { findProjectById } from "@dokploy/server/services/project";
|
import { findProjectById } from "@dokploy/server/services/project";
|
||||||
import {
|
|
||||||
getRemoteServiceContainer,
|
|
||||||
getServiceContainer,
|
|
||||||
} from "../docker/utils";
|
|
||||||
import { sendDatabaseBackupNotifications } from "../notifications/database-backup";
|
import { sendDatabaseBackupNotifications } from "../notifications/database-backup";
|
||||||
import { execAsync, execAsyncRemote } from "../process/execAsync";
|
import { execAsync, execAsyncRemote } from "../process/execAsync";
|
||||||
import { getS3Credentials, normalizeS3Path } from "./utils";
|
import { getBackupCommand, getS3Credentials, normalizeS3Path } from "./utils";
|
||||||
|
import {
|
||||||
|
createDeploymentBackup,
|
||||||
|
updateDeploymentStatus,
|
||||||
|
} from "@dokploy/server/services/deployment";
|
||||||
|
|
||||||
export const runMySqlBackup = async (mysql: MySql, backup: BackupSchedule) => {
|
export const runMySqlBackup = async (mysql: MySql, backup: BackupSchedule) => {
|
||||||
const { appName, databaseRootPassword, projectId, name } = mysql;
|
const { projectId, name } = mysql;
|
||||||
const project = await findProjectById(projectId);
|
const project = await findProjectById(projectId);
|
||||||
const { prefix, database } = backup;
|
const { prefix } = backup;
|
||||||
const destination = backup.destination;
|
const destination = backup.destination;
|
||||||
const backupFileName = `${new Date().toISOString()}.sql.gz`;
|
const backupFileName = `${new Date().toISOString()}.sql.gz`;
|
||||||
const bucketDestination = `${normalizeS3Path(prefix)}${backupFileName}`;
|
const bucketDestination = `${normalizeS3Path(prefix)}${backupFileName}`;
|
||||||
|
const deployment = await createDeploymentBackup({
|
||||||
|
backupId: backup.backupId,
|
||||||
|
title: "MySQL Backup",
|
||||||
|
description: "MySQL Backup",
|
||||||
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const rcloneFlags = getS3Credentials(destination);
|
const rcloneFlags = getS3Credentials(destination);
|
||||||
const rcloneDestination = `:s3:${destination.bucket}/${bucketDestination}`;
|
const rcloneDestination = `:s3:${destination.bucket}/${bucketDestination}`;
|
||||||
|
|
||||||
const rcloneCommand = `rclone rcat ${rcloneFlags.join(" ")} "${rcloneDestination}"`;
|
const rcloneCommand = `rclone rcat ${rcloneFlags.join(" ")} "${rcloneDestination}"`;
|
||||||
|
|
||||||
|
const backupCommand = getBackupCommand(
|
||||||
|
backup,
|
||||||
|
rcloneCommand,
|
||||||
|
deployment.logPath,
|
||||||
|
);
|
||||||
|
|
||||||
if (mysql.serverId) {
|
if (mysql.serverId) {
|
||||||
const { Id: containerId } = await getRemoteServiceContainer(
|
await execAsyncRemote(mysql.serverId, backupCommand);
|
||||||
mysql.serverId,
|
|
||||||
appName,
|
|
||||||
);
|
|
||||||
const mysqlDumpCommand = `docker exec ${containerId} sh -c "mysqldump --default-character-set=utf8mb4 -u 'root' --password='${databaseRootPassword}' --single-transaction --no-tablespaces --quick '${database}' | gzip"`;
|
|
||||||
|
|
||||||
await execAsyncRemote(
|
|
||||||
mysql.serverId,
|
|
||||||
`${mysqlDumpCommand} | ${rcloneCommand}`,
|
|
||||||
);
|
|
||||||
} else {
|
} else {
|
||||||
const { Id: containerId } = await getServiceContainer(appName);
|
await execAsync(backupCommand);
|
||||||
const mysqlDumpCommand = `docker exec ${containerId} sh -c "mysqldump --default-character-set=utf8mb4 -u 'root' --password='${databaseRootPassword}' --single-transaction --no-tablespaces --quick '${database}' | gzip"`;
|
|
||||||
|
|
||||||
await execAsync(`${mysqlDumpCommand} | ${rcloneCommand}`);
|
|
||||||
}
|
}
|
||||||
await sendDatabaseBackupNotifications({
|
await sendDatabaseBackupNotifications({
|
||||||
applicationName: name,
|
applicationName: name,
|
||||||
@@ -46,6 +46,7 @@ export const runMySqlBackup = async (mysql: MySql, backup: BackupSchedule) => {
|
|||||||
type: "success",
|
type: "success",
|
||||||
organizationId: project.organizationId,
|
organizationId: project.organizationId,
|
||||||
});
|
});
|
||||||
|
await updateDeploymentStatus(deployment.deploymentId, "done");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log(error);
|
console.log(error);
|
||||||
await sendDatabaseBackupNotifications({
|
await sendDatabaseBackupNotifications({
|
||||||
@@ -57,6 +58,7 @@ export const runMySqlBackup = async (mysql: MySql, backup: BackupSchedule) => {
|
|||||||
errorMessage: error?.message || "Error message not provided",
|
errorMessage: error?.message || "Error message not provided",
|
||||||
organizationId: project.organizationId,
|
organizationId: project.organizationId,
|
||||||
});
|
});
|
||||||
|
await updateDeploymentStatus(deployment.deploymentId, "error");
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,22 +1,27 @@
|
|||||||
import type { BackupSchedule } from "@dokploy/server/services/backup";
|
import type { BackupSchedule } from "@dokploy/server/services/backup";
|
||||||
import type { Postgres } from "@dokploy/server/services/postgres";
|
import type { Postgres } from "@dokploy/server/services/postgres";
|
||||||
import { findProjectById } from "@dokploy/server/services/project";
|
import { findProjectById } from "@dokploy/server/services/project";
|
||||||
import {
|
|
||||||
getRemoteServiceContainer,
|
|
||||||
getServiceContainer,
|
|
||||||
} from "../docker/utils";
|
|
||||||
import { sendDatabaseBackupNotifications } from "../notifications/database-backup";
|
import { sendDatabaseBackupNotifications } from "../notifications/database-backup";
|
||||||
import { execAsync, execAsyncRemote } from "../process/execAsync";
|
import { execAsync, execAsyncRemote } from "../process/execAsync";
|
||||||
import { getS3Credentials, normalizeS3Path } from "./utils";
|
import { getBackupCommand, getS3Credentials, normalizeS3Path } from "./utils";
|
||||||
|
import {
|
||||||
|
createDeploymentBackup,
|
||||||
|
updateDeploymentStatus,
|
||||||
|
} from "@dokploy/server/services/deployment";
|
||||||
|
|
||||||
export const runPostgresBackup = async (
|
export const runPostgresBackup = async (
|
||||||
postgres: Postgres,
|
postgres: Postgres,
|
||||||
backup: BackupSchedule,
|
backup: BackupSchedule,
|
||||||
) => {
|
) => {
|
||||||
const { appName, databaseUser, name, projectId } = postgres;
|
const { name, projectId } = postgres;
|
||||||
const project = await findProjectById(projectId);
|
const project = await findProjectById(projectId);
|
||||||
|
|
||||||
const { prefix, database } = backup;
|
const deployment = await createDeploymentBackup({
|
||||||
|
backupId: backup.backupId,
|
||||||
|
title: "Initializing Backup",
|
||||||
|
description: "Initializing Backup",
|
||||||
|
});
|
||||||
|
const { prefix } = backup;
|
||||||
const destination = backup.destination;
|
const destination = backup.destination;
|
||||||
const backupFileName = `${new Date().toISOString()}.sql.gz`;
|
const backupFileName = `${new Date().toISOString()}.sql.gz`;
|
||||||
const bucketDestination = `${normalizeS3Path(prefix)}${backupFileName}`;
|
const bucketDestination = `${normalizeS3Path(prefix)}${backupFileName}`;
|
||||||
@@ -25,22 +30,16 @@ export const runPostgresBackup = async (
|
|||||||
const rcloneDestination = `:s3:${destination.bucket}/${bucketDestination}`;
|
const rcloneDestination = `:s3:${destination.bucket}/${bucketDestination}`;
|
||||||
|
|
||||||
const rcloneCommand = `rclone rcat ${rcloneFlags.join(" ")} "${rcloneDestination}"`;
|
const rcloneCommand = `rclone rcat ${rcloneFlags.join(" ")} "${rcloneDestination}"`;
|
||||||
|
|
||||||
|
const backupCommand = getBackupCommand(
|
||||||
|
backup,
|
||||||
|
rcloneCommand,
|
||||||
|
deployment.logPath,
|
||||||
|
);
|
||||||
if (postgres.serverId) {
|
if (postgres.serverId) {
|
||||||
const { Id: containerId } = await getRemoteServiceContainer(
|
await execAsyncRemote(postgres.serverId, backupCommand);
|
||||||
postgres.serverId,
|
|
||||||
appName,
|
|
||||||
);
|
|
||||||
const pgDumpCommand = `docker exec ${containerId} sh -c "pg_dump -Fc --no-acl --no-owner -h localhost -U ${databaseUser} --no-password '${database}' | gzip"`;
|
|
||||||
|
|
||||||
await execAsyncRemote(
|
|
||||||
postgres.serverId,
|
|
||||||
`${pgDumpCommand} | ${rcloneCommand}`,
|
|
||||||
);
|
|
||||||
} else {
|
} else {
|
||||||
const { Id: containerId } = await getServiceContainer(appName);
|
await execAsync(backupCommand);
|
||||||
|
|
||||||
const pgDumpCommand = `docker exec ${containerId} sh -c "pg_dump -Fc --no-acl --no-owner -h localhost -U ${databaseUser} --no-password '${database}' | gzip"`;
|
|
||||||
await execAsync(`${pgDumpCommand} | ${rcloneCommand}`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await sendDatabaseBackupNotifications({
|
await sendDatabaseBackupNotifications({
|
||||||
@@ -50,6 +49,8 @@ export const runPostgresBackup = async (
|
|||||||
type: "success",
|
type: "success",
|
||||||
organizationId: project.organizationId,
|
organizationId: project.organizationId,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await updateDeploymentStatus(deployment.deploymentId, "done");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await sendDatabaseBackupNotifications({
|
await sendDatabaseBackupNotifications({
|
||||||
applicationName: name,
|
applicationName: name,
|
||||||
@@ -61,10 +62,9 @@ export const runPostgresBackup = async (
|
|||||||
organizationId: project.organizationId,
|
organizationId: project.organizationId,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await updateDeploymentStatus(deployment.deploymentId, "error");
|
||||||
|
|
||||||
throw error;
|
throw error;
|
||||||
} finally {
|
} finally {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Restore
|
|
||||||
// /Applications/pgAdmin 4.app/Contents/SharedSupport/pg_restore --host "localhost" --port "5432" --username "mauricio" --no-password --dbname "postgres" --verbose "/Users/mauricio/Downloads/_databases_2024-04-12T07_02_05.234Z.sql"
|
|
||||||
|
|||||||
@@ -7,26 +7,40 @@ import { runMongoBackup } from "./mongo";
|
|||||||
import { runMySqlBackup } from "./mysql";
|
import { runMySqlBackup } from "./mysql";
|
||||||
import { runPostgresBackup } from "./postgres";
|
import { runPostgresBackup } from "./postgres";
|
||||||
import { runWebServerBackup } from "./web-server";
|
import { runWebServerBackup } from "./web-server";
|
||||||
|
import { runComposeBackup } from "./compose";
|
||||||
|
|
||||||
export const scheduleBackup = (backup: BackupSchedule) => {
|
export const scheduleBackup = (backup: BackupSchedule) => {
|
||||||
const { schedule, backupId, databaseType, postgres, mysql, mongo, mariadb } =
|
const {
|
||||||
backup;
|
schedule,
|
||||||
|
backupId,
|
||||||
|
databaseType,
|
||||||
|
postgres,
|
||||||
|
mysql,
|
||||||
|
mongo,
|
||||||
|
mariadb,
|
||||||
|
compose,
|
||||||
|
} = backup;
|
||||||
scheduleJob(backupId, schedule, async () => {
|
scheduleJob(backupId, schedule, async () => {
|
||||||
if (databaseType === "postgres" && postgres) {
|
if (backup.backupType === "database") {
|
||||||
await runPostgresBackup(postgres, backup);
|
if (databaseType === "postgres" && postgres) {
|
||||||
await keepLatestNBackups(backup, postgres.serverId);
|
await runPostgresBackup(postgres, backup);
|
||||||
} else if (databaseType === "mysql" && mysql) {
|
await keepLatestNBackups(backup, postgres.serverId);
|
||||||
await runMySqlBackup(mysql, backup);
|
} else if (databaseType === "mysql" && mysql) {
|
||||||
await keepLatestNBackups(backup, mysql.serverId);
|
await runMySqlBackup(mysql, backup);
|
||||||
} else if (databaseType === "mongo" && mongo) {
|
await keepLatestNBackups(backup, mysql.serverId);
|
||||||
await runMongoBackup(mongo, backup);
|
} else if (databaseType === "mongo" && mongo) {
|
||||||
await keepLatestNBackups(backup, mongo.serverId);
|
await runMongoBackup(mongo, backup);
|
||||||
} else if (databaseType === "mariadb" && mariadb) {
|
await keepLatestNBackups(backup, mongo.serverId);
|
||||||
await runMariadbBackup(mariadb, backup);
|
} else if (databaseType === "mariadb" && mariadb) {
|
||||||
await keepLatestNBackups(backup, mariadb.serverId);
|
await runMariadbBackup(mariadb, backup);
|
||||||
} else if (databaseType === "web-server") {
|
await keepLatestNBackups(backup, mariadb.serverId);
|
||||||
await runWebServerBackup(backup);
|
} else if (databaseType === "web-server") {
|
||||||
await keepLatestNBackups(backup);
|
await runWebServerBackup(backup);
|
||||||
|
await keepLatestNBackups(backup);
|
||||||
|
}
|
||||||
|
} else if (backup.backupType === "compose" && compose) {
|
||||||
|
await runComposeBackup(compose, backup);
|
||||||
|
await keepLatestNBackups(backup, compose.serverId);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -61,3 +75,181 @@ export const getS3Credentials = (destination: Destination) => {
|
|||||||
|
|
||||||
return rcloneFlags;
|
return rcloneFlags;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const getPostgresBackupCommand = (
|
||||||
|
database: string,
|
||||||
|
databaseUser: string,
|
||||||
|
) => {
|
||||||
|
return `docker exec -i $CONTAINER_ID bash -c "set -o pipefail; pg_dump -Fc --no-acl --no-owner -h localhost -U ${databaseUser} --no-password '${database}' | gzip"`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getMariadbBackupCommand = (
|
||||||
|
database: string,
|
||||||
|
databaseUser: string,
|
||||||
|
databasePassword: string,
|
||||||
|
) => {
|
||||||
|
return `docker exec -i $CONTAINER_ID bash -c "set -o pipefail; mariadb-dump --user='${databaseUser}' --password='${databasePassword}' --databases ${database} | gzip"`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getMysqlBackupCommand = (
|
||||||
|
database: string,
|
||||||
|
databasePassword: string,
|
||||||
|
) => {
|
||||||
|
return `docker exec -i $CONTAINER_ID bash -c "set -o pipefail; mysqldump --default-character-set=utf8mb4 -u 'root' --password='${databasePassword}' --single-transaction --no-tablespaces --quick '${database}' | gzip"`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getMongoBackupCommand = (
|
||||||
|
database: string,
|
||||||
|
databaseUser: string,
|
||||||
|
databasePassword: string,
|
||||||
|
) => {
|
||||||
|
return `docker exec -i $CONTAINER_ID bash -c "set -o pipefail; mongodump -d '${database}' -u '${databaseUser}' -p '${databasePassword}' --archive --authenticationDatabase admin --gzip"`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getServiceContainerCommand = (appName: string) => {
|
||||||
|
return `docker ps -q --filter "status=running" --filter "label=com.docker.swarm.service.name=${appName}" | head -n 1`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getComposeContainerCommand = (
|
||||||
|
appName: string,
|
||||||
|
serviceName: string,
|
||||||
|
composeType: "stack" | "docker-compose" | undefined,
|
||||||
|
) => {
|
||||||
|
if (composeType === "stack") {
|
||||||
|
return `docker ps -q --filter "status=running" --filter "label=com.docker.stack.namespace=${appName}" --filter "label=com.docker.swarm.service.name=${serviceName}" | head -n 1`;
|
||||||
|
}
|
||||||
|
return `docker ps -q --filter "status=running" --filter "label=com.docker.compose.project=${appName}" --filter "label=com.docker.compose.service=${serviceName}" | head -n 1`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getContainerSearchCommand = (backup: BackupSchedule) => {
|
||||||
|
const { backupType, postgres, mysql, mariadb, mongo, compose, serviceName } =
|
||||||
|
backup;
|
||||||
|
|
||||||
|
if (backupType === "database") {
|
||||||
|
const appName =
|
||||||
|
postgres?.appName || mysql?.appName || mariadb?.appName || mongo?.appName;
|
||||||
|
return getServiceContainerCommand(appName || "");
|
||||||
|
}
|
||||||
|
if (backupType === "compose") {
|
||||||
|
const { appName, composeType } = compose || {};
|
||||||
|
return getComposeContainerCommand(
|
||||||
|
appName || "",
|
||||||
|
serviceName || "",
|
||||||
|
composeType,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const generateBackupCommand = (backup: BackupSchedule) => {
|
||||||
|
const { backupType, databaseType } = backup;
|
||||||
|
switch (databaseType) {
|
||||||
|
case "postgres": {
|
||||||
|
const postgres = backup.postgres;
|
||||||
|
if (backupType === "database" && postgres) {
|
||||||
|
return getPostgresBackupCommand(backup.database, postgres.databaseUser);
|
||||||
|
}
|
||||||
|
if (backupType === "compose" && backup.metadata?.postgres) {
|
||||||
|
return getPostgresBackupCommand(
|
||||||
|
backup.database,
|
||||||
|
backup.metadata.postgres.databaseUser,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "mysql": {
|
||||||
|
const mysql = backup.mysql;
|
||||||
|
if (backupType === "database" && mysql) {
|
||||||
|
return getMysqlBackupCommand(backup.database, mysql.databasePassword);
|
||||||
|
}
|
||||||
|
if (backupType === "compose" && backup.metadata?.mysql) {
|
||||||
|
return getMysqlBackupCommand(
|
||||||
|
backup.database,
|
||||||
|
backup.metadata?.mysql?.databaseRootPassword || "",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "mariadb": {
|
||||||
|
const mariadb = backup.mariadb;
|
||||||
|
if (backupType === "database" && mariadb) {
|
||||||
|
return getMariadbBackupCommand(
|
||||||
|
backup.database,
|
||||||
|
mariadb.databaseUser,
|
||||||
|
mariadb.databasePassword,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (backupType === "compose" && backup.metadata?.mariadb) {
|
||||||
|
return getMariadbBackupCommand(
|
||||||
|
backup.database,
|
||||||
|
backup.metadata.mariadb.databaseUser,
|
||||||
|
backup.metadata.mariadb.databasePassword,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "mongo": {
|
||||||
|
const mongo = backup.mongo;
|
||||||
|
if (backupType === "database" && mongo) {
|
||||||
|
return getMongoBackupCommand(
|
||||||
|
backup.database,
|
||||||
|
mongo.databaseUser,
|
||||||
|
mongo.databasePassword,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (backupType === "compose" && backup.metadata?.mongo) {
|
||||||
|
return getMongoBackupCommand(
|
||||||
|
backup.database,
|
||||||
|
backup.metadata.mongo.databaseUser,
|
||||||
|
backup.metadata.mongo.databasePassword,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
throw new Error(`Database type not supported: ${databaseType}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getBackupCommand = (
|
||||||
|
backup: BackupSchedule,
|
||||||
|
rcloneCommand: string,
|
||||||
|
logPath: string,
|
||||||
|
) => {
|
||||||
|
const containerSearch = getContainerSearchCommand(backup);
|
||||||
|
const backupCommand = generateBackupCommand(backup);
|
||||||
|
return `
|
||||||
|
set -eo pipefail;
|
||||||
|
echo "[$(date)] Starting backup process..." >> ${logPath};
|
||||||
|
echo "[$(date)] Executing backup command..." >> ${logPath};
|
||||||
|
CONTAINER_ID=$(${containerSearch})
|
||||||
|
|
||||||
|
if [ -z "$CONTAINER_ID" ]; then
|
||||||
|
echo "[$(date)] ❌ Error: Container not found" >> ${logPath};
|
||||||
|
exit 1;
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "[$(date)] Container Up: $CONTAINER_ID" >> ${logPath};
|
||||||
|
|
||||||
|
# Run the backup command and capture the exit status
|
||||||
|
BACKUP_OUTPUT=$(${backupCommand} 2>&1 >/dev/null) || {
|
||||||
|
echo "[$(date)] ❌ Error: Backup failed" >> ${logPath};
|
||||||
|
echo "Error: $BACKUP_OUTPUT" >> ${logPath};
|
||||||
|
exit 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "[$(date)] ✅ backup completed successfully" >> ${logPath};
|
||||||
|
echo "[$(date)] Starting upload to S3..." >> ${logPath};
|
||||||
|
|
||||||
|
# Run the upload command and capture the exit status
|
||||||
|
UPLOAD_OUTPUT=$(${backupCommand} | ${rcloneCommand} 2>&1 >/dev/null) || {
|
||||||
|
echo "[$(date)] ❌ Error: Upload to S3 failed" >> ${logPath};
|
||||||
|
echo "Error: $UPLOAD_OUTPUT" >> ${logPath};
|
||||||
|
exit 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "[$(date)] ✅ Upload to S3 completed successfully" >> ${logPath};
|
||||||
|
echo "Backup done ✅" >> ${logPath};
|
||||||
|
`;
|
||||||
|
};
|
||||||
|
|||||||
@@ -6,12 +6,25 @@ import { IS_CLOUD, paths } from "@dokploy/server/constants";
|
|||||||
import { mkdtemp } from "node:fs/promises";
|
import { mkdtemp } from "node:fs/promises";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
|
import {
|
||||||
|
createDeploymentBackup,
|
||||||
|
updateDeploymentStatus,
|
||||||
|
} from "@dokploy/server/services/deployment";
|
||||||
|
import { createWriteStream } from "node:fs";
|
||||||
|
|
||||||
export const runWebServerBackup = async (backup: BackupSchedule) => {
|
export const runWebServerBackup = async (backup: BackupSchedule) => {
|
||||||
|
if (IS_CLOUD) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const deployment = await createDeploymentBackup({
|
||||||
|
backupId: backup.backupId,
|
||||||
|
title: "Web Server Backup",
|
||||||
|
description: "Web Server Backup",
|
||||||
|
});
|
||||||
|
const writeStream = createWriteStream(deployment.logPath, { flags: "a" });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (IS_CLOUD) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const destination = await findDestinationById(backup.destinationId);
|
const destination = await findDestinationById(backup.destinationId);
|
||||||
const rcloneFlags = getS3Credentials(destination);
|
const rcloneFlags = getS3Credentials(destination);
|
||||||
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
|
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
|
||||||
@@ -29,29 +42,49 @@ export const runWebServerBackup = async (backup: BackupSchedule) => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (!containerId) {
|
if (!containerId) {
|
||||||
throw new Error("PostgreSQL container not found");
|
writeStream.write("Dokploy postgres container not found❌\n");
|
||||||
|
writeStream.end();
|
||||||
|
throw new Error("Dokploy postgres container not found");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
writeStream.write(`Dokploy postgres container ID: ${containerId}\n`);
|
||||||
|
|
||||||
const postgresContainerId = containerId.trim();
|
const postgresContainerId = containerId.trim();
|
||||||
|
|
||||||
const postgresCommand = `docker exec ${postgresContainerId} pg_dump -v -Fc -U dokploy -d dokploy > '${tempDir}/database.sql'`;
|
const postgresCommand = `docker exec ${postgresContainerId} pg_dump -v -Fc -U dokploy -d dokploy > '${tempDir}/database.sql'`;
|
||||||
|
|
||||||
|
writeStream.write(`Running command: ${postgresCommand}\n`);
|
||||||
await execAsync(postgresCommand);
|
await execAsync(postgresCommand);
|
||||||
|
|
||||||
await execAsync(`cp -r ${BASE_PATH}/* ${tempDir}/filesystem/`);
|
await execAsync(`cp -r ${BASE_PATH}/* ${tempDir}/filesystem/`);
|
||||||
|
|
||||||
|
writeStream.write("Copied filesystem to temp directory\n");
|
||||||
|
|
||||||
await execAsync(
|
await execAsync(
|
||||||
// Zip all .sql files since we created more than one
|
// Zip all .sql files since we created more than one
|
||||||
`cd ${tempDir} && zip -r ${backupFileName} *.sql filesystem/ > /dev/null 2>&1`,
|
`cd ${tempDir} && zip -r ${backupFileName} *.sql filesystem/ > /dev/null 2>&1`,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
writeStream.write("Zipped database and filesystem\n");
|
||||||
|
|
||||||
const uploadCommand = `rclone copyto ${rcloneFlags.join(" ")} "${tempDir}/${backupFileName}" "${s3Path}"`;
|
const uploadCommand = `rclone copyto ${rcloneFlags.join(" ")} "${tempDir}/${backupFileName}" "${s3Path}"`;
|
||||||
|
writeStream.write(`Running command: ${uploadCommand}\n`);
|
||||||
await execAsync(uploadCommand);
|
await execAsync(uploadCommand);
|
||||||
|
writeStream.write("Uploaded backup to S3 ✅\n");
|
||||||
|
writeStream.end();
|
||||||
|
await updateDeploymentStatus(deployment.deploymentId, "done");
|
||||||
return true;
|
return true;
|
||||||
} finally {
|
} finally {
|
||||||
await execAsync(`rm -rf ${tempDir}`);
|
await execAsync(`rm -rf ${tempDir}`);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Backup error:", error);
|
console.error("Backup error:", error);
|
||||||
|
writeStream.write("Backup error❌\n");
|
||||||
|
writeStream.write(
|
||||||
|
error instanceof Error ? error.message : "Unknown error\n",
|
||||||
|
);
|
||||||
|
writeStream.end();
|
||||||
|
await updateDeploymentStatus(deployment.deploymentId, "error");
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -494,56 +494,7 @@ export const getCreateFileCommand = (
|
|||||||
`;
|
`;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getServiceContainer = async (appName: string) => {
|
export const getServiceContainer = async (
|
||||||
try {
|
|
||||||
const filter = {
|
|
||||||
status: ["running"],
|
|
||||||
label: [`com.docker.swarm.service.name=${appName}`],
|
|
||||||
};
|
|
||||||
|
|
||||||
const containers = await docker.listContainers({
|
|
||||||
filters: JSON.stringify(filter),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (containers.length === 0 || !containers[0]) {
|
|
||||||
throw new Error(`No container found with name: ${appName}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const container = containers[0];
|
|
||||||
|
|
||||||
return container;
|
|
||||||
} catch (error) {
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getRemoteServiceContainer = async (
|
|
||||||
serverId: string,
|
|
||||||
appName: string,
|
|
||||||
) => {
|
|
||||||
try {
|
|
||||||
const filter = {
|
|
||||||
status: ["running"],
|
|
||||||
label: [`com.docker.swarm.service.name=${appName}`],
|
|
||||||
};
|
|
||||||
const remoteDocker = await getRemoteDocker(serverId);
|
|
||||||
const containers = await remoteDocker.listContainers({
|
|
||||||
filters: JSON.stringify(filter),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (containers.length === 0 || !containers[0]) {
|
|
||||||
throw new Error(`No container found with name: ${appName}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const container = containers[0];
|
|
||||||
|
|
||||||
return container;
|
|
||||||
} catch (error) {
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getServiceContainerIV2 = async (
|
|
||||||
appName: string,
|
appName: string,
|
||||||
serverId?: string | null,
|
serverId?: string | null,
|
||||||
) => {
|
) => {
|
||||||
@@ -558,10 +509,11 @@ export const getServiceContainerIV2 = async (
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (containers.length === 0 || !containers[0]) {
|
if (containers.length === 0 || !containers[0]) {
|
||||||
throw new Error(`No container found with name: ${appName}`);
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const container = containers[0];
|
const container = containers[0];
|
||||||
|
|
||||||
return container;
|
return container;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw error;
|
throw error;
|
||||||
@@ -597,7 +549,7 @@ export const getComposeContainer = async (
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (containers.length === 0 || !containers[0]) {
|
if (containers.length === 0 || !containers[0]) {
|
||||||
throw new Error(`No container found with name: ${appName}`);
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const container = containers[0];
|
const container = containers[0];
|
||||||
|
|||||||
@@ -5,6 +5,51 @@ import { Client } from "ssh2";
|
|||||||
|
|
||||||
export const execAsync = util.promisify(exec);
|
export const execAsync = util.promisify(exec);
|
||||||
|
|
||||||
|
interface ExecOptions {
|
||||||
|
cwd?: string;
|
||||||
|
env?: NodeJS.ProcessEnv;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const execAsyncStream = (
|
||||||
|
command: string,
|
||||||
|
onData?: (data: string) => void,
|
||||||
|
options: ExecOptions = {},
|
||||||
|
): Promise<{ stdout: string; stderr: string }> => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
let stdoutComplete = "";
|
||||||
|
let stderrComplete = "";
|
||||||
|
|
||||||
|
const childProcess = exec(command, options, (error) => {
|
||||||
|
if (error) {
|
||||||
|
reject(error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
resolve({ stdout: stdoutComplete, stderr: stderrComplete });
|
||||||
|
});
|
||||||
|
|
||||||
|
childProcess.stdout?.on("data", (data: Buffer | string) => {
|
||||||
|
const stringData = data.toString();
|
||||||
|
stdoutComplete += stringData;
|
||||||
|
if (onData) {
|
||||||
|
onData(stringData);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
childProcess.stderr?.on("data", (data: Buffer | string) => {
|
||||||
|
const stringData = data.toString();
|
||||||
|
stderrComplete += stringData;
|
||||||
|
if (onData) {
|
||||||
|
onData(stringData);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
childProcess.on("error", (error) => {
|
||||||
|
console.log(error);
|
||||||
|
reject(error);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
export const execFileAsync = async (
|
export const execFileAsync = async (
|
||||||
command: string,
|
command: string,
|
||||||
args: string[],
|
args: string[],
|
||||||
|
|||||||
97
packages/server/src/utils/restore/compose.ts
Normal file
97
packages/server/src/utils/restore/compose.ts
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
import type { Destination } from "@dokploy/server/services/destination";
|
||||||
|
import type { Compose } from "@dokploy/server/services/compose";
|
||||||
|
import { getS3Credentials } from "../backups/utils";
|
||||||
|
import { execAsync, execAsyncRemote } from "../process/execAsync";
|
||||||
|
import { getRestoreCommand } from "./utils";
|
||||||
|
import type { apiRestoreBackup } from "@dokploy/server/db/schema";
|
||||||
|
import type { z } from "zod";
|
||||||
|
|
||||||
|
interface DatabaseCredentials {
|
||||||
|
databaseUser?: string;
|
||||||
|
databasePassword?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const restoreComposeBackup = async (
|
||||||
|
compose: Compose,
|
||||||
|
destination: Destination,
|
||||||
|
backupInput: z.infer<typeof apiRestoreBackup>,
|
||||||
|
emit: (log: string) => void,
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
if (backupInput.databaseType === "web-server") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { serverId, appName, composeType } = compose;
|
||||||
|
|
||||||
|
const rcloneFlags = getS3Credentials(destination);
|
||||||
|
const bucketPath = `:s3:${destination.bucket}`;
|
||||||
|
const backupPath = `${bucketPath}/${backupInput.backupFile}`;
|
||||||
|
let rcloneCommand = `rclone cat ${rcloneFlags.join(" ")} "${backupPath}" | gunzip`;
|
||||||
|
|
||||||
|
if (backupInput.metadata?.mongo) {
|
||||||
|
rcloneCommand = `rclone copy ${rcloneFlags.join(" ")} "${backupPath}"`;
|
||||||
|
}
|
||||||
|
|
||||||
|
let credentials: DatabaseCredentials;
|
||||||
|
|
||||||
|
switch (backupInput.databaseType) {
|
||||||
|
case "postgres":
|
||||||
|
credentials = {
|
||||||
|
databaseUser: backupInput.metadata?.postgres?.databaseUser,
|
||||||
|
};
|
||||||
|
break;
|
||||||
|
case "mariadb":
|
||||||
|
credentials = {
|
||||||
|
databaseUser: backupInput.metadata?.mariadb?.databaseUser,
|
||||||
|
databasePassword: backupInput.metadata?.mariadb?.databasePassword,
|
||||||
|
};
|
||||||
|
break;
|
||||||
|
case "mysql":
|
||||||
|
credentials = {
|
||||||
|
databasePassword: backupInput.metadata?.mysql?.databaseRootPassword,
|
||||||
|
};
|
||||||
|
break;
|
||||||
|
case "mongo":
|
||||||
|
credentials = {
|
||||||
|
databaseUser: backupInput.metadata?.mongo?.databaseUser,
|
||||||
|
databasePassword: backupInput.metadata?.mongo?.databasePassword,
|
||||||
|
};
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const restoreCommand = getRestoreCommand({
|
||||||
|
appName: appName,
|
||||||
|
serviceName: backupInput.metadata?.serviceName,
|
||||||
|
type: backupInput.databaseType,
|
||||||
|
credentials: {
|
||||||
|
database: backupInput.databaseName,
|
||||||
|
...credentials,
|
||||||
|
},
|
||||||
|
restoreType: composeType,
|
||||||
|
rcloneCommand,
|
||||||
|
});
|
||||||
|
|
||||||
|
emit("Starting restore...");
|
||||||
|
emit(`Backup path: ${backupPath}`);
|
||||||
|
|
||||||
|
emit(`Executing command: ${restoreCommand}`);
|
||||||
|
|
||||||
|
if (serverId) {
|
||||||
|
await execAsyncRemote(serverId, restoreCommand);
|
||||||
|
} else {
|
||||||
|
await execAsync(restoreCommand);
|
||||||
|
}
|
||||||
|
|
||||||
|
emit("Restore completed successfully!");
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
emit(
|
||||||
|
`Error: ${
|
||||||
|
error instanceof Error ? error.message : "Error restoring mongo backup"
|
||||||
|
}`,
|
||||||
|
);
|
||||||
|
throw new Error(
|
||||||
|
error instanceof Error ? error.message : "Error restoring mongo backup",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -3,3 +3,4 @@ export { restoreMySqlBackup } from "./mysql";
|
|||||||
export { restoreMariadbBackup } from "./mariadb";
|
export { restoreMariadbBackup } from "./mariadb";
|
||||||
export { restoreMongoBackup } from "./mongo";
|
export { restoreMongoBackup } from "./mongo";
|
||||||
export { restoreWebServerBackup } from "./web-server";
|
export { restoreWebServerBackup } from "./web-server";
|
||||||
|
export { restoreComposeBackup } from "./compose";
|
||||||
|
|||||||
@@ -1,42 +1,46 @@
|
|||||||
import type { Destination } from "@dokploy/server/services/destination";
|
import type { Destination } from "@dokploy/server/services/destination";
|
||||||
import type { Mariadb } from "@dokploy/server/services/mariadb";
|
import type { Mariadb } from "@dokploy/server/services/mariadb";
|
||||||
import { getS3Credentials } from "../backups/utils";
|
import { getS3Credentials } from "../backups/utils";
|
||||||
import {
|
|
||||||
getRemoteServiceContainer,
|
|
||||||
getServiceContainer,
|
|
||||||
} from "../docker/utils";
|
|
||||||
import { execAsync, execAsyncRemote } from "../process/execAsync";
|
import { execAsync, execAsyncRemote } from "../process/execAsync";
|
||||||
|
import { getRestoreCommand } from "./utils";
|
||||||
|
import type { apiRestoreBackup } from "@dokploy/server/db/schema";
|
||||||
|
import type { z } from "zod";
|
||||||
|
|
||||||
export const restoreMariadbBackup = async (
|
export const restoreMariadbBackup = async (
|
||||||
mariadb: Mariadb,
|
mariadb: Mariadb,
|
||||||
destination: Destination,
|
destination: Destination,
|
||||||
database: string,
|
backupInput: z.infer<typeof apiRestoreBackup>,
|
||||||
backupFile: string,
|
|
||||||
emit: (log: string) => void,
|
emit: (log: string) => void,
|
||||||
) => {
|
) => {
|
||||||
try {
|
try {
|
||||||
const { appName, databasePassword, databaseUser, serverId } = mariadb;
|
const { appName, serverId, databaseUser, databasePassword } = mariadb;
|
||||||
|
|
||||||
const rcloneFlags = getS3Credentials(destination);
|
const rcloneFlags = getS3Credentials(destination);
|
||||||
const bucketPath = `:s3:${destination.bucket}`;
|
const bucketPath = `:s3:${destination.bucket}`;
|
||||||
const backupPath = `${bucketPath}/${backupFile}`;
|
const backupPath = `${bucketPath}/${backupInput.backupFile}`;
|
||||||
|
|
||||||
const { Id: containerName } = serverId
|
const rcloneCommand = `rclone cat ${rcloneFlags.join(" ")} "${backupPath}" | gunzip`;
|
||||||
? await getRemoteServiceContainer(serverId, appName)
|
|
||||||
: await getServiceContainer(appName);
|
|
||||||
|
|
||||||
const restoreCommand = `
|
const command = getRestoreCommand({
|
||||||
rclone cat ${rcloneFlags.join(" ")} "${backupPath}" | gunzip | docker exec -i ${containerName} mariadb -u ${databaseUser} -p${databasePassword} ${database}
|
appName,
|
||||||
`;
|
credentials: {
|
||||||
|
database: backupInput.databaseName,
|
||||||
|
databaseUser,
|
||||||
|
databasePassword,
|
||||||
|
},
|
||||||
|
type: "mariadb",
|
||||||
|
rcloneCommand,
|
||||||
|
restoreType: "database",
|
||||||
|
});
|
||||||
|
|
||||||
emit("Starting restore...");
|
emit("Starting restore...");
|
||||||
|
|
||||||
emit(`Executing command: ${restoreCommand}`);
|
emit(`Executing command: ${command}`);
|
||||||
|
|
||||||
if (serverId) {
|
if (serverId) {
|
||||||
await execAsyncRemote(serverId, restoreCommand);
|
await execAsyncRemote(serverId, command);
|
||||||
} else {
|
} else {
|
||||||
await execAsync(restoreCommand);
|
await execAsync(command);
|
||||||
}
|
}
|
||||||
|
|
||||||
emit("Restore completed successfully!");
|
emit("Restore completed successfully!");
|
||||||
|
|||||||
@@ -1,17 +1,15 @@
|
|||||||
import type { Destination } from "@dokploy/server/services/destination";
|
import type { Destination } from "@dokploy/server/services/destination";
|
||||||
import type { Mongo } from "@dokploy/server/services/mongo";
|
import type { Mongo } from "@dokploy/server/services/mongo";
|
||||||
import { getS3Credentials } from "../backups/utils";
|
import { getS3Credentials } from "../backups/utils";
|
||||||
import {
|
|
||||||
getRemoteServiceContainer,
|
|
||||||
getServiceContainer,
|
|
||||||
} from "../docker/utils";
|
|
||||||
import { execAsync, execAsyncRemote } from "../process/execAsync";
|
import { execAsync, execAsyncRemote } from "../process/execAsync";
|
||||||
|
import { getRestoreCommand } from "./utils";
|
||||||
|
import type { apiRestoreBackup } from "@dokploy/server/db/schema";
|
||||||
|
import type { z } from "zod";
|
||||||
|
|
||||||
export const restoreMongoBackup = async (
|
export const restoreMongoBackup = async (
|
||||||
mongo: Mongo,
|
mongo: Mongo,
|
||||||
destination: Destination,
|
destination: Destination,
|
||||||
database: string,
|
backupInput: z.infer<typeof apiRestoreBackup>,
|
||||||
backupFile: string,
|
|
||||||
emit: (log: string) => void,
|
emit: (log: string) => void,
|
||||||
) => {
|
) => {
|
||||||
try {
|
try {
|
||||||
@@ -19,34 +17,30 @@ export const restoreMongoBackup = async (
|
|||||||
|
|
||||||
const rcloneFlags = getS3Credentials(destination);
|
const rcloneFlags = getS3Credentials(destination);
|
||||||
const bucketPath = `:s3:${destination.bucket}`;
|
const bucketPath = `:s3:${destination.bucket}`;
|
||||||
const backupPath = `${bucketPath}/${backupFile}`;
|
const backupPath = `${bucketPath}/${backupInput.backupFile}`;
|
||||||
|
const rcloneCommand = `rclone copy ${rcloneFlags.join(" ")} "${backupPath}"`;
|
||||||
|
|
||||||
const { Id: containerName } = serverId
|
const command = getRestoreCommand({
|
||||||
? await getRemoteServiceContainer(serverId, appName)
|
appName,
|
||||||
: await getServiceContainer(appName);
|
type: "mongo",
|
||||||
|
credentials: {
|
||||||
// For MongoDB, we need to first download the backup file since mongorestore expects a directory
|
database: backupInput.databaseName,
|
||||||
const tempDir = "/tmp/dokploy-restore";
|
databaseUser,
|
||||||
const fileName = backupFile.split("/").pop() || "backup.dump.gz";
|
databasePassword,
|
||||||
const decompressedName = fileName.replace(".gz", "");
|
},
|
||||||
|
restoreType: "database",
|
||||||
const downloadCommand = `\
|
rcloneCommand,
|
||||||
rm -rf ${tempDir} && \
|
backupFile: backupInput.backupFile,
|
||||||
mkdir -p ${tempDir} && \
|
});
|
||||||
rclone copy ${rcloneFlags.join(" ")} "${backupPath}" ${tempDir} && \
|
|
||||||
cd ${tempDir} && \
|
|
||||||
gunzip -f "${fileName}" && \
|
|
||||||
docker exec -i ${containerName} mongorestore --username ${databaseUser} --password ${databasePassword} --authenticationDatabase admin --db ${database} --archive < "${decompressedName}" && \
|
|
||||||
rm -rf ${tempDir}`;
|
|
||||||
|
|
||||||
emit("Starting restore...");
|
emit("Starting restore...");
|
||||||
|
|
||||||
emit(`Executing command: ${downloadCommand}`);
|
emit(`Executing command: ${command}`);
|
||||||
|
|
||||||
if (serverId) {
|
if (serverId) {
|
||||||
await execAsyncRemote(serverId, downloadCommand);
|
await execAsyncRemote(serverId, command);
|
||||||
} else {
|
} else {
|
||||||
await execAsync(downloadCommand);
|
await execAsync(command);
|
||||||
}
|
}
|
||||||
|
|
||||||
emit("Restore completed successfully!");
|
emit("Restore completed successfully!");
|
||||||
|
|||||||
@@ -1,17 +1,15 @@
|
|||||||
import type { Destination } from "@dokploy/server/services/destination";
|
import type { Destination } from "@dokploy/server/services/destination";
|
||||||
import type { MySql } from "@dokploy/server/services/mysql";
|
import type { MySql } from "@dokploy/server/services/mysql";
|
||||||
import { getS3Credentials } from "../backups/utils";
|
import { getS3Credentials } from "../backups/utils";
|
||||||
import {
|
|
||||||
getRemoteServiceContainer,
|
|
||||||
getServiceContainer,
|
|
||||||
} from "../docker/utils";
|
|
||||||
import { execAsync, execAsyncRemote } from "../process/execAsync";
|
import { execAsync, execAsyncRemote } from "../process/execAsync";
|
||||||
|
import { getRestoreCommand } from "./utils";
|
||||||
|
import type { apiRestoreBackup } from "@dokploy/server/db/schema";
|
||||||
|
import type { z } from "zod";
|
||||||
|
|
||||||
export const restoreMySqlBackup = async (
|
export const restoreMySqlBackup = async (
|
||||||
mysql: MySql,
|
mysql: MySql,
|
||||||
destination: Destination,
|
destination: Destination,
|
||||||
database: string,
|
backupInput: z.infer<typeof apiRestoreBackup>,
|
||||||
backupFile: string,
|
|
||||||
emit: (log: string) => void,
|
emit: (log: string) => void,
|
||||||
) => {
|
) => {
|
||||||
try {
|
try {
|
||||||
@@ -19,24 +17,29 @@ export const restoreMySqlBackup = async (
|
|||||||
|
|
||||||
const rcloneFlags = getS3Credentials(destination);
|
const rcloneFlags = getS3Credentials(destination);
|
||||||
const bucketPath = `:s3:${destination.bucket}`;
|
const bucketPath = `:s3:${destination.bucket}`;
|
||||||
const backupPath = `${bucketPath}/${backupFile}`;
|
const backupPath = `${bucketPath}/${backupInput.backupFile}`;
|
||||||
|
|
||||||
const { Id: containerName } = serverId
|
const rcloneCommand = `rclone cat ${rcloneFlags.join(" ")} "${backupPath}" | gunzip`;
|
||||||
? await getRemoteServiceContainer(serverId, appName)
|
|
||||||
: await getServiceContainer(appName);
|
|
||||||
|
|
||||||
const restoreCommand = `
|
const command = getRestoreCommand({
|
||||||
rclone cat ${rcloneFlags.join(" ")} "${backupPath}" | gunzip | docker exec -i ${containerName} mysql -u root -p${databaseRootPassword} ${database}
|
appName,
|
||||||
`;
|
type: "mysql",
|
||||||
|
credentials: {
|
||||||
|
database: backupInput.databaseName,
|
||||||
|
databasePassword: databaseRootPassword,
|
||||||
|
},
|
||||||
|
restoreType: "database",
|
||||||
|
rcloneCommand,
|
||||||
|
});
|
||||||
|
|
||||||
emit("Starting restore...");
|
emit("Starting restore...");
|
||||||
|
|
||||||
emit(`Executing command: ${restoreCommand}`);
|
emit(`Executing command: ${command}`);
|
||||||
|
|
||||||
if (serverId) {
|
if (serverId) {
|
||||||
await execAsyncRemote(serverId, restoreCommand);
|
await execAsyncRemote(serverId, command);
|
||||||
} else {
|
} else {
|
||||||
await execAsync(restoreCommand);
|
await execAsync(command);
|
||||||
}
|
}
|
||||||
|
|
||||||
emit("Restore completed successfully!");
|
emit("Restore completed successfully!");
|
||||||
|
|||||||
@@ -1,17 +1,15 @@
|
|||||||
import type { Destination } from "@dokploy/server/services/destination";
|
import type { Destination } from "@dokploy/server/services/destination";
|
||||||
import type { Postgres } from "@dokploy/server/services/postgres";
|
import type { Postgres } from "@dokploy/server/services/postgres";
|
||||||
import { getS3Credentials } from "../backups/utils";
|
import { getS3Credentials } from "../backups/utils";
|
||||||
import {
|
|
||||||
getRemoteServiceContainer,
|
|
||||||
getServiceContainer,
|
|
||||||
} from "../docker/utils";
|
|
||||||
import { execAsync, execAsyncRemote } from "../process/execAsync";
|
import { execAsync, execAsyncRemote } from "../process/execAsync";
|
||||||
|
import { getRestoreCommand } from "./utils";
|
||||||
|
import type { apiRestoreBackup } from "@dokploy/server/db/schema";
|
||||||
|
import type { z } from "zod";
|
||||||
|
|
||||||
export const restorePostgresBackup = async (
|
export const restorePostgresBackup = async (
|
||||||
postgres: Postgres,
|
postgres: Postgres,
|
||||||
destination: Destination,
|
destination: Destination,
|
||||||
database: string,
|
backupInput: z.infer<typeof apiRestoreBackup>,
|
||||||
backupFile: string,
|
|
||||||
emit: (log: string) => void,
|
emit: (log: string) => void,
|
||||||
) => {
|
) => {
|
||||||
try {
|
try {
|
||||||
@@ -20,30 +18,30 @@ export const restorePostgresBackup = async (
|
|||||||
const rcloneFlags = getS3Credentials(destination);
|
const rcloneFlags = getS3Credentials(destination);
|
||||||
const bucketPath = `:s3:${destination.bucket}`;
|
const bucketPath = `:s3:${destination.bucket}`;
|
||||||
|
|
||||||
const backupPath = `${bucketPath}/${backupFile}`;
|
const backupPath = `${bucketPath}/${backupInput.backupFile}`;
|
||||||
|
|
||||||
const { Id: containerName } = serverId
|
const rcloneCommand = `rclone cat ${rcloneFlags.join(" ")} "${backupPath}" | gunzip`;
|
||||||
? await getRemoteServiceContainer(serverId, appName)
|
|
||||||
: await getServiceContainer(appName);
|
|
||||||
|
|
||||||
emit("Starting restore...");
|
emit("Starting restore...");
|
||||||
emit(`Backup path: ${backupPath}`);
|
emit(`Backup path: ${backupPath}`);
|
||||||
|
|
||||||
const command = `\
|
const command = getRestoreCommand({
|
||||||
rclone cat ${rcloneFlags.join(" ")} "${backupPath}" | gunzip | docker exec -i ${containerName} pg_restore -U ${databaseUser} -d ${database} --clean --if-exists`;
|
appName,
|
||||||
|
credentials: {
|
||||||
|
database: backupInput.databaseName,
|
||||||
|
databaseUser,
|
||||||
|
},
|
||||||
|
type: "postgres",
|
||||||
|
rcloneCommand,
|
||||||
|
restoreType: "database",
|
||||||
|
});
|
||||||
|
|
||||||
emit(`Executing command: ${command}`);
|
emit(`Executing command: ${command}`);
|
||||||
|
|
||||||
if (serverId) {
|
if (serverId) {
|
||||||
const { stdout, stderr } = await execAsyncRemote(serverId, command);
|
await execAsyncRemote(serverId, command);
|
||||||
emit(stdout);
|
|
||||||
emit(stderr);
|
|
||||||
} else {
|
} else {
|
||||||
const { stdout, stderr } = await execAsync(command);
|
await execAsync(command);
|
||||||
console.log("stdout", stdout);
|
|
||||||
console.log("stderr", stderr);
|
|
||||||
emit(stdout);
|
|
||||||
emit(stderr);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
emit("Restore completed successfully!");
|
emit("Restore completed successfully!");
|
||||||
|
|||||||
131
packages/server/src/utils/restore/utils.ts
Normal file
131
packages/server/src/utils/restore/utils.ts
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
import {
|
||||||
|
getComposeContainerCommand,
|
||||||
|
getServiceContainerCommand,
|
||||||
|
} from "../backups/utils";
|
||||||
|
|
||||||
|
export const getPostgresRestoreCommand = (
|
||||||
|
database: string,
|
||||||
|
databaseUser: string,
|
||||||
|
) => {
|
||||||
|
return `docker exec -i $CONTAINER_ID sh -c "pg_restore -U ${databaseUser} -d ${database} --clean --if-exists"`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getMariadbRestoreCommand = (
|
||||||
|
database: string,
|
||||||
|
databaseUser: string,
|
||||||
|
databasePassword: string,
|
||||||
|
) => {
|
||||||
|
return `docker exec -i $CONTAINER_ID sh -c "mariadb -u ${databaseUser} -p${databasePassword} ${database}"`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getMysqlRestoreCommand = (
|
||||||
|
database: string,
|
||||||
|
databasePassword: string,
|
||||||
|
) => {
|
||||||
|
return `docker exec -i $CONTAINER_ID sh -c "mysql -u root -p${databasePassword} ${database}"`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getMongoRestoreCommand = (
|
||||||
|
database: string,
|
||||||
|
databaseUser: string,
|
||||||
|
databasePassword: string,
|
||||||
|
) => {
|
||||||
|
return `docker exec -i $CONTAINER_ID sh -c "mongorestore --username ${databaseUser} --password ${databasePassword} --authenticationDatabase admin --db ${database} --archive"`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getComposeSearchCommand = (
|
||||||
|
appName: string,
|
||||||
|
type: "stack" | "docker-compose" | "database",
|
||||||
|
serviceName?: string,
|
||||||
|
) => {
|
||||||
|
if (type === "database") {
|
||||||
|
return getServiceContainerCommand(appName || "");
|
||||||
|
}
|
||||||
|
return getComposeContainerCommand(appName || "", serviceName || "", type);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface DatabaseCredentials {
|
||||||
|
database: string;
|
||||||
|
databaseUser?: string;
|
||||||
|
databasePassword?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const generateRestoreCommand = (
|
||||||
|
type: "postgres" | "mariadb" | "mysql" | "mongo",
|
||||||
|
credentials: DatabaseCredentials,
|
||||||
|
) => {
|
||||||
|
const { database, databaseUser, databasePassword } = credentials;
|
||||||
|
switch (type) {
|
||||||
|
case "postgres":
|
||||||
|
return getPostgresRestoreCommand(database, databaseUser || "");
|
||||||
|
case "mariadb":
|
||||||
|
return getMariadbRestoreCommand(
|
||||||
|
database,
|
||||||
|
databaseUser || "",
|
||||||
|
databasePassword || "",
|
||||||
|
);
|
||||||
|
case "mysql":
|
||||||
|
return getMysqlRestoreCommand(database, databasePassword || "");
|
||||||
|
case "mongo":
|
||||||
|
return getMongoRestoreCommand(
|
||||||
|
database,
|
||||||
|
databaseUser || "",
|
||||||
|
databasePassword || "",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getMongoSpecificCommand = (
|
||||||
|
rcloneCommand: string,
|
||||||
|
restoreCommand: string,
|
||||||
|
backupFile: string,
|
||||||
|
): string => {
|
||||||
|
const tempDir = "/tmp/dokploy-restore";
|
||||||
|
const fileName = backupFile.split("/").pop() || "backup.dump.gz";
|
||||||
|
const decompressedName = fileName.replace(".gz", "");
|
||||||
|
return `
|
||||||
|
rm -rf ${tempDir} && \
|
||||||
|
mkdir -p ${tempDir} && \
|
||||||
|
${rcloneCommand} ${tempDir} && \
|
||||||
|
cd ${tempDir} && \
|
||||||
|
gunzip -f "${fileName}" && \
|
||||||
|
${restoreCommand} < "${decompressedName}" && \
|
||||||
|
rm -rf ${tempDir}
|
||||||
|
`;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface RestoreOptions {
|
||||||
|
appName: string;
|
||||||
|
type: "postgres" | "mariadb" | "mysql" | "mongo";
|
||||||
|
restoreType: "stack" | "docker-compose" | "database";
|
||||||
|
credentials: DatabaseCredentials;
|
||||||
|
serviceName?: string;
|
||||||
|
rcloneCommand: string;
|
||||||
|
backupFile?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getRestoreCommand = ({
|
||||||
|
appName,
|
||||||
|
type,
|
||||||
|
restoreType,
|
||||||
|
credentials,
|
||||||
|
serviceName,
|
||||||
|
rcloneCommand,
|
||||||
|
backupFile,
|
||||||
|
}: RestoreOptions) => {
|
||||||
|
const containerSearch = getComposeSearchCommand(
|
||||||
|
appName,
|
||||||
|
restoreType,
|
||||||
|
serviceName,
|
||||||
|
);
|
||||||
|
const restoreCommand = generateRestoreCommand(type, credentials);
|
||||||
|
let cmd = `CONTAINER_ID=$(${containerSearch})`;
|
||||||
|
|
||||||
|
if (type !== "mongo") {
|
||||||
|
cmd += ` && ${rcloneCommand} | ${restoreCommand}`;
|
||||||
|
} else {
|
||||||
|
cmd += ` && ${getMongoSpecificCommand(rcloneCommand, restoreCommand, backupFile || "")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return cmd;
|
||||||
|
};
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { Schedule } from "@dokploy/server/db/schema/schedule";
|
import type { Schedule } from "@dokploy/server/db/schema/schedule";
|
||||||
import { findScheduleById } from "@dokploy/server/services/schedule";
|
import { findScheduleById } from "@dokploy/server/services/schedule";
|
||||||
import { scheduledJobs, scheduleJob as scheduleJobNode } from "node-schedule";
|
import { scheduledJobs, scheduleJob as scheduleJobNode } from "node-schedule";
|
||||||
import { getComposeContainer, getServiceContainerIV2 } from "../docker/utils";
|
import { getComposeContainer, getServiceContainer } from "../docker/utils";
|
||||||
import { execAsyncRemote } from "../process/execAsync";
|
import { execAsyncRemote } from "../process/execAsync";
|
||||||
import { spawnAsync } from "../process/spawnAsync";
|
import { spawnAsync } from "../process/spawnAsync";
|
||||||
import { createDeploymentSchedule } from "@dokploy/server/services/deployment";
|
import { createDeploymentSchedule } from "@dokploy/server/services/deployment";
|
||||||
@@ -45,16 +45,16 @@ export const runCommand = async (scheduleId: string) => {
|
|||||||
let containerId = "";
|
let containerId = "";
|
||||||
let serverId = "";
|
let serverId = "";
|
||||||
if (scheduleType === "application" && application) {
|
if (scheduleType === "application" && application) {
|
||||||
const container = await getServiceContainerIV2(
|
const container = await getServiceContainer(
|
||||||
application.appName,
|
application.appName,
|
||||||
application.serverId,
|
application.serverId,
|
||||||
);
|
);
|
||||||
containerId = container.Id;
|
containerId = container?.Id || "";
|
||||||
serverId = application.serverId || "";
|
serverId = application.serverId || "";
|
||||||
}
|
}
|
||||||
if (scheduleType === "compose" && compose) {
|
if (scheduleType === "compose" && compose) {
|
||||||
const container = await getComposeContainer(compose, serviceName || "");
|
const container = await getComposeContainer(compose, serviceName || "");
|
||||||
containerId = container.Id;
|
containerId = container?.Id || "";
|
||||||
serverId = compose.serverId || "";
|
serverId = compose.serverId || "";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,8 +64,8 @@ export const runCommand = async (scheduleId: string) => {
|
|||||||
serverId,
|
serverId,
|
||||||
`
|
`
|
||||||
set -e
|
set -e
|
||||||
echo "Running command: docker exec ${containerId} ${shellType} -c \"${command}\"" >> ${deployment.logPath};
|
echo "Running command: docker exec ${containerId} ${shellType} -c '${command}'" >> ${deployment.logPath};
|
||||||
docker exec ${containerId} ${shellType} -c "${command}" >> ${deployment.logPath} 2>> ${deployment.logPath} || {
|
docker exec ${containerId} ${shellType} -c '${command}' >> ${deployment.logPath} 2>> ${deployment.logPath} || {
|
||||||
echo "❌ Command failed" >> ${deployment.logPath};
|
echo "❌ Command failed" >> ${deployment.logPath};
|
||||||
exit 1;
|
exit 1;
|
||||||
}
|
}
|
||||||
@@ -81,7 +81,7 @@ export const runCommand = async (scheduleId: string) => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
writeStream.write(
|
writeStream.write(
|
||||||
`docker exec ${containerId} ${shellType} -c "${command}"\n`,
|
`docker exec ${containerId} ${shellType} -c ${command}\n`,
|
||||||
);
|
);
|
||||||
await spawnAsync(
|
await spawnAsync(
|
||||||
"docker",
|
"docker",
|
||||||
|
|||||||
Reference in New Issue
Block a user