mirror of
https://github.com/Dokploy/dokploy
synced 2025-06-26 18:27:59 +00:00
Compare commits
64 Commits
1730-pg_re
...
feat/intro
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
73d9373c70 | ||
|
|
bbc2cbc78c | ||
|
|
e2e6513ee1 | ||
|
|
d5c77fded3 | ||
|
|
d853b1d326 | ||
|
|
d57e347fdc | ||
|
|
25f3980492 | ||
|
|
c8e2f4bfdc | ||
|
|
4afc6ac250 | ||
|
|
52a660add3 | ||
|
|
3ad5982f39 | ||
|
|
8ba4ac22cc | ||
|
|
bcebcfdfdf | ||
|
|
77b1ec4733 | ||
|
|
cfae5f7e6c | ||
|
|
0f67e9e222 | ||
|
|
c4045795ee | ||
|
|
24f3be3c00 | ||
|
|
5055994bd3 | ||
|
|
ddcb22dff9 | ||
|
|
77d7dc1f22 | ||
|
|
19bf4f27b6 | ||
|
|
6b9765a26c | ||
|
|
8d91053c3a | ||
|
|
7c2eb63625 | ||
|
|
2ea2605ab1 | ||
|
|
7ae3ff22ee | ||
|
|
fa6baa0c1a | ||
|
|
5b43df92c1 | ||
|
|
f3032bc94f | ||
|
|
bb5c6bebff | ||
|
|
144d48057c | ||
|
|
55dc08b6ba | ||
|
|
56f525803b | ||
|
|
91bcd1238f | ||
|
|
120646c77b | ||
|
|
459c94929a | ||
|
|
8c36e48fe7 | ||
|
|
4b15177260 | ||
|
|
f5cffca37c | ||
|
|
e5ee06b67e | ||
|
|
4de599745e | ||
|
|
60e40f4ad0 | ||
|
|
fcb8a2bded | ||
|
|
439d2fe116 | ||
|
|
09d0435971 | ||
|
|
5810c94f4b | ||
|
|
1acd330462 | ||
|
|
4074942dbf | ||
|
|
5fd8fcfa1e | ||
|
|
39e6a98179 | ||
|
|
5180c785b4 | ||
|
|
c8e6df4c29 | ||
|
|
9e30525569 | ||
|
|
b7874f053f | ||
|
|
fb0272a64d | ||
|
|
2f4a3c964c | ||
|
|
feb6970b09 | ||
|
|
78682fa359 | ||
|
|
473d729416 | ||
|
|
523720606d | ||
|
|
4b6db35f16 | ||
|
|
9d047164ee | ||
|
|
a61436b8f0 |
@@ -36,6 +36,7 @@ const baseApp: ApplicationNested = {
|
|||||||
watchPaths: [],
|
watchPaths: [],
|
||||||
enableSubmodules: false,
|
enableSubmodules: false,
|
||||||
applicationStatus: "done",
|
applicationStatus: "done",
|
||||||
|
triggerType: "push",
|
||||||
appName: "",
|
appName: "",
|
||||||
autoDeploy: true,
|
autoDeploy: true,
|
||||||
serverId: "",
|
serverId: "",
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ const baseApp: ApplicationNested = {
|
|||||||
buildArgs: null,
|
buildArgs: null,
|
||||||
isPreviewDeploymentsActive: false,
|
isPreviewDeploymentsActive: false,
|
||||||
previewBuildArgs: null,
|
previewBuildArgs: null,
|
||||||
|
triggerType: "push",
|
||||||
previewCertificateType: "none",
|
previewCertificateType: "none",
|
||||||
previewEnv: null,
|
previewEnv: null,
|
||||||
previewHttps: false,
|
previewHttps: false,
|
||||||
|
|||||||
@@ -89,8 +89,6 @@ export const AddDomain = ({
|
|||||||
serverId: application?.serverId || "",
|
serverId: application?.serverId || "",
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log("canGenerateTraefikMeDomains", canGenerateTraefikMeDomains);
|
|
||||||
|
|
||||||
const form = useForm<Domain>({
|
const form = useForm<Domain>({
|
||||||
resolver: zodResolver(domain),
|
resolver: zodResolver(domain),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
@@ -276,6 +274,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,16 +8,49 @@ 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,
|
||||||
|
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 "./add-domain";
|
||||||
|
import { useState } from "react";
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipProvider,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from "@/components/ui/tooltip";
|
||||||
|
import type { ValidationStates } from "../../compose/domains/show-domains";
|
||||||
|
import { DnsHelperModal } from "../../compose/domains/dns-helper-modal";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
applicationId: string;
|
applicationId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ShowDomains = ({ applicationId }: Props) => {
|
export const ShowDomains = ({ applicationId }: Props) => {
|
||||||
|
const { data: application } = api.application.one.useQuery(
|
||||||
|
{
|
||||||
|
applicationId,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
enabled: !!applicationId,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const [validationStates, setValidationStates] = useState<ValidationStates>(
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
const { data: ip } = api.settings.getIp.useQuery();
|
||||||
|
|
||||||
const { data, refetch } = api.domain.byApplicationId.useQuery(
|
const { data, refetch } = api.domain.byApplicationId.useQuery(
|
||||||
{
|
{
|
||||||
applicationId,
|
applicationId,
|
||||||
@@ -26,10 +59,47 @@ export const ShowDomains = ({ applicationId }: Props) => {
|
|||||||
enabled: !!applicationId,
|
enabled: !!applicationId,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
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">
|
||||||
@@ -68,73 +138,208 @@ 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">
|
||||||
{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"
|
||||||
>
|
>
|
||||||
<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">
|
<Link
|
||||||
{item.host}
|
className="flex items-center gap-2 text-base font-medium hover:underline"
|
||||||
</span>
|
target="_blank"
|
||||||
<ExternalLink className="size-4 min-w-4" />
|
href={`${item.https ? "https" : "http"}://${item.host}${item.path}`}
|
||||||
</Link>
|
>
|
||||||
|
{item.host}
|
||||||
|
<ExternalLink className="size-4" />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{!item.host.includes("traefik.me") && (
|
||||||
|
<DnsHelperModal
|
||||||
|
domain={{
|
||||||
|
host: item.host,
|
||||||
|
https: item.https,
|
||||||
|
path: item.path || undefined,
|
||||||
|
}}
|
||||||
|
serverIp={
|
||||||
|
application?.server?.ipAddress?.toString() ||
|
||||||
|
ip?.toString()
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<AddDomain
|
||||||
|
applicationId={applicationId}
|
||||||
|
domainId={item.domainId}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="group hover:bg-blue-500/10"
|
||||||
|
>
|
||||||
|
<PenBoxIcon className="size-3.5 text-primary group-hover:text-blue-500" />
|
||||||
|
</Button>
|
||||||
|
</AddDomain>
|
||||||
|
<DialogAction
|
||||||
|
title="Delete Domain"
|
||||||
|
description="Are you sure you want to delete this domain?"
|
||||||
|
type="destructive"
|
||||||
|
onClick={async () => {
|
||||||
|
await deleteDomain({
|
||||||
|
domainId: item.domainId,
|
||||||
|
})
|
||||||
|
.then((_data) => {
|
||||||
|
refetch();
|
||||||
|
toast.success(
|
||||||
|
"Domain deleted successfully",
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
toast.error("Error deleting domain");
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="group hover:bg-red-500/10"
|
||||||
|
isLoading={isRemoving}
|
||||||
|
>
|
||||||
|
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
|
||||||
|
</Button>
|
||||||
|
</DialogAction>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-8">
|
{/* Domain Details */}
|
||||||
<div className="flex gap-8 opacity-50 items-center h-10 text-center text-sm font-medium">
|
<div className="flex flex-wrap gap-3">
|
||||||
<span>{item.path}</span>
|
<TooltipProvider>
|
||||||
<span>{item.port}</span>
|
<Tooltip>
|
||||||
<span>{item.https ? "HTTPS" : "HTTP"}</span>
|
<TooltipTrigger asChild>
|
||||||
</div>
|
<Badge variant="secondary">
|
||||||
|
<InfoIcon className="size-3 mr-1" />
|
||||||
|
Path: {item.path || "/"}
|
||||||
|
</Badge>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>URL path for this service</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
|
||||||
<div className="flex gap-2">
|
<TooltipProvider>
|
||||||
<AddDomain
|
<Tooltip>
|
||||||
applicationId={applicationId}
|
<TooltipTrigger asChild>
|
||||||
domainId={item.domainId}
|
<Badge variant="secondary">
|
||||||
>
|
<InfoIcon className="size-3 mr-1" />
|
||||||
<Button
|
Port: {item.port}
|
||||||
variant="ghost"
|
</Badge>
|
||||||
size="icon"
|
</TooltipTrigger>
|
||||||
className="group hover:bg-blue-500/10 "
|
<TooltipContent>
|
||||||
>
|
<p>Container port exposed</p>
|
||||||
<PenBoxIcon className="size-3.5 text-primary group-hover:text-blue-500" />
|
</TooltipContent>
|
||||||
</Button>
|
</Tooltip>
|
||||||
</AddDomain>
|
</TooltipProvider>
|
||||||
<DialogAction
|
|
||||||
title="Delete Domain"
|
<TooltipProvider>
|
||||||
description="Are you sure you want to delete this domain?"
|
<Tooltip>
|
||||||
type="destructive"
|
<TooltipTrigger asChild>
|
||||||
onClick={async () => {
|
<Badge
|
||||||
await deleteDomain({
|
variant={item.https ? "outline" : "secondary"}
|
||||||
domainId: item.domainId,
|
>
|
||||||
})
|
{item.https ? "HTTPS" : "HTTP"}
|
||||||
.then(() => {
|
</Badge>
|
||||||
refetch();
|
</TooltipTrigger>
|
||||||
toast.success("Domain deleted successfully");
|
<TooltipContent>
|
||||||
})
|
<p>
|
||||||
.catch(() => {
|
{item.https
|
||||||
toast.error("Error deleting domain");
|
? "Secure HTTPS connection"
|
||||||
});
|
: "Standard HTTP connection"}
|
||||||
}}
|
</p>
|
||||||
>
|
</TooltipContent>
|
||||||
<Button
|
</Tooltip>
|
||||||
variant="ghost"
|
</TooltipProvider>
|
||||||
size="icon"
|
|
||||||
className="group hover:bg-red-500/10"
|
{item.certificateType && (
|
||||||
isLoading={isRemoving}
|
<TooltipProvider>
|
||||||
>
|
<Tooltip>
|
||||||
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
|
<TooltipTrigger asChild>
|
||||||
</Button>
|
<Badge variant="outline">
|
||||||
</DialogAction>
|
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>
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ const GithubProviderSchema = z.object({
|
|||||||
branch: z.string().min(1, "Branch is required"),
|
branch: z.string().min(1, "Branch is required"),
|
||||||
githubId: z.string().min(1, "Github Provider is required"),
|
githubId: z.string().min(1, "Github Provider is required"),
|
||||||
watchPaths: z.array(z.string()).optional(),
|
watchPaths: z.array(z.string()).optional(),
|
||||||
|
triggerType: z.enum(["push", "tag"]).default("push"),
|
||||||
enableSubmodules: z.boolean().default(false),
|
enableSubmodules: z.boolean().default(false),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -83,6 +84,7 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
|
|||||||
},
|
},
|
||||||
githubId: "",
|
githubId: "",
|
||||||
branch: "",
|
branch: "",
|
||||||
|
triggerType: "push",
|
||||||
enableSubmodules: false,
|
enableSubmodules: false,
|
||||||
},
|
},
|
||||||
resolver: zodResolver(GithubProviderSchema),
|
resolver: zodResolver(GithubProviderSchema),
|
||||||
@@ -90,6 +92,7 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
|
|||||||
|
|
||||||
const repository = form.watch("repository");
|
const repository = form.watch("repository");
|
||||||
const githubId = form.watch("githubId");
|
const githubId = form.watch("githubId");
|
||||||
|
const triggerType = form.watch("triggerType");
|
||||||
|
|
||||||
const { data: repositories, isLoading: isLoadingRepositories } =
|
const { data: repositories, isLoading: isLoadingRepositories } =
|
||||||
api.github.getGithubRepositories.useQuery(
|
api.github.getGithubRepositories.useQuery(
|
||||||
@@ -127,6 +130,7 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
|
|||||||
buildPath: data.buildPath || "/",
|
buildPath: data.buildPath || "/",
|
||||||
githubId: data.githubId || "",
|
githubId: data.githubId || "",
|
||||||
watchPaths: data.watchPaths || [],
|
watchPaths: data.watchPaths || [],
|
||||||
|
triggerType: data.triggerType || "push",
|
||||||
enableSubmodules: data.enableSubmodules ?? false,
|
enableSubmodules: data.enableSubmodules ?? false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -141,6 +145,7 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
|
|||||||
buildPath: data.buildPath,
|
buildPath: data.buildPath,
|
||||||
githubId: data.githubId,
|
githubId: data.githubId,
|
||||||
watchPaths: data.watchPaths || [],
|
watchPaths: data.watchPaths || [],
|
||||||
|
triggerType: data.triggerType,
|
||||||
enableSubmodules: data.enableSubmodules,
|
enableSubmodules: data.enableSubmodules,
|
||||||
})
|
})
|
||||||
.then(async () => {
|
.then(async () => {
|
||||||
@@ -386,11 +391,11 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
|
|||||||
/>
|
/>
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="watchPaths"
|
name="triggerType"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem className="md:col-span-2">
|
<FormItem className="md:col-span-2">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2 ">
|
||||||
<FormLabel>Watch Paths</FormLabel>
|
<FormLabel>Trigger Type</FormLabel>
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
@@ -398,71 +403,114 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
|
|||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>
|
<TooltipContent>
|
||||||
<p>
|
<p>
|
||||||
Add paths to watch for changes. When files in these
|
Choose when to trigger deployments: on push to the
|
||||||
paths change, a new deployment will be triggered.
|
selected branch or when a new tag is created.
|
||||||
</p>
|
</p>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap gap-2 mb-2">
|
<Select
|
||||||
{field.value?.map((path, index) => (
|
onValueChange={field.onChange}
|
||||||
<Badge
|
defaultValue={field.value}
|
||||||
key={index}
|
value={field.value}
|
||||||
variant="secondary"
|
>
|
||||||
className="flex items-center gap-1"
|
|
||||||
>
|
|
||||||
{path}
|
|
||||||
<X
|
|
||||||
className="size-3 cursor-pointer hover:text-destructive"
|
|
||||||
onClick={() => {
|
|
||||||
const newPaths = [...(field.value || [])];
|
|
||||||
newPaths.splice(index, 1);
|
|
||||||
field.onChange(newPaths);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Badge>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<SelectTrigger>
|
||||||
placeholder="Enter a path to watch (e.g., src/*, dist/*)"
|
<SelectValue placeholder="Select a trigger type" />
|
||||||
onKeyDown={(e) => {
|
</SelectTrigger>
|
||||||
if (e.key === "Enter") {
|
|
||||||
e.preventDefault();
|
|
||||||
const input = e.currentTarget;
|
|
||||||
const path = input.value.trim();
|
|
||||||
if (path) {
|
|
||||||
field.onChange([...(field.value || []), path]);
|
|
||||||
input.value = "";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<Button
|
<SelectContent>
|
||||||
type="button"
|
<SelectItem value="push">On Push</SelectItem>
|
||||||
variant="outline"
|
<SelectItem value="tag">On Tag</SelectItem>
|
||||||
size="icon"
|
</SelectContent>
|
||||||
onClick={() => {
|
</Select>
|
||||||
const input = document.querySelector(
|
|
||||||
'input[placeholder*="Enter a path"]',
|
|
||||||
) as HTMLInputElement;
|
|
||||||
const path = input.value.trim();
|
|
||||||
if (path) {
|
|
||||||
field.onChange([...(field.value || []), path]);
|
|
||||||
input.value = "";
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Plus className="size-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
{triggerType === "push" && (
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="watchPaths"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="md:col-span-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<FormLabel>Watch Paths</FormLabel>
|
||||||
|
<TooltipProvider>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<HelpCircle className="size-4 text-muted-foreground hover:text-foreground transition-colors cursor-pointer" />
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>
|
||||||
|
Add paths to watch for changes. When files in
|
||||||
|
these paths change, a new deployment will be
|
||||||
|
triggered.
|
||||||
|
</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2 mb-2">
|
||||||
|
{field.value?.map((path, index) => (
|
||||||
|
<Badge
|
||||||
|
key={index}
|
||||||
|
variant="secondary"
|
||||||
|
className="flex items-center gap-1"
|
||||||
|
>
|
||||||
|
{path}
|
||||||
|
<X
|
||||||
|
className="size-3 cursor-pointer hover:text-destructive"
|
||||||
|
onClick={() => {
|
||||||
|
const newPaths = [...(field.value || [])];
|
||||||
|
newPaths.splice(index, 1);
|
||||||
|
field.onChange(newPaths);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
placeholder="Enter a path to watch (e.g., src/*, dist/*)"
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
e.preventDefault();
|
||||||
|
const input = e.currentTarget;
|
||||||
|
const path = input.value.trim();
|
||||||
|
if (path) {
|
||||||
|
field.onChange([...(field.value || []), path]);
|
||||||
|
input.value = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => {
|
||||||
|
const input = document.querySelector(
|
||||||
|
'input[placeholder*="Enter a path"]',
|
||||||
|
) as HTMLInputElement;
|
||||||
|
const path = input.value.trim();
|
||||||
|
if (path) {
|
||||||
|
field.onChange([...(field.value || []), path]);
|
||||||
|
input.value = "";
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Plus className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
|
|||||||
@@ -401,6 +401,11 @@ export const AddDomainCompose = ({
|
|||||||
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>
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { AlertBlock } from "@/components/shared/alert-block";
|
||||||
|
import { Copy, HelpCircle, Server } from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
domain: {
|
||||||
|
host: string;
|
||||||
|
https: boolean;
|
||||||
|
path?: string;
|
||||||
|
};
|
||||||
|
serverIp?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DnsHelperModal = ({ domain, serverIp }: Props) => {
|
||||||
|
const copyToClipboard = (text: string) => {
|
||||||
|
navigator.clipboard.writeText(text);
|
||||||
|
toast.success("Copied to clipboard!");
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog>
|
||||||
|
<DialogTrigger>
|
||||||
|
<Button variant="ghost" size="icon" className="group">
|
||||||
|
<HelpCircle className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-2xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
<Server className="size-5" />
|
||||||
|
DNS Configuration Guide
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Follow these steps to configure your DNS records for {domain.host}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<AlertBlock type="info">
|
||||||
|
To make your domain accessible, you need to configure your DNS
|
||||||
|
records with your domain provider (e.g., Cloudflare, GoDaddy,
|
||||||
|
NameCheap).
|
||||||
|
</AlertBlock>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
<div className="rounded-lg border p-4">
|
||||||
|
<h3 className="font-medium mb-2">1. Add A Record</h3>
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Create an A record that points your domain to the server's IP
|
||||||
|
address:
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<div className="flex items-center justify-between gap-2 bg-muted p-3 rounded-md">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium">Type: A</p>
|
||||||
|
<p className="text-sm">
|
||||||
|
Name: @ or {domain.host.split(".")[0]}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm">
|
||||||
|
Value: {serverIp || "Your server IP"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => copyToClipboard(serverIp || "")}
|
||||||
|
disabled={!serverIp}
|
||||||
|
>
|
||||||
|
<Copy className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-lg border p-4">
|
||||||
|
<h3 className="font-medium mb-2">2. Verify Configuration</h3>
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
After configuring your DNS records:
|
||||||
|
</p>
|
||||||
|
<ul className="list-disc list-inside space-y-1 text-sm">
|
||||||
|
<li>Wait for DNS propagation (usually 15-30 minutes)</li>
|
||||||
|
<li>
|
||||||
|
Test your domain by visiting:{" "}
|
||||||
|
{domain.https ? "https://" : "http://"}
|
||||||
|
{domain.host}
|
||||||
|
{domain.path || "/"}
|
||||||
|
</li>
|
||||||
|
<li>Use a DNS lookup tool to verify your records</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -7,17 +7,55 @@ import {
|
|||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
} from "@/components/ui/card";
|
} from "@/components/ui/card";
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipProvider,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from "@/components/ui/tooltip";
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
import { ExternalLink, GlobeIcon, PenBoxIcon, Trash2 } from "lucide-react";
|
import {
|
||||||
|
ExternalLink,
|
||||||
|
GlobeIcon,
|
||||||
|
PenBoxIcon,
|
||||||
|
Trash2,
|
||||||
|
InfoIcon,
|
||||||
|
Server,
|
||||||
|
CheckCircle2,
|
||||||
|
XCircle,
|
||||||
|
Loader2,
|
||||||
|
RefreshCw,
|
||||||
|
} from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { AddDomainCompose } from "./add-domain";
|
import { AddDomainCompose } from "./add-domain";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { DnsHelperModal } from "./dns-helper-modal";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
composeId: string;
|
composeId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ValidationState = {
|
||||||
|
isLoading: boolean;
|
||||||
|
isValid?: boolean;
|
||||||
|
error?: string;
|
||||||
|
resolvedIp?: string;
|
||||||
|
message?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ValidationStates = {
|
||||||
|
[key: string]: ValidationState;
|
||||||
|
};
|
||||||
|
|
||||||
export const ShowDomainsCompose = ({ composeId }: Props) => {
|
export const ShowDomainsCompose = ({ composeId }: Props) => {
|
||||||
|
const [validationStates, setValidationStates] = useState<ValidationStates>(
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
|
||||||
|
const { data: ip } = api.settings.getIp.useQuery();
|
||||||
|
|
||||||
const { data, refetch } = api.domain.byComposeId.useQuery(
|
const { data, refetch } = api.domain.byComposeId.useQuery(
|
||||||
{
|
{
|
||||||
composeId,
|
composeId,
|
||||||
@@ -27,11 +65,58 @@ export const ShowDomainsCompose = ({ composeId }: Props) => {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const { data: compose } = api.compose.one.useQuery(
|
||||||
|
{
|
||||||
|
composeId,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
enabled: !!composeId,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const { mutateAsync: validateDomain } =
|
||||||
|
api.domain.validateDomain.useMutation();
|
||||||
const { mutateAsync: deleteDomain, isLoading: isRemoving } =
|
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:
|
||||||
|
compose?.server?.ipAddress?.toString() || ip?.toString() || "",
|
||||||
|
});
|
||||||
|
|
||||||
|
setValidationStates((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[host]: {
|
||||||
|
isLoading: false,
|
||||||
|
isValid: result.isValid,
|
||||||
|
error: result.error,
|
||||||
|
resolvedIp: result.resolvedIp,
|
||||||
|
message: result.error && result.isValid ? result.error : undefined,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
} catch (err) {
|
||||||
|
const error = err as Error;
|
||||||
|
setValidationStates((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[host]: {
|
||||||
|
isLoading: false,
|
||||||
|
isValid: false,
|
||||||
|
error: error.message || "Failed to validate domain",
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
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">
|
||||||
<CardHeader className="flex flex-row items-center flex-wrap gap-4 justify-between">
|
<CardHeader className="flex flex-row items-center flex-wrap gap-4 justify-between">
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
@@ -45,100 +130,248 @@ export const ShowDomainsCompose = ({ composeId }: Props) => {
|
|||||||
{data && data?.length > 0 && (
|
{data && data?.length > 0 && (
|
||||||
<AddDomainCompose composeId={composeId}>
|
<AddDomainCompose composeId={composeId}>
|
||||||
<Button>
|
<Button>
|
||||||
<GlobeIcon className="size-4" /> Add Domain
|
<GlobeIcon className="size-4 mr-2" /> Add Domain
|
||||||
</Button>
|
</Button>
|
||||||
</AddDomainCompose>
|
</AddDomainCompose>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="flex w-full flex-row gap-4">
|
<CardContent>
|
||||||
{data?.length === 0 ? (
|
{data?.length === 0 ? (
|
||||||
<div className="flex w-full flex-col items-center justify-center gap-3">
|
<div className="flex w-full flex-col items-center justify-center gap-3 py-8">
|
||||||
<GlobeIcon className="size-8 text-muted-foreground" />
|
<GlobeIcon className="size-8 text-muted-foreground" />
|
||||||
<span className="text-base text-muted-foreground">
|
<span className="text-base text-muted-foreground text-center">
|
||||||
To access to the application it is required to set at least 1
|
To access to 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">
|
||||||
<AddDomainCompose composeId={composeId}>
|
<AddDomainCompose composeId={composeId}>
|
||||||
<Button>
|
<Button>
|
||||||
<GlobeIcon className="size-4" /> Add Domain
|
<GlobeIcon className="size-4 mr-2" /> Add Domain
|
||||||
</Button>
|
</Button>
|
||||||
</AddDomainCompose>
|
</AddDomainCompose>
|
||||||
</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">
|
||||||
{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 border bg-card transition-all hover:shadow-md bg-transparent"
|
||||||
>
|
>
|
||||||
<div className="md:basis-1/2 flex gap-6 w-full items-center">
|
<CardContent className="p-6">
|
||||||
<span className="opacity-50 text-center font-medium text-sm whitespace-nowrap">
|
<div className="flex flex-col gap-4">
|
||||||
{item.serviceName}
|
{/* Service & Domain Info */}
|
||||||
</span>
|
<div className="flex items-start justify-between">
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Badge variant="outline" className="w-fit">
|
||||||
|
<Server className="size-3 mr-1" />
|
||||||
|
{item.serviceName}
|
||||||
|
</Badge>
|
||||||
|
<Link
|
||||||
|
className="flex items-center gap-2 text-base font-medium hover:underline"
|
||||||
|
target="_blank"
|
||||||
|
href={`${item.https ? "https" : "http"}://${item.host}${item.path}`}
|
||||||
|
>
|
||||||
|
{item.host}
|
||||||
|
<ExternalLink className="size-4" />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{!item.host.includes("traefik.me") && (
|
||||||
|
<DnsHelperModal
|
||||||
|
domain={{
|
||||||
|
host: item.host,
|
||||||
|
https: item.https,
|
||||||
|
path: item.path || undefined,
|
||||||
|
}}
|
||||||
|
serverIp={
|
||||||
|
compose?.server?.ipAddress?.toString() ||
|
||||||
|
ip?.toString()
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<AddDomainCompose
|
||||||
|
composeId={composeId}
|
||||||
|
domainId={item.domainId}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="group hover:bg-blue-500/10"
|
||||||
|
>
|
||||||
|
<PenBoxIcon className="size-3.5 text-primary group-hover:text-blue-500" />
|
||||||
|
</Button>
|
||||||
|
</AddDomainCompose>
|
||||||
|
<DialogAction
|
||||||
|
title="Delete Domain"
|
||||||
|
description="Are you sure you want to delete this domain?"
|
||||||
|
type="destructive"
|
||||||
|
onClick={async () => {
|
||||||
|
await deleteDomain({
|
||||||
|
domainId: item.domainId,
|
||||||
|
})
|
||||||
|
.then((_data) => {
|
||||||
|
refetch();
|
||||||
|
toast.success(
|
||||||
|
"Domain deleted successfully",
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
toast.error("Error deleting domain");
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="group hover:bg-red-500/10"
|
||||||
|
isLoading={isRemoving}
|
||||||
|
>
|
||||||
|
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
|
||||||
|
</Button>
|
||||||
|
</DialogAction>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<Link
|
{/* Domain Details */}
|
||||||
className="flex gap-2 items-center hover:underline transition-all w-full max-w-[calc(100%-4rem)]"
|
<div className="flex flex-wrap gap-3">
|
||||||
target="_blank"
|
<TooltipProvider>
|
||||||
href={`${item.https ? "https" : "http"}://${item.host}${item.path}`}
|
<Tooltip>
|
||||||
>
|
<TooltipTrigger asChild>
|
||||||
<span className="truncate text-sm">{item.host}</span>
|
<Badge variant="secondary">
|
||||||
<ExternalLink className="size-4 min-w-4" />
|
<InfoIcon className="size-3 mr-1" />
|
||||||
</Link>
|
Path: {item.path || "/"}
|
||||||
</div>
|
</Badge>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>URL path for this service</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
|
||||||
<div className="flex gap-8">
|
<TooltipProvider>
|
||||||
<div className="flex gap-8 opacity-50 items-center h-10 text-center text-sm font-medium">
|
<Tooltip>
|
||||||
<span>{item.path}</span>
|
<TooltipTrigger asChild>
|
||||||
<span>{item.port}</span>
|
<Badge variant="secondary">
|
||||||
<span>{item.https ? "HTTPS" : "HTTP"}</span>
|
<InfoIcon className="size-3 mr-1" />
|
||||||
|
Port: {item.port}
|
||||||
|
</Badge>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>Container port exposed</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
|
||||||
|
<TooltipProvider>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Badge
|
||||||
|
variant={item.https ? "outline" : "secondary"}
|
||||||
|
>
|
||||||
|
{item.https ? "HTTPS" : "HTTP"}
|
||||||
|
</Badge>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>
|
||||||
|
{item.https
|
||||||
|
? "Secure HTTPS connection"
|
||||||
|
: "Standard HTTP connection"}
|
||||||
|
</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
|
||||||
|
{item.certificateType && (
|
||||||
|
<TooltipProvider>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Badge variant="outline">
|
||||||
|
Cert: {item.certificateType}
|
||||||
|
</Badge>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>SSL Certificate Provider</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<TooltipProvider>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className={
|
||||||
|
validationState?.isValid
|
||||||
|
? "bg-green-500/10 text-green-500 cursor-pointer"
|
||||||
|
: validationState?.error
|
||||||
|
? "bg-red-500/10 text-red-500 cursor-pointer"
|
||||||
|
: "bg-yellow-500/10 text-yellow-500 cursor-pointer"
|
||||||
|
}
|
||||||
|
onClick={() =>
|
||||||
|
handleValidateDomain(item.host)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{validationState?.isLoading ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="size-3 mr-1 animate-spin" />
|
||||||
|
Checking DNS...
|
||||||
|
</>
|
||||||
|
) : validationState?.isValid ? (
|
||||||
|
<>
|
||||||
|
<CheckCircle2 className="size-3 mr-1" />
|
||||||
|
{validationState.message
|
||||||
|
? "Behind Cloudflare"
|
||||||
|
: "DNS Valid"}
|
||||||
|
</>
|
||||||
|
) : validationState?.error ? (
|
||||||
|
<>
|
||||||
|
<XCircle className="size-3 mr-1" />
|
||||||
|
{validationState.error}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<RefreshCw className="size-3 mr-1" />
|
||||||
|
Validate DNS
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Badge>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent className="max-w-xs">
|
||||||
|
{validationState?.error &&
|
||||||
|
!validationState.isValid ? (
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<p className="font-medium text-red-500">
|
||||||
|
Error:
|
||||||
|
</p>
|
||||||
|
<p>{validationState.error}</p>
|
||||||
|
</div>
|
||||||
|
) : validationState?.isValid ? (
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<p className="font-medium text-green-500">
|
||||||
|
{validationState.message
|
||||||
|
? "Info:"
|
||||||
|
: "Valid Configuration:"}
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
{validationState.message ||
|
||||||
|
`Domain points to ${validationState.resolvedIp}`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
"Click to validate DNS configuration"
|
||||||
|
)}
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</CardContent>
|
||||||
<div className="flex gap-2">
|
</Card>
|
||||||
<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>
|
</div>
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ import {
|
|||||||
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 { CheckIcon, ChevronsUpDown, X } from "lucide-react";
|
import { CheckIcon, ChevronsUpDown, HelpCircle, X } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
@@ -58,6 +58,7 @@ const GithubProviderSchema = z.object({
|
|||||||
branch: z.string().min(1, "Branch is required"),
|
branch: z.string().min(1, "Branch is required"),
|
||||||
githubId: z.string().min(1, "Github Provider is required"),
|
githubId: z.string().min(1, "Github Provider is required"),
|
||||||
watchPaths: z.array(z.string()).optional(),
|
watchPaths: z.array(z.string()).optional(),
|
||||||
|
triggerType: z.enum(["push", "tag"]).default("push"),
|
||||||
enableSubmodules: z.boolean().default(false),
|
enableSubmodules: z.boolean().default(false),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -84,6 +85,7 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
|
|||||||
githubId: "",
|
githubId: "",
|
||||||
branch: "",
|
branch: "",
|
||||||
watchPaths: [],
|
watchPaths: [],
|
||||||
|
triggerType: "push",
|
||||||
enableSubmodules: false,
|
enableSubmodules: false,
|
||||||
},
|
},
|
||||||
resolver: zodResolver(GithubProviderSchema),
|
resolver: zodResolver(GithubProviderSchema),
|
||||||
@@ -91,7 +93,7 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
|
|||||||
|
|
||||||
const repository = form.watch("repository");
|
const repository = form.watch("repository");
|
||||||
const githubId = form.watch("githubId");
|
const githubId = form.watch("githubId");
|
||||||
|
const triggerType = form.watch("triggerType");
|
||||||
const { data: repositories, isLoading: isLoadingRepositories } =
|
const { data: repositories, isLoading: isLoadingRepositories } =
|
||||||
api.github.getGithubRepositories.useQuery(
|
api.github.getGithubRepositories.useQuery(
|
||||||
{
|
{
|
||||||
@@ -128,6 +130,7 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
|
|||||||
composePath: data.composePath,
|
composePath: data.composePath,
|
||||||
githubId: data.githubId || "",
|
githubId: data.githubId || "",
|
||||||
watchPaths: data.watchPaths || [],
|
watchPaths: data.watchPaths || [],
|
||||||
|
triggerType: data.triggerType || "push",
|
||||||
enableSubmodules: data.enableSubmodules ?? false,
|
enableSubmodules: data.enableSubmodules ?? false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -145,6 +148,7 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
|
|||||||
composeStatus: "idle",
|
composeStatus: "idle",
|
||||||
watchPaths: data.watchPaths,
|
watchPaths: data.watchPaths,
|
||||||
enableSubmodules: data.enableSubmodules,
|
enableSubmodules: data.enableSubmodules,
|
||||||
|
triggerType: data.triggerType,
|
||||||
})
|
})
|
||||||
.then(async () => {
|
.then(async () => {
|
||||||
toast.success("Service Provided Saved");
|
toast.success("Service Provided Saved");
|
||||||
@@ -389,82 +393,128 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
|
|||||||
/>
|
/>
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="watchPaths"
|
name="triggerType"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem className="md:col-span-2">
|
<FormItem className="md:col-span-2">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<FormLabel>Watch Paths</FormLabel>
|
<FormLabel>Trigger Type</FormLabel>
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger>
|
<TooltipTrigger asChild>
|
||||||
<div className="size-4 rounded-full bg-muted flex items-center justify-center text-[10px] font-bold">
|
<HelpCircle className="size-4 text-muted-foreground hover:text-foreground transition-colors cursor-pointer" />
|
||||||
?
|
|
||||||
</div>
|
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>
|
<TooltipContent>
|
||||||
<p>
|
<p>
|
||||||
Add paths to watch for changes. When files in these
|
Choose when to trigger deployments: on push to the
|
||||||
paths change, a new deployment will be triggered.
|
selected branch or when a new tag is created.
|
||||||
</p>
|
</p>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap gap-2 mb-2">
|
<Select
|
||||||
{field.value?.map((path, index) => (
|
onValueChange={field.onChange}
|
||||||
<Badge key={index} variant="secondary">
|
defaultValue={field.value}
|
||||||
{path}
|
value={field.value}
|
||||||
<X
|
>
|
||||||
className="ml-1 size-3 cursor-pointer"
|
<FormControl>
|
||||||
onClick={() => {
|
<SelectTrigger>
|
||||||
const newPaths = [...(field.value || [])];
|
<SelectValue placeholder="Select a trigger type" />
|
||||||
newPaths.splice(index, 1);
|
</SelectTrigger>
|
||||||
form.setValue("watchPaths", newPaths);
|
</FormControl>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="push">On Push</SelectItem>
|
||||||
|
<SelectItem value="tag">On Tag</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{triggerType === "push" && (
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="watchPaths"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="md:col-span-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<FormLabel>Watch Paths</FormLabel>
|
||||||
|
<TooltipProvider>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger>
|
||||||
|
<div className="size-4 rounded-full bg-muted flex items-center justify-center text-[10px] font-bold">
|
||||||
|
?
|
||||||
|
</div>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>
|
||||||
|
Add paths to watch for changes. When files in
|
||||||
|
these paths change, a new deployment will be
|
||||||
|
triggered.
|
||||||
|
</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2 mb-2">
|
||||||
|
{field.value?.map((path, index) => (
|
||||||
|
<Badge key={index} variant="secondary">
|
||||||
|
{path}
|
||||||
|
<X
|
||||||
|
className="ml-1 size-3 cursor-pointer"
|
||||||
|
onClick={() => {
|
||||||
|
const newPaths = [...(field.value || [])];
|
||||||
|
newPaths.splice(index, 1);
|
||||||
|
form.setValue("watchPaths", newPaths);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<FormControl>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
placeholder="Enter a path to watch (e.g., src/*, dist/*)"
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
e.preventDefault();
|
||||||
|
const input = e.currentTarget;
|
||||||
|
const value = input.value.trim();
|
||||||
|
if (value) {
|
||||||
|
const newPaths = [
|
||||||
|
...(field.value || []),
|
||||||
|
value,
|
||||||
|
];
|
||||||
|
form.setValue("watchPaths", newPaths);
|
||||||
|
input.value = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Badge>
|
<Button
|
||||||
))}
|
type="button"
|
||||||
</div>
|
variant="secondary"
|
||||||
<FormControl>
|
onClick={() => {
|
||||||
<div className="flex gap-2">
|
const input = document.querySelector(
|
||||||
<Input
|
'input[placeholder="Enter a path to watch (e.g., src/*, dist/*)"]',
|
||||||
placeholder="Enter a path to watch (e.g., src/*, dist/*)"
|
) as HTMLInputElement;
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === "Enter") {
|
|
||||||
e.preventDefault();
|
|
||||||
const input = e.currentTarget;
|
|
||||||
const value = input.value.trim();
|
const value = input.value.trim();
|
||||||
if (value) {
|
if (value) {
|
||||||
const newPaths = [...(field.value || []), value];
|
const newPaths = [...(field.value || []), value];
|
||||||
form.setValue("watchPaths", newPaths);
|
form.setValue("watchPaths", newPaths);
|
||||||
input.value = "";
|
input.value = "";
|
||||||
}
|
}
|
||||||
}
|
}}
|
||||||
}}
|
>
|
||||||
/>
|
Add
|
||||||
<Button
|
</Button>
|
||||||
type="button"
|
</div>
|
||||||
variant="secondary"
|
</FormControl>
|
||||||
onClick={() => {
|
<FormMessage />
|
||||||
const input = document.querySelector(
|
</FormItem>
|
||||||
'input[placeholder="Enter a path to watch (e.g., src/*, dist/*)"]',
|
)}
|
||||||
) as HTMLInputElement;
|
/>
|
||||||
const value = input.value.trim();
|
)}
|
||||||
if (value) {
|
|
||||||
const newPaths = [...(field.value || []), value];
|
|
||||||
form.setValue("watchPaths", newPaths);
|
|
||||||
input.value = "";
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Add
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="enableSubmodules"
|
name="enableSubmodules"
|
||||||
|
|||||||
@@ -1,347 +0,0 @@
|
|||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import {
|
|
||||||
Command,
|
|
||||||
CommandEmpty,
|
|
||||||
CommandGroup,
|
|
||||||
CommandInput,
|
|
||||||
CommandItem,
|
|
||||||
} from "@/components/ui/command";
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
DialogTrigger,
|
|
||||||
} from "@/components/ui/dialog";
|
|
||||||
import {
|
|
||||||
Form,
|
|
||||||
FormControl,
|
|
||||||
FormDescription,
|
|
||||||
FormField,
|
|
||||||
FormItem,
|
|
||||||
FormLabel,
|
|
||||||
FormMessage,
|
|
||||||
} from "@/components/ui/form";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import {
|
|
||||||
Popover,
|
|
||||||
PopoverContent,
|
|
||||||
PopoverTrigger,
|
|
||||||
} from "@/components/ui/popover";
|
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
|
||||||
import { Switch } from "@/components/ui/switch";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
import { api } from "@/utils/api";
|
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
|
||||||
import { PlusIcon } from "lucide-react";
|
|
||||||
import { CheckIcon, ChevronsUpDown } from "lucide-react";
|
|
||||||
import { useEffect } from "react";
|
|
||||||
import { useForm } from "react-hook-form";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
import { z } from "zod";
|
|
||||||
|
|
||||||
const AddPostgresBackup1Schema = z.object({
|
|
||||||
destinationId: z.string().min(1, "Destination required"),
|
|
||||||
schedule: z.string().min(1, "Schedule (Cron) required"),
|
|
||||||
// .regex(
|
|
||||||
// new RegExp(
|
|
||||||
// /^(\*|([0-9]|1[0-9]|2[0-9]|3[0-9]|4[0-9]|5[0-9])|\*\/([0-9]|1[0-9]|2[0-9]|3[0-9]|4[0-9]|5[0-9])) (\*|([0-9]|1[0-9]|2[0-3])|\*\/([0-9]|1[0-9]|2[0-3])) (\*|([1-9]|1[0-9]|2[0-9]|3[0-1])|\*\/([1-9]|1[0-9]|2[0-9]|3[0-1])) (\*|([1-9]|1[0-2])|\*\/([1-9]|1[0-2])) (\*|([0-6])|\*\/([0-6]))$/,
|
|
||||||
// ),
|
|
||||||
// "Invalid Cron",
|
|
||||||
// ),
|
|
||||||
prefix: z.string().min(1, "Prefix required"),
|
|
||||||
enabled: z.boolean(),
|
|
||||||
database: z.string().min(1, "Database required"),
|
|
||||||
keepLatestCount: z.coerce.number().optional(),
|
|
||||||
});
|
|
||||||
|
|
||||||
type AddPostgresBackup = z.infer<typeof AddPostgresBackup1Schema>;
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
databaseId: string;
|
|
||||||
databaseType: "postgres" | "mariadb" | "mysql" | "mongo" | "web-server";
|
|
||||||
refetch: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const AddBackup = ({ databaseId, databaseType, refetch }: Props) => {
|
|
||||||
const { data, isLoading } = api.destination.all.useQuery();
|
|
||||||
|
|
||||||
const { mutateAsync: createBackup, isLoading: isCreatingPostgresBackup } =
|
|
||||||
api.backup.create.useMutation();
|
|
||||||
|
|
||||||
const form = useForm<AddPostgresBackup>({
|
|
||||||
defaultValues: {
|
|
||||||
database: "",
|
|
||||||
destinationId: "",
|
|
||||||
enabled: true,
|
|
||||||
prefix: "/",
|
|
||||||
schedule: "",
|
|
||||||
keepLatestCount: undefined,
|
|
||||||
},
|
|
||||||
resolver: zodResolver(AddPostgresBackup1Schema),
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
form.reset({
|
|
||||||
database: databaseType === "web-server" ? "dokploy" : "",
|
|
||||||
destinationId: "",
|
|
||||||
enabled: true,
|
|
||||||
prefix: "/",
|
|
||||||
schedule: "",
|
|
||||||
keepLatestCount: undefined,
|
|
||||||
});
|
|
||||||
}, [form, form.reset, form.formState.isSubmitSuccessful]);
|
|
||||||
|
|
||||||
const onSubmit = async (data: AddPostgresBackup) => {
|
|
||||||
const getDatabaseId =
|
|
||||||
databaseType === "postgres"
|
|
||||||
? {
|
|
||||||
postgresId: databaseId,
|
|
||||||
}
|
|
||||||
: databaseType === "mariadb"
|
|
||||||
? {
|
|
||||||
mariadbId: databaseId,
|
|
||||||
}
|
|
||||||
: databaseType === "mysql"
|
|
||||||
? {
|
|
||||||
mysqlId: databaseId,
|
|
||||||
}
|
|
||||||
: databaseType === "mongo"
|
|
||||||
? {
|
|
||||||
mongoId: databaseId,
|
|
||||||
}
|
|
||||||
: databaseType === "web-server"
|
|
||||||
? {
|
|
||||||
userId: databaseId,
|
|
||||||
}
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
await createBackup({
|
|
||||||
destinationId: data.destinationId,
|
|
||||||
prefix: data.prefix,
|
|
||||||
schedule: data.schedule,
|
|
||||||
enabled: data.enabled,
|
|
||||||
database: data.database,
|
|
||||||
keepLatestCount: data.keepLatestCount,
|
|
||||||
databaseType,
|
|
||||||
...getDatabaseId,
|
|
||||||
})
|
|
||||||
.then(async () => {
|
|
||||||
toast.success("Backup Created");
|
|
||||||
refetch();
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
toast.error("Error creating a backup");
|
|
||||||
});
|
|
||||||
};
|
|
||||||
return (
|
|
||||||
<Dialog>
|
|
||||||
<DialogTrigger asChild>
|
|
||||||
<Button>
|
|
||||||
<PlusIcon className="h-4 w-4" />
|
|
||||||
Create Backup
|
|
||||||
</Button>
|
|
||||||
</DialogTrigger>
|
|
||||||
<DialogContent className="sm:max-w-lg max-h-screen overflow-y-auto">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Create a backup</DialogTitle>
|
|
||||||
<DialogDescription>Add a new backup</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<Form {...form}>
|
|
||||||
<form
|
|
||||||
id="hook-form-add-backup"
|
|
||||||
onSubmit={form.handleSubmit(onSubmit)}
|
|
||||||
className="grid w-full gap-4"
|
|
||||||
>
|
|
||||||
<div className="grid grid-cols-1 gap-4">
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="destinationId"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem className="">
|
|
||||||
<FormLabel>Destination</FormLabel>
|
|
||||||
<Popover>
|
|
||||||
<PopoverTrigger asChild>
|
|
||||||
<FormControl>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
className={cn(
|
|
||||||
"w-full justify-between !bg-input",
|
|
||||||
!field.value && "text-muted-foreground",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{isLoading
|
|
||||||
? "Loading...."
|
|
||||||
: field.value
|
|
||||||
? data?.find(
|
|
||||||
(destination) =>
|
|
||||||
destination.destinationId === field.value,
|
|
||||||
)?.name
|
|
||||||
: "Select Destination"}
|
|
||||||
|
|
||||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
|
||||||
</Button>
|
|
||||||
</FormControl>
|
|
||||||
</PopoverTrigger>
|
|
||||||
<PopoverContent className="p-0" align="start">
|
|
||||||
<Command>
|
|
||||||
<CommandInput
|
|
||||||
placeholder="Search Destination..."
|
|
||||||
className="h-9"
|
|
||||||
/>
|
|
||||||
{isLoading && (
|
|
||||||
<span className="py-6 text-center text-sm">
|
|
||||||
Loading Destinations....
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<CommandEmpty>No destinations found.</CommandEmpty>
|
|
||||||
<ScrollArea className="h-64">
|
|
||||||
<CommandGroup>
|
|
||||||
{data?.map((destination) => (
|
|
||||||
<CommandItem
|
|
||||||
value={destination.destinationId}
|
|
||||||
key={destination.destinationId}
|
|
||||||
onSelect={() => {
|
|
||||||
form.setValue(
|
|
||||||
"destinationId",
|
|
||||||
destination.destinationId,
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{destination.name}
|
|
||||||
<CheckIcon
|
|
||||||
className={cn(
|
|
||||||
"ml-auto h-4 w-4",
|
|
||||||
destination.destinationId === field.value
|
|
||||||
? "opacity-100"
|
|
||||||
: "opacity-0",
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</CommandItem>
|
|
||||||
))}
|
|
||||||
</CommandGroup>
|
|
||||||
</ScrollArea>
|
|
||||||
</Command>
|
|
||||||
</PopoverContent>
|
|
||||||
</Popover>
|
|
||||||
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="database"
|
|
||||||
render={({ field }) => {
|
|
||||||
return (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Database</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input
|
|
||||||
disabled={databaseType === "web-server"}
|
|
||||||
placeholder={"dokploy"}
|
|
||||||
{...field}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="schedule"
|
|
||||||
render={({ field }) => {
|
|
||||||
return (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Schedule (Cron)</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input placeholder={"0 0 * * *"} {...field} />
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="prefix"
|
|
||||||
render={({ field }) => {
|
|
||||||
return (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Prefix Destination</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input placeholder={"dokploy/"} {...field} />
|
|
||||||
</FormControl>
|
|
||||||
<FormDescription>
|
|
||||||
Use if you want to back up in a specific path of your
|
|
||||||
destination/bucket
|
|
||||||
</FormDescription>
|
|
||||||
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="keepLatestCount"
|
|
||||||
render={({ field }) => {
|
|
||||||
return (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Keep the latest</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
placeholder={"keeps all the backups if left empty"}
|
|
||||||
{...field}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
<FormDescription>
|
|
||||||
Optional. If provided, only keeps the latest N backups
|
|
||||||
in the cloud.
|
|
||||||
</FormDescription>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="enabled"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3 ">
|
|
||||||
<div className="space-y-0.5">
|
|
||||||
<FormLabel>Enabled</FormLabel>
|
|
||||||
<FormDescription>
|
|
||||||
Enable or disable the backup
|
|
||||||
</FormDescription>
|
|
||||||
</div>
|
|
||||||
<FormControl>
|
|
||||||
<Switch
|
|
||||||
checked={field.value}
|
|
||||||
onCheckedChange={field.onChange}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<DialogFooter>
|
|
||||||
<Button
|
|
||||||
isLoading={isCreatingPostgresBackup}
|
|
||||||
form="hook-form-add-backup"
|
|
||||||
type="submit"
|
|
||||||
>
|
|
||||||
Create
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</form>
|
|
||||||
</Form>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -0,0 +1,767 @@
|
|||||||
|
import { AlertBlock } from "@/components/shared/alert-block";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Command,
|
||||||
|
CommandEmpty,
|
||||||
|
CommandGroup,
|
||||||
|
CommandInput,
|
||||||
|
CommandItem,
|
||||||
|
} from "@/components/ui/command";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormDescription,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from "@/components/ui/form";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import {
|
||||||
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverTrigger,
|
||||||
|
} from "@/components/ui/popover";
|
||||||
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipProvider,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from "@/components/ui/tooltip";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { api } from "@/utils/api";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { DatabaseZap, PenBoxIcon, PlusIcon, RefreshCw } from "lucide-react";
|
||||||
|
import { CheckIcon, ChevronsUpDown } from "lucide-react";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
type CacheType = "cache" | "fetch";
|
||||||
|
|
||||||
|
type DatabaseType = "postgres" | "mariadb" | "mysql" | "mongo" | "web-server";
|
||||||
|
|
||||||
|
const Schema = z
|
||||||
|
.object({
|
||||||
|
destinationId: z.string().min(1, "Destination required"),
|
||||||
|
schedule: z.string().min(1, "Schedule (Cron) required"),
|
||||||
|
prefix: z.string().min(1, "Prefix required"),
|
||||||
|
enabled: z.boolean(),
|
||||||
|
database: z.string().min(1, "Database required"),
|
||||||
|
keepLatestCount: z.coerce.number().optional(),
|
||||||
|
serviceName: z.string().nullable(),
|
||||||
|
databaseType: z
|
||||||
|
.enum(["postgres", "mariadb", "mysql", "mongo", "web-server"])
|
||||||
|
.optional(),
|
||||||
|
backupType: z.enum(["database", "compose"]),
|
||||||
|
metadata: z
|
||||||
|
.object({
|
||||||
|
postgres: z
|
||||||
|
.object({
|
||||||
|
databaseUser: z.string(),
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
|
mariadb: z
|
||||||
|
.object({
|
||||||
|
databaseUser: z.string(),
|
||||||
|
databasePassword: z.string(),
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
|
mongo: z
|
||||||
|
.object({
|
||||||
|
databaseUser: z.string(),
|
||||||
|
databasePassword: z.string(),
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
|
mysql: z
|
||||||
|
.object({
|
||||||
|
databaseRootPassword: z.string(),
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
|
})
|
||||||
|
.superRefine((data, ctx) => {
|
||||||
|
if (data.backupType === "compose" && !data.databaseType) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message: "Database type is required for compose backups",
|
||||||
|
path: ["databaseType"],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (data.backupType === "compose" && data.databaseType) {
|
||||||
|
if (data.databaseType === "postgres") {
|
||||||
|
if (!data.metadata?.postgres?.databaseUser) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message: "Database user is required for PostgreSQL",
|
||||||
|
path: ["metadata", "postgres", "databaseUser"],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else if (data.databaseType === "mariadb") {
|
||||||
|
if (!data.metadata?.mariadb?.databaseUser) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message: "Database user is required for MariaDB",
|
||||||
|
path: ["metadata", "mariadb", "databaseUser"],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!data.metadata?.mariadb?.databasePassword) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message: "Database password is required for MariaDB",
|
||||||
|
path: ["metadata", "mariadb", "databasePassword"],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else if (data.databaseType === "mongo") {
|
||||||
|
if (!data.metadata?.mongo?.databaseUser) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message: "Database user is required for MongoDB",
|
||||||
|
path: ["metadata", "mongo", "databaseUser"],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!data.metadata?.mongo?.databasePassword) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message: "Database password is required for MongoDB",
|
||||||
|
path: ["metadata", "mongo", "databasePassword"],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else if (data.databaseType === "mysql") {
|
||||||
|
if (!data.metadata?.mysql?.databaseRootPassword) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message: "Root password is required for MySQL",
|
||||||
|
path: ["metadata", "mysql", "databaseRootPassword"],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
id?: string;
|
||||||
|
backupId?: string;
|
||||||
|
databaseType?: DatabaseType;
|
||||||
|
refetch: () => void;
|
||||||
|
backupType: "database" | "compose";
|
||||||
|
}
|
||||||
|
|
||||||
|
export const HandleBackup = ({
|
||||||
|
id,
|
||||||
|
backupId,
|
||||||
|
databaseType = "postgres",
|
||||||
|
refetch,
|
||||||
|
backupType = "database",
|
||||||
|
}: Props) => {
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
|
||||||
|
const { data, isLoading } = api.destination.all.useQuery();
|
||||||
|
const { data: backup } = api.backup.one.useQuery(
|
||||||
|
{
|
||||||
|
backupId: backupId ?? "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
enabled: !!backupId,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const [cacheType, setCacheType] = useState<CacheType>("cache");
|
||||||
|
const { mutateAsync: createBackup, isLoading: isCreatingPostgresBackup } =
|
||||||
|
backupId
|
||||||
|
? api.backup.update.useMutation()
|
||||||
|
: api.backup.create.useMutation();
|
||||||
|
|
||||||
|
const form = useForm<z.infer<typeof Schema>>({
|
||||||
|
defaultValues: {
|
||||||
|
database: databaseType === "web-server" ? "dokploy" : "",
|
||||||
|
destinationId: "",
|
||||||
|
enabled: true,
|
||||||
|
prefix: "/",
|
||||||
|
schedule: "",
|
||||||
|
keepLatestCount: undefined,
|
||||||
|
serviceName: null,
|
||||||
|
databaseType: backupType === "compose" ? undefined : databaseType,
|
||||||
|
backupType: backupType,
|
||||||
|
metadata: {},
|
||||||
|
},
|
||||||
|
resolver: zodResolver(Schema),
|
||||||
|
});
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: services,
|
||||||
|
isFetching: isLoadingServices,
|
||||||
|
error: errorServices,
|
||||||
|
refetch: refetchServices,
|
||||||
|
} = api.compose.loadServices.useQuery(
|
||||||
|
{
|
||||||
|
composeId: backup?.composeId ?? id ?? "",
|
||||||
|
type: cacheType,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
retry: false,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
enabled: backupType === "compose" && !!backup?.composeId && !!id,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
form.reset({
|
||||||
|
database: backup?.database
|
||||||
|
? backup?.database
|
||||||
|
: databaseType === "web-server"
|
||||||
|
? "dokploy"
|
||||||
|
: "",
|
||||||
|
destinationId: backup?.destinationId ?? "",
|
||||||
|
enabled: backup?.enabled ?? true,
|
||||||
|
prefix: backup?.prefix ?? "/",
|
||||||
|
schedule: backup?.schedule ?? "",
|
||||||
|
keepLatestCount: backup?.keepLatestCount ?? undefined,
|
||||||
|
serviceName: backup?.serviceName ?? null,
|
||||||
|
databaseType: backup?.databaseType ?? databaseType,
|
||||||
|
backupType: backup?.backupType ?? backupType,
|
||||||
|
metadata: backup?.metadata ?? {},
|
||||||
|
});
|
||||||
|
}, [form, form.reset, backupId, backup]);
|
||||||
|
|
||||||
|
const onSubmit = async (data: z.infer<typeof Schema>) => {
|
||||||
|
const getDatabaseId =
|
||||||
|
backupType === "compose"
|
||||||
|
? {
|
||||||
|
composeId: id,
|
||||||
|
}
|
||||||
|
: databaseType === "postgres"
|
||||||
|
? {
|
||||||
|
postgresId: id,
|
||||||
|
}
|
||||||
|
: databaseType === "mariadb"
|
||||||
|
? {
|
||||||
|
mariadbId: id,
|
||||||
|
}
|
||||||
|
: databaseType === "mysql"
|
||||||
|
? {
|
||||||
|
mysqlId: id,
|
||||||
|
}
|
||||||
|
: databaseType === "mongo"
|
||||||
|
? {
|
||||||
|
mongoId: id,
|
||||||
|
}
|
||||||
|
: databaseType === "web-server"
|
||||||
|
? {
|
||||||
|
userId: id,
|
||||||
|
}
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
await createBackup({
|
||||||
|
destinationId: data.destinationId,
|
||||||
|
prefix: data.prefix,
|
||||||
|
schedule: data.schedule,
|
||||||
|
enabled: data.enabled,
|
||||||
|
database: data.database,
|
||||||
|
keepLatestCount: data.keepLatestCount ?? null,
|
||||||
|
databaseType: data.databaseType || databaseType,
|
||||||
|
serviceName: data.serviceName,
|
||||||
|
...getDatabaseId,
|
||||||
|
backupId: backupId ?? "",
|
||||||
|
backupType,
|
||||||
|
metadata: data.metadata,
|
||||||
|
})
|
||||||
|
.then(async () => {
|
||||||
|
toast.success(`Backup ${backupId ? "Updated" : "Created"}`);
|
||||||
|
refetch();
|
||||||
|
setIsOpen(false);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
toast.error(`Error ${backupId ? "updating" : "creating"} a backup`);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
{backupId ? (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="group hover:bg-blue-500/10 size-8"
|
||||||
|
>
|
||||||
|
<PenBoxIcon className="size-3.5 text-primary group-hover:text-blue-500" />
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button>
|
||||||
|
<PlusIcon className="h-4 w-4" />
|
||||||
|
{backupId ? "Update Backup" : "Create Backup"}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent className="sm:max-w-2xl max-h-screen overflow-y-auto">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
{backupId ? "Update Backup" : "Create Backup"}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{backupId ? "Update a backup" : "Add a new backup"}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<Form {...form}>
|
||||||
|
<form
|
||||||
|
id="hook-form-add-backup"
|
||||||
|
onSubmit={form.handleSubmit(onSubmit)}
|
||||||
|
className="grid w-full gap-4"
|
||||||
|
>
|
||||||
|
<div className="grid grid-cols-1 gap-4">
|
||||||
|
{errorServices && (
|
||||||
|
<AlertBlock type="warning" className="[overflow-wrap:anywhere]">
|
||||||
|
{errorServices?.message}
|
||||||
|
</AlertBlock>
|
||||||
|
)}
|
||||||
|
{backupType === "compose" && (
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="databaseType"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Database Type</FormLabel>
|
||||||
|
<Select
|
||||||
|
value={field.value}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
field.onChange(value as DatabaseType);
|
||||||
|
form.setValue("metadata", {});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-full">
|
||||||
|
<SelectValue placeholder="Select a database type" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="postgres">PostgreSQL</SelectItem>
|
||||||
|
<SelectItem value="mariadb">MariaDB</SelectItem>
|
||||||
|
<SelectItem value="mysql">MySQL</SelectItem>
|
||||||
|
<SelectItem value="mongo">MongoDB</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="destinationId"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="">
|
||||||
|
<FormLabel>Destination</FormLabel>
|
||||||
|
<Popover>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<FormControl>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className={cn(
|
||||||
|
"w-full justify-between !bg-input",
|
||||||
|
!field.value && "text-muted-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isLoading
|
||||||
|
? "Loading...."
|
||||||
|
: field.value
|
||||||
|
? data?.find(
|
||||||
|
(destination) =>
|
||||||
|
destination.destinationId === field.value,
|
||||||
|
)?.name
|
||||||
|
: "Select Destination"}
|
||||||
|
|
||||||
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
|
</Button>
|
||||||
|
</FormControl>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="p-0" align="start">
|
||||||
|
<Command>
|
||||||
|
<CommandInput
|
||||||
|
placeholder="Search Destination..."
|
||||||
|
className="h-9"
|
||||||
|
/>
|
||||||
|
{isLoading && (
|
||||||
|
<span className="py-6 text-center text-sm">
|
||||||
|
Loading Destinations....
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<CommandEmpty>No destinations found.</CommandEmpty>
|
||||||
|
<ScrollArea className="h-64">
|
||||||
|
<CommandGroup>
|
||||||
|
{data?.map((destination) => (
|
||||||
|
<CommandItem
|
||||||
|
value={destination.destinationId}
|
||||||
|
key={destination.destinationId}
|
||||||
|
onSelect={() => {
|
||||||
|
form.setValue(
|
||||||
|
"destinationId",
|
||||||
|
destination.destinationId,
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{destination.name}
|
||||||
|
<CheckIcon
|
||||||
|
className={cn(
|
||||||
|
"ml-auto h-4 w-4",
|
||||||
|
destination.destinationId === field.value
|
||||||
|
? "opacity-100"
|
||||||
|
: "opacity-0",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
</ScrollArea>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{backupType === "compose" && (
|
||||||
|
<div className="flex flex-row items-end w-full gap-4">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="serviceName"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="w-full">
|
||||||
|
<FormLabel>Service Name</FormLabel>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Select
|
||||||
|
onValueChange={field.onChange}
|
||||||
|
value={field.value || undefined}
|
||||||
|
>
|
||||||
|
<FormControl>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select a service name" />
|
||||||
|
</SelectTrigger>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
<SelectContent>
|
||||||
|
{services?.map((service, index) => (
|
||||||
|
<SelectItem
|
||||||
|
value={service}
|
||||||
|
key={`${service}-${index}`}
|
||||||
|
>
|
||||||
|
{service}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
{(!services || services.length === 0) && (
|
||||||
|
<SelectItem value="none" disabled>
|
||||||
|
Empty
|
||||||
|
</SelectItem>
|
||||||
|
)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<TooltipProvider delayDuration={0}>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
type="button"
|
||||||
|
isLoading={isLoadingServices}
|
||||||
|
onClick={() => {
|
||||||
|
if (cacheType === "fetch") {
|
||||||
|
refetchServices();
|
||||||
|
} else {
|
||||||
|
setCacheType("fetch");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<RefreshCw className="size-4 text-muted-foreground" />
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent
|
||||||
|
side="left"
|
||||||
|
sideOffset={5}
|
||||||
|
className="max-w-[10rem]"
|
||||||
|
>
|
||||||
|
<p>
|
||||||
|
Fetch: Will clone the repository and load the
|
||||||
|
services
|
||||||
|
</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
<TooltipProvider delayDuration={0}>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
type="button"
|
||||||
|
isLoading={isLoadingServices}
|
||||||
|
onClick={() => {
|
||||||
|
if (cacheType === "cache") {
|
||||||
|
refetchServices();
|
||||||
|
} else {
|
||||||
|
setCacheType("cache");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DatabaseZap className="size-4 text-muted-foreground" />
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent
|
||||||
|
side="left"
|
||||||
|
sideOffset={5}
|
||||||
|
className="max-w-[10rem]"
|
||||||
|
>
|
||||||
|
<p>
|
||||||
|
Cache: If you previously deployed this
|
||||||
|
compose, it will read the services from the
|
||||||
|
last deployment/fetch from the repository
|
||||||
|
</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="database"
|
||||||
|
render={({ field }) => {
|
||||||
|
return (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Database</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
disabled={databaseType === "web-server"}
|
||||||
|
placeholder={"dokploy"}
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="schedule"
|
||||||
|
render={({ field }) => {
|
||||||
|
return (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Schedule (Cron)</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder={"0 0 * * *"} {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="prefix"
|
||||||
|
render={({ field }) => {
|
||||||
|
return (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Prefix Destination</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder={"dokploy/"} {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
Use if you want to back up in a specific path of your
|
||||||
|
destination/bucket
|
||||||
|
</FormDescription>
|
||||||
|
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="keepLatestCount"
|
||||||
|
render={({ field }) => {
|
||||||
|
return (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Keep the latest</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
placeholder={"keeps all the backups if left empty"}
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
Optional. If provided, only keeps the latest N backups
|
||||||
|
in the cloud.
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="enabled"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3 ">
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<FormLabel>Enabled</FormLabel>
|
||||||
|
<FormDescription>
|
||||||
|
Enable or disable the backup
|
||||||
|
</FormDescription>
|
||||||
|
</div>
|
||||||
|
<FormControl>
|
||||||
|
<Switch
|
||||||
|
checked={field.value}
|
||||||
|
onCheckedChange={field.onChange}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{backupType === "compose" && (
|
||||||
|
<>
|
||||||
|
{form.watch("databaseType") === "postgres" && (
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="metadata.postgres.databaseUser"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Database User</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="postgres" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{form.watch("databaseType") === "mariadb" && (
|
||||||
|
<>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="metadata.mariadb.databaseUser"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Database User</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="mariadb" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="metadata.mariadb.databasePassword"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Database Password</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
placeholder="••••••••"
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{form.watch("databaseType") === "mongo" && (
|
||||||
|
<>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="metadata.mongo.databaseUser"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Database User</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="mongo" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="metadata.mongo.databasePassword"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Database Password</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
placeholder="••••••••"
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{form.watch("databaseType") === "mysql" && (
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="metadata.mysql.databaseRootPassword"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Root Password</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
placeholder="••••••••"
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
isLoading={isCreatingPostgresBackup}
|
||||||
|
form="hook-form-add-backup"
|
||||||
|
type="submit"
|
||||||
|
>
|
||||||
|
{backupId ? "Update" : "Create"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -32,50 +32,163 @@ import {
|
|||||||
PopoverTrigger,
|
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
|
||||||
type RestoreBackup = z.infer<typeof RestoreBackupSchema>;
|
.enum(["postgres", "mariadb", "mysql", "mongo", "web-server"])
|
||||||
|
.optional(),
|
||||||
|
backupType: z.enum(["database", "compose"]).default("database"),
|
||||||
|
serviceName: z.string().nullable().optional(),
|
||||||
|
metadata: z
|
||||||
|
.object({
|
||||||
|
postgres: z
|
||||||
|
.object({
|
||||||
|
databaseUser: z.string(),
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
|
mariadb: z
|
||||||
|
.object({
|
||||||
|
databaseUser: z.string(),
|
||||||
|
databasePassword: z.string(),
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
|
mongo: z
|
||||||
|
.object({
|
||||||
|
databaseUser: z.string(),
|
||||||
|
databasePassword: z.string(),
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
|
mysql: z
|
||||||
|
.object({
|
||||||
|
databaseRootPassword: z.string(),
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
|
})
|
||||||
|
.superRefine((data, ctx) => {
|
||||||
|
if (data.backupType === "compose" && !data.databaseType) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message: "Database type is required for compose backups",
|
||||||
|
path: ["databaseType"],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (data.backupType === "compose" && data.databaseType) {
|
||||||
|
if (data.databaseType === "postgres") {
|
||||||
|
if (!data.metadata?.postgres?.databaseUser) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message: "Database user is required for PostgreSQL",
|
||||||
|
path: ["metadata", "postgres", "databaseUser"],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else if (data.databaseType === "mariadb") {
|
||||||
|
if (!data.metadata?.mariadb?.databaseUser) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message: "Database user is required for MariaDB",
|
||||||
|
path: ["metadata", "mariadb", "databaseUser"],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!data.metadata?.mariadb?.databasePassword) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message: "Database password is required for MariaDB",
|
||||||
|
path: ["metadata", "mariadb", "databasePassword"],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else if (data.databaseType === "mongo") {
|
||||||
|
if (!data.metadata?.mongo?.databaseUser) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message: "Database user is required for MongoDB",
|
||||||
|
path: ["metadata", "mongo", "databaseUser"],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!data.metadata?.mongo?.databasePassword) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message: "Database password is required for MongoDB",
|
||||||
|
path: ["metadata", "mongo", "databasePassword"],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else if (data.databaseType === "mysql") {
|
||||||
|
if (!data.metadata?.mysql?.databaseRootPassword) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message: "Root password is required for MySQL",
|
||||||
|
path: ["metadata", "mysql", "databaseRootPassword"],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const formatBytes = (bytes: number): string => {
|
const formatBytes = (bytes: number): string => {
|
||||||
if (bytes === 0) return "0 Bytes";
|
if (bytes === 0) return "0 Bytes";
|
||||||
@@ -86,9 +199,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 +210,21 @@ 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,
|
||||||
|
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 +250,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 +280,32 @@ export const RestoreBackup = ({
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
const onSubmit = async (_data: RestoreBackup) => {
|
const onSubmit = async (data: z.infer<typeof RestoreBackupSchema>) => {
|
||||||
|
if (backupType === "compose" && !data.databaseType) {
|
||||||
|
toast.error("Please select a database type");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.log({ data });
|
||||||
setIsDeploying(true);
|
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 +314,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 +517,270 @@ 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 placeholder="Enter database name" {...field} />
|
||||||
disabled={databaseType === "web-server"}
|
|
||||||
{...field}
|
|
||||||
placeholder="Enter database name"
|
|
||||||
/>
|
|
||||||
</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="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>
|
||||||
|
|||||||
@@ -19,44 +19,71 @@ 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";
|
||||||
|
|
||||||
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 +105,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>
|
||||||
@@ -110,7 +139,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 +147,15 @@ 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}
|
||||||
serverId={
|
serverId={
|
||||||
"serverId" in postgres ? postgres.serverId : undefined
|
"serverId" in postgres ? postgres.serverId : undefined
|
||||||
}
|
}
|
||||||
@@ -133,56 +163,118 @@ 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}>
|
<div key={backup.backupId}>
|
||||||
<div className="flex w-full flex-col md:flex-row md:items-center justify-between gap-4 md:gap-10 border rounded-lg p-4">
|
<div className="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="grid grid-cols-1 md:grid-cols-3 xl:grid-cols-6 flex-col gap-8">
|
<div className="flex flex-col w-full gap-4">
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex items-center gap-3">
|
||||||
<span className="font-medium">Destination</span>
|
{backup.backupType === "compose" && (
|
||||||
<span className="text-sm text-muted-foreground">
|
<div className="flex items-center justify-center size-10 rounded-lg">
|
||||||
{backup.destination.name}
|
{backup.databaseType === "postgres" && (
|
||||||
</span>
|
<PostgresqlIcon className="size-7" />
|
||||||
|
)}
|
||||||
|
{backup.databaseType === "mysql" && (
|
||||||
|
<MysqlIcon className="size-7" />
|
||||||
|
)}
|
||||||
|
{backup.databaseType === "mariadb" && (
|
||||||
|
<MariadbIcon className="size-7" />
|
||||||
|
)}
|
||||||
|
{backup.databaseType === "mongo" && (
|
||||||
|
<MongodbIcon className="size-7" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
{backup.backupType === "compose" && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<h3 className="font-medium">
|
||||||
|
{backup.serviceName}
|
||||||
|
</h3>
|
||||||
|
<span className="px-1.5 py-0.5 rounded-full bg-muted text-xs font-medium capitalize">
|
||||||
|
{backup.databaseType}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"size-1.5 rounded-full",
|
||||||
|
backup.enabled
|
||||||
|
? "bg-green-500"
|
||||||
|
: "bg-red-500",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{backup.enabled ? "Active" : "Inactive"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
<span className="font-medium">Database</span>
|
<div className="flex flex-wrap gap-x-8 gap-y-2">
|
||||||
<span className="text-sm text-muted-foreground">
|
<div className="min-w-[200px]">
|
||||||
{backup.database}
|
<span className="text-sm font-medium text-muted-foreground">
|
||||||
</span>
|
Destination
|
||||||
</div>
|
</span>
|
||||||
<div className="flex flex-col gap-1">
|
<p className="font-medium text-sm mt-0.5">
|
||||||
<span className="font-medium">Scheduled</span>
|
{backup.destination.name}
|
||||||
<span className="text-sm text-muted-foreground">
|
</p>
|
||||||
{backup.schedule}
|
</div>
|
||||||
</span>
|
|
||||||
</div>
|
<div className="min-w-[150px]">
|
||||||
<div className="flex flex-col gap-1">
|
<span className="text-sm font-medium text-muted-foreground">
|
||||||
<span className="font-medium">Prefix Storage</span>
|
Database
|
||||||
<span className="text-sm text-muted-foreground">
|
</span>
|
||||||
{backup.prefix}
|
<p className="font-medium text-sm mt-0.5">
|
||||||
</span>
|
{backup.database}
|
||||||
</div>
|
</p>
|
||||||
<div className="flex flex-col gap-1">
|
</div>
|
||||||
<span className="font-medium">Enabled</span>
|
|
||||||
<span className="text-sm text-muted-foreground">
|
<div className="min-w-[120px]">
|
||||||
{backup.enabled ? "Yes" : "No"}
|
<span className="text-sm font-medium text-muted-foreground">
|
||||||
</span>
|
Schedule
|
||||||
</div>
|
</span>
|
||||||
<div className="flex flex-col gap-1">
|
<p className="font-medium text-sm mt-0.5">
|
||||||
<span className="font-medium">Keep Latest</span>
|
{backup.schedule}
|
||||||
<span className="text-sm text-muted-foreground">
|
</p>
|
||||||
{backup.keepLatestCount || "All"}
|
</div>
|
||||||
</span>
|
|
||||||
|
<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>
|
</div>
|
||||||
<div className="flex flex-row gap-4">
|
|
||||||
|
<div className="flex flex-row md:flex-col gap-1.5">
|
||||||
<TooltipProvider delayDuration={0}>
|
<TooltipProvider delayDuration={0}>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="size-8"
|
||||||
isLoading={
|
isLoading={
|
||||||
isManualBackup &&
|
isManualBackup &&
|
||||||
activeManualBackup === backup.backupId
|
activeManualBackup === backup.backupId
|
||||||
@@ -205,14 +297,15 @@ export const ShowBackups = ({ id, type }: Props) => {
|
|||||||
setActiveManualBackup(undefined);
|
setActiveManualBackup(undefined);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Play className="size-5 text-muted-foreground" />
|
<Play className="size-4 " />
|
||||||
</Button>
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>Run Manual Backup</TooltipContent>
|
<TooltipContent>Run Manual Backup</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
|
|
||||||
<UpdateBackup
|
<HandleBackup
|
||||||
|
backupType={backup.backupType}
|
||||||
backupId={backup.backupId}
|
backupId={backup.backupId}
|
||||||
refetch={refetch}
|
refetch={refetch}
|
||||||
/>
|
/>
|
||||||
@@ -236,7 +329,7 @@ export const ShowBackups = ({ id, type }: Props) => {
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="group hover:bg-red-500/10"
|
className="group hover:bg-red-500/10 size-8"
|
||||||
isLoading={isRemoving}
|
isLoading={isRemoving}
|
||||||
>
|
>
|
||||||
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
|
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
|
||||||
|
|||||||
@@ -1,329 +0,0 @@
|
|||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import {
|
|
||||||
Command,
|
|
||||||
CommandEmpty,
|
|
||||||
CommandGroup,
|
|
||||||
CommandInput,
|
|
||||||
CommandItem,
|
|
||||||
} from "@/components/ui/command";
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
DialogTrigger,
|
|
||||||
} from "@/components/ui/dialog";
|
|
||||||
import {
|
|
||||||
Form,
|
|
||||||
FormControl,
|
|
||||||
FormDescription,
|
|
||||||
FormField,
|
|
||||||
FormItem,
|
|
||||||
FormLabel,
|
|
||||||
FormMessage,
|
|
||||||
} from "@/components/ui/form";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import {
|
|
||||||
Popover,
|
|
||||||
PopoverContent,
|
|
||||||
PopoverTrigger,
|
|
||||||
} from "@/components/ui/popover";
|
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
|
||||||
import { Switch } from "@/components/ui/switch";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
import { api } from "@/utils/api";
|
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
|
||||||
import { CheckIcon, ChevronsUpDown, PenBoxIcon } from "lucide-react";
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import { useForm } from "react-hook-form";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
import { z } from "zod";
|
|
||||||
|
|
||||||
const UpdateBackupSchema = z.object({
|
|
||||||
destinationId: z.string().min(1, "Destination required"),
|
|
||||||
schedule: z.string().min(1, "Schedule (Cron) required"),
|
|
||||||
prefix: z.string().min(1, "Prefix required"),
|
|
||||||
enabled: z.boolean(),
|
|
||||||
database: z.string().min(1, "Database required"),
|
|
||||||
keepLatestCount: z.coerce.number().optional(),
|
|
||||||
});
|
|
||||||
|
|
||||||
type UpdateBackup = z.infer<typeof UpdateBackupSchema>;
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
backupId: string;
|
|
||||||
refetch: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const UpdateBackup = ({ backupId, refetch }: Props) => {
|
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
|
||||||
const { data, isLoading } = api.destination.all.useQuery();
|
|
||||||
const { data: backup } = api.backup.one.useQuery(
|
|
||||||
{
|
|
||||||
backupId,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
enabled: !!backupId,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const { mutateAsync, isLoading: isLoadingUpdate } =
|
|
||||||
api.backup.update.useMutation();
|
|
||||||
|
|
||||||
const form = useForm<UpdateBackup>({
|
|
||||||
defaultValues: {
|
|
||||||
database: "",
|
|
||||||
destinationId: "",
|
|
||||||
enabled: true,
|
|
||||||
prefix: "/",
|
|
||||||
schedule: "",
|
|
||||||
keepLatestCount: undefined,
|
|
||||||
},
|
|
||||||
resolver: zodResolver(UpdateBackupSchema),
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (backup) {
|
|
||||||
form.reset({
|
|
||||||
database: backup.database,
|
|
||||||
destinationId: backup.destinationId,
|
|
||||||
enabled: backup.enabled || false,
|
|
||||||
prefix: backup.prefix,
|
|
||||||
schedule: backup.schedule,
|
|
||||||
keepLatestCount: backup.keepLatestCount
|
|
||||||
? Number(backup.keepLatestCount)
|
|
||||||
: undefined,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [form, form.reset, backup]);
|
|
||||||
|
|
||||||
const onSubmit = async (data: UpdateBackup) => {
|
|
||||||
await mutateAsync({
|
|
||||||
backupId,
|
|
||||||
destinationId: data.destinationId,
|
|
||||||
prefix: data.prefix,
|
|
||||||
schedule: data.schedule,
|
|
||||||
enabled: data.enabled,
|
|
||||||
database: data.database,
|
|
||||||
keepLatestCount: data.keepLatestCount as number | null,
|
|
||||||
})
|
|
||||||
.then(async () => {
|
|
||||||
toast.success("Backup Updated");
|
|
||||||
refetch();
|
|
||||||
setIsOpen(false);
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
toast.error("Error updating the Backup");
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
|
||||||
<DialogTrigger asChild>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="group hover:bg-blue-500/10 "
|
|
||||||
>
|
|
||||||
<PenBoxIcon className="size-3.5 text-primary group-hover:text-blue-500" />
|
|
||||||
</Button>
|
|
||||||
</DialogTrigger>
|
|
||||||
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-lg">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Update Backup</DialogTitle>
|
|
||||||
<DialogDescription>Update the backup</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<Form {...form}>
|
|
||||||
<form
|
|
||||||
id="hook-form-update-backup"
|
|
||||||
onSubmit={form.handleSubmit(onSubmit)}
|
|
||||||
className="grid w-full gap-4"
|
|
||||||
>
|
|
||||||
<div className="grid grid-cols-1 gap-4">
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="destinationId"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem className="">
|
|
||||||
<FormLabel>Destination</FormLabel>
|
|
||||||
<Popover>
|
|
||||||
<PopoverTrigger asChild>
|
|
||||||
<FormControl>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
className={cn(
|
|
||||||
"w-full justify-between !bg-input",
|
|
||||||
!field.value && "text-muted-foreground",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{isLoading
|
|
||||||
? "Loading...."
|
|
||||||
: field.value
|
|
||||||
? data?.find(
|
|
||||||
(destination) =>
|
|
||||||
destination.destinationId === field.value,
|
|
||||||
)?.name
|
|
||||||
: "Select Destination"}
|
|
||||||
|
|
||||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
|
||||||
</Button>
|
|
||||||
</FormControl>
|
|
||||||
</PopoverTrigger>
|
|
||||||
<PopoverContent className="p-0" align="start">
|
|
||||||
<Command>
|
|
||||||
<CommandInput
|
|
||||||
placeholder="Search Destination..."
|
|
||||||
className="h-9"
|
|
||||||
/>
|
|
||||||
{isLoading && (
|
|
||||||
<span className="py-6 text-center text-sm">
|
|
||||||
Loading Destinations....
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<CommandEmpty>No destinations found.</CommandEmpty>
|
|
||||||
<ScrollArea className="h-64">
|
|
||||||
<CommandGroup>
|
|
||||||
{data?.map((destination) => (
|
|
||||||
<CommandItem
|
|
||||||
value={destination.destinationId}
|
|
||||||
key={destination.destinationId}
|
|
||||||
onSelect={() => {
|
|
||||||
form.setValue(
|
|
||||||
"destinationId",
|
|
||||||
destination.destinationId,
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{destination.name}
|
|
||||||
<CheckIcon
|
|
||||||
className={cn(
|
|
||||||
"ml-auto h-4 w-4",
|
|
||||||
destination.destinationId === field.value
|
|
||||||
? "opacity-100"
|
|
||||||
: "opacity-0",
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</CommandItem>
|
|
||||||
))}
|
|
||||||
</CommandGroup>
|
|
||||||
</ScrollArea>
|
|
||||||
</Command>
|
|
||||||
</PopoverContent>
|
|
||||||
</Popover>
|
|
||||||
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="database"
|
|
||||||
render={({ field }) => {
|
|
||||||
return (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Database</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input placeholder={"dokploy"} {...field} />
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="schedule"
|
|
||||||
render={({ field }) => {
|
|
||||||
return (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Schedule (Cron)</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input placeholder={"0 0 * * *"} {...field} />
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="prefix"
|
|
||||||
render={({ field }) => {
|
|
||||||
return (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Prefix Destination</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input placeholder={"dokploy/"} {...field} />
|
|
||||||
</FormControl>
|
|
||||||
<FormDescription>
|
|
||||||
Use if you want to back up in a specific path of your
|
|
||||||
destination/bucket
|
|
||||||
</FormDescription>
|
|
||||||
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="keepLatestCount"
|
|
||||||
render={({ field }) => {
|
|
||||||
return (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Keep the latest</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
placeholder={"keeps all the backups if left empty"}
|
|
||||||
{...field}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
<FormDescription>
|
|
||||||
Optional. If provided, only keeps the latest N backups
|
|
||||||
in the cloud.
|
|
||||||
</FormDescription>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="enabled"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3 ">
|
|
||||||
<div className="space-y-0.5">
|
|
||||||
<FormLabel>Enabled</FormLabel>
|
|
||||||
<FormDescription>
|
|
||||||
Enable or disable the backup
|
|
||||||
</FormDescription>
|
|
||||||
</div>
|
|
||||||
<FormControl>
|
|
||||||
<Switch
|
|
||||||
checked={field.value}
|
|
||||||
onCheckedChange={field.onChange}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<DialogFooter>
|
|
||||||
<Button
|
|
||||||
isLoading={isLoadingUpdate}
|
|
||||||
form="hook-form-update-backup"
|
|
||||||
type="submit"
|
|
||||||
>
|
|
||||||
Update
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</form>
|
|
||||||
</Form>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
import { Card } from "@/components/ui/card";
|
||||||
|
import {
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import { SparklesIcon } from "lucide-react";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { api } from "@/utils/api";
|
||||||
|
import { SetupMonitoring } from "./servers/setup-monitoring";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
|
||||||
|
export const EnablePaidFeatures = () => {
|
||||||
|
const { data, refetch } = api.user.get.useQuery();
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const { mutateAsync: saveLicense } = api.user.saveLicense.useMutation();
|
||||||
|
const { mutateAsync: deactivateLicense } =
|
||||||
|
api.user.deactivateLicense.useMutation();
|
||||||
|
const { mutateAsync: validateLicense } =
|
||||||
|
api.user.validateLicense.useMutation();
|
||||||
|
const { mutateAsync: update } = api.user.update.useMutation();
|
||||||
|
const [licenseKey, setLicenseKey] = useState("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (data?.user?.enablePaidFeatures) {
|
||||||
|
setLicenseKey(data.user.licenseKey || "");
|
||||||
|
}
|
||||||
|
}, [data?.user?.enablePaidFeatures]);
|
||||||
|
|
||||||
|
const handleSaveLicense = async () => {
|
||||||
|
if (!licenseKey) {
|
||||||
|
toast.error("Please enter a license key");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setIsLoading(true);
|
||||||
|
await saveLicense({
|
||||||
|
licenseKey,
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
toast.success("License validated successfully");
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
toast.error("Error validating license", {
|
||||||
|
description: e.message,
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
setIsLoading(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
refetch();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleValidateLicense = async () => {
|
||||||
|
if (!licenseKey) {
|
||||||
|
toast.error("Please enter a license key");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setIsLoading(true);
|
||||||
|
await validateLicense({
|
||||||
|
licenseKey,
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
toast.success("License validated successfully");
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
toast.error("Error validating license", {
|
||||||
|
description: e.message,
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
setIsLoading(false);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="h-full bg-sidebar p-2.5 rounded-xl">
|
||||||
|
<div className="rounded-xl bg-background shadow-md">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-xl flex flex-row items-center gap-3">
|
||||||
|
<div className="p-2 rounded-lg bg-primary/10">
|
||||||
|
<SparklesIcon className="size-5 text-primary" />
|
||||||
|
</div>
|
||||||
|
Paid Features
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription className="mt-2">
|
||||||
|
Unlock advanced capabilities like monitoring and enhanced
|
||||||
|
performance tracking
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-6">
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
<div className="flex flex-row items-center justify-between p-4 border rounded-lg bg-card/50 hover:bg-card/80 transition-colors">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<h3 className="font-medium">Enable Premium Features</h3>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Access advanced monitoring tools and premium capabilities
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
className="ml-4"
|
||||||
|
checked={data?.user?.enablePaidFeatures}
|
||||||
|
onCheckedChange={() => {
|
||||||
|
update({
|
||||||
|
enablePaidFeatures: !data?.user?.enablePaidFeatures,
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
toast.success(
|
||||||
|
`Premium features ${
|
||||||
|
data?.user?.enablePaidFeatures
|
||||||
|
? "disabled"
|
||||||
|
: "enabled"
|
||||||
|
} successfully`,
|
||||||
|
);
|
||||||
|
refetch();
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
toast.error("Error updating premium features");
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{data?.user?.enablePaidFeatures && (
|
||||||
|
<div className="flex flex-row items-center gap-4 p-4 border rounded-lg bg-card/50">
|
||||||
|
<div className="flex-grow">
|
||||||
|
<Input
|
||||||
|
placeholder="Enter your license key"
|
||||||
|
value={licenseKey}
|
||||||
|
disabled={data?.user?.licenseKey !== null}
|
||||||
|
onChange={(e) => setLicenseKey(e.target.value)}
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{!data?.user?.licenseKey ? (
|
||||||
|
<Button
|
||||||
|
onClick={handleSaveLicense}
|
||||||
|
variant="secondary"
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
{isLoading ? "Saving..." : "Save License"}
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
onClick={handleValidateLicense}
|
||||||
|
variant="secondary"
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
{isLoading ? "Validating..." : "Validate"}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
setIsLoading(true);
|
||||||
|
deactivateLicense({
|
||||||
|
licenseKey,
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
setLicenseKey("");
|
||||||
|
toast.success("License removed successfully");
|
||||||
|
refetch();
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
toast.error("Error removing license", {
|
||||||
|
description: e.message,
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
setIsLoading(false);
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
variant="destructive"
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
{data?.user?.enablePaidFeatures && <SetupMonitoring />}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -22,6 +22,9 @@ export const ShowDokployActions = () => {
|
|||||||
const { mutateAsync: reloadServer, isLoading } =
|
const { mutateAsync: reloadServer, isLoading } =
|
||||||
api.settings.reloadServer.useMutation();
|
api.settings.reloadServer.useMutation();
|
||||||
|
|
||||||
|
const { mutateAsync: cleanRedis } = api.settings.cleanRedis.useMutation();
|
||||||
|
const { mutateAsync: reloadRedis } = api.settings.reloadRedis.useMutation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild disabled={isLoading}>
|
<DropdownMenuTrigger asChild disabled={isLoading}>
|
||||||
@@ -69,6 +72,36 @@ export const ShowDokployActions = () => {
|
|||||||
{t("settings.server.webServer.updateServerIp")}
|
{t("settings.server.webServer.updateServerIp")}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</UpdateServerIp>
|
</UpdateServerIp>
|
||||||
|
|
||||||
|
<DropdownMenuItem
|
||||||
|
className="cursor-pointer"
|
||||||
|
onClick={async () => {
|
||||||
|
await cleanRedis()
|
||||||
|
.then(async () => {
|
||||||
|
toast.success("Redis cleaned");
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
toast.error("Error cleaning Redis");
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Clean Redis
|
||||||
|
</DropdownMenuItem>
|
||||||
|
|
||||||
|
<DropdownMenuItem
|
||||||
|
className="cursor-pointer"
|
||||||
|
onClick={async () => {
|
||||||
|
await reloadRedis()
|
||||||
|
.then(async () => {
|
||||||
|
toast.success("Redis reloaded");
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
toast.error("Error reloading Redis");
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Reload Redis
|
||||||
|
</DropdownMenuItem>
|
||||||
</DropdownMenuGroup>
|
</DropdownMenuGroup>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
|
|||||||
@@ -202,7 +202,7 @@ export const SetupMonitoring = ({ serverId }: Props) => {
|
|||||||
|
|
||||||
const { mutateAsync } = serverId
|
const { mutateAsync } = serverId
|
||||||
? api.server.setupMonitoring.useMutation()
|
? api.server.setupMonitoring.useMutation()
|
||||||
: api.admin.setupMonitoring.useMutation();
|
: api.user.setupMonitoring.useMutation();
|
||||||
|
|
||||||
const generateToken = () => {
|
const generateToken = () => {
|
||||||
const array = new Uint8Array(64);
|
const array = new Uint8Array(64);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
3
apps/dokploy/drizzle/0087_lively_risque.sql
Normal file
3
apps/dokploy/drizzle/0087_lively_risque.sql
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
CREATE TYPE "public"."triggerType" AS ENUM('push', 'tag');--> statement-breakpoint
|
||||||
|
ALTER TABLE "application" ADD COLUMN "triggerType" "triggerType" DEFAULT 'push';--> statement-breakpoint
|
||||||
|
ALTER TABLE "compose" ADD COLUMN "triggerType" "triggerType" DEFAULT 'push';
|
||||||
5
apps/dokploy/drizzle/0088_same_ezekiel.sql
Normal file
5
apps/dokploy/drizzle/0088_same_ezekiel.sql
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
CREATE TYPE "public"."backupType" AS ENUM('database', 'compose');--> statement-breakpoint
|
||||||
|
ALTER TABLE "backup" ADD COLUMN "serviceName" text;--> statement-breakpoint
|
||||||
|
ALTER TABLE "backup" ADD COLUMN "backupType" "backupType" DEFAULT 'database' NOT NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE "backup" ADD COLUMN "composeId" text;--> statement-breakpoint
|
||||||
|
ALTER TABLE "backup" ADD CONSTRAINT "backup_composeId_compose_composeId_fk" FOREIGN KEY ("composeId") REFERENCES "public"."compose"("composeId") ON DELETE cascade ON UPDATE no action;
|
||||||
1
apps/dokploy/drizzle/0089_dazzling_marrow.sql
Normal file
1
apps/dokploy/drizzle/0089_dazzling_marrow.sql
Normal file
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE "backup" ADD COLUMN "metadata" jsonb;
|
||||||
1
apps/dokploy/drizzle/0090_tiny_phil_sheldon.sql
Normal file
1
apps/dokploy/drizzle/0090_tiny_phil_sheldon.sql
Normal file
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE "user_temp" ADD COLUMN "licenseKey" text;
|
||||||
5407
apps/dokploy/drizzle/meta/0087_snapshot.json
Normal file
5407
apps/dokploy/drizzle/meta/0087_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
5448
apps/dokploy/drizzle/meta/0088_snapshot.json
Normal file
5448
apps/dokploy/drizzle/meta/0088_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
5454
apps/dokploy/drizzle/meta/0089_snapshot.json
Normal file
5454
apps/dokploy/drizzle/meta/0089_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
5460
apps/dokploy/drizzle/meta/0090_snapshot.json
Normal file
5460
apps/dokploy/drizzle/meta/0090_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -610,6 +610,34 @@
|
|||||||
"when": 1745706676004,
|
"when": 1745706676004,
|
||||||
"tag": "0086_rainy_gertrude_yorkes",
|
"tag": "0086_rainy_gertrude_yorkes",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 87,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1745723563822,
|
||||||
|
"tag": "0087_lively_risque",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 88,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1745801614194,
|
||||||
|
"tag": "0088_same_ezekiel",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 89,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1745812150155,
|
||||||
|
"tag": "0089_dazzling_marrow",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 90,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1746156076450,
|
||||||
|
"tag": "0090_tiny_phil_sheldon",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "dokploy",
|
"name": "dokploy",
|
||||||
"version": "v0.21.7",
|
"version": "v0.21.8",
|
||||||
"private": true,
|
"private": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@@ -89,6 +89,115 @@ export default async function handler(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Handle tag creation event
|
||||||
|
if (
|
||||||
|
req.headers["x-github-event"] === "push" &&
|
||||||
|
githubBody?.ref?.startsWith("refs/tags/")
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const tagName = githubBody?.ref.replace("refs/tags/", "");
|
||||||
|
const repository = githubBody?.repository?.name;
|
||||||
|
const owner = githubBody?.repository?.owner?.name;
|
||||||
|
const deploymentTitle = `Tag created: ${tagName}`;
|
||||||
|
const deploymentHash = extractHash(req.headers, githubBody);
|
||||||
|
|
||||||
|
// Find applications configured to deploy on tag
|
||||||
|
const apps = await db.query.applications.findMany({
|
||||||
|
where: and(
|
||||||
|
eq(applications.sourceType, "github"),
|
||||||
|
eq(applications.autoDeploy, true),
|
||||||
|
eq(applications.triggerType, "tag"),
|
||||||
|
eq(applications.repository, repository),
|
||||||
|
eq(applications.owner, owner),
|
||||||
|
eq(applications.githubId, githubResult.githubId),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const app of apps) {
|
||||||
|
const jobData: DeploymentJob = {
|
||||||
|
applicationId: app.applicationId as string,
|
||||||
|
titleLog: deploymentTitle,
|
||||||
|
descriptionLog: `Hash: ${deploymentHash}`,
|
||||||
|
type: "deploy",
|
||||||
|
applicationType: "application",
|
||||||
|
server: !!app.serverId,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (IS_CLOUD && app.serverId) {
|
||||||
|
jobData.serverId = app.serverId;
|
||||||
|
await deploy(jobData);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
await myQueue.add(
|
||||||
|
"deployments",
|
||||||
|
{ ...jobData },
|
||||||
|
{
|
||||||
|
removeOnComplete: true,
|
||||||
|
removeOnFail: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find compose apps configured to deploy on tag
|
||||||
|
const composeApps = await db.query.compose.findMany({
|
||||||
|
where: and(
|
||||||
|
eq(compose.sourceType, "github"),
|
||||||
|
eq(compose.autoDeploy, true),
|
||||||
|
eq(compose.triggerType, "tag"),
|
||||||
|
eq(compose.repository, repository),
|
||||||
|
eq(compose.owner, owner),
|
||||||
|
eq(compose.githubId, githubResult.githubId),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const composeApp of composeApps) {
|
||||||
|
const jobData: DeploymentJob = {
|
||||||
|
composeId: composeApp.composeId as string,
|
||||||
|
titleLog: deploymentTitle,
|
||||||
|
type: "deploy",
|
||||||
|
applicationType: "compose",
|
||||||
|
descriptionLog: `Hash: ${deploymentHash}`,
|
||||||
|
server: !!composeApp.serverId,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (IS_CLOUD && composeApp.serverId) {
|
||||||
|
jobData.serverId = composeApp.serverId;
|
||||||
|
await deploy(jobData);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
await myQueue.add(
|
||||||
|
"deployments",
|
||||||
|
{ ...jobData },
|
||||||
|
{
|
||||||
|
removeOnComplete: true,
|
||||||
|
removeOnFail: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalApps = apps.length + composeApps.length;
|
||||||
|
|
||||||
|
if (totalApps === 0) {
|
||||||
|
res
|
||||||
|
.status(200)
|
||||||
|
.json({ message: "No apps configured to deploy on tag" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
message: `Deployed ${totalApps} apps based on tag ${tagName}`,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error deploying applications on tag:", error);
|
||||||
|
res
|
||||||
|
.status(400)
|
||||||
|
.json({ message: "Error deploying applications on tag", error });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (req.headers["x-github-event"] === "push") {
|
if (req.headers["x-github-event"] === "push") {
|
||||||
try {
|
try {
|
||||||
const branchName = githubBody?.ref?.replace("refs/heads/", "");
|
const branchName = githubBody?.ref?.replace("refs/heads/", "");
|
||||||
@@ -105,6 +214,7 @@ export default async function handler(
|
|||||||
where: and(
|
where: and(
|
||||||
eq(applications.sourceType, "github"),
|
eq(applications.sourceType, "github"),
|
||||||
eq(applications.autoDeploy, true),
|
eq(applications.autoDeploy, true),
|
||||||
|
eq(applications.triggerType, "push"),
|
||||||
eq(applications.branch, branchName),
|
eq(applications.branch, branchName),
|
||||||
eq(applications.repository, repository),
|
eq(applications.repository, repository),
|
||||||
eq(applications.owner, owner),
|
eq(applications.owner, owner),
|
||||||
@@ -150,6 +260,7 @@ export default async function handler(
|
|||||||
where: and(
|
where: and(
|
||||||
eq(compose.sourceType, "github"),
|
eq(compose.sourceType, "github"),
|
||||||
eq(compose.autoDeploy, true),
|
eq(compose.autoDeploy, true),
|
||||||
|
eq(compose.triggerType, "push"),
|
||||||
eq(compose.branch, branchName),
|
eq(compose.branch, branchName),
|
||||||
eq(compose.repository, repository),
|
eq(compose.repository, repository),
|
||||||
eq(compose.owner, owner),
|
eq(compose.owner, owner),
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
import { ContainerFreeMonitoring } from "@/components/dashboard/monitoring/free/container/show-free-container-monitoring";
|
import { ContainerFreeMonitoring } from "@/components/dashboard/monitoring/free/container/show-free-container-monitoring";
|
||||||
import { ShowPaidMonitoring } from "@/components/dashboard/monitoring/paid/servers/show-paid-monitoring";
|
import { ShowPaidMonitoring } from "@/components/dashboard/monitoring/paid/servers/show-paid-monitoring";
|
||||||
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
|
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
|
||||||
|
import { AlertBlock } from "@/components/shared/alert-block";
|
||||||
import { Card } from "@/components/ui/card";
|
import { Card } from "@/components/ui/card";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
import { useLocalStorage } from "@/hooks/useLocalStorage";
|
import { useLocalStorage } from "@/hooks/useLocalStorage";
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
import { IS_CLOUD } from "@dokploy/server/constants";
|
import { IS_CLOUD } from "@dokploy/server/constants";
|
||||||
@@ -23,7 +26,7 @@ const Dashboard = () => {
|
|||||||
const { data: monitoring, isLoading } = api.user.getMetricsToken.useQuery();
|
const { data: monitoring, isLoading } = api.user.getMetricsToken.useQuery();
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4 pb-10">
|
<div className="space-y-4 pb-10">
|
||||||
{/* <AlertBlock>
|
<AlertBlock>
|
||||||
You are watching the <strong>Free</strong> plan.{" "}
|
You are watching the <strong>Free</strong> plan.{" "}
|
||||||
<a
|
<a
|
||||||
href="https://dokploy.com#pricing"
|
href="https://dokploy.com#pricing"
|
||||||
@@ -34,7 +37,7 @@ const Dashboard = () => {
|
|||||||
Upgrade
|
Upgrade
|
||||||
</a>{" "}
|
</a>{" "}
|
||||||
to get more features.
|
to get more features.
|
||||||
</AlertBlock> */}
|
</AlertBlock>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<Card className="bg-sidebar p-2.5 rounded-xl mx-auto items-center">
|
<Card className="bg-sidebar p-2.5 rounded-xl mx-auto items-center">
|
||||||
<div className="rounded-xl bg-background flex shadow-md px-4 min-h-[50vh] justify-center items-center text-muted-foreground">
|
<div className="rounded-xl bg-background flex shadow-md px-4 min-h-[50vh] justify-center items-center text-muted-foreground">
|
||||||
@@ -44,15 +47,15 @@ const Dashboard = () => {
|
|||||||
</Card>
|
</Card>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{/* {monitoring?.enabledFeatures && (
|
{monitoring?.enabledFeatures && (
|
||||||
<div className="flex flex-row border w-fit p-4 rounded-lg items-center gap-2">
|
<div className="flex flex-row border w-fit p-4 rounded-lg items-center gap-2">
|
||||||
<Label className="text-muted-foreground">Change Monitoring</Label>
|
<Label className="text-muted-foreground">Change Monitoring</Label>
|
||||||
<Switch
|
<Switch
|
||||||
checked={toggleMonitoring}
|
checked={toggleMonitoring}
|
||||||
onCheckedChange={setToggleMonitoring}
|
onCheckedChange={_setToggleMonitoring}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)} */}
|
)}
|
||||||
{toggleMonitoring ? (
|
{toggleMonitoring ? (
|
||||||
<Card className="bg-sidebar p-2.5 rounded-xl mx-auto">
|
<Card className="bg-sidebar p-2.5 rounded-xl mx-auto">
|
||||||
<div className="rounded-xl bg-background shadow-md">
|
<div className="rounded-xl bg-background shadow-md">
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import {
|
|||||||
CardTitle,
|
CardTitle,
|
||||||
} from "@/components/ui/card";
|
} from "@/components/ui/card";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import {
|
import {
|
||||||
Tooltip,
|
Tooltip,
|
||||||
@@ -65,7 +66,7 @@ type TabState =
|
|||||||
const Service = (
|
const Service = (
|
||||||
props: InferGetServerSidePropsType<typeof getServerSideProps>,
|
props: InferGetServerSidePropsType<typeof getServerSideProps>,
|
||||||
) => {
|
) => {
|
||||||
const [_toggleMonitoring, _setToggleMonitoring] = useState(false);
|
const [_toggleMonitoring, _setToggleMonitoring] = useState(true);
|
||||||
const { applicationId, activeTab } = props;
|
const { applicationId, activeTab } = props;
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { projectId } = router.query;
|
const { projectId } = router.query;
|
||||||
@@ -86,6 +87,7 @@ const Service = (
|
|||||||
|
|
||||||
const { data: isCloud } = api.settings.isCloud.useQuery();
|
const { data: isCloud } = api.settings.isCloud.useQuery();
|
||||||
const { data: auth } = api.user.get.useQuery();
|
const { data: auth } = api.user.get.useQuery();
|
||||||
|
const { data: monitoring } = api.user.getMetricsToken.useQuery();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="pb-10">
|
<div className="pb-10">
|
||||||
@@ -265,21 +267,19 @@ const Service = (
|
|||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{/* {monitoring?.enabledFeatures &&
|
{monitoring?.enabledFeatures && (
|
||||||
isCloud &&
|
<div className="flex flex-row border w-fit p-4 rounded-lg items-center gap-2">
|
||||||
data?.serverId && (
|
<Label className="text-muted-foreground">
|
||||||
<div className="flex flex-row border w-fit p-4 rounded-lg items-center gap-2">
|
Change Monitoring
|
||||||
<Label className="text-muted-foreground">
|
</Label>
|
||||||
Change Monitoring
|
<Switch
|
||||||
</Label>
|
checked={_toggleMonitoring}
|
||||||
<Switch
|
onCheckedChange={_setToggleMonitoring}
|
||||||
checked={toggleMonitoring}
|
/>
|
||||||
onCheckedChange={setToggleMonitoring}
|
</div>
|
||||||
/>
|
)}
|
||||||
</div>
|
|
||||||
)} */}
|
|
||||||
|
|
||||||
{/* {toggleMonitoring ? (
|
{_toggleMonitoring ? (
|
||||||
<ContainerPaidMonitoring
|
<ContainerPaidMonitoring
|
||||||
appName={data?.appName || ""}
|
appName={data?.appName || ""}
|
||||||
baseUrl={`http://${monitoring?.serverIp}:${monitoring?.metricsConfig?.server?.port}`}
|
baseUrl={`http://${monitoring?.serverIp}:${monitoring?.metricsConfig?.server?.port}`}
|
||||||
@@ -287,13 +287,13 @@ const Service = (
|
|||||||
monitoring?.metricsConfig?.server?.token || ""
|
monitoring?.metricsConfig?.server?.token || ""
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
) : ( */}
|
) : (
|
||||||
<div>
|
<div>
|
||||||
<ContainerFreeMonitoring
|
<ContainerFreeMonitoring
|
||||||
appName={data?.appName || ""}
|
appName={data?.appName || ""}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{/* )} */}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ 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";
|
||||||
@@ -217,16 +218,17 @@ 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-7"
|
? "lg:grid-cols-8"
|
||||||
: data?.serverId
|
: data?.serverId
|
||||||
? "lg:grid-cols-6"
|
? "lg:grid-cols-7"
|
||||||
: "lg:grid-cols-7",
|
: "lg:grid-cols-8",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<TabsTrigger value="general">General</TabsTrigger>
|
<TabsTrigger value="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="backups">Backups</TabsTrigger>
|
||||||
<TabsTrigger value="logs">Logs</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>
|
||||||
@@ -245,6 +247,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="monitoring">
|
<TabsContent value="monitoring">
|
||||||
<div className="pt-2.5">
|
<div className="pt-2.5">
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { WebDomain } from "@/components/dashboard/settings/web-domain";
|
import { WebDomain } from "@/components/dashboard/settings/web-domain";
|
||||||
import { WebServer } from "@/components/dashboard/settings/web-server";
|
import { WebServer } from "@/components/dashboard/settings/web-server";
|
||||||
|
import { EnablePaidFeatures } from "@/components/dashboard/settings/enable-paid-features";
|
||||||
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
|
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
|
||||||
import { appRouter } from "@/server/api/root";
|
import { appRouter } from "@/server/api/root";
|
||||||
import { getLocale, serverSideTranslations } from "@/utils/i18n";
|
import { getLocale, serverSideTranslations } from "@/utils/i18n";
|
||||||
@@ -12,6 +13,7 @@ import { api } from "@/utils/api";
|
|||||||
import { ShowBackups } from "@/components/dashboard/database/backups/show-backups";
|
import { ShowBackups } from "@/components/dashboard/database/backups/show-backups";
|
||||||
import { Card } from "@/components/ui/card";
|
import { Card } from "@/components/ui/card";
|
||||||
const Page = () => {
|
const Page = () => {
|
||||||
|
const { data: isCloud } = api.settings.isCloud.useQuery();
|
||||||
const { data: user } = api.user.get.useQuery();
|
const { data: user } = api.user.get.useQuery();
|
||||||
return (
|
return (
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
@@ -20,9 +22,14 @@ 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>
|
||||||
|
{!isCloud && <EnablePaidFeatures />}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { createTRPCRouter } from "../api/trpc";
|
import { createTRPCRouter } from "../api/trpc";
|
||||||
import { adminRouter } from "./routers/admin";
|
|
||||||
import { aiRouter } from "./routers/ai";
|
import { aiRouter } from "./routers/ai";
|
||||||
import { applicationRouter } from "./routers/application";
|
import { applicationRouter } from "./routers/application";
|
||||||
import { backupRouter } from "./routers/backup";
|
import { backupRouter } from "./routers/backup";
|
||||||
@@ -42,7 +41,6 @@ import { userRouter } from "./routers/user";
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
export const appRouter = createTRPCRouter({
|
export const appRouter = createTRPCRouter({
|
||||||
admin: adminRouter,
|
|
||||||
docker: dockerRouter,
|
docker: dockerRouter,
|
||||||
project: projectRouter,
|
project: projectRouter,
|
||||||
application: applicationRouter,
|
application: applicationRouter,
|
||||||
|
|||||||
@@ -1,61 +0,0 @@
|
|||||||
import { apiUpdateWebServerMonitoring } from "@/server/db/schema";
|
|
||||||
import {
|
|
||||||
IS_CLOUD,
|
|
||||||
findUserById,
|
|
||||||
setupWebMonitoring,
|
|
||||||
updateUser,
|
|
||||||
} from "@dokploy/server";
|
|
||||||
import { TRPCError } from "@trpc/server";
|
|
||||||
import { adminProcedure, createTRPCRouter } from "../trpc";
|
|
||||||
|
|
||||||
export const adminRouter = createTRPCRouter({
|
|
||||||
setupMonitoring: adminProcedure
|
|
||||||
.input(apiUpdateWebServerMonitoring)
|
|
||||||
.mutation(async ({ input, ctx }) => {
|
|
||||||
try {
|
|
||||||
if (IS_CLOUD) {
|
|
||||||
throw new TRPCError({
|
|
||||||
code: "UNAUTHORIZED",
|
|
||||||
message: "Feature disabled on cloud",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
const user = await findUserById(ctx.user.ownerId);
|
|
||||||
if (user.id !== ctx.user.ownerId) {
|
|
||||||
throw new TRPCError({
|
|
||||||
code: "UNAUTHORIZED",
|
|
||||||
message: "You are not authorized to setup the monitoring",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
await updateUser(user.id, {
|
|
||||||
metricsConfig: {
|
|
||||||
server: {
|
|
||||||
type: "Dokploy",
|
|
||||||
refreshRate: input.metricsConfig.server.refreshRate,
|
|
||||||
port: input.metricsConfig.server.port,
|
|
||||||
token: input.metricsConfig.server.token,
|
|
||||||
cronJob: input.metricsConfig.server.cronJob,
|
|
||||||
urlCallback: input.metricsConfig.server.urlCallback,
|
|
||||||
retentionDays: input.metricsConfig.server.retentionDays,
|
|
||||||
thresholds: {
|
|
||||||
cpu: input.metricsConfig.server.thresholds.cpu,
|
|
||||||
memory: input.metricsConfig.server.thresholds.memory,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
containers: {
|
|
||||||
refreshRate: input.metricsConfig.containers.refreshRate,
|
|
||||||
services: {
|
|
||||||
include: input.metricsConfig.containers.services.include || [],
|
|
||||||
exclude: input.metricsConfig.containers.services.exclude || [],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const currentServer = await setupWebMonitoring(user.id);
|
|
||||||
return currentServer;
|
|
||||||
} catch (error) {
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
@@ -355,6 +355,7 @@ export const applicationRouter = createTRPCRouter({
|
|||||||
applicationStatus: "idle",
|
applicationStatus: "idle",
|
||||||
githubId: input.githubId,
|
githubId: input.githubId,
|
||||||
watchPaths: input.watchPaths,
|
watchPaths: input.watchPaths,
|
||||||
|
triggerType: input.triggerType,
|
||||||
enableSubmodules: input.enableSubmodules,
|
enableSubmodules: input.enableSubmodules,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import {
|
|||||||
IS_CLOUD,
|
IS_CLOUD,
|
||||||
createBackup,
|
createBackup,
|
||||||
findBackupById,
|
findBackupById,
|
||||||
|
findComposeByBackupId,
|
||||||
|
findComposeById,
|
||||||
findMariadbByBackupId,
|
findMariadbByBackupId,
|
||||||
findMariadbById,
|
findMariadbById,
|
||||||
findMongoByBackupId,
|
findMongoByBackupId,
|
||||||
@@ -31,6 +33,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 +43,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 +86,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 +241,22 @@ export const backupRouter = createTRPCRouter({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
|
manualBackupCompose: protectedProcedure
|
||||||
|
.input(apiFindOneBackup)
|
||||||
|
.mutation(async ({ input }) => {
|
||||||
|
try {
|
||||||
|
const backup = await findBackupById(input.backupId);
|
||||||
|
const compose = await findComposeByBackupId(backup.backupId);
|
||||||
|
await runComposeBackup(compose, backup);
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "BAD_REQUEST",
|
||||||
|
message: "Error running manual Compose backup ",
|
||||||
|
cause: error,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}),
|
||||||
manualBackupMongo: protectedProcedure
|
manualBackupMongo: protectedProcedure
|
||||||
.input(apiFindOneBackup)
|
.input(apiFindOneBackup)
|
||||||
.mutation(async ({ input }) => {
|
.mutation(async ({ input }) => {
|
||||||
@@ -351,78 +376,96 @@ export const backupRouter = createTRPCRouter({
|
|||||||
"mongo",
|
"mongo",
|
||||||
"web-server",
|
"web-server",
|
||||||
]),
|
]),
|
||||||
|
backupType: z.enum(["database", "compose"]),
|
||||||
databaseName: z.string().min(1),
|
databaseName: z.string().min(1),
|
||||||
backupFile: z.string().min(1),
|
backupFile: z.string().min(1),
|
||||||
destinationId: z.string().min(1),
|
destinationId: z.string().min(1),
|
||||||
|
metadata: z.any(),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.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,
|
postgres,
|
||||||
destination,
|
destination,
|
||||||
input.databaseName,
|
input.databaseName,
|
||||||
input.backupFile,
|
input.backupFile,
|
||||||
(log) => {
|
(log) => {
|
||||||
emit.next(log);
|
emit.next(log);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
});
|
|
||||||
}
|
|
||||||
if (input.databaseType === "mysql") {
|
|
||||||
const mysql = await findMySqlById(input.databaseId);
|
|
||||||
return observable<string>((emit) => {
|
|
||||||
restoreMySqlBackup(
|
|
||||||
mysql,
|
|
||||||
destination,
|
|
||||||
input.databaseName,
|
|
||||||
input.backupFile,
|
|
||||||
(log) => {
|
|
||||||
emit.next(log);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (input.databaseType === "mariadb") {
|
|
||||||
const mariadb = await findMariadbById(input.databaseId);
|
|
||||||
return observable<string>((emit) => {
|
|
||||||
restoreMariadbBackup(
|
|
||||||
mariadb,
|
|
||||||
destination,
|
|
||||||
input.databaseName,
|
|
||||||
input.backupFile,
|
|
||||||
(log) => {
|
|
||||||
emit.next(log);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (input.databaseType === "mongo") {
|
|
||||||
const mongo = await findMongoById(input.databaseId);
|
|
||||||
return observable<string>((emit) => {
|
|
||||||
restoreMongoBackup(
|
|
||||||
mongo,
|
|
||||||
destination,
|
|
||||||
input.databaseName,
|
|
||||||
input.backupFile,
|
|
||||||
(log) => {
|
|
||||||
emit.next(log);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (input.databaseType === "web-server") {
|
|
||||||
return observable<string>((emit) => {
|
|
||||||
restoreWebServerBackup(destination, input.backupFile, (log) => {
|
|
||||||
emit.next(log);
|
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
if (input.databaseType === "mysql") {
|
||||||
|
const mysql = await findMySqlById(input.databaseId);
|
||||||
|
return observable<string>((emit) => {
|
||||||
|
restoreMySqlBackup(
|
||||||
|
mysql,
|
||||||
|
destination,
|
||||||
|
input.databaseName,
|
||||||
|
input.backupFile,
|
||||||
|
(log) => {
|
||||||
|
emit.next(log);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (input.databaseType === "mariadb") {
|
||||||
|
const mariadb = await findMariadbById(input.databaseId);
|
||||||
|
return observable<string>((emit) => {
|
||||||
|
restoreMariadbBackup(
|
||||||
|
mariadb,
|
||||||
|
destination,
|
||||||
|
input.databaseName,
|
||||||
|
input.backupFile,
|
||||||
|
(log) => {
|
||||||
|
emit.next(log);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (input.databaseType === "mongo") {
|
||||||
|
const mongo = await findMongoById(input.databaseId);
|
||||||
|
return observable<string>((emit) => {
|
||||||
|
restoreMongoBackup(
|
||||||
|
mongo,
|
||||||
|
destination,
|
||||||
|
input.databaseName,
|
||||||
|
input.backupFile,
|
||||||
|
(log) => {
|
||||||
|
emit.next(log);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (input.databaseType === "web-server") {
|
||||||
|
return observable<string>((emit) => {
|
||||||
|
restoreWebServerBackup(destination, input.backupFile, (log) => {
|
||||||
|
emit.next(log);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (input.backupType === "compose") {
|
||||||
|
const compose = await findComposeById(input.databaseId);
|
||||||
|
return observable<string>((emit) => {
|
||||||
|
restoreComposeBackup(
|
||||||
|
compose,
|
||||||
|
destination,
|
||||||
|
input.databaseName,
|
||||||
|
input.backupFile,
|
||||||
|
input.metadata,
|
||||||
|
(log) => {
|
||||||
|
emit.next(log);
|
||||||
|
},
|
||||||
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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";
|
||||||
@@ -224,4 +225,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));
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -79,6 +79,33 @@ export const settingsRouter = createTRPCRouter({
|
|||||||
await execAsync(`docker service update --force ${stdout.trim()}`);
|
await execAsync(`docker service update --force ${stdout.trim()}`);
|
||||||
return true;
|
return true;
|
||||||
}),
|
}),
|
||||||
|
cleanRedis: adminProcedure.mutation(async () => {
|
||||||
|
if (IS_CLOUD) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { stdout: containerId } = await execAsync(
|
||||||
|
`docker ps --filter "name=dokploy-redis" --filter "status=running" -q | head -n 1`,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!containerId) {
|
||||||
|
throw new Error("Redis container not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
const redisContainerId = containerId.trim();
|
||||||
|
|
||||||
|
await execAsync(`docker exec -i ${redisContainerId} redis-cli flushall`);
|
||||||
|
return true;
|
||||||
|
}),
|
||||||
|
reloadRedis: adminProcedure.mutation(async () => {
|
||||||
|
if (IS_CLOUD) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
await execAsync("docker service scale dokploy-redis=0");
|
||||||
|
await execAsync("docker service scale dokploy-redis=1");
|
||||||
|
return true;
|
||||||
|
}),
|
||||||
reloadTraefik: adminProcedure
|
reloadTraefik: adminProcedure
|
||||||
.input(apiServerSchema)
|
.input(apiServerSchema)
|
||||||
.mutation(async ({ input }) => {
|
.mutation(async ({ input }) => {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
findUserById,
|
findUserById,
|
||||||
getUserByToken,
|
getUserByToken,
|
||||||
removeUserById,
|
removeUserById,
|
||||||
|
setupWebMonitoring,
|
||||||
updateUser,
|
updateUser,
|
||||||
} from "@dokploy/server";
|
} from "@dokploy/server";
|
||||||
import { db } from "@dokploy/server/db";
|
import { db } from "@dokploy/server/db";
|
||||||
@@ -13,6 +14,7 @@ import {
|
|||||||
apiAssignPermissions,
|
apiAssignPermissions,
|
||||||
apiFindOneToken,
|
apiFindOneToken,
|
||||||
apiUpdateUser,
|
apiUpdateUser,
|
||||||
|
apiUpdateWebServerMonitoring,
|
||||||
apikey,
|
apikey,
|
||||||
invitation,
|
invitation,
|
||||||
member,
|
member,
|
||||||
@@ -27,7 +29,11 @@ import {
|
|||||||
protectedProcedure,
|
protectedProcedure,
|
||||||
publicProcedure,
|
publicProcedure,
|
||||||
} from "../trpc";
|
} from "../trpc";
|
||||||
|
import {
|
||||||
|
validateLicense,
|
||||||
|
activateLicense,
|
||||||
|
deactivateLicense,
|
||||||
|
} from "@/server/utils/validate-license";
|
||||||
const apiCreateApiKey = z.object({
|
const apiCreateApiKey = z.object({
|
||||||
name: z.string().min(1),
|
name: z.string().min(1),
|
||||||
prefix: z.string().optional(),
|
prefix: z.string().optional(),
|
||||||
@@ -160,6 +166,73 @@ export const userRouter = createTRPCRouter({
|
|||||||
}
|
}
|
||||||
return await updateUser(ctx.user.id, input);
|
return await updateUser(ctx.user.id, input);
|
||||||
}),
|
}),
|
||||||
|
saveLicense: adminProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
licenseKey: z.string(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.mutation(async ({ input, ctx }) => {
|
||||||
|
const owner = await findUserById(ctx.user.ownerId);
|
||||||
|
|
||||||
|
const result = await activateLicense(
|
||||||
|
input.licenseKey,
|
||||||
|
owner?.serverIp || "",
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "BAD_REQUEST",
|
||||||
|
message: result.error,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await updateUser(ctx.user.id, {
|
||||||
|
licenseKey: input.licenseKey,
|
||||||
|
});
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}),
|
||||||
|
deactivateLicense: adminProcedure
|
||||||
|
.input(z.object({ licenseKey: z.string().min(1) }))
|
||||||
|
.mutation(async ({ input, ctx }) => {
|
||||||
|
const owner = await findUserById(ctx.user.ownerId);
|
||||||
|
const result = await deactivateLicense(
|
||||||
|
input.licenseKey,
|
||||||
|
owner?.serverIp || "",
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "BAD_REQUEST",
|
||||||
|
message: result.error,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await updateUser(ctx.user.id, {
|
||||||
|
licenseKey: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}),
|
||||||
|
validateLicense: adminProcedure
|
||||||
|
.input(z.object({ licenseKey: z.string().min(1) }))
|
||||||
|
.mutation(async ({ input, ctx }) => {
|
||||||
|
const owner = await findUserById(ctx.user.ownerId);
|
||||||
|
const result = await validateLicense(
|
||||||
|
input.licenseKey,
|
||||||
|
owner?.serverIp || "",
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "BAD_REQUEST",
|
||||||
|
message: result.error,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}),
|
||||||
getUserByToken: publicProcedure
|
getUserByToken: publicProcedure
|
||||||
.input(apiFindOneToken)
|
.input(apiFindOneToken)
|
||||||
.query(async ({ input }) => {
|
.query(async ({ input }) => {
|
||||||
@@ -349,4 +422,62 @@ export const userRouter = createTRPCRouter({
|
|||||||
|
|
||||||
return organizations.length;
|
return organizations.length;
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
setupMonitoring: adminProcedure
|
||||||
|
.input(apiUpdateWebServerMonitoring)
|
||||||
|
.mutation(async ({ input, ctx }) => {
|
||||||
|
try {
|
||||||
|
if (IS_CLOUD) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "UNAUTHORIZED",
|
||||||
|
message: "Feature disabled on cloud",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await findUserById(ctx.user.id);
|
||||||
|
|
||||||
|
if (!validateLicense(user?.licenseKey || "", user?.serverIp || "")) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "UNAUTHORIZED",
|
||||||
|
message: "Invalid license key",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (user.id !== ctx.user.ownerId) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "UNAUTHORIZED",
|
||||||
|
message: "You are not authorized to setup the monitoring",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await updateUser(user.id, {
|
||||||
|
metricsConfig: {
|
||||||
|
server: {
|
||||||
|
type: "Dokploy",
|
||||||
|
refreshRate: input.metricsConfig.server.refreshRate,
|
||||||
|
port: input.metricsConfig.server.port,
|
||||||
|
token: input.metricsConfig.server.token,
|
||||||
|
cronJob: input.metricsConfig.server.cronJob,
|
||||||
|
urlCallback: input.metricsConfig.server.urlCallback,
|
||||||
|
retentionDays: input.metricsConfig.server.retentionDays,
|
||||||
|
thresholds: {
|
||||||
|
cpu: input.metricsConfig.server.thresholds.cpu,
|
||||||
|
memory: input.metricsConfig.server.thresholds.memory,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
containers: {
|
||||||
|
refreshRate: input.metricsConfig.containers.refreshRate,
|
||||||
|
services: {
|
||||||
|
include: input.metricsConfig.containers.services.include || [],
|
||||||
|
exclude: input.metricsConfig.containers.services.exclude || [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const currentServer = await setupWebMonitoring(user.id);
|
||||||
|
return currentServer;
|
||||||
|
} catch (error) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
47
apps/dokploy/server/utils/validate-license.ts
Normal file
47
apps/dokploy/server/utils/validate-license.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
const licensesUrl = process.env.LICENSES_URL || "http://localhost:4002";
|
||||||
|
|
||||||
|
export const validateLicense = async (licenseKey: string, serverIp: string) => {
|
||||||
|
const response = await fetch(`${licensesUrl}/api/license/validate`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ licenseKey, serverIp }),
|
||||||
|
});
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (!response.ok && data.error?.issues) {
|
||||||
|
console.log("Validation errors:", data.error.issues);
|
||||||
|
}
|
||||||
|
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const activateLicense = async (licenseKey: string, serverIp: string) => {
|
||||||
|
const response = await fetch(`${licensesUrl}/api/license/activate`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ licenseKey, serverIp }),
|
||||||
|
});
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deactivateLicense = async (
|
||||||
|
licenseKey: string,
|
||||||
|
serverIp: string,
|
||||||
|
) => {
|
||||||
|
const response = await fetch(`${licensesUrl}/api/license/deactivate`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ licenseKey, serverIp }),
|
||||||
|
});
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
return data;
|
||||||
|
};
|
||||||
2
apps/licenses/.env.example
Normal file
2
apps/licenses/.env.example
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
LEMON_SQUEEZY_API_KEY=""
|
||||||
|
LEMON_SQUEEZY_STORE_ID=""
|
||||||
28
apps/licenses/.gitignore
vendored
Normal file
28
apps/licenses/.gitignore
vendored
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
# dev
|
||||||
|
.yarn/
|
||||||
|
!.yarn/releases
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/launch.json
|
||||||
|
!.vscode/*.code-snippets
|
||||||
|
.idea/workspace.xml
|
||||||
|
.idea/usage.statistics.xml
|
||||||
|
.idea/shelf
|
||||||
|
|
||||||
|
# deps
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# env
|
||||||
|
.env
|
||||||
|
.env.production
|
||||||
|
|
||||||
|
# logs
|
||||||
|
logs/
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
# misc
|
||||||
|
.DS_Store
|
||||||
8
apps/licenses/README.md
Normal file
8
apps/licenses/README.md
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
```
|
||||||
|
npm install
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
```
|
||||||
|
open http://localhost:4002/api/health
|
||||||
|
```
|
||||||
15
apps/licenses/drizzle.config.ts
Normal file
15
apps/licenses/drizzle.config.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import type { Config } from "drizzle-kit";
|
||||||
|
import "dotenv/config";
|
||||||
|
|
||||||
|
const connectionString = process.env.DATABASE_URL!;
|
||||||
|
|
||||||
|
export default {
|
||||||
|
schema: "./src/schema.ts",
|
||||||
|
out: "./drizzle",
|
||||||
|
dialect: "postgresql",
|
||||||
|
dbCredentials: {
|
||||||
|
url: connectionString,
|
||||||
|
},
|
||||||
|
verbose: true,
|
||||||
|
strict: true,
|
||||||
|
} satisfies Config;
|
||||||
28
apps/licenses/drizzle/0000_famous_vermin.sql
Normal file
28
apps/licenses/drizzle/0000_famous_vermin.sql
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
CREATE TABLE "licenses" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"product_id" text NOT NULL,
|
||||||
|
"license_key" text NOT NULL,
|
||||||
|
"server_ips" text[],
|
||||||
|
"activated_at" timestamp,
|
||||||
|
"last_verified_at" timestamp,
|
||||||
|
"stripeCustomerId" text NOT NULL,
|
||||||
|
"stripeSubscriptionId" text NOT NULL,
|
||||||
|
"created_at" timestamp DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updated_at" timestamp DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"metadata" text,
|
||||||
|
"user_id" uuid,
|
||||||
|
CONSTRAINT "licenses_license_key_unique" UNIQUE("license_key")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "user" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"email" text NOT NULL,
|
||||||
|
"created_at" timestamp DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updated_at" timestamp DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"otp_code" text,
|
||||||
|
"otp_code_expires_at" timestamp,
|
||||||
|
"temporal_id" uuid DEFAULT gen_random_uuid(),
|
||||||
|
CONSTRAINT "user_email_unique" UNIQUE("email")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "licenses" ADD CONSTRAINT "licenses_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE no action ON UPDATE no action;
|
||||||
196
apps/licenses/drizzle/meta/0000_snapshot.json
Normal file
196
apps/licenses/drizzle/meta/0000_snapshot.json
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
{
|
||||||
|
"id": "553c7c08-f9c6-4090-8372-8d27a389eaa7",
|
||||||
|
"prevId": "00000000-0000-0000-0000-000000000000",
|
||||||
|
"version": "7",
|
||||||
|
"dialect": "postgresql",
|
||||||
|
"tables": {
|
||||||
|
"public.licenses": {
|
||||||
|
"name": "licenses",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "gen_random_uuid()"
|
||||||
|
},
|
||||||
|
"product_id": {
|
||||||
|
"name": "product_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"license_key": {
|
||||||
|
"name": "license_key",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"server_ips": {
|
||||||
|
"name": "server_ips",
|
||||||
|
"type": "text[]",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"activated_at": {
|
||||||
|
"name": "activated_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"last_verified_at": {
|
||||||
|
"name": "last_verified_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"stripeCustomerId": {
|
||||||
|
"name": "stripeCustomerId",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"stripeSubscriptionId": {
|
||||||
|
"name": "stripeSubscriptionId",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"default": "CURRENT_TIMESTAMP"
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"default": "CURRENT_TIMESTAMP"
|
||||||
|
},
|
||||||
|
"metadata": {
|
||||||
|
"name": "metadata",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"licenses_user_id_user_id_fk": {
|
||||||
|
"name": "licenses_user_id_user_id_fk",
|
||||||
|
"tableFrom": "licenses",
|
||||||
|
"tableTo": "user",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "no action",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {
|
||||||
|
"licenses_license_key_unique": {
|
||||||
|
"name": "licenses_license_key_unique",
|
||||||
|
"nullsNotDistinct": false,
|
||||||
|
"columns": [
|
||||||
|
"license_key"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.user": {
|
||||||
|
"name": "user",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "gen_random_uuid()"
|
||||||
|
},
|
||||||
|
"email": {
|
||||||
|
"name": "email",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"default": "CURRENT_TIMESTAMP"
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"default": "CURRENT_TIMESTAMP"
|
||||||
|
},
|
||||||
|
"otp_code": {
|
||||||
|
"name": "otp_code",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"otp_code_expires_at": {
|
||||||
|
"name": "otp_code_expires_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"temporal_id": {
|
||||||
|
"name": "temporal_id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"default": "gen_random_uuid()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {
|
||||||
|
"user_email_unique": {
|
||||||
|
"name": "user_email_unique",
|
||||||
|
"nullsNotDistinct": false,
|
||||||
|
"columns": [
|
||||||
|
"email"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"enums": {},
|
||||||
|
"schemas": {},
|
||||||
|
"sequences": {},
|
||||||
|
"roles": {},
|
||||||
|
"policies": {},
|
||||||
|
"views": {},
|
||||||
|
"_meta": {
|
||||||
|
"columns": {},
|
||||||
|
"schemas": {},
|
||||||
|
"tables": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
13
apps/licenses/drizzle/meta/_journal.json
Normal file
13
apps/licenses/drizzle/meta/_journal.json
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"version": "7",
|
||||||
|
"dialect": "postgresql",
|
||||||
|
"entries": [
|
||||||
|
{
|
||||||
|
"idx": 0,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1742773711509,
|
||||||
|
"tag": "0000_famous_vermin",
|
||||||
|
"breakpoints": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
22
apps/licenses/migrate.ts
Normal file
22
apps/licenses/migrate.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { drizzle } from "drizzle-orm/postgres-js";
|
||||||
|
import { migrate } from "drizzle-orm/postgres-js/migrator";
|
||||||
|
import postgres from "postgres";
|
||||||
|
import "dotenv/config";
|
||||||
|
import * as schema from "./src/schema";
|
||||||
|
|
||||||
|
const connectionString = process.env.DATABASE_URL!;
|
||||||
|
const sql = postgres(connectionString, { max: 1 });
|
||||||
|
const db = drizzle(sql, { schema });
|
||||||
|
|
||||||
|
await migrate(db, { migrationsFolder: "drizzle" })
|
||||||
|
.then(() => {
|
||||||
|
console.log("Migration complete");
|
||||||
|
sql.end();
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error("Migration failed", error);
|
||||||
|
process.exit(1);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
sql.end();
|
||||||
|
});
|
||||||
48
apps/licenses/package.json
Normal file
48
apps/licenses/package.json
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
{
|
||||||
|
"name": "@dokploy/licenses",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "PORT=4002 tsx watch -r dotenv/config src/index.ts",
|
||||||
|
"build": "tsc --project tsconfig.json",
|
||||||
|
"start": "node dist/index.js",
|
||||||
|
"typecheck": "tsc --noEmit",
|
||||||
|
"generate": "drizzle-kit generate",
|
||||||
|
"drop": "drizzle-kit drop",
|
||||||
|
"push": "drizzle-kit push",
|
||||||
|
"migrate": "tsx ./migrate.ts",
|
||||||
|
"truncate": "tsx ./truncate.ts",
|
||||||
|
"reset:all": "tsx ./truncate.ts && tsx ./migrate.ts",
|
||||||
|
"studio": "drizzle-kit studio"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"nanoid": "5.1.5",
|
||||||
|
"@react-email/components": "^0.0.21",
|
||||||
|
"@hono/node-server": "^1.12.1",
|
||||||
|
"@hono/zod-validator": "0.3.0",
|
||||||
|
"@react-email/render": "^1.0.5",
|
||||||
|
"@types/pg": "^8.11.11",
|
||||||
|
"dotenv": "^16.3.1",
|
||||||
|
"drizzle-orm": "^0.39.1",
|
||||||
|
"hono": "^4.5.8",
|
||||||
|
"nodemailer": "6.9.14",
|
||||||
|
"pg": "^8.14.1",
|
||||||
|
"pino": "9.4.0",
|
||||||
|
"pino-pretty": "11.2.2",
|
||||||
|
"postgres": "3.4.4",
|
||||||
|
"react": "18.2.0",
|
||||||
|
"react-dom": "18.2.0",
|
||||||
|
"stripe": "17.2.0",
|
||||||
|
"zod": "^3.23.4"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^20.11.17",
|
||||||
|
"@types/nodemailer": "^6.4.16",
|
||||||
|
"@types/react": "^18.2.37",
|
||||||
|
"@types/react-dom": "^18.2.15",
|
||||||
|
"drizzle-kit": "^0.30.4",
|
||||||
|
"tsx": "^4.7.1",
|
||||||
|
"typescript": "^5.4.2"
|
||||||
|
},
|
||||||
|
"packageManager": "pnpm@9.5.0"
|
||||||
|
}
|
||||||
265
apps/licenses/src/api/license.ts
Normal file
265
apps/licenses/src/api/license.ts
Normal file
@@ -0,0 +1,265 @@
|
|||||||
|
import { zValidator } from "@hono/zod-validator";
|
||||||
|
import { Hono } from "hono";
|
||||||
|
import { z } from "zod";
|
||||||
|
import {
|
||||||
|
activateLicense,
|
||||||
|
deactivateLicense,
|
||||||
|
validateLicense,
|
||||||
|
cleanLicense,
|
||||||
|
} from "../utils/license";
|
||||||
|
import { logger } from "../logger";
|
||||||
|
import { eq, desc } from "drizzle-orm";
|
||||||
|
import { users, licenses } from "../schema";
|
||||||
|
import { db } from "../db";
|
||||||
|
import { transporter } from "../email";
|
||||||
|
import { nanoid } from "nanoid";
|
||||||
|
import { stripe } from "../stripe";
|
||||||
|
import type Stripe from "stripe";
|
||||||
|
import { getLicenseTypeFromPriceId } from "../utils";
|
||||||
|
const validateSchema = z.object({
|
||||||
|
licenseKey: z.string(),
|
||||||
|
serverIp: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const licenseRouter = new Hono();
|
||||||
|
|
||||||
|
licenseRouter.post(
|
||||||
|
"/validate",
|
||||||
|
zValidator("json", validateSchema),
|
||||||
|
async (c) => {
|
||||||
|
const { licenseKey, serverIp } = c.req.valid("json");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await validateLicense(licenseKey, serverIp);
|
||||||
|
return c.json(result);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Error validating license:", { error });
|
||||||
|
return c.json({ success: false, error: "Error validating license" }, 500);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
licenseRouter.post(
|
||||||
|
"/activate",
|
||||||
|
zValidator("json", validateSchema),
|
||||||
|
async (c) => {
|
||||||
|
const { licenseKey, serverIp } = c.req.valid("json");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const license = await activateLicense(licenseKey, serverIp);
|
||||||
|
return c.json({ success: true, license });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Error activating license:", error);
|
||||||
|
if (error instanceof Error) {
|
||||||
|
return c.json({ success: false, error: error.message }, 400);
|
||||||
|
}
|
||||||
|
return c.json({ success: false, error: "Unknown error occurred" }, 400);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
licenseRouter.post(
|
||||||
|
"/deactivate",
|
||||||
|
zValidator("json", validateSchema),
|
||||||
|
async (c) => {
|
||||||
|
const { licenseKey, serverIp } = c.req.valid("json");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const license = await deactivateLicense(licenseKey, serverIp);
|
||||||
|
return c.json({ success: true, license });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Error deactivating license:", error);
|
||||||
|
return c.json(
|
||||||
|
{ success: false, error: "Error deactivating license" },
|
||||||
|
500,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
licenseRouter.post(
|
||||||
|
"/remove-server",
|
||||||
|
zValidator(
|
||||||
|
"json",
|
||||||
|
z.object({ licenseKey: z.string().min(1), serverIp: z.string().min(1) }),
|
||||||
|
),
|
||||||
|
async (c) => {
|
||||||
|
const { licenseKey, serverIp } = c.req.valid("json");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const license = await cleanLicense(licenseKey, serverIp);
|
||||||
|
return c.json({ success: true, license });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Error cleaning license:", error);
|
||||||
|
return c.json({ success: false, error: "Error cleaning license" }, 500);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
// router.post("/resend-license", zValidator("json", resendSchema), async (c) => {
|
||||||
|
// const { licenseKey } = c.req.valid("json");
|
||||||
|
|
||||||
|
// try {
|
||||||
|
// const license = await db.query.licenses.findFirst({
|
||||||
|
// where: eq(licenses.licenseKey, licenseKey),
|
||||||
|
// });
|
||||||
|
|
||||||
|
// if (!license) {
|
||||||
|
// return c.json({ success: false, error: "License not found" }, 404);
|
||||||
|
// }
|
||||||
|
|
||||||
|
// const suscription = await stripe.subscriptions.retrieve(
|
||||||
|
// license.stripeSubscriptionId,
|
||||||
|
// );
|
||||||
|
|
||||||
|
// const priceId = suscription.items.data[0].price.id;
|
||||||
|
// const { type } = getLicenseTypeFromPriceId(priceId);
|
||||||
|
|
||||||
|
// const emailHtml = await render(
|
||||||
|
// ResendLicenseEmail({
|
||||||
|
// licenseKey: license.licenseKey,
|
||||||
|
// productName: `Dokploy Self Hosted ${type}`,
|
||||||
|
// requestDate: new Date(),
|
||||||
|
// customerName: license.email,
|
||||||
|
// }),
|
||||||
|
// );
|
||||||
|
|
||||||
|
// await transporter.sendMail({
|
||||||
|
// from: process.env.SMTP_FROM_ADDRESS,
|
||||||
|
// to: license.email,
|
||||||
|
// subject: "Your Dokploy License Key",
|
||||||
|
// html: emailHtml,
|
||||||
|
// });
|
||||||
|
|
||||||
|
// return c.json({ success: true });
|
||||||
|
// } catch (error) {
|
||||||
|
// logger.error("Error resending license:", error);
|
||||||
|
// return c.json({ success: false, error: "Error resending license" }, 500);
|
||||||
|
// }
|
||||||
|
// });
|
||||||
|
|
||||||
|
licenseRouter.post(
|
||||||
|
"/send-otp",
|
||||||
|
zValidator("json", z.object({ email: z.string().email() })),
|
||||||
|
async (c) => {
|
||||||
|
const { email } = c.req.valid("json");
|
||||||
|
|
||||||
|
const user = await db.query.users.findFirst({
|
||||||
|
where: eq(users.email, email.toLowerCase()),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return c.json({ success: false, error: "User not found" }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
const generateOtpCode = Math.floor(100000 + Math.random() * 900000);
|
||||||
|
const otpCodeExpiresAt = new Date(Date.now() + 10 * 60 * 1000);
|
||||||
|
|
||||||
|
await db
|
||||||
|
.update(users)
|
||||||
|
.set({ otpCode: generateOtpCode.toString(), otpCodeExpiresAt })
|
||||||
|
.where(eq(users.id, user.id));
|
||||||
|
|
||||||
|
await transporter.sendMail({
|
||||||
|
from: process.env.SMTP_FROM_ADDRESS,
|
||||||
|
to: user.email,
|
||||||
|
subject: "Your Dokploy License Key ",
|
||||||
|
html: `Your OTP code is ${generateOtpCode}, it will expire in 10 minutes`,
|
||||||
|
});
|
||||||
|
|
||||||
|
return c.json({ success: true });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
licenseRouter.post(
|
||||||
|
"/verify-otp",
|
||||||
|
zValidator(
|
||||||
|
"json",
|
||||||
|
z.object({ email: z.string().email(), otpCode: z.string().length(6) }),
|
||||||
|
),
|
||||||
|
async (c) => {
|
||||||
|
const { email, otpCode } = c.req.valid("json");
|
||||||
|
|
||||||
|
const user = await db.query.users.findFirst({
|
||||||
|
where: eq(users.email, email.toLowerCase()),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return c.json({ success: false, error: "User not found" }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.otpCode !== otpCode) {
|
||||||
|
return c.json({ success: false, error: "Invalid code" }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.otpCodeExpiresAt && user.otpCodeExpiresAt < new Date()) {
|
||||||
|
return c.json({ success: false, error: "Code expired" }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await db
|
||||||
|
.update(users)
|
||||||
|
.set({
|
||||||
|
otpCode: null,
|
||||||
|
otpCodeExpiresAt: null,
|
||||||
|
temporalId: nanoid(),
|
||||||
|
temporalIdExpiresAt: new Date(Date.now() + 20 * 60 * 1000),
|
||||||
|
})
|
||||||
|
.where(eq(users.id, user.id))
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
return c.json({ success: true, temporalId: result[0].temporalId });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
licenseRouter.get(
|
||||||
|
"/all",
|
||||||
|
zValidator("query", z.object({ temporalId: z.string() })),
|
||||||
|
async (c) => {
|
||||||
|
const { temporalId } = c.req.valid("query");
|
||||||
|
|
||||||
|
const user = await db.query.users.findFirst({
|
||||||
|
where: eq(users.temporalId, temporalId),
|
||||||
|
with: {
|
||||||
|
licenses: true,
|
||||||
|
},
|
||||||
|
orderBy: desc(licenses.createdAt),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return c.json({ success: false, error: "User not found" }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.temporalIdExpiresAt && user.temporalIdExpiresAt < new Date()) {
|
||||||
|
return c.json({ success: false, error: "Session expired" }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const suscriptions: Stripe.Subscription[] = [];
|
||||||
|
for (const license of user.licenses) {
|
||||||
|
const suscription = await stripe.subscriptions.retrieve(
|
||||||
|
license.stripeSubscriptionId,
|
||||||
|
);
|
||||||
|
|
||||||
|
suscriptions.push(suscription);
|
||||||
|
}
|
||||||
|
|
||||||
|
const formated = user.licenses.map((license) => {
|
||||||
|
const suscription = suscriptions.find(
|
||||||
|
(suscription) => suscription.id === license.stripeSubscriptionId,
|
||||||
|
);
|
||||||
|
|
||||||
|
const { type } = getLicenseTypeFromPriceId(
|
||||||
|
suscription?.items.data[0].price.id || "",
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
license: license,
|
||||||
|
stripeSuscription: {
|
||||||
|
quantity: suscription?.items.data[0].quantity,
|
||||||
|
billingType: suscription?.items.data[0].price.recurring?.interval,
|
||||||
|
type: type,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return c.json({ success: true, licenses: formated });
|
||||||
|
},
|
||||||
|
);
|
||||||
220
apps/licenses/src/api/stripe.ts
Normal file
220
apps/licenses/src/api/stripe.ts
Normal file
@@ -0,0 +1,220 @@
|
|||||||
|
import { zValidator } from "@hono/zod-validator";
|
||||||
|
import { Hono } from "hono";
|
||||||
|
import { createCheckoutSessionSchema } from "../validators/stripe";
|
||||||
|
import { getStripeItems } from "../utils/license";
|
||||||
|
import { stripe } from "../stripe";
|
||||||
|
import { WEBSITE_URL } from "../constants";
|
||||||
|
import { logger } from "../logger";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import { licenses } from "../schema";
|
||||||
|
import { db } from "../db";
|
||||||
|
import { getLicenseFeatures, getLicenseTypeFromPriceId } from "../utils";
|
||||||
|
import { z } from "zod";
|
||||||
|
import type Stripe from "stripe";
|
||||||
|
import { createLicense } from "../utils/license";
|
||||||
|
import { render } from "@react-email/render";
|
||||||
|
import { LicenseEmail } from "../../templates/emails/license-email";
|
||||||
|
import { transporter } from "../email";
|
||||||
|
|
||||||
|
export const stripeRouter = new Hono();
|
||||||
|
|
||||||
|
stripeRouter.post(
|
||||||
|
"/create-checkout-session",
|
||||||
|
zValidator("json", createCheckoutSessionSchema),
|
||||||
|
async (c) => {
|
||||||
|
const { type, serverQuantity, isAnnual } = c.req.valid("json");
|
||||||
|
|
||||||
|
const items = getStripeItems(type, serverQuantity, isAnnual);
|
||||||
|
const session = await stripe.checkout.sessions.create({
|
||||||
|
mode: "subscription",
|
||||||
|
line_items: items,
|
||||||
|
allow_promotion_codes: true,
|
||||||
|
success_url: `${WEBSITE_URL}/license/success?session_id={CHECKOUT_SESSION_ID}`,
|
||||||
|
cancel_url: `${WEBSITE_URL}#pricing`,
|
||||||
|
});
|
||||||
|
|
||||||
|
return c.json({ sessionId: session.id });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
stripeRouter.get(
|
||||||
|
"/get-license-from-session",
|
||||||
|
zValidator("query", z.object({ sessionId: z.string().min(1) })),
|
||||||
|
async (c) => {
|
||||||
|
const { sessionId } = c.req.valid("query");
|
||||||
|
console.log("Session ID", sessionId);
|
||||||
|
|
||||||
|
if (!sessionId) {
|
||||||
|
return c.json({ error: "Session ID is required" }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const session = await stripe.checkout.sessions.retrieve(sessionId);
|
||||||
|
if (session.status !== "complete") {
|
||||||
|
return c.json({ error: "Session is not complete" }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const subscription = await stripe.subscriptions.retrieve(
|
||||||
|
session.subscription as string,
|
||||||
|
);
|
||||||
|
|
||||||
|
const license = await db.query.licenses.findFirst({
|
||||||
|
where: eq(licenses.stripeSubscriptionId, subscription.id),
|
||||||
|
});
|
||||||
|
|
||||||
|
const priceId = subscription.items.data[0].price.id;
|
||||||
|
const { type, billingType } = getLicenseTypeFromPriceId(priceId);
|
||||||
|
|
||||||
|
return c.json({ type, billingType, key: license?.licenseKey });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Error retrieving session:", error);
|
||||||
|
return c.json({ error: "Error retrieving session" }, 400);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
stripeRouter.post("/webhook", async (c) => {
|
||||||
|
const rawBody = await c.req.raw.text();
|
||||||
|
const sig = c.req.header("stripe-signature");
|
||||||
|
|
||||||
|
let event: Stripe.Event;
|
||||||
|
|
||||||
|
try {
|
||||||
|
event = stripe.webhooks.constructEvent(
|
||||||
|
rawBody,
|
||||||
|
sig!,
|
||||||
|
process.env.STRIPE_WEBHOOK_SECRET!,
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
logger.error("Webhook signature verification failed:", err);
|
||||||
|
return c.json({ error: "Webhook signature verification failed" }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const allowedEvents = ["invoice.paid"];
|
||||||
|
|
||||||
|
if (!allowedEvents.includes(event.type)) {
|
||||||
|
return c.json({ error: "Event not allowed" }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
switch (event.type) {
|
||||||
|
case "invoice.paid": {
|
||||||
|
const invoice = event.data.object as Stripe.Invoice;
|
||||||
|
|
||||||
|
if (!invoice.subscription) break;
|
||||||
|
|
||||||
|
if (invoice.billing_reason === "subscription_create") {
|
||||||
|
const customerResponse = await stripe.customers.retrieve(
|
||||||
|
invoice.customer as string,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (customerResponse.deleted) {
|
||||||
|
throw new Error("Customer was deleted");
|
||||||
|
}
|
||||||
|
|
||||||
|
const subscriptionId = invoice.subscription as string;
|
||||||
|
const subscription =
|
||||||
|
await stripe.subscriptions.retrieve(subscriptionId);
|
||||||
|
const priceId = subscription.items.data[0].price.id;
|
||||||
|
const { type } = getLicenseTypeFromPriceId(priceId);
|
||||||
|
|
||||||
|
const { license, user } = await createLicense({
|
||||||
|
productId: subscriptionId,
|
||||||
|
email: customerResponse.email!.toLowerCase(),
|
||||||
|
stripeCustomerId: customerResponse.id,
|
||||||
|
stripeSubscriptionId: subscriptionId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const features = getLicenseFeatures(type);
|
||||||
|
const emailHtml = await render(
|
||||||
|
LicenseEmail({
|
||||||
|
customerName: customerResponse.name || "Customer",
|
||||||
|
licenseKey: license.licenseKey,
|
||||||
|
productName: `Dokploy Self Hosted ${type}`,
|
||||||
|
features: features,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await transporter.sendMail({
|
||||||
|
from: process.env.SMTP_FROM_ADDRESS,
|
||||||
|
to: user.email,
|
||||||
|
subject: "Your Dokploy License Key ",
|
||||||
|
html: emailHtml,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.json({ received: true });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error processing webhook:", error);
|
||||||
|
if (error instanceof Error) {
|
||||||
|
return c.json({ error: error.message }, 500);
|
||||||
|
}
|
||||||
|
return c.json({ error: "Unknown error occurred" }, 500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
stripeRouter.post(
|
||||||
|
"/create-customer-portal-session",
|
||||||
|
zValidator("json", z.object({ customerId: z.string().min(1) })),
|
||||||
|
async (c) => {
|
||||||
|
try {
|
||||||
|
const { customerId } = c.req.valid("json");
|
||||||
|
|
||||||
|
const session = await stripe.billingPortal.sessions.create({
|
||||||
|
customer: customerId,
|
||||||
|
configuration: process.env.STRIPE_PORTAL_CONFIGURATION_ID,
|
||||||
|
return_url: `${WEBSITE_URL}/dashboard/settings/billing`,
|
||||||
|
});
|
||||||
|
|
||||||
|
return c.json({ url: session.url });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Error creating customer portal session:", error);
|
||||||
|
return c.json({ error: "Error creating customer portal session" }, 500);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
// Execute 1 time to create the configuration
|
||||||
|
// const configuration = await stripe.billingPortal.configurations.create({
|
||||||
|
// business_profile: {
|
||||||
|
// headline: "Manage your Dokploy subscription",
|
||||||
|
// },
|
||||||
|
// features: {
|
||||||
|
// subscription_update: {
|
||||||
|
// enabled: true,
|
||||||
|
// products: [
|
||||||
|
// {
|
||||||
|
// product: "prod_RxSOzNrwlfVWmv",
|
||||||
|
// prices: [
|
||||||
|
// "price_1R3XTqF3cxQuHeOzZjgaG262",
|
||||||
|
// "price_1R3XmmF3cxQuHeOzCEjz0HFz",
|
||||||
|
// ],
|
||||||
|
// },
|
||||||
|
// {
|
||||||
|
// product: "prod_RxSVDolGpUy97g",
|
||||||
|
// prices: [
|
||||||
|
// "price_1R3XanF3cxQuHeOzh7VdbbUs",
|
||||||
|
// "price_1R3Xp4F3cxQuHeOzUTNYDC9I",
|
||||||
|
// ],
|
||||||
|
// },
|
||||||
|
// {
|
||||||
|
// product: "prod_RxSaEQpbsiPFgv",
|
||||||
|
// prices: [
|
||||||
|
// "price_1R3XfOF3cxQuHeOzYFUa0eNy",
|
||||||
|
// "price_1R3XptF3cxQuHeOzZEBGMsEm",
|
||||||
|
// ],
|
||||||
|
// },
|
||||||
|
// ],
|
||||||
|
// default_allowed_updates: ["price", "quantity"],
|
||||||
|
// },
|
||||||
|
// subscription_cancel: {
|
||||||
|
// enabled: true,
|
||||||
|
// },
|
||||||
|
// payment_method_update: {
|
||||||
|
// enabled: true,
|
||||||
|
// },
|
||||||
|
// },
|
||||||
|
// });
|
||||||
4
apps/licenses/src/constants.ts
Normal file
4
apps/licenses/src/constants.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
export const WEBSITE_URL =
|
||||||
|
process.env.NODE_ENV === "development"
|
||||||
|
? "http://localhost:3001"
|
||||||
|
: process.env.SITE_URL;
|
||||||
20
apps/licenses/src/db.ts
Normal file
20
apps/licenses/src/db.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { type PostgresJsDatabase, drizzle } from "drizzle-orm/postgres-js";
|
||||||
|
import postgres from "postgres";
|
||||||
|
import * as schema from "./schema";
|
||||||
|
declare global {
|
||||||
|
var db: PostgresJsDatabase<typeof schema> | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export let db: PostgresJsDatabase<typeof schema>;
|
||||||
|
if (process.env.NODE_ENV === "production") {
|
||||||
|
db = drizzle(postgres(process.env.DATABASE_URL!), {
|
||||||
|
schema,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
if (!global.db)
|
||||||
|
global.db = drizzle(postgres(process.env.DATABASE_URL!), {
|
||||||
|
schema,
|
||||||
|
});
|
||||||
|
|
||||||
|
db = global.db;
|
||||||
|
}
|
||||||
10
apps/licenses/src/email/index.ts
Normal file
10
apps/licenses/src/email/index.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { createTransport } from "nodemailer";
|
||||||
|
|
||||||
|
export const transporter = createTransport({
|
||||||
|
host: process.env.SMTP_SERVER,
|
||||||
|
port: Number(process.env.SMTP_PORT),
|
||||||
|
auth: {
|
||||||
|
user: process.env.SMTP_USERNAME,
|
||||||
|
pass: process.env.SMTP_PASSWORD,
|
||||||
|
},
|
||||||
|
});
|
||||||
39
apps/licenses/src/index.ts
Normal file
39
apps/licenses/src/index.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import { serve } from "@hono/node-server";
|
||||||
|
import { Hono } from "hono";
|
||||||
|
import { cors } from "hono/cors";
|
||||||
|
import { logger } from "./logger";
|
||||||
|
import { db } from "./db";
|
||||||
|
import { sql } from "drizzle-orm";
|
||||||
|
import "dotenv/config";
|
||||||
|
import { licenseRouter } from "./api/license";
|
||||||
|
import { stripeRouter } from "./api/stripe";
|
||||||
|
|
||||||
|
const app = new Hono();
|
||||||
|
const router = new Hono();
|
||||||
|
router.use(
|
||||||
|
"/*",
|
||||||
|
cors({
|
||||||
|
origin: ["http://localhost:3001"],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
router.get("/health", async (c) => {
|
||||||
|
try {
|
||||||
|
await db.execute(sql`SELECT 1`);
|
||||||
|
return c.json({ status: "ok" });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Database connection error:", error);
|
||||||
|
return c.json({ status: "error" }, 500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.route("/api", router);
|
||||||
|
app.route("/api/license", licenseRouter);
|
||||||
|
app.route("/api/stripe", stripeRouter);
|
||||||
|
const port = process.env.PORT || 4002;
|
||||||
|
console.log(`Server is running on port http://localhost:${port}`);
|
||||||
|
|
||||||
|
serve({
|
||||||
|
fetch: app.fetch,
|
||||||
|
port: Number(port),
|
||||||
|
});
|
||||||
10
apps/licenses/src/logger.ts
Normal file
10
apps/licenses/src/logger.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import pino from "pino";
|
||||||
|
|
||||||
|
export const logger = pino({
|
||||||
|
transport: {
|
||||||
|
target: "pino-pretty",
|
||||||
|
options: {
|
||||||
|
colorize: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
43
apps/licenses/src/schema.ts
Normal file
43
apps/licenses/src/schema.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import { sql } from "drizzle-orm";
|
||||||
|
import { pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core";
|
||||||
|
import { relations } from "drizzle-orm";
|
||||||
|
export const users = pgTable("user", {
|
||||||
|
id: uuid("id").defaultRandom().primaryKey(),
|
||||||
|
email: text("email").notNull().unique(),
|
||||||
|
createdAt: timestamp("created_at").default(sql`CURRENT_TIMESTAMP`),
|
||||||
|
updatedAt: timestamp("updated_at").default(sql`CURRENT_TIMESTAMP`),
|
||||||
|
otpCode: text("otp_code"),
|
||||||
|
otpCodeExpiresAt: timestamp("otp_code_expires_at"),
|
||||||
|
temporalId: text("temporal_id"),
|
||||||
|
temporalIdExpiresAt: timestamp("temporal_id_expires_at"),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const usersRelations = relations(users, ({ many }) => ({
|
||||||
|
licenses: many(licenses),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const licenses = pgTable("licenses", {
|
||||||
|
id: uuid("id").defaultRandom().primaryKey(),
|
||||||
|
productId: text("product_id").notNull(),
|
||||||
|
licenseKey: text("license_key").notNull().unique(),
|
||||||
|
serverIps: text("server_ips").array(),
|
||||||
|
activatedAt: timestamp("activated_at"),
|
||||||
|
lastVerifiedAt: timestamp("last_verified_at"),
|
||||||
|
stripeCustomerId: text("stripeCustomerId").notNull(),
|
||||||
|
|
||||||
|
stripeSubscriptionId: text("stripeSubscriptionId").notNull(),
|
||||||
|
createdAt: timestamp("created_at").default(sql`CURRENT_TIMESTAMP`),
|
||||||
|
updatedAt: timestamp("updated_at").default(sql`CURRENT_TIMESTAMP`),
|
||||||
|
metadata: text("metadata"),
|
||||||
|
userId: uuid("user_id").references(() => users.id),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const licensesRelations = relations(licenses, ({ one }) => ({
|
||||||
|
user: one(users, {
|
||||||
|
fields: [licenses.userId],
|
||||||
|
references: [users.id],
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export type License = typeof licenses.$inferSelect;
|
||||||
|
export type NewLicense = typeof licenses.$inferInsert;
|
||||||
5
apps/licenses/src/stripe/index.ts
Normal file
5
apps/licenses/src/stripe/index.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import Stripe from "stripe";
|
||||||
|
|
||||||
|
export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
|
||||||
|
apiVersion: "2024-09-30.acacia",
|
||||||
|
});
|
||||||
70
apps/licenses/src/utils.ts
Normal file
70
apps/licenses/src/utils.ts
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
export const getLicenseFeatures = (type: string): string[] => {
|
||||||
|
const baseFeatures = [
|
||||||
|
"Unlimited deployments",
|
||||||
|
"Basic monitoring",
|
||||||
|
"Email support",
|
||||||
|
];
|
||||||
|
|
||||||
|
const premiumFeatures = [
|
||||||
|
...baseFeatures,
|
||||||
|
"Priority support",
|
||||||
|
"Advanced monitoring",
|
||||||
|
"Custom domains",
|
||||||
|
"Team collaboration",
|
||||||
|
];
|
||||||
|
|
||||||
|
const businessFeatures = [
|
||||||
|
...premiumFeatures,
|
||||||
|
"24/7 support",
|
||||||
|
"Custom integrations",
|
||||||
|
"SLA guarantees",
|
||||||
|
"Dedicated account manager",
|
||||||
|
];
|
||||||
|
|
||||||
|
switch (type) {
|
||||||
|
case "basic":
|
||||||
|
return baseFeatures;
|
||||||
|
case "premium":
|
||||||
|
return premiumFeatures;
|
||||||
|
case "business":
|
||||||
|
return businessFeatures;
|
||||||
|
default:
|
||||||
|
return baseFeatures;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getLicenseTypeFromPriceId = (
|
||||||
|
priceId: string,
|
||||||
|
): {
|
||||||
|
type: "basic" | "premium" | "business";
|
||||||
|
billingType: "monthly" | "annual";
|
||||||
|
} => {
|
||||||
|
const priceMap = {
|
||||||
|
[process.env.SELF_HOSTED_BASIC_PRICE_MONTHLY_ID!]: {
|
||||||
|
type: "basic",
|
||||||
|
billingType: "monthly",
|
||||||
|
},
|
||||||
|
[process.env.SELF_HOSTED_BASIC_PRICE_ANNUAL_ID!]: {
|
||||||
|
type: "basic",
|
||||||
|
billingType: "annual",
|
||||||
|
},
|
||||||
|
[process.env.SELF_HOSTED_PREMIUM_PRICE_MONTHLY_ID!]: {
|
||||||
|
type: "premium",
|
||||||
|
billingType: "monthly",
|
||||||
|
},
|
||||||
|
[process.env.SELF_HOSTED_PREMIUM_PRICE_ANNUAL_ID!]: {
|
||||||
|
type: "premium",
|
||||||
|
billingType: "annual",
|
||||||
|
},
|
||||||
|
[process.env.SELF_HOSTED_BUSINESS_PRICE_MONTHLY_ID!]: {
|
||||||
|
type: "business",
|
||||||
|
billingType: "monthly",
|
||||||
|
},
|
||||||
|
[process.env.SELF_HOSTED_BUSINESS_PRICE_ANNUAL_ID!]: {
|
||||||
|
type: "business",
|
||||||
|
billingType: "annual",
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
return priceMap[priceId] || { type: "basic", billingType: "monthly" };
|
||||||
|
};
|
||||||
270
apps/licenses/src/utils/license.ts
Normal file
270
apps/licenses/src/utils/license.ts
Normal file
@@ -0,0 +1,270 @@
|
|||||||
|
import { randomBytes } from "node:crypto";
|
||||||
|
import { db } from "../db";
|
||||||
|
import { licenses, users } from "../schema";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import { stripe } from "../stripe";
|
||||||
|
import type Stripe from "stripe";
|
||||||
|
|
||||||
|
export const generateLicenseKey = () => {
|
||||||
|
return randomBytes(32).toString("hex");
|
||||||
|
};
|
||||||
|
|
||||||
|
interface CreateLicenseProps {
|
||||||
|
productId: string;
|
||||||
|
email: string;
|
||||||
|
stripeCustomerId: string;
|
||||||
|
stripeSubscriptionId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createLicense = async ({
|
||||||
|
productId,
|
||||||
|
email,
|
||||||
|
stripeCustomerId,
|
||||||
|
stripeSubscriptionId,
|
||||||
|
}: CreateLicenseProps) => {
|
||||||
|
const licenseKey = `dokploy-${generateLicenseKey()}`;
|
||||||
|
|
||||||
|
return await db.transaction(async (tx) => {
|
||||||
|
let user = await tx
|
||||||
|
.insert(users)
|
||||||
|
.values({ email })
|
||||||
|
.onConflictDoNothing()
|
||||||
|
.returning()
|
||||||
|
.then((res) => res[0]);
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
const result = await tx.query.users.findFirst({
|
||||||
|
where: eq(users.email, email),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result) {
|
||||||
|
throw new Error("User not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
user = result;
|
||||||
|
}
|
||||||
|
|
||||||
|
const license = await tx
|
||||||
|
.insert(licenses)
|
||||||
|
.values({
|
||||||
|
productId,
|
||||||
|
licenseKey,
|
||||||
|
stripeCustomerId,
|
||||||
|
stripeSubscriptionId,
|
||||||
|
userId: user.id,
|
||||||
|
})
|
||||||
|
.returning()
|
||||||
|
.then((res) => res[0]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
license,
|
||||||
|
user,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const validateLicense = async (licenseKey: string, serverIp: string) => {
|
||||||
|
const license = await db.query.licenses.findFirst({
|
||||||
|
where: eq(licenses.licenseKey, licenseKey),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!license) {
|
||||||
|
return { success: false, error: "License not found" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const suscription = await stripe.subscriptions.retrieve(
|
||||||
|
license.stripeSubscriptionId,
|
||||||
|
);
|
||||||
|
const currentServerQuantity = license.serverIps?.length || 0;
|
||||||
|
const serversQuantity = suscription.items.data[0].quantity || 0;
|
||||||
|
|
||||||
|
if (license.serverIps?.includes(serverIp)) {
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
license,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentServerQuantity >= serversQuantity) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error:
|
||||||
|
"You have reached the maximum number of servers, please upgrade your license to add more servers",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (suscription.status !== "active") {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: `License is ${getLicenseStatus(suscription)}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (license.serverIps && !license.serverIps.includes(serverIp)) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error:
|
||||||
|
"This server is not authorized to use this license, please remove the current license from the UI, and activate a new one",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
await db
|
||||||
|
.update(licenses)
|
||||||
|
.set({ lastVerifiedAt: new Date() })
|
||||||
|
.where(eq(licenses.id, license.id));
|
||||||
|
|
||||||
|
return { success: true, license };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const activateLicense = async (licenseKey: string, serverIp: string) => {
|
||||||
|
const license = await db.query.licenses.findFirst({
|
||||||
|
where: eq(licenses.licenseKey, licenseKey),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!license) {
|
||||||
|
throw new Error("License not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
const suscription = await stripe.subscriptions.retrieve(
|
||||||
|
license.stripeSubscriptionId,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (suscription.status !== "active") {
|
||||||
|
throw new Error(`License is ${getLicenseStatus(suscription)}`);
|
||||||
|
}
|
||||||
|
const currentServerQuantity = license.serverIps?.length || 0;
|
||||||
|
const serversQuantity = suscription.items.data[0].quantity || 0;
|
||||||
|
|
||||||
|
if (currentServerQuantity >= serversQuantity) {
|
||||||
|
throw new Error(
|
||||||
|
"You have reached the maximum number of servers, please upgrade your license to add more servers",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (license.serverIps?.includes(serverIp)) {
|
||||||
|
return license;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Activate the license with the server IP
|
||||||
|
const updatedLicense = await db
|
||||||
|
.update(licenses)
|
||||||
|
.set({
|
||||||
|
serverIps: [...(license.serverIps || []), serverIp],
|
||||||
|
activatedAt: new Date(),
|
||||||
|
lastVerifiedAt: new Date(),
|
||||||
|
})
|
||||||
|
.where(eq(licenses.id, license.id))
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
return updatedLicense[0];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deactivateLicense = async (
|
||||||
|
licenseKey: string,
|
||||||
|
serverIp: string,
|
||||||
|
) => {
|
||||||
|
const license = await db.query.licenses.findFirst({
|
||||||
|
where: eq(licenses.licenseKey, licenseKey),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!license) {
|
||||||
|
throw new Error("License not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedLicense = await db
|
||||||
|
.update(licenses)
|
||||||
|
.set({ serverIps: license.serverIps?.filter((ip) => ip !== serverIp) })
|
||||||
|
.where(eq(licenses.id, license.id))
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
return updatedLicense[0];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const cleanLicense = async (licenseKey: string, serverIp: string) => {
|
||||||
|
const license = await db.query.licenses.findFirst({
|
||||||
|
where: eq(licenses.licenseKey, licenseKey),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!license) {
|
||||||
|
throw new Error("License not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedLicense = await db
|
||||||
|
.update(licenses)
|
||||||
|
.set({ serverIps: license.serverIps?.filter((ip) => ip !== serverIp) })
|
||||||
|
.where(eq(licenses.id, license.id))
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
return updatedLicense[0];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getLicenseStatus = async (license: Stripe.Subscription) => {
|
||||||
|
if (license.status === "active") {
|
||||||
|
return "active";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (license.status === "canceled") {
|
||||||
|
return "canceled";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (license.status === "incomplete") {
|
||||||
|
return "incomplete";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (license.status === "incomplete_expired") {
|
||||||
|
return "incomplete expired";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (license.status === "past_due") {
|
||||||
|
return "past due";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (license.status === "paused") {
|
||||||
|
return "paused";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (license.status === "trialing") {
|
||||||
|
return "trialing";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (license.status === "unpaid") {
|
||||||
|
return "unpaid";
|
||||||
|
}
|
||||||
|
|
||||||
|
return "unknown";
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getStripeItems = (
|
||||||
|
type: "basic" | "premium" | "business",
|
||||||
|
serverQuantity: number,
|
||||||
|
isAnnual: boolean,
|
||||||
|
) => {
|
||||||
|
const items = [];
|
||||||
|
|
||||||
|
if (type === "basic") {
|
||||||
|
items.push({
|
||||||
|
price: isAnnual
|
||||||
|
? process.env.SELF_HOSTED_BASIC_PRICE_ANNUAL_ID
|
||||||
|
: process.env.SELF_HOSTED_BASIC_PRICE_MONTHLY_ID,
|
||||||
|
quantity: serverQuantity,
|
||||||
|
});
|
||||||
|
} else if (type === "premium") {
|
||||||
|
items.push({
|
||||||
|
price: isAnnual
|
||||||
|
? process.env.SELF_HOSTED_PREMIUM_PRICE_ANNUAL_ID
|
||||||
|
: process.env.SELF_HOSTED_PREMIUM_PRICE_MONTHLY_ID,
|
||||||
|
quantity: serverQuantity,
|
||||||
|
});
|
||||||
|
} else if (type === "business") {
|
||||||
|
items.push({
|
||||||
|
price: isAnnual
|
||||||
|
? process.env.SELF_HOSTED_BUSINESS_PRICE_ANNUAL_ID
|
||||||
|
: process.env.SELF_HOSTED_BUSINESS_PRICE_MONTHLY_ID,
|
||||||
|
quantity: serverQuantity,
|
||||||
|
});
|
||||||
|
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
return items;
|
||||||
|
};
|
||||||
7
apps/licenses/src/validators/stripe.ts
Normal file
7
apps/licenses/src/validators/stripe.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
export const createCheckoutSessionSchema = z.object({
|
||||||
|
type: z.enum(["basic", "premium", "business"]),
|
||||||
|
serverQuantity: z.number().min(1),
|
||||||
|
isAnnual: z.boolean(),
|
||||||
|
});
|
||||||
289
apps/licenses/templates/emails/license-email.tsx
Normal file
289
apps/licenses/templates/emails/license-email.tsx
Normal file
@@ -0,0 +1,289 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Container,
|
||||||
|
Head,
|
||||||
|
Heading,
|
||||||
|
Hr,
|
||||||
|
Html,
|
||||||
|
Img,
|
||||||
|
Link,
|
||||||
|
Preview,
|
||||||
|
Section,
|
||||||
|
Text,
|
||||||
|
Button,
|
||||||
|
} from "@react-email/components";
|
||||||
|
import * as React from "react";
|
||||||
|
|
||||||
|
interface LicenseEmailProps {
|
||||||
|
customerName: string;
|
||||||
|
licenseKey: string;
|
||||||
|
productName: string;
|
||||||
|
features: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseUrl = "https://dokploy.com";
|
||||||
|
|
||||||
|
export const LicenseEmail = ({
|
||||||
|
customerName = "John Doe",
|
||||||
|
licenseKey = "1234567890",
|
||||||
|
productName = "Dokploy",
|
||||||
|
features = ["Feature 1", "Feature 2", "Feature 3"],
|
||||||
|
}: LicenseEmailProps): React.ReactElement => {
|
||||||
|
return (
|
||||||
|
<Html>
|
||||||
|
<Head />
|
||||||
|
<Preview>Your Dokploy License Key is Here! 🚀</Preview>
|
||||||
|
<Body style={main}>
|
||||||
|
<Container style={container}>
|
||||||
|
<Section style={heroSection}>
|
||||||
|
<Img
|
||||||
|
src={`${baseUrl}/og.png`}
|
||||||
|
width="180"
|
||||||
|
height="auto"
|
||||||
|
alt="Dokploy"
|
||||||
|
style={{ borderRadius: "10px", margin: "0 auto" }}
|
||||||
|
/>
|
||||||
|
<Heading style={heroTitle}>
|
||||||
|
Welcome to the Future of Deployment
|
||||||
|
</Heading>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section style={mainContent}>
|
||||||
|
<Text style={greeting}>Hi {customerName},</Text>
|
||||||
|
<Text style={text}>
|
||||||
|
Thank you for choosing {productName}! We're excited to have you on
|
||||||
|
board. Your premium license key is ready to unlock all the
|
||||||
|
powerful features:
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Section style={licenseContainer}>
|
||||||
|
<Text style={licenseLabel}>Your License Key</Text>
|
||||||
|
<Text style={licenseKeyStyle}>{licenseKey}</Text>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section style={featuresContainer}>
|
||||||
|
<Heading as="h2" style={h2}>
|
||||||
|
🎉 Your Premium Features
|
||||||
|
</Heading>
|
||||||
|
<div style={featureGrid}>
|
||||||
|
{features.map((feature, index) => (
|
||||||
|
<Text key={index} style={featureItem}>
|
||||||
|
<span style={checkmark}>✓</span> {feature}
|
||||||
|
</Text>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section style={activationSection}>
|
||||||
|
<Heading as="h2" style={h2}>
|
||||||
|
Getting Started
|
||||||
|
</Heading>
|
||||||
|
<div style={stepsContainer}>
|
||||||
|
<Text style={steps}>
|
||||||
|
1. Go to your Dokploy dashboard
|
||||||
|
<br />
|
||||||
|
2. Navigate to Settings → License
|
||||||
|
<br />
|
||||||
|
3. Enter your license key
|
||||||
|
<br />
|
||||||
|
4. Click "Activate License"
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
<Button href="https://dokploy.com/dashboard" style={ctaButton}>
|
||||||
|
Activate Your License
|
||||||
|
</Button>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Hr style={hr} />
|
||||||
|
|
||||||
|
<Section style={supportSection}>
|
||||||
|
<Text style={supportText}>
|
||||||
|
Need help? Our support team is ready to assist you.
|
||||||
|
<br />
|
||||||
|
<Link style={link} href="mailto:support@dokploy.com">
|
||||||
|
support@dokploy.com
|
||||||
|
</Link>
|
||||||
|
</Text>
|
||||||
|
</Section>
|
||||||
|
</Section>
|
||||||
|
</Container>
|
||||||
|
</Body>
|
||||||
|
</Html>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const main = {
|
||||||
|
backgroundColor: "#f6f9fc",
|
||||||
|
fontFamily:
|
||||||
|
'-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Oxygen-Sans,Ubuntu,Cantarell,"Helvetica Neue",sans-serif',
|
||||||
|
};
|
||||||
|
|
||||||
|
const container = {
|
||||||
|
margin: "0 auto",
|
||||||
|
padding: "40px 0",
|
||||||
|
maxWidth: "600px",
|
||||||
|
};
|
||||||
|
|
||||||
|
const heroSection = {
|
||||||
|
backgroundColor: "#ffffff",
|
||||||
|
borderRadius: "8px",
|
||||||
|
padding: "40px 20px",
|
||||||
|
textAlign: "center" as const,
|
||||||
|
boxShadow: "0 2px 8px rgba(0,0,0,0.05)",
|
||||||
|
};
|
||||||
|
|
||||||
|
const heroTitle = {
|
||||||
|
color: "#1a1a1a",
|
||||||
|
fontSize: "28px",
|
||||||
|
fontWeight: "800",
|
||||||
|
lineHeight: "1.3",
|
||||||
|
margin: "20px 0 0",
|
||||||
|
padding: "0",
|
||||||
|
};
|
||||||
|
|
||||||
|
const mainContent = {
|
||||||
|
backgroundColor: "#ffffff",
|
||||||
|
borderRadius: "8px",
|
||||||
|
marginTop: "24px",
|
||||||
|
padding: "40px",
|
||||||
|
boxShadow: "0 2px 8px rgba(0,0,0,0.05)",
|
||||||
|
};
|
||||||
|
|
||||||
|
const greeting = {
|
||||||
|
fontSize: "20px",
|
||||||
|
lineHeight: "1.3",
|
||||||
|
fontWeight: "600",
|
||||||
|
color: "#1a1a1a",
|
||||||
|
margin: "0 0 20px",
|
||||||
|
};
|
||||||
|
|
||||||
|
const text = {
|
||||||
|
color: "#4a5568",
|
||||||
|
fontSize: "16px",
|
||||||
|
lineHeight: "1.6",
|
||||||
|
margin: "0 0 24px",
|
||||||
|
};
|
||||||
|
|
||||||
|
const licenseContainer = {
|
||||||
|
background: "linear-gradient(135deg, #2563eb 0%, #1e40af 100%)",
|
||||||
|
borderRadius: "12px",
|
||||||
|
padding: "24px",
|
||||||
|
margin: "32px 0",
|
||||||
|
textAlign: "center" as const,
|
||||||
|
};
|
||||||
|
|
||||||
|
const licenseLabel = {
|
||||||
|
color: "#ffffff",
|
||||||
|
fontSize: "14px",
|
||||||
|
textTransform: "uppercase" as const,
|
||||||
|
letterSpacing: "1px",
|
||||||
|
margin: "0 0 12px",
|
||||||
|
};
|
||||||
|
|
||||||
|
const licenseKeyStyle = {
|
||||||
|
fontFamily: "monospace",
|
||||||
|
fontSize: "24px",
|
||||||
|
color: "#ffffff",
|
||||||
|
margin: "0",
|
||||||
|
wordBreak: "break-all" as const,
|
||||||
|
fontWeight: "600",
|
||||||
|
};
|
||||||
|
|
||||||
|
const validitySection = {
|
||||||
|
textAlign: "center" as const,
|
||||||
|
margin: "24px 0",
|
||||||
|
};
|
||||||
|
|
||||||
|
const validityText = {
|
||||||
|
color: "#4a5568",
|
||||||
|
fontSize: "16px",
|
||||||
|
};
|
||||||
|
|
||||||
|
const featuresContainer = {
|
||||||
|
margin: "40px 0",
|
||||||
|
};
|
||||||
|
|
||||||
|
const featureGrid = {
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: "1fr",
|
||||||
|
gap: "12px",
|
||||||
|
};
|
||||||
|
|
||||||
|
const featureItem = {
|
||||||
|
color: "#4a5568",
|
||||||
|
fontSize: "16px",
|
||||||
|
lineHeight: "1.5",
|
||||||
|
margin: "0",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
};
|
||||||
|
|
||||||
|
const checkmark = {
|
||||||
|
color: "#2563eb",
|
||||||
|
fontWeight: "bold",
|
||||||
|
marginRight: "12px",
|
||||||
|
fontSize: "18px",
|
||||||
|
};
|
||||||
|
|
||||||
|
const h2 = {
|
||||||
|
color: "#1a1a1a",
|
||||||
|
fontSize: "20px",
|
||||||
|
fontWeight: "600",
|
||||||
|
margin: "0 0 20px",
|
||||||
|
padding: "0",
|
||||||
|
};
|
||||||
|
|
||||||
|
const activationSection = {
|
||||||
|
backgroundColor: "#f8fafc",
|
||||||
|
borderRadius: "8px",
|
||||||
|
padding: "24px",
|
||||||
|
margin: "32px 0",
|
||||||
|
};
|
||||||
|
|
||||||
|
const stepsContainer = {
|
||||||
|
margin: "20px 0",
|
||||||
|
};
|
||||||
|
|
||||||
|
const steps = {
|
||||||
|
color: "#4a5568",
|
||||||
|
fontSize: "16px",
|
||||||
|
lineHeight: "1.8",
|
||||||
|
margin: "0",
|
||||||
|
};
|
||||||
|
|
||||||
|
const ctaButton = {
|
||||||
|
backgroundColor: "#2563eb",
|
||||||
|
borderRadius: "6px",
|
||||||
|
color: "#ffffff",
|
||||||
|
fontSize: "16px",
|
||||||
|
fontWeight: "600",
|
||||||
|
textDecoration: "none",
|
||||||
|
textAlign: "center" as const,
|
||||||
|
display: "inline-block",
|
||||||
|
padding: "12px 24px",
|
||||||
|
margin: "20px 0 0",
|
||||||
|
};
|
||||||
|
|
||||||
|
const hr = {
|
||||||
|
borderColor: "#e2e8f0",
|
||||||
|
margin: "40px 0",
|
||||||
|
};
|
||||||
|
|
||||||
|
const supportSection = {
|
||||||
|
textAlign: "center" as const,
|
||||||
|
};
|
||||||
|
|
||||||
|
const supportText = {
|
||||||
|
color: "#64748b",
|
||||||
|
fontSize: "14px",
|
||||||
|
lineHeight: "1.5",
|
||||||
|
margin: "0",
|
||||||
|
};
|
||||||
|
|
||||||
|
const link = {
|
||||||
|
color: "#2563eb",
|
||||||
|
textDecoration: "none",
|
||||||
|
fontWeight: "500",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default LicenseEmail;
|
||||||
278
apps/licenses/templates/emails/resend-license-email.tsx
Normal file
278
apps/licenses/templates/emails/resend-license-email.tsx
Normal file
@@ -0,0 +1,278 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Container,
|
||||||
|
Head,
|
||||||
|
Heading,
|
||||||
|
Hr,
|
||||||
|
Html,
|
||||||
|
Img,
|
||||||
|
Link,
|
||||||
|
Preview,
|
||||||
|
Section,
|
||||||
|
Text,
|
||||||
|
Button,
|
||||||
|
} from "@react-email/components";
|
||||||
|
import * as React from "react";
|
||||||
|
|
||||||
|
interface ResendLicenseEmailProps {
|
||||||
|
customerName: string;
|
||||||
|
licenseKey: string;
|
||||||
|
productName: string;
|
||||||
|
requestDate?: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseUrl = "https://dokploy.com";
|
||||||
|
|
||||||
|
export const ResendLicenseEmail = ({
|
||||||
|
customerName = "John Doe",
|
||||||
|
licenseKey = "1234567890",
|
||||||
|
productName = "Dokploy",
|
||||||
|
requestDate = new Date(),
|
||||||
|
}: ResendLicenseEmailProps): React.ReactElement => {
|
||||||
|
const formattedRequestDate = requestDate.toLocaleDateString("en-US", {
|
||||||
|
year: "numeric",
|
||||||
|
month: "long",
|
||||||
|
day: "numeric",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Html>
|
||||||
|
<Head />
|
||||||
|
<Preview>Your Requested Dokploy License Key 🔑</Preview>
|
||||||
|
<Body style={main}>
|
||||||
|
<Container style={container}>
|
||||||
|
<Section style={heroSection}>
|
||||||
|
<Img
|
||||||
|
src={`${baseUrl}/og.png`}
|
||||||
|
width="180"
|
||||||
|
height="auto"
|
||||||
|
alt="Dokploy"
|
||||||
|
style={{ borderRadius: "10px", margin: "0 auto" }}
|
||||||
|
/>
|
||||||
|
<Heading style={heroTitle}>Here's Your License Key</Heading>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section style={mainContent}>
|
||||||
|
<Text style={greeting}>Hi {customerName},</Text>
|
||||||
|
<Text style={text}>
|
||||||
|
As requested on {formattedRequestDate}, here is your {productName}{" "}
|
||||||
|
license key. This is the same active license key associated with
|
||||||
|
your account:
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Section style={licenseContainer}>
|
||||||
|
<Text style={licenseLabel}>Your Active License Key</Text>
|
||||||
|
<Text style={licenseKeyStyle}>{licenseKey}</Text>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section style={activationSection}>
|
||||||
|
<Heading as="h2" style={h2}>
|
||||||
|
Quick Activation Guide
|
||||||
|
</Heading>
|
||||||
|
<div style={stepsContainer}>
|
||||||
|
<Text style={steps}>
|
||||||
|
1. Go to your Dokploy dashboard
|
||||||
|
<br />
|
||||||
|
2. Navigate to Settings → License
|
||||||
|
<br />
|
||||||
|
3. Enter your license key above
|
||||||
|
<br />
|
||||||
|
4. Click "Activate License"
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
<Button href="https://dokploy.com/dashboard" style={ctaButton}>
|
||||||
|
Go to Dashboard
|
||||||
|
</Button>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Hr style={hr} />
|
||||||
|
|
||||||
|
<Section style={securitySection}>
|
||||||
|
<Text style={securityText}>
|
||||||
|
🔒 For security: If you didn't request this license key, please
|
||||||
|
contact our support team immediately.
|
||||||
|
</Text>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section style={supportSection}>
|
||||||
|
<Text style={supportText}>
|
||||||
|
Need help? Our support team is ready to assist you.
|
||||||
|
<br />
|
||||||
|
<Link style={link} href="mailto:support@dokploy.com">
|
||||||
|
support@dokploy.com
|
||||||
|
</Link>
|
||||||
|
</Text>
|
||||||
|
</Section>
|
||||||
|
</Section>
|
||||||
|
</Container>
|
||||||
|
</Body>
|
||||||
|
</Html>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const main = {
|
||||||
|
backgroundColor: "#f6f9fc",
|
||||||
|
fontFamily:
|
||||||
|
'-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Oxygen-Sans,Ubuntu,Cantarell,"Helvetica Neue",sans-serif',
|
||||||
|
};
|
||||||
|
|
||||||
|
const container = {
|
||||||
|
margin: "0 auto",
|
||||||
|
padding: "40px 0",
|
||||||
|
maxWidth: "600px",
|
||||||
|
};
|
||||||
|
|
||||||
|
const heroSection = {
|
||||||
|
backgroundColor: "#ffffff",
|
||||||
|
borderRadius: "8px",
|
||||||
|
padding: "40px 20px",
|
||||||
|
textAlign: "center" as const,
|
||||||
|
boxShadow: "0 2px 8px rgba(0,0,0,0.05)",
|
||||||
|
};
|
||||||
|
|
||||||
|
const heroTitle = {
|
||||||
|
color: "#1a1a1a",
|
||||||
|
fontSize: "28px",
|
||||||
|
fontWeight: "800",
|
||||||
|
lineHeight: "1.3",
|
||||||
|
margin: "20px 0 0",
|
||||||
|
padding: "0",
|
||||||
|
};
|
||||||
|
|
||||||
|
const mainContent = {
|
||||||
|
backgroundColor: "#ffffff",
|
||||||
|
borderRadius: "8px",
|
||||||
|
marginTop: "24px",
|
||||||
|
padding: "40px",
|
||||||
|
boxShadow: "0 2px 8px rgba(0,0,0,0.05)",
|
||||||
|
};
|
||||||
|
|
||||||
|
const greeting = {
|
||||||
|
fontSize: "20px",
|
||||||
|
lineHeight: "1.3",
|
||||||
|
fontWeight: "600",
|
||||||
|
color: "#1a1a1a",
|
||||||
|
margin: "0 0 20px",
|
||||||
|
};
|
||||||
|
|
||||||
|
const text = {
|
||||||
|
color: "#4a5568",
|
||||||
|
fontSize: "16px",
|
||||||
|
lineHeight: "1.6",
|
||||||
|
margin: "0 0 24px",
|
||||||
|
};
|
||||||
|
|
||||||
|
const licenseContainer = {
|
||||||
|
background: "linear-gradient(135deg, #2563eb 0%, #1e40af 100%)",
|
||||||
|
borderRadius: "12px",
|
||||||
|
padding: "24px",
|
||||||
|
margin: "32px 0",
|
||||||
|
textAlign: "center" as const,
|
||||||
|
};
|
||||||
|
|
||||||
|
const licenseLabel = {
|
||||||
|
color: "#ffffff",
|
||||||
|
fontSize: "14px",
|
||||||
|
textTransform: "uppercase" as const,
|
||||||
|
letterSpacing: "1px",
|
||||||
|
margin: "0 0 12px",
|
||||||
|
};
|
||||||
|
|
||||||
|
const licenseKeyStyle = {
|
||||||
|
fontFamily: "monospace",
|
||||||
|
fontSize: "24px",
|
||||||
|
color: "#ffffff",
|
||||||
|
margin: "0",
|
||||||
|
wordBreak: "break-all" as const,
|
||||||
|
fontWeight: "600",
|
||||||
|
};
|
||||||
|
|
||||||
|
const validitySection = {
|
||||||
|
textAlign: "center" as const,
|
||||||
|
margin: "24px 0",
|
||||||
|
};
|
||||||
|
|
||||||
|
const validityText = {
|
||||||
|
color: "#4a5568",
|
||||||
|
fontSize: "16px",
|
||||||
|
};
|
||||||
|
|
||||||
|
const h2 = {
|
||||||
|
color: "#1a1a1a",
|
||||||
|
fontSize: "20px",
|
||||||
|
fontWeight: "600",
|
||||||
|
margin: "0 0 20px",
|
||||||
|
padding: "0",
|
||||||
|
};
|
||||||
|
|
||||||
|
const activationSection = {
|
||||||
|
backgroundColor: "#f8fafc",
|
||||||
|
borderRadius: "8px",
|
||||||
|
padding: "24px",
|
||||||
|
margin: "32px 0",
|
||||||
|
};
|
||||||
|
|
||||||
|
const stepsContainer = {
|
||||||
|
margin: "20px 0",
|
||||||
|
};
|
||||||
|
|
||||||
|
const steps = {
|
||||||
|
color: "#4a5568",
|
||||||
|
fontSize: "16px",
|
||||||
|
lineHeight: "1.8",
|
||||||
|
margin: "0",
|
||||||
|
};
|
||||||
|
|
||||||
|
const ctaButton = {
|
||||||
|
backgroundColor: "#2563eb",
|
||||||
|
borderRadius: "6px",
|
||||||
|
color: "#ffffff",
|
||||||
|
fontSize: "16px",
|
||||||
|
fontWeight: "600",
|
||||||
|
textDecoration: "none",
|
||||||
|
textAlign: "center" as const,
|
||||||
|
display: "inline-block",
|
||||||
|
padding: "12px 24px",
|
||||||
|
margin: "20px 0 0",
|
||||||
|
};
|
||||||
|
|
||||||
|
const hr = {
|
||||||
|
borderColor: "#e2e8f0",
|
||||||
|
margin: "40px 0",
|
||||||
|
};
|
||||||
|
|
||||||
|
const securitySection = {
|
||||||
|
backgroundColor: "#fff5f5",
|
||||||
|
borderRadius: "8px",
|
||||||
|
padding: "16px",
|
||||||
|
margin: "0 0 32px 0",
|
||||||
|
};
|
||||||
|
|
||||||
|
const securityText = {
|
||||||
|
color: "#e53e3e",
|
||||||
|
fontSize: "14px",
|
||||||
|
lineHeight: "1.5",
|
||||||
|
margin: "0",
|
||||||
|
textAlign: "center" as const,
|
||||||
|
};
|
||||||
|
|
||||||
|
const supportSection = {
|
||||||
|
textAlign: "center" as const,
|
||||||
|
};
|
||||||
|
|
||||||
|
const supportText = {
|
||||||
|
color: "#64748b",
|
||||||
|
fontSize: "14px",
|
||||||
|
lineHeight: "1.5",
|
||||||
|
margin: "0",
|
||||||
|
};
|
||||||
|
|
||||||
|
const link = {
|
||||||
|
color: "#2563eb",
|
||||||
|
textDecoration: "none",
|
||||||
|
fontWeight: "500",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ResendLicenseEmail;
|
||||||
BIN
apps/licenses/templates/emails/static/vercel-user.png
Normal file
BIN
apps/licenses/templates/emails/static/vercel-user.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 54 KiB |
3495
apps/licenses/templates/package-lock.json
generated
Normal file
3495
apps/licenses/templates/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
21
apps/licenses/templates/package.json
Normal file
21
apps/licenses/templates/package.json
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"name": "react-email-starter",
|
||||||
|
"version": "0.1.10",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"build": "email build",
|
||||||
|
"dev": "email dev",
|
||||||
|
"export": "email export"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@react-email/components": "0.0.34",
|
||||||
|
"react-dom": "18.3.1",
|
||||||
|
"react": "18.3.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "18.2.33",
|
||||||
|
"@types/react-dom": "18.2.14",
|
||||||
|
"react-email": "3.0.7"
|
||||||
|
}
|
||||||
|
}
|
||||||
27
apps/licenses/templates/readme.md
Normal file
27
apps/licenses/templates/readme.md
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
# React Email Starter
|
||||||
|
|
||||||
|
A live preview right in your browser so you don't need to keep sending real emails during development.
|
||||||
|
|
||||||
|
## Getting Started
|
||||||
|
|
||||||
|
First, install the dependencies:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm install
|
||||||
|
# or
|
||||||
|
yarn
|
||||||
|
```
|
||||||
|
|
||||||
|
Then, run the development server:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm run dev
|
||||||
|
# or
|
||||||
|
yarn dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Open [localhost:3000](http://localhost:3000) with your browser to see the result.
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT License
|
||||||
24
apps/licenses/truncate.ts
Normal file
24
apps/licenses/truncate.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { sql } from "drizzle-orm";
|
||||||
|
// Credits to Louistiti from Drizzle Discord: https://discord.com/channels/1043890932593987624/1130802621750448160/1143083373535973406
|
||||||
|
import { drizzle } from "drizzle-orm/postgres-js";
|
||||||
|
import "dotenv/config";
|
||||||
|
import postgres from "postgres";
|
||||||
|
|
||||||
|
const connectionString = process.env.DATABASE_URL!;
|
||||||
|
|
||||||
|
const pg = postgres(connectionString, { max: 1 });
|
||||||
|
const db = drizzle(pg);
|
||||||
|
|
||||||
|
const clearDb = async (): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const tablesQuery = sql<string>`DROP SCHEMA public CASCADE; CREATE SCHEMA public; DROP schema drizzle CASCADE;`;
|
||||||
|
const tables = await db.execute(tablesQuery);
|
||||||
|
console.log(tables);
|
||||||
|
await pg.end();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error cleaning database", error);
|
||||||
|
} finally {
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
clearDb();
|
||||||
21
apps/licenses/tsconfig.json
Normal file
21
apps/licenses/tsconfig.json
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ESNext",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"strict": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"outDir": "dist",
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"jsxImportSource": "hono/jsx",
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./*"],
|
||||||
|
"@dokploy/server/*": ["../../packages/server/src/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["src/**/*", "truncate.ts"],
|
||||||
|
"exclude": ["node_modules", "dist"]
|
||||||
|
}
|
||||||
@@ -9,10 +9,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, server } from "@dokploy/server/dist/db/schema";
|
import { backups, server } from "@dokploy/server/dist/db/schema";
|
||||||
import { eq } from "drizzle-orm";
|
import { and, eq } from "drizzle-orm";
|
||||||
import { logger } from "./logger.js";
|
import { 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";
|
||||||
@@ -22,40 +23,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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (job.type === "server") {
|
if (job.type === "server") {
|
||||||
@@ -80,7 +98,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) {
|
||||||
@@ -92,7 +113,7 @@ export const initializeJobs = async () => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info({ Quantity: servers.length }, "Servers Initialized");
|
logger.info({ Quantity: servers.length }, "Active Servers Initialized");
|
||||||
|
|
||||||
const backupsResult = await db.query.backups.findMany({
|
const backupsResult = await db.query.backups.findMany({
|
||||||
where: eq(backups.enabled, true),
|
where: eq(backups.enabled, true),
|
||||||
@@ -101,6 +122,7 @@ export const initializeJobs = async () => {
|
|||||||
mysql: true,
|
mysql: true,
|
||||||
postgres: true,
|
postgres: true,
|
||||||
mongo: true,
|
mongo: true,
|
||||||
|
compose: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,21 +1,32 @@
|
|||||||
{
|
{
|
||||||
"name": "@dokploy/server",
|
"name": "@dokploy/server",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"main": "./src/index.ts",
|
"main": "./dist/index.js",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"exports": {
|
"exports": {
|
||||||
".": "./src/index.ts",
|
".": {
|
||||||
|
"import": "./dist/index.js",
|
||||||
|
"require": "./dist/index.cjs.js"
|
||||||
|
},
|
||||||
"./db": {
|
"./db": {
|
||||||
"import": "./src/db/index.ts",
|
"import": "./dist/db/index.js",
|
||||||
"require": "./dist/db/index.cjs.js"
|
"require": "./dist/db/index.cjs.js"
|
||||||
},
|
},
|
||||||
"./setup/*": {
|
"./*": {
|
||||||
"import": "./src/setup/*.ts",
|
"import": "./dist/*",
|
||||||
"require": "./dist/setup/index.cjs.js"
|
"require": "./dist/*.cjs"
|
||||||
},
|
},
|
||||||
"./constants": {
|
"./dist": {
|
||||||
"import": "./src/constants/index.ts",
|
"import": "./dist/index.js",
|
||||||
"require": "./dist/constants.cjs.js"
|
"require": "./dist/index.cjs.js"
|
||||||
|
},
|
||||||
|
"./dist/db": {
|
||||||
|
"import": "./dist/db/index.js",
|
||||||
|
"require": "./dist/db/index.cjs.js"
|
||||||
|
},
|
||||||
|
"./dist/db/schema": {
|
||||||
|
"import": "./dist/db/schema/index.js",
|
||||||
|
"require": "./dist/db/schema/index.cjs.js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ import { redirects } from "./redirects";
|
|||||||
import { registry } from "./registry";
|
import { registry } from "./registry";
|
||||||
import { security } from "./security";
|
import { security } from "./security";
|
||||||
import { server } from "./server";
|
import { server } from "./server";
|
||||||
import { applicationStatus, certificateType } from "./shared";
|
import { applicationStatus, certificateType, triggerType } from "./shared";
|
||||||
import { sshKeys } from "./ssh-key";
|
import { sshKeys } from "./ssh-key";
|
||||||
import { generateAppName } from "./utils";
|
import { generateAppName } from "./utils";
|
||||||
|
|
||||||
@@ -149,6 +149,7 @@ export const applications = pgTable("application", {
|
|||||||
owner: text("owner"),
|
owner: text("owner"),
|
||||||
branch: text("branch"),
|
branch: text("branch"),
|
||||||
buildPath: text("buildPath").default("/"),
|
buildPath: text("buildPath").default("/"),
|
||||||
|
triggerType: triggerType("triggerType").default("push"),
|
||||||
autoDeploy: boolean("autoDeploy").$defaultFn(() => true),
|
autoDeploy: boolean("autoDeploy").$defaultFn(() => true),
|
||||||
// Gitlab
|
// Gitlab
|
||||||
gitlabProjectId: integer("gitlabProjectId"),
|
gitlabProjectId: integer("gitlabProjectId"),
|
||||||
@@ -473,7 +474,10 @@ export const apiSaveGithubProvider = createSchema
|
|||||||
watchPaths: true,
|
watchPaths: true,
|
||||||
enableSubmodules: true,
|
enableSubmodules: true,
|
||||||
})
|
})
|
||||||
.required();
|
.required()
|
||||||
|
.extend({
|
||||||
|
triggerType: z.enum(["push", "tag"]).default("push"),
|
||||||
|
});
|
||||||
|
|
||||||
export const apiSaveGitlabProvider = createSchema
|
export const apiSaveGitlabProvider = createSchema
|
||||||
.pick({
|
.pick({
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
type AnyPgColumn,
|
type AnyPgColumn,
|
||||||
boolean,
|
boolean,
|
||||||
integer,
|
integer,
|
||||||
|
jsonb,
|
||||||
pgEnum,
|
pgEnum,
|
||||||
pgTable,
|
pgTable,
|
||||||
text,
|
text,
|
||||||
@@ -16,6 +17,8 @@ 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";
|
||||||
|
|
||||||
export const databaseType = pgEnum("databaseType", [
|
export const databaseType = pgEnum("databaseType", [
|
||||||
"postgres",
|
"postgres",
|
||||||
"mariadb",
|
"mariadb",
|
||||||
@@ -24,6 +27,8 @@ 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()
|
||||||
@@ -33,14 +38,19 @@ export const backups = pgTable("backup", {
|
|||||||
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,6 +70,26 @@ 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 }) => ({
|
||||||
@@ -87,6 +117,10 @@ 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],
|
||||||
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const createSchema = createInsertSchema(backups, {
|
const createSchema = createInsertSchema(backups, {
|
||||||
@@ -103,6 +137,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 +153,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 +180,8 @@ export const apiUpdateBackup = createSchema
|
|||||||
destinationId: true,
|
destinationId: true,
|
||||||
database: true,
|
database: true,
|
||||||
keepLatestCount: true,
|
keepLatestCount: true,
|
||||||
|
serviceName: true,
|
||||||
|
metadata: true,
|
||||||
|
databaseType: true,
|
||||||
})
|
})
|
||||||
.required();
|
.required();
|
||||||
|
|||||||
@@ -12,9 +12,10 @@ import { gitlab } from "./gitlab";
|
|||||||
import { mounts } from "./mount";
|
import { mounts } from "./mount";
|
||||||
import { projects } from "./project";
|
import { projects } from "./project";
|
||||||
import { server } from "./server";
|
import { server } from "./server";
|
||||||
import { applicationStatus } 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";
|
||||||
|
|
||||||
export const sourceTypeCompose = pgEnum("sourceTypeCompose", [
|
export const sourceTypeCompose = pgEnum("sourceTypeCompose", [
|
||||||
"git",
|
"git",
|
||||||
@@ -77,6 +78,7 @@ export const compose = pgTable("compose", {
|
|||||||
suffix: text("suffix").notNull().default(""),
|
suffix: text("suffix").notNull().default(""),
|
||||||
randomize: boolean("randomize").notNull().default(false),
|
randomize: boolean("randomize").notNull().default(false),
|
||||||
isolatedDeployment: boolean("isolatedDeployment").notNull().default(false),
|
isolatedDeployment: boolean("isolatedDeployment").notNull().default(false),
|
||||||
|
triggerType: triggerType("triggerType").default("push"),
|
||||||
composeStatus: applicationStatus("composeStatus").notNull().default("idle"),
|
composeStatus: applicationStatus("composeStatus").notNull().default("idle"),
|
||||||
projectId: text("projectId")
|
projectId: text("projectId")
|
||||||
.notNull()
|
.notNull()
|
||||||
@@ -134,6 +136,7 @@ export const composeRelations = relations(compose, ({ one, many }) => ({
|
|||||||
fields: [compose.serverId],
|
fields: [compose.serverId],
|
||||||
references: [server.serverId],
|
references: [server.serverId],
|
||||||
}),
|
}),
|
||||||
|
backups: many(backups),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const createSchema = createInsertSchema(compose, {
|
const createSchema = createInsertSchema(compose, {
|
||||||
|
|||||||
@@ -12,3 +12,5 @@ export const certificateType = pgEnum("certificateType", [
|
|||||||
"none",
|
"none",
|
||||||
"custom",
|
"custom",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
export const triggerType = pgEnum("triggerType", ["push", "tag"]);
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ export const users_temp = pgTable("user_temp", {
|
|||||||
logCleanupCron: text("logCleanupCron"),
|
logCleanupCron: text("logCleanupCron"),
|
||||||
// Metrics
|
// Metrics
|
||||||
enablePaidFeatures: boolean("enablePaidFeatures").notNull().default(false),
|
enablePaidFeatures: boolean("enablePaidFeatures").notNull().default(false),
|
||||||
|
licenseKey: text("licenseKey"),
|
||||||
metricsConfig: jsonb("metricsConfig")
|
metricsConfig: jsonb("metricsConfig")
|
||||||
.$type<{
|
.$type<{
|
||||||
server: {
|
server: {
|
||||||
|
|||||||
@@ -49,6 +49,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,11 @@ export const findComposeById = async (composeId: string) => {
|
|||||||
bitbucket: true,
|
bitbucket: true,
|
||||||
gitea: true,
|
gitea: true,
|
||||||
server: true,
|
server: true,
|
||||||
|
backups: {
|
||||||
|
with: {
|
||||||
|
destination: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (!result) {
|
if (!result) {
|
||||||
|
|||||||
@@ -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",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|||||||
@@ -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";
|
||||||
@@ -103,6 +108,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)
|
||||||
|
|||||||
113
packages/server/src/utils/backups/compose.ts
Normal file
113
packages/server/src/utils/backups/compose.ts
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
import type { BackupSchedule } from "@dokploy/server/services/backup";
|
||||||
|
import type { Compose } from "@dokploy/server/services/compose";
|
||||||
|
import { findProjectById } from "@dokploy/server/services/project";
|
||||||
|
import { sendDatabaseBackupNotifications } from "../notifications/database-backup";
|
||||||
|
import { execAsync, execAsyncRemote } from "../process/execAsync";
|
||||||
|
import { getS3Credentials, normalizeS3Path } from "./utils";
|
||||||
|
|
||||||
|
export const runComposeBackup = async (
|
||||||
|
compose: Compose,
|
||||||
|
backup: BackupSchedule,
|
||||||
|
) => {
|
||||||
|
const { projectId, name } = compose;
|
||||||
|
const project = await findProjectById(projectId);
|
||||||
|
const { prefix, database } = backup;
|
||||||
|
const destination = backup.destination;
|
||||||
|
const backupFileName = `${new Date().toISOString()}.dump.gz`;
|
||||||
|
const bucketDestination = `${normalizeS3Path(prefix)}${backupFileName}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const rcloneFlags = getS3Credentials(destination);
|
||||||
|
const rcloneDestination = `:s3:${destination.bucket}/${bucketDestination}`;
|
||||||
|
|
||||||
|
const rcloneCommand = `rclone rcat ${rcloneFlags.join(" ")} "${rcloneDestination}"`;
|
||||||
|
const command = getFindContainerCommand(compose, backup.serviceName || "");
|
||||||
|
if (compose.serverId) {
|
||||||
|
const { stdout } = await execAsyncRemote(compose.serverId, command);
|
||||||
|
if (!stdout) {
|
||||||
|
throw new Error("Container not found");
|
||||||
|
}
|
||||||
|
const containerId = stdout.trim();
|
||||||
|
|
||||||
|
let backupCommand = "";
|
||||||
|
|
||||||
|
if (backup.databaseType === "postgres") {
|
||||||
|
backupCommand = `docker exec ${containerId} sh -c "pg_dump -Fc --no-acl --no-owner -h localhost -U ${backup.metadata?.postgres?.databaseUser} --no-password '${database}' | gzip"`;
|
||||||
|
} else if (backup.databaseType === "mariadb") {
|
||||||
|
backupCommand = `docker exec ${containerId} sh -c "mariadb-dump --user='${backup.metadata?.mariadb?.databaseUser}' --password='${backup.metadata?.mariadb?.databasePassword}' --databases ${database} | gzip"`;
|
||||||
|
} else if (backup.databaseType === "mysql") {
|
||||||
|
backupCommand = `docker exec ${containerId} sh -c "mysqldump --default-character-set=utf8mb4 -u 'root' --password='${backup.metadata?.mysql?.databaseRootPassword}' --single-transaction --no-tablespaces --quick '${database}' | gzip"`;
|
||||||
|
} else if (backup.databaseType === "mongo") {
|
||||||
|
backupCommand = `docker exec ${containerId} sh -c "mongodump -d '${database}' -u '${backup.metadata?.mongo?.databaseUser}' -p '${backup.metadata?.mongo?.databasePassword}' --archive --authenticationDatabase admin --gzip"`;
|
||||||
|
}
|
||||||
|
|
||||||
|
await execAsyncRemote(
|
||||||
|
compose.serverId,
|
||||||
|
`${backupCommand} | ${rcloneCommand}`,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
const { stdout } = await execAsync(command);
|
||||||
|
if (!stdout) {
|
||||||
|
throw new Error("Container not found");
|
||||||
|
}
|
||||||
|
const containerId = stdout.trim();
|
||||||
|
|
||||||
|
let backupCommand = "";
|
||||||
|
|
||||||
|
if (backup.databaseType === "postgres") {
|
||||||
|
backupCommand = `docker exec ${containerId} sh -c "pg_dump -Fc --no-acl --no-owner -h localhost -U ${backup.metadata?.postgres?.databaseUser} --no-password '${database}' | gzip"`;
|
||||||
|
} else if (backup.databaseType === "mariadb") {
|
||||||
|
backupCommand = `docker exec ${containerId} sh -c "mariadb-dump --user='${backup.metadata?.mariadb?.databaseUser}' --password='${backup.metadata?.mariadb?.databasePassword}' --databases ${database} | gzip"`;
|
||||||
|
} else if (backup.databaseType === "mysql") {
|
||||||
|
backupCommand = `docker exec ${containerId} sh -c "mysqldump --default-character-set=utf8mb4 -u 'root' --password='${backup.metadata?.mysql?.databaseRootPassword}' --single-transaction --no-tablespaces --quick '${database}' | gzip"`;
|
||||||
|
} else if (backup.databaseType === "mongo") {
|
||||||
|
backupCommand = `docker exec ${containerId} sh -c "mongodump -d '${database}' -u '${backup.metadata?.mongo?.databaseUser}' -p '${backup.metadata?.mongo?.databasePassword}' --archive --authenticationDatabase admin --gzip"`;
|
||||||
|
}
|
||||||
|
|
||||||
|
await execAsync(`${backupCommand} | ${rcloneCommand}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
await sendDatabaseBackupNotifications({
|
||||||
|
applicationName: name,
|
||||||
|
projectName: project.name,
|
||||||
|
databaseType: "mongodb",
|
||||||
|
type: "success",
|
||||||
|
organizationId: project.organizationId,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.log(error);
|
||||||
|
await sendDatabaseBackupNotifications({
|
||||||
|
applicationName: name,
|
||||||
|
projectName: project.name,
|
||||||
|
databaseType: "mongodb",
|
||||||
|
type: "error",
|
||||||
|
// @ts-ignore
|
||||||
|
errorMessage: error?.message || "Error message not provided",
|
||||||
|
organizationId: project.organizationId,
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getFindContainerCommand = (
|
||||||
|
compose: Compose,
|
||||||
|
serviceName: string,
|
||||||
|
) => {
|
||||||
|
const { appName, composeType } = compose;
|
||||||
|
const labels =
|
||||||
|
composeType === "stack"
|
||||||
|
? {
|
||||||
|
namespace: `label=com.docker.stack.namespace=${appName}`,
|
||||||
|
service: `label=com.docker.swarm.service.name=${appName}_${serviceName}`,
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
project: `label=com.docker.compose.project=${appName}`,
|
||||||
|
service: `label=com.docker.compose.service=${serviceName}`,
|
||||||
|
};
|
||||||
|
|
||||||
|
const command = `docker ps --filter "status=running" \
|
||||||
|
--filter "${Object.values(labels).join('" --filter "')}" \
|
||||||
|
--format "{{.ID}}" | head -n 1`;
|
||||||
|
|
||||||
|
return command.trim();
|
||||||
|
};
|
||||||
@@ -70,6 +70,7 @@ export const initCronJobs = async () => {
|
|||||||
mysql: true,
|
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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
4
packages/server/src/utils/docker/backup.ts
Normal file
4
packages/server/src/utils/docker/backup.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
export const createBackupLabels = (backupId: string) => {
|
||||||
|
const labels = [`dokploy.backup.id=${backupId}`];
|
||||||
|
return labels;
|
||||||
|
};
|
||||||
93
packages/server/src/utils/restore/compose.ts
Normal file
93
packages/server/src/utils/restore/compose.ts
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
import type { Destination } from "@dokploy/server/services/destination";
|
||||||
|
import type { Compose } from "@dokploy/server/services/compose";
|
||||||
|
import { getS3Credentials } from "../backups/utils";
|
||||||
|
import { execAsync, execAsyncRemote } from "../process/execAsync";
|
||||||
|
import type { Backup } from "@dokploy/server/services/backup";
|
||||||
|
import { getFindContainerCommand } from "../backups/compose";
|
||||||
|
|
||||||
|
export const restoreComposeBackup = async (
|
||||||
|
compose: Compose,
|
||||||
|
destination: Destination,
|
||||||
|
database: string,
|
||||||
|
backupFile: string,
|
||||||
|
metadata: Backup["metadata"] & { serviceName: string },
|
||||||
|
emit: (log: string) => void,
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
const { serverId } = compose;
|
||||||
|
|
||||||
|
const rcloneFlags = getS3Credentials(destination);
|
||||||
|
const bucketPath = `:s3:${destination.bucket}`;
|
||||||
|
const backupPath = `${bucketPath}/${backupFile}`;
|
||||||
|
|
||||||
|
const command = getFindContainerCommand(compose, metadata.serviceName);
|
||||||
|
|
||||||
|
let containerId = "";
|
||||||
|
if (serverId) {
|
||||||
|
const { stdout, stderr } = await execAsyncRemote(serverId, command);
|
||||||
|
emit(stdout);
|
||||||
|
emit(stderr);
|
||||||
|
containerId = stdout.trim();
|
||||||
|
} else {
|
||||||
|
const { stdout, stderr } = await execAsync(command);
|
||||||
|
emit(stdout);
|
||||||
|
emit(stderr);
|
||||||
|
containerId = stdout.trim();
|
||||||
|
}
|
||||||
|
let restoreCommand = "";
|
||||||
|
|
||||||
|
if (metadata.postgres) {
|
||||||
|
restoreCommand = `rclone cat ${rcloneFlags.join(" ")} "${backupPath}" | gunzip | docker exec -i ${containerId} pg_restore -U ${metadata.postgres.databaseUser} -d ${database} --clean --if-exists`;
|
||||||
|
} else if (metadata.mariadb) {
|
||||||
|
restoreCommand = `
|
||||||
|
rclone cat ${rcloneFlags.join(" ")} "${backupPath}" | gunzip | docker exec -i ${containerId} mariadb -u ${metadata.mariadb.databaseUser} -p${metadata.mariadb.databasePassword} ${database}
|
||||||
|
`;
|
||||||
|
} else if (metadata.mysql) {
|
||||||
|
restoreCommand = `
|
||||||
|
rclone cat ${rcloneFlags.join(" ")} "${backupPath}" | gunzip | docker exec -i ${containerId} mysql -u root -p${metadata.mysql.databaseRootPassword} ${database}
|
||||||
|
`;
|
||||||
|
} else if (metadata.mongo) {
|
||||||
|
const tempDir = "/tmp/dokploy-restore";
|
||||||
|
const fileName = backupFile.split("/").pop() || "backup.dump.gz";
|
||||||
|
const decompressedName = fileName.replace(".gz", "");
|
||||||
|
restoreCommand = `\
|
||||||
|
rm -rf ${tempDir} && \
|
||||||
|
mkdir -p ${tempDir} && \
|
||||||
|
rclone copy ${rcloneFlags.join(" ")} "${backupPath}" ${tempDir} && \
|
||||||
|
cd ${tempDir} && \
|
||||||
|
gunzip -f "${fileName}" && \
|
||||||
|
docker exec -i ${containerId} mongorestore --username ${metadata.mongo.databaseUser} --password ${metadata.mongo.databasePassword} --authenticationDatabase admin --db ${database} --archive < "${decompressedName}" && \
|
||||||
|
rm -rf ${tempDir}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
emit("Starting restore...");
|
||||||
|
emit(`Backup path: ${backupPath}`);
|
||||||
|
|
||||||
|
emit(`Executing command: ${restoreCommand}`);
|
||||||
|
|
||||||
|
if (serverId) {
|
||||||
|
const { stdout, stderr } = await execAsyncRemote(
|
||||||
|
serverId,
|
||||||
|
restoreCommand,
|
||||||
|
);
|
||||||
|
emit(stdout);
|
||||||
|
emit(stderr);
|
||||||
|
} else {
|
||||||
|
const { stdout, stderr } = await execAsync(restoreCommand);
|
||||||
|
emit(stdout);
|
||||||
|
emit(stderr);
|
||||||
|
}
|
||||||
|
|
||||||
|
emit("Restore completed successfully!");
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
emit(
|
||||||
|
`Error: ${
|
||||||
|
error instanceof Error ? error.message : "Error restoring mongo backup"
|
||||||
|
}`,
|
||||||
|
);
|
||||||
|
throw new Error(
|
||||||
|
error instanceof Error ? error.message : "Error restoring mongo backup",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -3,3 +3,4 @@ export { restoreMySqlBackup } from "./mysql";
|
|||||||
export { restoreMariadbBackup } from "./mariadb";
|
export { 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";
|
||||||
|
|||||||
@@ -40,8 +40,6 @@ rclone cat ${rcloneFlags.join(" ")} "${backupPath}" | gunzip | docker exec -i ${
|
|||||||
emit(stderr);
|
emit(stderr);
|
||||||
} else {
|
} else {
|
||||||
const { stdout, stderr } = await execAsync(command);
|
const { stdout, stderr } = await execAsync(command);
|
||||||
console.log("stdout", stdout);
|
|
||||||
console.log("stderr", stderr);
|
|
||||||
emit(stdout);
|
emit(stdout);
|
||||||
emit(stderr);
|
emit(stderr);
|
||||||
}
|
}
|
||||||
|
|||||||
299
pnpm-lock.yaml
generated
299
pnpm-lock.yaml
generated
@@ -300,10 +300,10 @@ importers:
|
|||||||
version: 16.4.5
|
version: 16.4.5
|
||||||
drizzle-orm:
|
drizzle-orm:
|
||||||
specifier: ^0.39.1
|
specifier: ^0.39.1
|
||||||
version: 0.39.1(@opentelemetry/api@1.9.0)(@types/react@18.3.5)(kysely@0.27.6)(postgres@3.4.4)(react@18.2.0)(sqlite3@5.1.7)
|
version: 0.39.1(@opentelemetry/api@1.9.0)(@types/pg@8.11.11)(@types/react@18.3.5)(kysely@0.27.6)(pg@8.14.1)(postgres@3.4.4)(react@18.2.0)(sqlite3@5.1.7)
|
||||||
drizzle-zod:
|
drizzle-zod:
|
||||||
specifier: 0.5.1
|
specifier: 0.5.1
|
||||||
version: 0.5.1(drizzle-orm@0.39.1(@opentelemetry/api@1.9.0)(@types/react@18.3.5)(kysely@0.27.6)(postgres@3.4.4)(react@18.2.0)(sqlite3@5.1.7))(zod@3.23.8)
|
version: 0.5.1(drizzle-orm@0.39.1(@opentelemetry/api@1.9.0)(@types/pg@8.11.11)(@types/react@18.3.5)(kysely@0.27.6)(pg@8.14.1)(postgres@3.4.4)(react@18.2.0)(sqlite3@5.1.7))(zod@3.23.8)
|
||||||
fancy-ansi:
|
fancy-ansi:
|
||||||
specifier: ^0.1.3
|
specifier: ^0.1.3
|
||||||
version: 0.1.3
|
version: 0.1.3
|
||||||
@@ -525,6 +525,85 @@ importers:
|
|||||||
specifier: ^1.6.0
|
specifier: ^1.6.0
|
||||||
version: 1.6.0(@types/node@18.19.42)(terser@5.31.3)
|
version: 1.6.0(@types/node@18.19.42)(terser@5.31.3)
|
||||||
|
|
||||||
|
apps/licenses:
|
||||||
|
dependencies:
|
||||||
|
'@hono/node-server':
|
||||||
|
specifier: ^1.12.1
|
||||||
|
version: 1.12.1
|
||||||
|
'@hono/zod-validator':
|
||||||
|
specifier: 0.3.0
|
||||||
|
version: 0.3.0(hono@4.5.8)(zod@3.24.1)
|
||||||
|
'@react-email/components':
|
||||||
|
specifier: ^0.0.21
|
||||||
|
version: 0.0.21(@types/react@18.3.5)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
|
||||||
|
'@react-email/render':
|
||||||
|
specifier: ^1.0.5
|
||||||
|
version: 1.0.5(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
|
||||||
|
'@types/pg':
|
||||||
|
specifier: ^8.11.11
|
||||||
|
version: 8.11.11
|
||||||
|
dotenv:
|
||||||
|
specifier: ^16.3.1
|
||||||
|
version: 16.4.5
|
||||||
|
drizzle-orm:
|
||||||
|
specifier: ^0.39.1
|
||||||
|
version: 0.39.1(@opentelemetry/api@1.9.0)(@types/pg@8.11.11)(@types/react@18.3.5)(kysely@0.27.6)(pg@8.14.1)(postgres@3.4.4)(react@18.2.0)(sqlite3@5.1.7)
|
||||||
|
hono:
|
||||||
|
specifier: ^4.5.8
|
||||||
|
version: 4.5.8
|
||||||
|
nanoid:
|
||||||
|
specifier: 5.1.5
|
||||||
|
version: 5.1.5
|
||||||
|
nodemailer:
|
||||||
|
specifier: 6.9.14
|
||||||
|
version: 6.9.14
|
||||||
|
pg:
|
||||||
|
specifier: ^8.14.1
|
||||||
|
version: 8.14.1
|
||||||
|
pino:
|
||||||
|
specifier: 9.4.0
|
||||||
|
version: 9.4.0
|
||||||
|
pino-pretty:
|
||||||
|
specifier: 11.2.2
|
||||||
|
version: 11.2.2
|
||||||
|
postgres:
|
||||||
|
specifier: 3.4.4
|
||||||
|
version: 3.4.4
|
||||||
|
react:
|
||||||
|
specifier: 18.2.0
|
||||||
|
version: 18.2.0
|
||||||
|
react-dom:
|
||||||
|
specifier: 18.2.0
|
||||||
|
version: 18.2.0(react@18.2.0)
|
||||||
|
stripe:
|
||||||
|
specifier: 17.2.0
|
||||||
|
version: 17.2.0
|
||||||
|
zod:
|
||||||
|
specifier: ^3.23.4
|
||||||
|
version: 3.24.1
|
||||||
|
devDependencies:
|
||||||
|
'@types/node':
|
||||||
|
specifier: ^20.11.17
|
||||||
|
version: 20.14.10
|
||||||
|
'@types/nodemailer':
|
||||||
|
specifier: ^6.4.16
|
||||||
|
version: 6.4.16
|
||||||
|
'@types/react':
|
||||||
|
specifier: 18.3.5
|
||||||
|
version: 18.3.5
|
||||||
|
'@types/react-dom':
|
||||||
|
specifier: 18.3.0
|
||||||
|
version: 18.3.0
|
||||||
|
drizzle-kit:
|
||||||
|
specifier: ^0.30.4
|
||||||
|
version: 0.30.4
|
||||||
|
tsx:
|
||||||
|
specifier: ^4.7.1
|
||||||
|
version: 4.16.2
|
||||||
|
typescript:
|
||||||
|
specifier: ^5.4.2
|
||||||
|
version: 5.7.2
|
||||||
|
|
||||||
apps/schedules:
|
apps/schedules:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@dokploy/server':
|
'@dokploy/server':
|
||||||
@@ -544,7 +623,7 @@ importers:
|
|||||||
version: 16.4.5
|
version: 16.4.5
|
||||||
drizzle-orm:
|
drizzle-orm:
|
||||||
specifier: ^0.39.1
|
specifier: ^0.39.1
|
||||||
version: 0.39.1(@opentelemetry/api@1.9.0)(@types/react@18.3.5)(kysely@0.27.6)(postgres@3.4.4)(react@18.2.0)(sqlite3@5.1.7)
|
version: 0.39.1(@opentelemetry/api@1.9.0)(@types/pg@8.11.11)(@types/react@18.3.5)(kysely@0.27.6)(pg@8.14.1)(postgres@3.4.4)(react@18.2.0)(sqlite3@5.1.7)
|
||||||
hono:
|
hono:
|
||||||
specifier: ^4.5.8
|
specifier: ^4.5.8
|
||||||
version: 4.5.8
|
version: 4.5.8
|
||||||
@@ -659,13 +738,13 @@ importers:
|
|||||||
version: 16.4.5
|
version: 16.4.5
|
||||||
drizzle-dbml-generator:
|
drizzle-dbml-generator:
|
||||||
specifier: 0.10.0
|
specifier: 0.10.0
|
||||||
version: 0.10.0(drizzle-orm@0.39.1(@opentelemetry/api@1.9.0)(@types/react@18.3.5)(kysely@0.27.6)(postgres@3.4.4)(react@18.2.0)(sqlite3@5.1.7))
|
version: 0.10.0(drizzle-orm@0.39.1(@opentelemetry/api@1.9.0)(@types/pg@8.11.11)(@types/react@18.3.5)(kysely@0.27.6)(pg@8.14.1)(postgres@3.4.4)(react@18.2.0)(sqlite3@5.1.7))
|
||||||
drizzle-orm:
|
drizzle-orm:
|
||||||
specifier: ^0.39.1
|
specifier: ^0.39.1
|
||||||
version: 0.39.1(@opentelemetry/api@1.9.0)(@types/react@18.3.5)(kysely@0.27.6)(postgres@3.4.4)(react@18.2.0)(sqlite3@5.1.7)
|
version: 0.39.1(@opentelemetry/api@1.9.0)(@types/pg@8.11.11)(@types/react@18.3.5)(kysely@0.27.6)(pg@8.14.1)(postgres@3.4.4)(react@18.2.0)(sqlite3@5.1.7)
|
||||||
drizzle-zod:
|
drizzle-zod:
|
||||||
specifier: 0.5.1
|
specifier: 0.5.1
|
||||||
version: 0.5.1(drizzle-orm@0.39.1(@opentelemetry/api@1.9.0)(@types/react@18.3.5)(kysely@0.27.6)(postgres@3.4.4)(react@18.2.0)(sqlite3@5.1.7))(zod@3.23.8)
|
version: 0.5.1(drizzle-orm@0.39.1(@opentelemetry/api@1.9.0)(@types/pg@8.11.11)(@types/react@18.3.5)(kysely@0.27.6)(pg@8.14.1)(postgres@3.4.4)(react@18.2.0)(sqlite3@5.1.7))(zod@3.23.8)
|
||||||
hi-base32:
|
hi-base32:
|
||||||
specifier: ^0.5.1
|
specifier: ^0.5.1
|
||||||
version: 0.5.1
|
version: 0.5.1
|
||||||
@@ -3075,6 +3154,13 @@ packages:
|
|||||||
react: ^18.2.0
|
react: ^18.2.0
|
||||||
react-dom: ^18.2.0
|
react-dom: ^18.2.0
|
||||||
|
|
||||||
|
'@react-email/render@1.0.5':
|
||||||
|
resolution: {integrity: sha512-CA69HYXPk21HhtAXATIr+9JJwpDNmAFCvdMUjWmeoD1+KhJ9NAxusMRxKNeibdZdslmq3edaeOKGbdQ9qjK8LQ==}
|
||||||
|
engines: {node: '>=18.0.0'}
|
||||||
|
peerDependencies:
|
||||||
|
react: ^18.0 || ^19.0 || ^19.0.0-rc
|
||||||
|
react-dom: ^18.0 || ^19.0 || ^19.0.0-rc
|
||||||
|
|
||||||
'@react-email/row@0.0.8':
|
'@react-email/row@0.0.8':
|
||||||
resolution: {integrity: sha512-JsB6pxs/ZyjYpEML3nbwJRGAerjcN/Pa/QG48XUwnT/MioDWrUuyQuefw+CwCrSUZ2P1IDrv2tUD3/E3xzcoKw==}
|
resolution: {integrity: sha512-JsB6pxs/ZyjYpEML3nbwJRGAerjcN/Pa/QG48XUwnT/MioDWrUuyQuefw+CwCrSUZ2P1IDrv2tUD3/E3xzcoKw==}
|
||||||
engines: {node: '>=18.0.0'}
|
engines: {node: '>=18.0.0'}
|
||||||
@@ -3533,6 +3619,9 @@ packages:
|
|||||||
'@types/nodemailer@6.4.16':
|
'@types/nodemailer@6.4.16':
|
||||||
resolution: {integrity: sha512-uz6hN6Pp0upXMcilM61CoKyjT7sskBoOWpptkjjJp8jIMlTdc3xG01U7proKkXzruMS4hS0zqtHNkNPFB20rKQ==}
|
resolution: {integrity: sha512-uz6hN6Pp0upXMcilM61CoKyjT7sskBoOWpptkjjJp8jIMlTdc3xG01U7proKkXzruMS4hS0zqtHNkNPFB20rKQ==}
|
||||||
|
|
||||||
|
'@types/pg@8.11.11':
|
||||||
|
resolution: {integrity: sha512-kGT1qKM8wJQ5qlawUrEkXgvMSXoV213KfMGXcwfDwUIfUHXqXYXOfS1nE1LINRJVVVx5wCm70XnFlMHaIcQAfw==}
|
||||||
|
|
||||||
'@types/prop-types@15.7.12':
|
'@types/prop-types@15.7.12':
|
||||||
resolution: {integrity: sha512-5zvhXYtRNRluoE/jAp4GVsSduVUzNWKkOZrCDBWYtE7biZywwdC2AcEzg+cSMLFRfVgeAFqpfNabiPjxFddV1Q==}
|
resolution: {integrity: sha512-5zvhXYtRNRluoE/jAp4GVsSduVUzNWKkOZrCDBWYtE7biZywwdC2AcEzg+cSMLFRfVgeAFqpfNabiPjxFddV1Q==}
|
||||||
|
|
||||||
@@ -5766,6 +5855,11 @@ packages:
|
|||||||
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
|
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
nanoid@5.1.5:
|
||||||
|
resolution: {integrity: sha512-Ir/+ZpE9fDsNH0hQ3C68uyThDXzYcim2EqcZ8zn8Chtt1iylPT9xXJB0kPCnqzgcEGikO9RxSrh63MsmVCU7Fw==}
|
||||||
|
engines: {node: ^18 || >=20}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
nanostores@0.11.3:
|
nanostores@0.11.3:
|
||||||
resolution: {integrity: sha512-TUes3xKIX33re4QzdxwZ6tdbodjmn3tWXCEc1uokiEmo14sI1EaGYNs2k3bU2pyyGNmBqFGAVl6jAGWd06AVIg==}
|
resolution: {integrity: sha512-TUes3xKIX33re4QzdxwZ6tdbodjmn3tWXCEc1uokiEmo14sI1EaGYNs2k3bU2pyyGNmBqFGAVl6jAGWd06AVIg==}
|
||||||
engines: {node: ^18.0.0 || >=20.0.0}
|
engines: {node: ^18.0.0 || >=20.0.0}
|
||||||
@@ -5928,6 +6022,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-IRZSRuzJiynemAXPYtPe5BoI/RESNYR7TYm50MC5Mqbd3Jmw5y790sErYw3V6SryFJD64b74qQQs9wn5Bg/k3g==}
|
resolution: {integrity: sha512-IRZSRuzJiynemAXPYtPe5BoI/RESNYR7TYm50MC5Mqbd3Jmw5y790sErYw3V6SryFJD64b74qQQs9wn5Bg/k3g==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
|
obuf@1.1.2:
|
||||||
|
resolution: {integrity: sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg==}
|
||||||
|
|
||||||
octokit@3.1.2:
|
octokit@3.1.2:
|
||||||
resolution: {integrity: sha512-MG5qmrTL5y8KYwFgE1A4JWmgfQBaIETE/lOlfwNYx1QOtCQHGVxkRJmdUJltFc1HVn73d61TlMhMyNTOtMl+ng==}
|
resolution: {integrity: sha512-MG5qmrTL5y8KYwFgE1A4JWmgfQBaIETE/lOlfwNYx1QOtCQHGVxkRJmdUJltFc1HVn73d61TlMhMyNTOtMl+ng==}
|
||||||
engines: {node: '>= 18'}
|
engines: {node: '>= 18'}
|
||||||
@@ -6072,6 +6169,48 @@ packages:
|
|||||||
peberminta@0.9.0:
|
peberminta@0.9.0:
|
||||||
resolution: {integrity: sha512-XIxfHpEuSJbITd1H3EeQwpcZbTLHc+VVr8ANI9t5sit565tsI4/xK3KWTUFE2e6QiangUkh3B0jihzmGnNrRsQ==}
|
resolution: {integrity: sha512-XIxfHpEuSJbITd1H3EeQwpcZbTLHc+VVr8ANI9t5sit565tsI4/xK3KWTUFE2e6QiangUkh3B0jihzmGnNrRsQ==}
|
||||||
|
|
||||||
|
pg-cloudflare@1.1.1:
|
||||||
|
resolution: {integrity: sha512-xWPagP/4B6BgFO+EKz3JONXv3YDgvkbVrGw2mTo3D6tVDQRh1e7cqVGvyR3BE+eQgAvx1XhW/iEASj4/jCWl3Q==}
|
||||||
|
|
||||||
|
pg-connection-string@2.7.0:
|
||||||
|
resolution: {integrity: sha512-PI2W9mv53rXJQEOb8xNR8lH7Hr+EKa6oJa38zsK0S/ky2er16ios1wLKhZyxzD7jUReiWokc9WK5nxSnC7W1TA==}
|
||||||
|
|
||||||
|
pg-int8@1.0.1:
|
||||||
|
resolution: {integrity: sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==}
|
||||||
|
engines: {node: '>=4.0.0'}
|
||||||
|
|
||||||
|
pg-numeric@1.0.2:
|
||||||
|
resolution: {integrity: sha512-BM/Thnrw5jm2kKLE5uJkXqqExRUY/toLHda65XgFTBTFYZyopbKjBe29Ii3RbkvlsMoFwD+tHeGaCjjv0gHlyw==}
|
||||||
|
engines: {node: '>=4'}
|
||||||
|
|
||||||
|
pg-pool@3.8.0:
|
||||||
|
resolution: {integrity: sha512-VBw3jiVm6ZOdLBTIcXLNdSotb6Iy3uOCwDGFAksZCXmi10nyRvnP2v3jl4d+IsLYRyXf6o9hIm/ZtUzlByNUdw==}
|
||||||
|
peerDependencies:
|
||||||
|
pg: '>=8.0'
|
||||||
|
|
||||||
|
pg-protocol@1.8.0:
|
||||||
|
resolution: {integrity: sha512-jvuYlEkL03NRvOoyoRktBK7+qU5kOvlAwvmrH8sr3wbLrOdVWsRxQfz8mMy9sZFsqJ1hEWNfdWKI4SAmoL+j7g==}
|
||||||
|
|
||||||
|
pg-types@2.2.0:
|
||||||
|
resolution: {integrity: sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==}
|
||||||
|
engines: {node: '>=4'}
|
||||||
|
|
||||||
|
pg-types@4.0.2:
|
||||||
|
resolution: {integrity: sha512-cRL3JpS3lKMGsKaWndugWQoLOCoP+Cic8oseVcbr0qhPzYD5DWXK+RZ9LY9wxRf7RQia4SCwQlXk0q6FCPrVng==}
|
||||||
|
engines: {node: '>=10'}
|
||||||
|
|
||||||
|
pg@8.14.1:
|
||||||
|
resolution: {integrity: sha512-0TdbqfjwIun9Fm/r89oB7RFQ0bLgduAhiIqIXOsyKoiC/L54DbuAAzIEN/9Op0f1Po9X7iCPXGoa/Ah+2aI8Xw==}
|
||||||
|
engines: {node: '>= 8.0.0'}
|
||||||
|
peerDependencies:
|
||||||
|
pg-native: '>=3.0.1'
|
||||||
|
peerDependenciesMeta:
|
||||||
|
pg-native:
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
pgpass@1.0.5:
|
||||||
|
resolution: {integrity: sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==}
|
||||||
|
|
||||||
picocolors@1.0.1:
|
picocolors@1.0.1:
|
||||||
resolution: {integrity: sha512-anP1Z8qwhkbmu7MFP5iTt+wQKXgwzf7zTyGlcdzabySa9vd0Xt392U0rVmz9poOaBj0uHJKyyo9/upk0HrEQew==}
|
resolution: {integrity: sha512-anP1Z8qwhkbmu7MFP5iTt+wQKXgwzf7zTyGlcdzabySa9vd0Xt392U0rVmz9poOaBj0uHJKyyo9/upk0HrEQew==}
|
||||||
|
|
||||||
@@ -6166,6 +6305,41 @@ packages:
|
|||||||
resolution: {integrity: sha512-YF2kKIUzAofPMpfH6hOi2cGnv/HrUlfucspc7pDyvv7kGdqXrfj8SCl/t8owkEgKEuu8ZcRjSOxFxVLqwChZ2Q==}
|
resolution: {integrity: sha512-YF2kKIUzAofPMpfH6hOi2cGnv/HrUlfucspc7pDyvv7kGdqXrfj8SCl/t8owkEgKEuu8ZcRjSOxFxVLqwChZ2Q==}
|
||||||
engines: {node: ^10 || ^12 || >=14}
|
engines: {node: ^10 || ^12 || >=14}
|
||||||
|
|
||||||
|
postgres-array@2.0.0:
|
||||||
|
resolution: {integrity: sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==}
|
||||||
|
engines: {node: '>=4'}
|
||||||
|
|
||||||
|
postgres-array@3.0.4:
|
||||||
|
resolution: {integrity: sha512-nAUSGfSDGOaOAEGwqsRY27GPOea7CNipJPOA7lPbdEpx5Kg3qzdP0AaWC5MlhTWV9s4hFX39nomVZ+C4tnGOJQ==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
|
postgres-bytea@1.0.0:
|
||||||
|
resolution: {integrity: sha512-xy3pmLuQqRBZBXDULy7KbaitYqLcmxigw14Q5sj8QBVLqEwXfeybIKVWiqAXTlcvdvb0+xkOtDbfQMOf4lST1w==}
|
||||||
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
|
postgres-bytea@3.0.0:
|
||||||
|
resolution: {integrity: sha512-CNd4jim9RFPkObHSjVHlVrxoVQXz7quwNFpz7RY1okNNme49+sVyiTvTRobiLV548Hx/hb1BG+iE7h9493WzFw==}
|
||||||
|
engines: {node: '>= 6'}
|
||||||
|
|
||||||
|
postgres-date@1.0.7:
|
||||||
|
resolution: {integrity: sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==}
|
||||||
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
|
postgres-date@2.1.0:
|
||||||
|
resolution: {integrity: sha512-K7Juri8gtgXVcDfZttFKVmhglp7epKb1K4pgrkLxehjqkrgPhfG6OO8LHLkfaqkbpjNRnra018XwAr1yQFWGcA==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
|
postgres-interval@1.2.0:
|
||||||
|
resolution: {integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==}
|
||||||
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
|
postgres-interval@3.0.0:
|
||||||
|
resolution: {integrity: sha512-BSNDnbyZCXSxgA+1f5UU2GmwhoI0aU5yMxRGO8CdFEcY2BQF9xm/7MqKnYoM1nJDk8nONNWDk9WeSmePFhQdlw==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
|
postgres-range@1.1.4:
|
||||||
|
resolution: {integrity: sha512-i/hbxIE9803Alj/6ytL7UHQxRvZkI9O4Sy+J3HGc4F4oo/2eQAjTSNJ0bfxyse3bH0nuVesCk+3IRLaMtG3H6w==}
|
||||||
|
|
||||||
postgres@3.4.4:
|
postgres@3.4.4:
|
||||||
resolution: {integrity: sha512-IbyN+9KslkqcXa8AO9fxpk97PA4pzewvpi2B3Dwy9u4zpV32QicaEdgmF3eSQUzdRk7ttDHQejNgAEr4XoeH4A==}
|
resolution: {integrity: sha512-IbyN+9KslkqcXa8AO9fxpk97PA4pzewvpi2B3Dwy9u4zpV32QicaEdgmF3eSQUzdRk7ttDHQejNgAEr4XoeH4A==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
@@ -6175,6 +6349,11 @@ packages:
|
|||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
prettier@3.4.2:
|
||||||
|
resolution: {integrity: sha512-e9MewbtFo+Fevyuxn/4rrcDAaq0IYxPGLvObpQjiZBMAzB9IGmzlnG9RZy3FFas+eBMu2vA0CszMeduow5dIuQ==}
|
||||||
|
engines: {node: '>=14'}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
pretty-format@29.7.0:
|
pretty-format@29.7.0:
|
||||||
resolution: {integrity: sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==}
|
resolution: {integrity: sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==}
|
||||||
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
|
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
|
||||||
@@ -8129,6 +8308,11 @@ snapshots:
|
|||||||
hono: 4.5.8
|
hono: 4.5.8
|
||||||
zod: 3.23.8
|
zod: 3.23.8
|
||||||
|
|
||||||
|
'@hono/zod-validator@0.3.0(hono@4.5.8)(zod@3.24.1)':
|
||||||
|
dependencies:
|
||||||
|
hono: 4.5.8
|
||||||
|
zod: 3.24.1
|
||||||
|
|
||||||
'@hookform/resolvers@3.9.0(react-hook-form@7.52.1(react@18.2.0))':
|
'@hookform/resolvers@3.9.0(react-hook-form@7.52.1(react@18.2.0))':
|
||||||
dependencies:
|
dependencies:
|
||||||
react-hook-form: 7.52.1(react@18.2.0)
|
react-hook-form: 7.52.1(react@18.2.0)
|
||||||
@@ -9539,6 +9723,14 @@ snapshots:
|
|||||||
react-dom: 18.2.0(react@18.2.0)
|
react-dom: 18.2.0(react@18.2.0)
|
||||||
react-promise-suspense: 0.3.4
|
react-promise-suspense: 0.3.4
|
||||||
|
|
||||||
|
'@react-email/render@1.0.5(react-dom@18.2.0(react@18.2.0))(react@18.2.0)':
|
||||||
|
dependencies:
|
||||||
|
html-to-text: 9.0.5
|
||||||
|
prettier: 3.4.2
|
||||||
|
react: 18.2.0
|
||||||
|
react-dom: 18.2.0(react@18.2.0)
|
||||||
|
react-promise-suspense: 0.3.4
|
||||||
|
|
||||||
'@react-email/row@0.0.8(react@18.2.0)':
|
'@react-email/row@0.0.8(react@18.2.0)':
|
||||||
dependencies:
|
dependencies:
|
||||||
react: 18.2.0
|
react: 18.2.0
|
||||||
@@ -10208,6 +10400,12 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@types/node': 20.14.10
|
'@types/node': 20.14.10
|
||||||
|
|
||||||
|
'@types/pg@8.11.11':
|
||||||
|
dependencies:
|
||||||
|
'@types/node': 20.14.10
|
||||||
|
pg-protocol: 1.8.0
|
||||||
|
pg-types: 4.0.2
|
||||||
|
|
||||||
'@types/prop-types@15.7.12': {}
|
'@types/prop-types@15.7.12': {}
|
||||||
|
|
||||||
'@types/qrcode@1.5.5':
|
'@types/qrcode@1.5.5':
|
||||||
@@ -11169,9 +11367,9 @@ snapshots:
|
|||||||
|
|
||||||
drange@1.1.1: {}
|
drange@1.1.1: {}
|
||||||
|
|
||||||
drizzle-dbml-generator@0.10.0(drizzle-orm@0.39.1(@opentelemetry/api@1.9.0)(@types/react@18.3.5)(kysely@0.27.6)(postgres@3.4.4)(react@18.2.0)(sqlite3@5.1.7)):
|
drizzle-dbml-generator@0.10.0(drizzle-orm@0.39.1(@opentelemetry/api@1.9.0)(@types/pg@8.11.11)(@types/react@18.3.5)(kysely@0.27.6)(pg@8.14.1)(postgres@3.4.4)(react@18.2.0)(sqlite3@5.1.7)):
|
||||||
dependencies:
|
dependencies:
|
||||||
drizzle-orm: 0.39.1(@opentelemetry/api@1.9.0)(@types/react@18.3.5)(kysely@0.27.6)(postgres@3.4.4)(react@18.2.0)(sqlite3@5.1.7)
|
drizzle-orm: 0.39.1(@opentelemetry/api@1.9.0)(@types/pg@8.11.11)(@types/react@18.3.5)(kysely@0.27.6)(pg@8.14.1)(postgres@3.4.4)(react@18.2.0)(sqlite3@5.1.7)
|
||||||
|
|
||||||
drizzle-kit@0.30.4:
|
drizzle-kit@0.30.4:
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -11182,18 +11380,20 @@ snapshots:
|
|||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
drizzle-orm@0.39.1(@opentelemetry/api@1.9.0)(@types/react@18.3.5)(kysely@0.27.6)(postgres@3.4.4)(react@18.2.0)(sqlite3@5.1.7):
|
drizzle-orm@0.39.1(@opentelemetry/api@1.9.0)(@types/pg@8.11.11)(@types/react@18.3.5)(kysely@0.27.6)(pg@8.14.1)(postgres@3.4.4)(react@18.2.0)(sqlite3@5.1.7):
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@opentelemetry/api': 1.9.0
|
'@opentelemetry/api': 1.9.0
|
||||||
|
'@types/pg': 8.11.11
|
||||||
'@types/react': 18.3.5
|
'@types/react': 18.3.5
|
||||||
kysely: 0.27.6
|
kysely: 0.27.6
|
||||||
|
pg: 8.14.1
|
||||||
postgres: 3.4.4
|
postgres: 3.4.4
|
||||||
react: 18.2.0
|
react: 18.2.0
|
||||||
sqlite3: 5.1.7
|
sqlite3: 5.1.7
|
||||||
|
|
||||||
drizzle-zod@0.5.1(drizzle-orm@0.39.1(@opentelemetry/api@1.9.0)(@types/react@18.3.5)(kysely@0.27.6)(postgres@3.4.4)(react@18.2.0)(sqlite3@5.1.7))(zod@3.23.8):
|
drizzle-zod@0.5.1(drizzle-orm@0.39.1(@opentelemetry/api@1.9.0)(@types/pg@8.11.11)(@types/react@18.3.5)(kysely@0.27.6)(pg@8.14.1)(postgres@3.4.4)(react@18.2.0)(sqlite3@5.1.7))(zod@3.23.8):
|
||||||
dependencies:
|
dependencies:
|
||||||
drizzle-orm: 0.39.1(@opentelemetry/api@1.9.0)(@types/react@18.3.5)(kysely@0.27.6)(postgres@3.4.4)(react@18.2.0)(sqlite3@5.1.7)
|
drizzle-orm: 0.39.1(@opentelemetry/api@1.9.0)(@types/pg@8.11.11)(@types/react@18.3.5)(kysely@0.27.6)(pg@8.14.1)(postgres@3.4.4)(react@18.2.0)(sqlite3@5.1.7)
|
||||||
zod: 3.23.8
|
zod: 3.23.8
|
||||||
|
|
||||||
eastasianwidth@0.2.0: {}
|
eastasianwidth@0.2.0: {}
|
||||||
@@ -12673,6 +12873,8 @@ snapshots:
|
|||||||
|
|
||||||
nanoid@3.3.8: {}
|
nanoid@3.3.8: {}
|
||||||
|
|
||||||
|
nanoid@5.1.5: {}
|
||||||
|
|
||||||
nanostores@0.11.3: {}
|
nanostores@0.11.3: {}
|
||||||
|
|
||||||
napi-build-utils@1.0.2:
|
napi-build-utils@1.0.2:
|
||||||
@@ -12845,6 +13047,8 @@ snapshots:
|
|||||||
|
|
||||||
object-inspect@1.13.2: {}
|
object-inspect@1.13.2: {}
|
||||||
|
|
||||||
|
obuf@1.1.2: {}
|
||||||
|
|
||||||
octokit@3.1.2:
|
octokit@3.1.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@octokit/app': 14.1.0
|
'@octokit/app': 14.1.0
|
||||||
@@ -12991,6 +13195,53 @@ snapshots:
|
|||||||
|
|
||||||
peberminta@0.9.0: {}
|
peberminta@0.9.0: {}
|
||||||
|
|
||||||
|
pg-cloudflare@1.1.1:
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
pg-connection-string@2.7.0: {}
|
||||||
|
|
||||||
|
pg-int8@1.0.1: {}
|
||||||
|
|
||||||
|
pg-numeric@1.0.2: {}
|
||||||
|
|
||||||
|
pg-pool@3.8.0(pg@8.14.1):
|
||||||
|
dependencies:
|
||||||
|
pg: 8.14.1
|
||||||
|
|
||||||
|
pg-protocol@1.8.0: {}
|
||||||
|
|
||||||
|
pg-types@2.2.0:
|
||||||
|
dependencies:
|
||||||
|
pg-int8: 1.0.1
|
||||||
|
postgres-array: 2.0.0
|
||||||
|
postgres-bytea: 1.0.0
|
||||||
|
postgres-date: 1.0.7
|
||||||
|
postgres-interval: 1.2.0
|
||||||
|
|
||||||
|
pg-types@4.0.2:
|
||||||
|
dependencies:
|
||||||
|
pg-int8: 1.0.1
|
||||||
|
pg-numeric: 1.0.2
|
||||||
|
postgres-array: 3.0.4
|
||||||
|
postgres-bytea: 3.0.0
|
||||||
|
postgres-date: 2.1.0
|
||||||
|
postgres-interval: 3.0.0
|
||||||
|
postgres-range: 1.1.4
|
||||||
|
|
||||||
|
pg@8.14.1:
|
||||||
|
dependencies:
|
||||||
|
pg-connection-string: 2.7.0
|
||||||
|
pg-pool: 3.8.0(pg@8.14.1)
|
||||||
|
pg-protocol: 1.8.0
|
||||||
|
pg-types: 2.2.0
|
||||||
|
pgpass: 1.0.5
|
||||||
|
optionalDependencies:
|
||||||
|
pg-cloudflare: 1.1.1
|
||||||
|
|
||||||
|
pgpass@1.0.5:
|
||||||
|
dependencies:
|
||||||
|
split2: 4.2.0
|
||||||
|
|
||||||
picocolors@1.0.1: {}
|
picocolors@1.0.1: {}
|
||||||
|
|
||||||
picomatch@2.3.1: {}
|
picomatch@2.3.1: {}
|
||||||
@@ -13096,10 +13347,32 @@ snapshots:
|
|||||||
|
|
||||||
postcss@8.4.40:
|
postcss@8.4.40:
|
||||||
dependencies:
|
dependencies:
|
||||||
nanoid: 3.3.7
|
nanoid: 3.3.8
|
||||||
picocolors: 1.0.1
|
picocolors: 1.0.1
|
||||||
source-map-js: 1.2.0
|
source-map-js: 1.2.0
|
||||||
|
|
||||||
|
postgres-array@2.0.0: {}
|
||||||
|
|
||||||
|
postgres-array@3.0.4: {}
|
||||||
|
|
||||||
|
postgres-bytea@1.0.0: {}
|
||||||
|
|
||||||
|
postgres-bytea@3.0.0:
|
||||||
|
dependencies:
|
||||||
|
obuf: 1.1.2
|
||||||
|
|
||||||
|
postgres-date@1.0.7: {}
|
||||||
|
|
||||||
|
postgres-date@2.1.0: {}
|
||||||
|
|
||||||
|
postgres-interval@1.2.0:
|
||||||
|
dependencies:
|
||||||
|
xtend: 4.0.2
|
||||||
|
|
||||||
|
postgres-interval@3.0.0: {}
|
||||||
|
|
||||||
|
postgres-range@1.1.4: {}
|
||||||
|
|
||||||
postgres@3.4.4: {}
|
postgres@3.4.4: {}
|
||||||
|
|
||||||
prebuild-install@7.1.2:
|
prebuild-install@7.1.2:
|
||||||
@@ -13118,6 +13391,8 @@ snapshots:
|
|||||||
tunnel-agent: 0.6.0
|
tunnel-agent: 0.6.0
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
prettier@3.4.2: {}
|
||||||
|
|
||||||
pretty-format@29.7.0:
|
pretty-format@29.7.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@jest/schemas': 29.6.3
|
'@jest/schemas': 29.6.3
|
||||||
|
|||||||
@@ -5,3 +5,4 @@ packages:
|
|||||||
- "apps/schedules"
|
- "apps/schedules"
|
||||||
- "apps/models"
|
- "apps/models"
|
||||||
- "packages/server"
|
- "packages/server"
|
||||||
|
- "apps/licenses"
|
||||||
|
|||||||
Reference in New Issue
Block a user