mirror of
https://github.com/Dokploy/dokploy.git
synced 2026-07-13 18:05:31 +02:00
Compare commits
83 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3ed9da147b | ||
|
|
636dec4f09 | ||
|
|
4dcf6cf4c3 | ||
|
|
7356d71626 | ||
|
|
76603f598c | ||
|
|
e050c218e2 | ||
|
|
46e0b5df75 | ||
|
|
5b36503a3f | ||
|
|
b9afc551db | ||
|
|
078ca19578 | ||
|
|
b7dc7bbf0c | ||
|
|
b9ee81aa59 | ||
|
|
b2d01a2889 | ||
|
|
5ec3d63ab2 | ||
|
|
f0ef06ed8c | ||
|
|
75b2c34a13 | ||
|
|
cd4533df9e | ||
|
|
65a3d8175a | ||
|
|
d3702d22f2 | ||
|
|
d4b74c54da | ||
|
|
80ede659fb | ||
|
|
c59ea57814 | ||
|
|
381186c9f1 | ||
|
|
05e0031daf | ||
|
|
1bacd42bf5 | ||
|
|
2bc12a20ba | ||
|
|
1943a9e8fa | ||
|
|
db9109a3be | ||
|
|
2f6084ec8f | ||
|
|
a62c9f63e1 | ||
|
|
ee2bbf5e37 | ||
|
|
d27dff4906 | ||
|
|
7874445510 | ||
|
|
cf3f44f686 | ||
|
|
119883e746 | ||
|
|
2918868166 | ||
|
|
8e8712e33d | ||
|
|
9cfbd664c5 | ||
|
|
3970cd452b | ||
|
|
107cdcee49 | ||
|
|
6521491e2f | ||
|
|
c5311f2a9f | ||
|
|
7726c8db21 | ||
|
|
b272f01a18 | ||
|
|
bddb07898e | ||
|
|
934ec9b16a | ||
|
|
c6d760a904 | ||
|
|
4f021a3f79 | ||
|
|
d15ccfe505 | ||
|
|
e21605030a | ||
|
|
f1c46f0d19 | ||
|
|
fbf3776548 | ||
|
|
46d84eaa71 | ||
|
|
2a2b947998 | ||
|
|
9974b2326f | ||
|
|
c042c8c0c5 | ||
|
|
819a310d48 | ||
|
|
12860a0736 | ||
|
|
392e2d66ec | ||
|
|
49edf17463 | ||
|
|
6eea02c098 | ||
|
|
a5bba9a11b | ||
|
|
ce88a0a5f2 | ||
|
|
40f28705cb | ||
|
|
17c5a42d8e | ||
|
|
fac96b5db5 | ||
|
|
8d81440d9b | ||
|
|
9ede3bd71b | ||
|
|
df6a72ea50 | ||
|
|
3b5428697b | ||
|
|
dde12e132a | ||
|
|
fd0f679d0f | ||
|
|
df8f1252a0 | ||
|
|
8599f519a4 | ||
|
|
113e4ae4b5 | ||
|
|
7f0bdc7e00 | ||
|
|
b685a817fd | ||
|
|
6061a443d1 | ||
|
|
4c9835d1f3 | ||
|
|
f2671f9369 | ||
|
|
bb904bb011 | ||
|
|
cad628d155 | ||
|
|
cd8230b0e5 |
83
README.md
83
README.md
@@ -1,20 +1,16 @@
|
|||||||
<div align="center">
|
<div align="center">
|
||||||
<div>
|
<a href="https://dokploy.com">
|
||||||
<a href="https://dokploy.com" target="_blank" rel="noopener">
|
<img src=".github/sponsors/logo.png" alt="Dokploy - Open Source Alternative to Vercel, Heroku and Netlify." align="center" width="100%" />
|
||||||
<img style="object-fit: cover;" align="center" width="100%"src=".github/sponsors/logo.png" alt="Dokploy - Open Source Alternative to Vercel, Heroku and Netlify." />
|
</a>
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</br>
|
|
||||||
<div align="center">
|
|
||||||
<div>Join us on Discord for help, feedback, and discussions!</div>
|
|
||||||
</br>
|
</br>
|
||||||
|
</br>
|
||||||
|
<p>Join us on Discord for help, feedback, and discussions!</p>
|
||||||
<a href="https://discord.gg/2tBnJ3jDJc">
|
<a href="https://discord.gg/2tBnJ3jDJc">
|
||||||
<img src="https://discordapp.com/api/guilds/1234073262418563112/widget.png?style=banner2" alt="Discord Shield"/>
|
<img src="https://discordapp.com/api/guilds/1234073262418563112/widget.png?style=banner2" alt="Discord Shield"/>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<br />
|
<br />
|
||||||
|
|
||||||
Dokploy is a free, self-hostable Platform as a Service (PaaS) that simplifies the deployment and management of applications and databases.
|
Dokploy is a free, self-hostable Platform as a Service (PaaS) that simplifies the deployment and management of applications and databases.
|
||||||
|
|
||||||
### Features
|
### Features
|
||||||
@@ -61,55 +57,52 @@ For detailed documentation, visit [docs.dokploy.com](https://docs.dokploy.com).
|
|||||||
|
|
||||||
### Hero Sponsors 🎖
|
### Hero Sponsors 🎖
|
||||||
|
|
||||||
<div style="display: flex; align-items: center; gap: 20px;">
|
<div>
|
||||||
<a href="https://www.hostinger.com/vps-hosting?ref=dokploy" target="_blank" style="display: inline-block; margin-right: 10px;"><img src=".github/sponsors/hostinger.jpg" alt="Hostinger" height="50"/></a>
|
<a href="https://www.hostinger.com/vps-hosting?ref=dokploy"><img src=".github/sponsors/hostinger.jpg" alt="Hostinger" width="300"/></a>
|
||||||
<a href="https://www.lxaer.com/?ref=dokploy" target="_blank" style="display: inline-block; margin-right: 10px;"><img src=".github/sponsors/lxaer.png" alt="LX Aer" height="50"/></a>
|
<a href="https://www.lxaer.com/?ref=dokploy"><img src=".github/sponsors/lxaer.png" alt="LX Aer" width="100"/></a>
|
||||||
<a href="https://mandarin3d.com/?ref=dokploy" target="_blank" style="display: inline-block;"><img src=".github/sponsors/mandarin.png" alt="Mandarin" height="50"/></a>
|
<a href="https://mandarin3d.com/?ref=dokploy"><img src=".github/sponsors/mandarin.png" alt="Mandarin" width="100"/></a>
|
||||||
<a href="https://lightnode.com/?ref=dokploy" target="_blank" style="display: inline-block;"><img src=".github/sponsors/light-node.webp" alt="Lightnode" height="70"/></a>
|
<a href="https://lightnode.com/?ref=dokploy"><img src=".github/sponsors/light-node.webp" alt="Lightnode" width="300"/></a>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Premium Supporters 🥇 -->
|
||||||
|
|
||||||
|
<!-- Add Premium Supporters here -->
|
||||||
|
|
||||||
### Premium Supporters 🥇
|
### Premium Supporters 🥇
|
||||||
|
|
||||||
<div style="display: flex; align-items: center; gap: 20px;">
|
<div>
|
||||||
<a href="https://supafort.com/?ref=dokploy" target="_blank" style="display: inline-block; margin-right: 20px;"><img src="https://supafort.com/build/q-4Ht4rBZR.webp" alt="Supafort.com" height="50"/></a>
|
<a href="https://supafort.com/?ref=dokploy"><img src="https://supafort.com/build/q-4Ht4rBZR.webp" alt="Supafort.com" width="300"/></a>
|
||||||
<a href="https://agentdock.ai/?ref=dokploy" target="_blank" style="display: inline-block; margin-right: 50px;"><img src=".github/sponsors/agentdock.png" alt="agentdock.ai" height="70"/></a>
|
<a href="https://agentdock.ai/?ref=dokploy"><img src=".github/sponsors/agentdock.png" alt="agentdock.ai" width="100"/></a>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
### Elite Contributors 🥈
|
|
||||||
|
|
||||||
<div style="display: flex; align-items: center; gap: 20px;">
|
|
||||||
<a href="https://americancloud.com/?ref=dokploy" target="_blank" style="display: inline-block; padding: 10px; border-radius: 10px;"><img src=".github/sponsors/american-cloud.png" alt="AmericanCloud" height="70"/></a>
|
|
||||||
<a href="https://tolgee.io/?utm_source=github_dokploy&utm_medium=banner&utm_campaign=dokploy" target="_blank" style="display: inline-block; margin-right: 10px;"><img src="https://dokploy.com/tolgee-logo.png" alt="Tolgee" height="80"/></a>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
<!-- Elite Contributors 🥈 -->
|
<!-- Elite Contributors 🥈 -->
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<!-- Add Elite Contributors here -->
|
<!-- Add Elite Contributors here -->
|
||||||
|
|
||||||
### Supporting Members 🥉
|
### Elite Contributors 🥈
|
||||||
|
|
||||||
<div style="display: flex; gap: 30px; flex-wrap: wrap;">
|
|
||||||
<a href="https://lightspeed.run/?ref=dokploy"><img src="https://github.com/lightspeedrun.png" width="60px" alt="Lightspeed.run"/></a>
|
|
||||||
<a href="https://cloudblast.io/?ref=dokploy "><img src="https://cloudblast.io/img/logo-icon.193cf13e.svg" width="250px" alt="Cloudblast.io"/></a>
|
|
||||||
<a href="https://startupfa.me/?ref=dokploy "><img src=".github/sponsors/startupfame.png" width="65px" alt="Startupfame"/></a>
|
|
||||||
<a href="https://itsdb-center.com?ref=dokploy "><img src=".github/sponsors/its.png" width="65px" alt="Itsdb-center"/></a>
|
|
||||||
<a href="https://openalternative.co/?ref=dokploy "><img src=".github/sponsors/openalternative.png" width="65px" alt="Openalternative"/></a>
|
|
||||||
<a href="https://synexa.ai/?ref=dokploy"><img src=".github/sponsors/synexa.png" width="65px" alt="Synexa"/></a>
|
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<a href="https://americancloud.com/?ref=dokploy"><img src=".github/sponsors/american-cloud.png" alt="AmericanCloud" width="300"/></a>
|
||||||
|
<a href="https://tolgee.io/?utm_source=github_dokploy&utm_medium=banner&utm_campaign=dokploy"><img src="https://dokploy.com/tolgee-logo.png" alt="Tolgee" width="100"/></a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
### Supporting Members 🥉
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<a href="https://lightspeed.run/?ref=dokploy"><img src="https://github.com/lightspeedrun.png" width="60px" alt="Lightspeed.run"/></a>
|
||||||
|
<a href="https://cloudblast.io/?ref=dokploy"><img src="https://cloudblast.io/img/logo-icon.193cf13e.svg" width="250px" alt="Cloudblast.io"/></a>
|
||||||
|
<a href="https://startupfa.me/?ref=dokploy"><img src=".github/sponsors/startupfame.png" width="65px" alt="Startupfame"/></a>
|
||||||
|
<a href="https://itsdb-center.com?ref=dokploy"><img src=".github/sponsors/its.png" width="65px" alt="Itsdb-center"/></a>
|
||||||
|
<a href="https://openalternative.co/?ref=dokploy"><img src=".github/sponsors/openalternative.png" width="65px" alt="Openalternative"/></a>
|
||||||
|
<a href="https://synexa.ai/?ref=dokploy"><img src=".github/sponsors/synexa.png" width="65px" alt="Synexa"/></a>
|
||||||
|
</div>
|
||||||
|
|
||||||
### Community Backers 🤝
|
### Community Backers 🤝
|
||||||
|
|
||||||
<div style="display: flex; gap: 30px; flex-wrap: wrap;">
|
<div>
|
||||||
<a href="https://steamsets.com/?ref=dokploy"><img src="https://avatars.githubusercontent.com/u/111978405?s=200&v=4" width="60px" alt="Steamsets.com"/></a>
|
<a href="https://steamsets.com/?ref=dokploy"><img src="https://avatars.githubusercontent.com/u/111978405?s=200&v=4" width="60px" alt="Steamsets.com"/></a>
|
||||||
<a href="https://rivo.gg/?ref=dokploy"><img src="https://avatars.githubusercontent.com/u/126797452?s=200&v=4" width="60px" alt="Rivo.gg"/></a>
|
<a href="https://rivo.gg/?ref=dokploy"><img src="https://avatars.githubusercontent.com/u/126797452?s=200&v=4" width="60px" alt="Rivo.gg"/></a>
|
||||||
<a href="https://photoquest.wedding/?ref=dokploy"><img src="https://photoquest.wedding/favicon/android-chrome-512x512.png" width="60px" alt="Rivo.gg"/></a>
|
<a href="https://photoquest.wedding/?ref=dokploy"><img src="https://photoquest.wedding/favicon/android-chrome-512x512.png" width="60px" alt="Rivo.gg"/></a>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
#### Organizations:
|
#### Organizations:
|
||||||
@@ -124,12 +117,12 @@ For detailed documentation, visit [docs.dokploy.com](https://docs.dokploy.com).
|
|||||||
|
|
||||||
<a href="https://github.com/dokploy/dokploy/graphs/contributors">
|
<a href="https://github.com/dokploy/dokploy/graphs/contributors">
|
||||||
<img src="https://contrib.rocks/image?repo=dokploy/dokploy" />
|
<img src="https://contrib.rocks/image?repo=dokploy/dokploy" />
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
## Video Tutorial
|
## Video Tutorial
|
||||||
|
|
||||||
<a href="https://youtu.be/mznYKPvhcfw">
|
<a href="https://youtu.be/mznYKPvhcfw">
|
||||||
<img src="https://dokploy.com/banner.png" alt="Watch the video" width="400" style="border-radius:20px;"/>
|
<img src="https://dokploy.com/banner.png" alt="Watch the video" width="400"/>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ describe("createDomainLabels", () => {
|
|||||||
path: "/",
|
path: "/",
|
||||||
createdAt: "",
|
createdAt: "",
|
||||||
previewDeploymentId: "",
|
previewDeploymentId: "",
|
||||||
|
internalPath: "/",
|
||||||
|
stripPath: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
it("should create basic labels for web entrypoint", async () => {
|
it("should create basic labels for web entrypoint", async () => {
|
||||||
|
|||||||
@@ -119,6 +119,8 @@ const baseDomain: Domain = {
|
|||||||
domainType: "application",
|
domainType: "application",
|
||||||
uniqueConfigKey: 1,
|
uniqueConfigKey: 1,
|
||||||
previewDeploymentId: "",
|
previewDeploymentId: "",
|
||||||
|
internalPath: "/",
|
||||||
|
stripPath: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
const baseRedirect: Redirect = {
|
const baseRedirect: Redirect = {
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { AlertBlock } from "@/components/shared/alert-block";
|
||||||
import { CodeEditor } from "@/components/shared/code-editor";
|
import { CodeEditor } from "@/components/shared/code-editor";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
@@ -169,6 +170,23 @@ export const AddVolumes = ({
|
|||||||
onSubmit={form.handleSubmit(onSubmit)}
|
onSubmit={form.handleSubmit(onSubmit)}
|
||||||
className="grid w-full gap-8 "
|
className="grid w-full gap-8 "
|
||||||
>
|
>
|
||||||
|
{type === "bind" && (
|
||||||
|
<AlertBlock>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p>
|
||||||
|
Make sure the host path is a valid path and exists in the
|
||||||
|
host machine.
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
<strong>Cluster Warning:</strong> If you're using cluster
|
||||||
|
features, bind mounts may cause deployment failures since
|
||||||
|
the path must exist on all worker/manager nodes. Consider
|
||||||
|
using external tools to distribute the folder across nodes
|
||||||
|
or use named volumes instead.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</AlertBlock>
|
||||||
|
)}
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
defaultValue={form.control._defaultValues.type}
|
defaultValue={form.control._defaultValues.type}
|
||||||
|
|||||||
@@ -14,7 +14,8 @@ interface Props {
|
|||||||
| "schedule"
|
| "schedule"
|
||||||
| "server"
|
| "server"
|
||||||
| "backup"
|
| "backup"
|
||||||
| "previewDeployment";
|
| "previewDeployment"
|
||||||
|
| "volumeBackup";
|
||||||
serverId?: string;
|
serverId?: string;
|
||||||
refreshToken?: string;
|
refreshToken?: string;
|
||||||
children?: React.ReactNode;
|
children?: React.ReactNode;
|
||||||
|
|||||||
@@ -27,7 +27,8 @@ interface Props {
|
|||||||
| "schedule"
|
| "schedule"
|
||||||
| "server"
|
| "server"
|
||||||
| "backup"
|
| "backup"
|
||||||
| "previewDeployment";
|
| "previewDeployment"
|
||||||
|
| "volumeBackup";
|
||||||
refreshToken?: string;
|
refreshToken?: string;
|
||||||
serverId?: string;
|
serverId?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,6 +49,8 @@ export const domain = z
|
|||||||
.object({
|
.object({
|
||||||
host: z.string().min(1, { message: "Add a hostname" }),
|
host: z.string().min(1, { message: "Add a hostname" }),
|
||||||
path: z.string().min(1).optional(),
|
path: z.string().min(1).optional(),
|
||||||
|
internalPath: z.string().optional(),
|
||||||
|
stripPath: z.boolean().optional(),
|
||||||
port: z
|
port: z
|
||||||
.number()
|
.number()
|
||||||
.min(1, { message: "Port must be at least 1" })
|
.min(1, { message: "Port must be at least 1" })
|
||||||
@@ -84,6 +86,29 @@ export const domain = z
|
|||||||
message: "Required",
|
message: "Required",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Validate stripPath requires a valid path
|
||||||
|
if (input.stripPath && (!input.path || input.path === "/")) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
path: ["stripPath"],
|
||||||
|
message:
|
||||||
|
"Strip path can only be enabled when a path other than '/' is specified",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate internalPath starts with /
|
||||||
|
if (
|
||||||
|
input.internalPath &&
|
||||||
|
input.internalPath !== "/" &&
|
||||||
|
!input.internalPath.startsWith("/")
|
||||||
|
) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
path: ["internalPath"],
|
||||||
|
message: "Internal path must start with '/'",
|
||||||
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
type Domain = z.infer<typeof domain>;
|
type Domain = z.infer<typeof domain>;
|
||||||
@@ -162,6 +187,8 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
|
|||||||
defaultValues: {
|
defaultValues: {
|
||||||
host: "",
|
host: "",
|
||||||
path: undefined,
|
path: undefined,
|
||||||
|
internalPath: undefined,
|
||||||
|
stripPath: false,
|
||||||
port: undefined,
|
port: undefined,
|
||||||
https: false,
|
https: false,
|
||||||
certificateType: undefined,
|
certificateType: undefined,
|
||||||
@@ -182,6 +209,8 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
|
|||||||
...data,
|
...data,
|
||||||
/* Convert null to undefined */
|
/* Convert null to undefined */
|
||||||
path: data?.path || undefined,
|
path: data?.path || undefined,
|
||||||
|
internalPath: data?.internalPath || undefined,
|
||||||
|
stripPath: data?.stripPath || false,
|
||||||
port: data?.port || undefined,
|
port: data?.port || undefined,
|
||||||
certificateType: data?.certificateType || undefined,
|
certificateType: data?.certificateType || undefined,
|
||||||
customCertResolver: data?.customCertResolver || undefined,
|
customCertResolver: data?.customCertResolver || undefined,
|
||||||
@@ -194,6 +223,8 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
|
|||||||
form.reset({
|
form.reset({
|
||||||
host: "",
|
host: "",
|
||||||
path: undefined,
|
path: undefined,
|
||||||
|
internalPath: undefined,
|
||||||
|
stripPath: false,
|
||||||
port: undefined,
|
port: undefined,
|
||||||
https: false,
|
https: false,
|
||||||
certificateType: undefined,
|
certificateType: undefined,
|
||||||
@@ -469,6 +500,49 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="internalPath"
|
||||||
|
render={({ field }) => {
|
||||||
|
return (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Internal Path</FormLabel>
|
||||||
|
<FormDescription>
|
||||||
|
The path where your application expects to receive
|
||||||
|
requests internally (defaults to "/")
|
||||||
|
</FormDescription>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder={"/"} {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="stripPath"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="flex flex-row items-center justify-between p-3 border rounded-lg shadow-sm">
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<FormLabel>Strip Path</FormLabel>
|
||||||
|
<FormDescription>
|
||||||
|
Remove the external path from the request before
|
||||||
|
forwarding to the application
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</div>
|
||||||
|
<FormControl>
|
||||||
|
<Switch
|
||||||
|
checked={field.value}
|
||||||
|
onCheckedChange={field.onChange}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="port"
|
name="port"
|
||||||
|
|||||||
@@ -278,7 +278,7 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
|
|||||||
{repositories?.map((repo) => {
|
{repositories?.map((repo) => {
|
||||||
return (
|
return (
|
||||||
<CommandItem
|
<CommandItem
|
||||||
value={repo.name}
|
value={repo.url}
|
||||||
key={repo.url}
|
key={repo.url}
|
||||||
onSelect={() => {
|
onSelect={() => {
|
||||||
form.setValue("repository", {
|
form.setValue("repository", {
|
||||||
@@ -299,7 +299,8 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
|
|||||||
<CheckIcon
|
<CheckIcon
|
||||||
className={cn(
|
className={cn(
|
||||||
"ml-auto h-4 w-4",
|
"ml-auto h-4 w-4",
|
||||||
repo.name === field.value.repo
|
repo.url ===
|
||||||
|
field.value.gitlabPathNamespace
|
||||||
? "opacity-100"
|
? "opacity-100"
|
||||||
: "opacity-0",
|
: "opacity-0",
|
||||||
)}
|
)}
|
||||||
|
|||||||
108
apps/dokploy/components/dashboard/application/rollbacks/Backup
Normal file
108
apps/dokploy/components/dashboard/application/rollbacks/Backup
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
Backup
|
||||||
|
# license-namedbackups-abxelc
|
||||||
|
1. docker ps --filter "label=com.docker.swarm.service.name=license-namedbackups-abxelc" --format "{{.Names}}"
|
||||||
|
2. docker run --rm \
|
||||||
|
--volumes-from "license-namedbackups-abxelc.1.m3cxy78ocj3w0zu42kmgamc5y" \
|
||||||
|
-v $(pwd):/backup \
|
||||||
|
ubuntu \
|
||||||
|
tar cvf /backup/backup.tar /var/lib/postgresql/data
|
||||||
|
|
||||||
|
|
||||||
|
# Official Command Backup
|
||||||
|
|
||||||
|
1. Backup
|
||||||
|
|
||||||
|
docker run --rm \
|
||||||
|
-v license-namedbackups-abxelc-data:/volume_data \
|
||||||
|
-v $(pwd):/backup \
|
||||||
|
ubuntu \
|
||||||
|
bash -c "cd /volume_data && tar cvf /backup/generic_backup.tar ."
|
||||||
|
|
||||||
|
|
||||||
|
2. Restore
|
||||||
|
|
||||||
|
docker service scale license-namedbackups-abxelc=0
|
||||||
|
|
||||||
|
docker volume rm license-namedbackups-abxelc-data
|
||||||
|
|
||||||
|
2. docker run --rm \
|
||||||
|
-v license-namedbackups-abxelc-data:/volume_data \
|
||||||
|
-v $(pwd):/backup \
|
||||||
|
ubuntu \
|
||||||
|
bash -c "cd /volume_data && tar xvf /backup/generic_backup.tar ."
|
||||||
|
|
||||||
|
docker service scale license-namedbackups-abxelc=1
|
||||||
|
|
||||||
|
|
||||||
|
root@srv594061:~# docker volume inspect n8n_data-data
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"CreatedAt": "2025-06-28T18:07:44Z",
|
||||||
|
"Driver": "local",
|
||||||
|
"Labels": null,
|
||||||
|
"Mountpoint": "/var/lib/docker/volumes/n8n_data-data/_data",
|
||||||
|
"Name": "n8n_data-data",
|
||||||
|
"Options": null,
|
||||||
|
"Scope": "local"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
Archivos funcuionando creados por N8N
|
||||||
|
|
||||||
|
# root@srv594061:~# cd /var/lib/docker/volumes/n8n_data-data/_data
|
||||||
|
# root@srv594061:/var/lib/docker/volumes/n8n_data-data/_data# ls
|
||||||
|
# binaryData config crash.journal database.sqlite git n8nEventLog.log ssh
|
||||||
|
|
||||||
|
Luego que intente hacer el backup con el comando de backup
|
||||||
|
|
||||||
|
|
||||||
|
root@srv594061:~# docker run --rm -v n8n_data-data:/volume_data -v $(pwd):/backup ubuntu bash -c "cd /volume_data && tar cvf /backup/generic_backup6.tar ."
|
||||||
|
./
|
||||||
|
./config
|
||||||
|
./crash.journal
|
||||||
|
./binaryData/
|
||||||
|
./git/
|
||||||
|
./database.sqlite
|
||||||
|
./ssh/
|
||||||
|
./n8nEventLog.log
|
||||||
|
root@srv594061:~#
|
||||||
|
|
||||||
|
# Paramos la aplicacion
|
||||||
|
docker service scale n8n=0
|
||||||
|
|
||||||
|
# Haciendo el restore
|
||||||
|
root@srv594061:~# docker volume rm n8n_data-data
|
||||||
|
n8n_data-data
|
||||||
|
root@srv594061:~# docker run --rm -v n8n_data-data:/volume_data -v $(pwd):/backup ubuntu bash -c "cd /volume_data && tar xvf /backup/generic_backup6.tar && chown -R 999:999 ."
|
||||||
|
./
|
||||||
|
./config
|
||||||
|
./crash.journal
|
||||||
|
./binaryData/
|
||||||
|
./git/
|
||||||
|
./database.sqlite
|
||||||
|
./ssh/
|
||||||
|
./n8nEventLog.log
|
||||||
|
|
||||||
|
# Tenemos los archivos en el volumen
|
||||||
|
root@srv594061:~# ls /var/lib/docker/volumes/n8n_data-data/_data
|
||||||
|
binaryData config crash.journal database.sqlite git n8nEventLog.log ssh
|
||||||
|
root@srv594061:~#
|
||||||
|
|
||||||
|
docker service scale n8n=1
|
||||||
|
|
||||||
|
# Luego en N8N Cuando se que el volumen tiene la data
|
||||||
|
Permissions 0644 for n8n settings file /home/node/.n8n/config are too wide. This is ignored for now, but in the future n8n will attempt to change the permissions automatically. To automatically enforce correct permissions now set N8N_ENFORCE_SETTINGS_FILE_PERMISSIONS=true (recommended), or turn this check off set N8N_ENFORCE_SETTINGS_FILE_PERMISSIONS=false.
|
||||||
|
User settings loaded from: /home/node/.n8n/config
|
||||||
|
Last session crashed
|
||||||
|
Error: EACCES: permission denied, open '/home/node/.n8n/crash.journal'
|
||||||
|
at open (node:internal/fs/promises:639:25)
|
||||||
|
at touchFile (/usr/local/lib/node_modules/n8n/dist/crash-journal.js:18:20)
|
||||||
|
at Object.init (/usr/local/lib/node_modules/n8n/dist/crash-journal.js:32:5)
|
||||||
|
at Start.initCrashJournal (/usr/local/lib/node_modules/n8n/dist/commands/base-command.js:113:9)
|
||||||
|
at Start.init (/usr/local/lib/node_modules/n8n/dist/commands/start.js:141:9)
|
||||||
|
at Start._run (/usr/local/lib/node_modules/n8n/node_modules/@oclif/core/lib/command.js:301:13)
|
||||||
|
at Config.runCommand (/usr/local/lib/node_modules/n8n/node_modules/@oclif/core/lib/config/config.js:424:25)
|
||||||
|
at run (/usr/local/lib/node_modules/n8n/node_modules/@oclif/core/lib/main.js:94:16)
|
||||||
|
at /usr/local/lib/node_modules/n8n/bin/n8n:71:2
|
||||||
|
TypeError: Cannot read properties of undefined (reading 'error')
|
||||||
|
|
||||||
@@ -0,0 +1,672 @@
|
|||||||
|
import { AlertBlock } from "@/components/shared/alert-block";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
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 {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipProvider,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from "@/components/ui/tooltip";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { api } from "@/utils/api";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import {
|
||||||
|
DatabaseZap,
|
||||||
|
Info,
|
||||||
|
PenBoxIcon,
|
||||||
|
PlusCircle,
|
||||||
|
RefreshCw,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { z } from "zod";
|
||||||
|
import type { CacheType } from "../domains/handle-domain";
|
||||||
|
import { commonCronExpressions } from "../schedules/handle-schedules";
|
||||||
|
|
||||||
|
const formSchema = z
|
||||||
|
.object({
|
||||||
|
name: z.string().min(1, "Name is required"),
|
||||||
|
cronExpression: z.string().min(1, "Cron expression is required"),
|
||||||
|
volumeName: z.string().min(1, "Volume name is required"),
|
||||||
|
prefix: z.string(),
|
||||||
|
// keepLatestCount: z.coerce.number().optional(),
|
||||||
|
turnOff: z.boolean().default(false),
|
||||||
|
enabled: z.boolean().default(true),
|
||||||
|
serviceType: z.enum([
|
||||||
|
"application",
|
||||||
|
"compose",
|
||||||
|
"postgres",
|
||||||
|
"mariadb",
|
||||||
|
"mongo",
|
||||||
|
"mysql",
|
||||||
|
"redis",
|
||||||
|
]),
|
||||||
|
serviceName: z.string(),
|
||||||
|
destinationId: z.string().min(1, "Destination required"),
|
||||||
|
})
|
||||||
|
.superRefine((data, ctx) => {
|
||||||
|
if (data.serviceType === "compose" && !data.serviceName) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message: "Service name is required",
|
||||||
|
path: ["serviceName"],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.serviceType === "compose" && !data.serviceName) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message: "Service name is required",
|
||||||
|
path: ["serviceName"],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
id?: string;
|
||||||
|
volumeBackupId?: string;
|
||||||
|
volumeBackupType?:
|
||||||
|
| "application"
|
||||||
|
| "compose"
|
||||||
|
| "postgres"
|
||||||
|
| "mariadb"
|
||||||
|
| "mongo"
|
||||||
|
| "mysql"
|
||||||
|
| "redis";
|
||||||
|
}
|
||||||
|
|
||||||
|
export const HandleVolumeBackups = ({
|
||||||
|
id,
|
||||||
|
volumeBackupId,
|
||||||
|
volumeBackupType,
|
||||||
|
}: Props) => {
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const [cacheType, setCacheType] = useState<CacheType>("cache");
|
||||||
|
|
||||||
|
const utils = api.useUtils();
|
||||||
|
const form = useForm<z.infer<typeof formSchema>>({
|
||||||
|
resolver: zodResolver(formSchema),
|
||||||
|
defaultValues: {
|
||||||
|
name: "",
|
||||||
|
cronExpression: "",
|
||||||
|
volumeName: "",
|
||||||
|
prefix: "",
|
||||||
|
// keepLatestCount: undefined,
|
||||||
|
turnOff: false,
|
||||||
|
enabled: true,
|
||||||
|
serviceName: "",
|
||||||
|
serviceType: volumeBackupType,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const serviceTypeForm = volumeBackupType;
|
||||||
|
const { data: destinations } = api.destination.all.useQuery();
|
||||||
|
const { data: volumeBackup } = api.volumeBackups.one.useQuery(
|
||||||
|
{ volumeBackupId: volumeBackupId || "" },
|
||||||
|
{ enabled: !!volumeBackupId },
|
||||||
|
);
|
||||||
|
|
||||||
|
const { data: mounts } = api.mounts.allNamedByApplicationId.useQuery(
|
||||||
|
{ applicationId: id || "" },
|
||||||
|
{ enabled: !!id && volumeBackupType === "application" },
|
||||||
|
);
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: services,
|
||||||
|
isFetching: isLoadingServices,
|
||||||
|
error: errorServices,
|
||||||
|
refetch: refetchServices,
|
||||||
|
} = api.compose.loadServices.useQuery(
|
||||||
|
{
|
||||||
|
composeId: id || "",
|
||||||
|
type: cacheType,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
retry: false,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
enabled: !!id && volumeBackupType === "compose",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const serviceName = form.watch("serviceName");
|
||||||
|
|
||||||
|
const { data: mountsByService } = api.compose.loadMountsByService.useQuery(
|
||||||
|
{
|
||||||
|
composeId: id || "",
|
||||||
|
serviceName,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
enabled: !!id && volumeBackupType === "compose" && !!serviceName,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (volumeBackupId && volumeBackup) {
|
||||||
|
form.reset({
|
||||||
|
name: volumeBackup.name,
|
||||||
|
cronExpression: volumeBackup.cronExpression,
|
||||||
|
volumeName: volumeBackup.volumeName || "",
|
||||||
|
prefix: volumeBackup.prefix,
|
||||||
|
// keepLatestCount: volumeBackup.keepLatestCount || undefined,
|
||||||
|
turnOff: volumeBackup.turnOff,
|
||||||
|
enabled: volumeBackup.enabled || false,
|
||||||
|
serviceName: volumeBackup.serviceName || "",
|
||||||
|
destinationId: volumeBackup.destinationId,
|
||||||
|
serviceType: volumeBackup.serviceType,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [form, volumeBackup, volumeBackupId]);
|
||||||
|
|
||||||
|
const { mutateAsync, isLoading } = volumeBackupId
|
||||||
|
? api.volumeBackups.update.useMutation()
|
||||||
|
: api.volumeBackups.create.useMutation();
|
||||||
|
|
||||||
|
const onSubmit = async (values: z.infer<typeof formSchema>) => {
|
||||||
|
if (!id && !volumeBackupId) return;
|
||||||
|
|
||||||
|
await mutateAsync({
|
||||||
|
...values,
|
||||||
|
destinationId: values.destinationId,
|
||||||
|
volumeBackupId: volumeBackupId || "",
|
||||||
|
serviceType: volumeBackupType,
|
||||||
|
...(volumeBackupType === "application" && {
|
||||||
|
applicationId: id || "",
|
||||||
|
}),
|
||||||
|
...(volumeBackupType === "compose" && {
|
||||||
|
composeId: id || "",
|
||||||
|
}),
|
||||||
|
...(volumeBackupType === "postgres" && {
|
||||||
|
serverId: id || "",
|
||||||
|
}),
|
||||||
|
...(volumeBackupType === "postgres" && {
|
||||||
|
postgresId: id || "",
|
||||||
|
}),
|
||||||
|
...(volumeBackupType === "mariadb" && {
|
||||||
|
mariadbId: id || "",
|
||||||
|
}),
|
||||||
|
...(volumeBackupType === "mongo" && {
|
||||||
|
mongoId: id || "",
|
||||||
|
}),
|
||||||
|
...(volumeBackupType === "mysql" && {
|
||||||
|
mysqlId: id || "",
|
||||||
|
}),
|
||||||
|
...(volumeBackupType === "redis" && {
|
||||||
|
redisId: id || "",
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
toast.success(
|
||||||
|
`Volume backup ${volumeBackupId ? "updated" : "created"} successfully`,
|
||||||
|
);
|
||||||
|
utils.volumeBackups.list.invalidate({
|
||||||
|
id,
|
||||||
|
volumeBackupType,
|
||||||
|
});
|
||||||
|
setIsOpen(false);
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
toast.error(
|
||||||
|
error instanceof Error ? error.message : "An unknown error occurred",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
{volumeBackupId ? (
|
||||||
|
<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>
|
||||||
|
) : (
|
||||||
|
<Button>
|
||||||
|
<PlusCircle className="w-4 h-4 mr-2" />
|
||||||
|
Add Volume Backup
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent
|
||||||
|
className={cn(
|
||||||
|
"max-h-screen overflow-y-auto",
|
||||||
|
volumeBackupType === "compose" || volumeBackupType === "application"
|
||||||
|
? "max-h-[95vh] sm:max-w-2xl"
|
||||||
|
: " sm:max-w-lg",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
{volumeBackupId ? "Edit" : "Create"} Volume Backup
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Create a volume backup to backup your volume to a destination
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="name"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel className="flex items-center gap-2">
|
||||||
|
Task Name
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="Daily Database Backup" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
A descriptive name for your scheduled task
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="cronExpression"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel className="flex items-center gap-2">
|
||||||
|
Schedule
|
||||||
|
<TooltipProvider>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Info className="w-4 h-4 text-muted-foreground cursor-help" />
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>
|
||||||
|
Cron expression format: minute hour day month
|
||||||
|
weekday
|
||||||
|
</p>
|
||||||
|
<p>Example: 0 0 * * * (daily at midnight)</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
</FormLabel>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Select
|
||||||
|
onValueChange={(value) => {
|
||||||
|
field.onChange(value);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FormControl>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select a predefined schedule" />
|
||||||
|
</SelectTrigger>
|
||||||
|
</FormControl>
|
||||||
|
<SelectContent>
|
||||||
|
{commonCronExpressions.map((expr) => (
|
||||||
|
<SelectItem key={expr.value} value={expr.value}>
|
||||||
|
{expr.label} ({expr.value})
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<div className="relative">
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
placeholder="Custom cron expression (e.g., 0 0 * * *)"
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<FormDescription>
|
||||||
|
Choose a predefined schedule or enter a custom cron
|
||||||
|
expression
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="destinationId"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Destination</FormLabel>
|
||||||
|
<Select
|
||||||
|
onValueChange={field.onChange}
|
||||||
|
defaultValue={field.value}
|
||||||
|
>
|
||||||
|
<FormControl>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select a destination" />
|
||||||
|
</SelectTrigger>
|
||||||
|
</FormControl>
|
||||||
|
<SelectContent>
|
||||||
|
{destinations?.map((destination) => (
|
||||||
|
<SelectItem
|
||||||
|
key={destination.destinationId}
|
||||||
|
value={destination.destinationId}
|
||||||
|
>
|
||||||
|
{destination.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<FormDescription>
|
||||||
|
Choose the backup destination where files will be stored
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{serviceTypeForm === "compose" && (
|
||||||
|
<>
|
||||||
|
<div className="flex flex-col w-full gap-4">
|
||||||
|
{errorServices && (
|
||||||
|
<AlertBlock
|
||||||
|
type="warning"
|
||||||
|
className="[overflow-wrap:anywhere]"
|
||||||
|
>
|
||||||
|
{errorServices?.message}
|
||||||
|
</AlertBlock>
|
||||||
|
)}
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="serviceName"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="w-full">
|
||||||
|
<FormLabel>Service Name</FormLabel>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Select
|
||||||
|
onValueChange={field.onChange}
|
||||||
|
defaultValue={field.value || ""}
|
||||||
|
>
|
||||||
|
<FormControl>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select a service name" />
|
||||||
|
</SelectTrigger>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
<SelectContent>
|
||||||
|
{services?.map((service, index) => (
|
||||||
|
<SelectItem
|
||||||
|
value={service}
|
||||||
|
key={`${service}-${index}`}
|
||||||
|
>
|
||||||
|
{service}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
<SelectItem value="none" disabled>
|
||||||
|
Empty
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<TooltipProvider delayDuration={0}>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
type="button"
|
||||||
|
isLoading={isLoadingServices}
|
||||||
|
onClick={() => {
|
||||||
|
if (cacheType === "fetch") {
|
||||||
|
refetchServices();
|
||||||
|
} else {
|
||||||
|
setCacheType("fetch");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<RefreshCw className="size-4 text-muted-foreground" />
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent
|
||||||
|
side="left"
|
||||||
|
sideOffset={5}
|
||||||
|
className="max-w-[10rem]"
|
||||||
|
>
|
||||||
|
<p>
|
||||||
|
Fetch: Will clone the repository and load the
|
||||||
|
services
|
||||||
|
</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
<TooltipProvider delayDuration={0}>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
type="button"
|
||||||
|
isLoading={isLoadingServices}
|
||||||
|
onClick={() => {
|
||||||
|
if (cacheType === "cache") {
|
||||||
|
refetchServices();
|
||||||
|
} else {
|
||||||
|
setCacheType("cache");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DatabaseZap className="size-4 text-muted-foreground" />
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent
|
||||||
|
side="left"
|
||||||
|
sideOffset={5}
|
||||||
|
className="max-w-[10rem]"
|
||||||
|
>
|
||||||
|
<p>
|
||||||
|
Cache: If you previously deployed this
|
||||||
|
compose, it will read the services from the
|
||||||
|
last deployment/fetch from the repository
|
||||||
|
</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{mountsByService && mountsByService.length > 0 && (
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="volumeName"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Volumes</FormLabel>
|
||||||
|
<Select
|
||||||
|
onValueChange={field.onChange}
|
||||||
|
defaultValue={field.value || ""}
|
||||||
|
>
|
||||||
|
<FormControl>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select a volume name" />
|
||||||
|
</SelectTrigger>
|
||||||
|
</FormControl>
|
||||||
|
<SelectContent>
|
||||||
|
{mountsByService?.map((volume) => (
|
||||||
|
<SelectItem
|
||||||
|
key={volume.Name}
|
||||||
|
value={volume.Name || ""}
|
||||||
|
>
|
||||||
|
{volume.Name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<FormDescription>
|
||||||
|
Choose the volume to backup, if you dont see the
|
||||||
|
volume here, you can type the volume name manually
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{serviceTypeForm === "application" && (
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="volumeName"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Volumes</FormLabel>
|
||||||
|
<Select
|
||||||
|
onValueChange={field.onChange}
|
||||||
|
defaultValue={field.value || ""}
|
||||||
|
>
|
||||||
|
<FormControl>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select a volume name" />
|
||||||
|
</SelectTrigger>
|
||||||
|
</FormControl>
|
||||||
|
<SelectContent>
|
||||||
|
{mounts?.map((mount) => (
|
||||||
|
<SelectItem key={mount.Name} value={mount.Name || ""}>
|
||||||
|
{mount.Name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<FormDescription>
|
||||||
|
Choose the volume to backup, if you dont see the volume
|
||||||
|
here, you can type the volume name manually
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="volumeName"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Volume Name</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="my-volume-name" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
The name of the Docker volume to backup
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="prefix"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Backup Prefix</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="backup-" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
Prefix for backup files (optional)
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* <FormField
|
||||||
|
control={form.control}
|
||||||
|
name="keepLatestCount"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Keep Latest Count</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
placeholder="5"
|
||||||
|
{...field}
|
||||||
|
onChange={(e) =>
|
||||||
|
field.onChange(Number(e.target.value) || undefined)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
Number of backup files to keep (optional)
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/> */}
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="turnOff"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel className="flex items-center gap-2">
|
||||||
|
<Switch
|
||||||
|
checked={field.value}
|
||||||
|
onCheckedChange={field.onChange}
|
||||||
|
/>
|
||||||
|
Turn Off Container During Backup
|
||||||
|
</FormLabel>
|
||||||
|
<FormDescription className="text-amber-600 dark:text-amber-400">
|
||||||
|
⚠️ The container will be temporarily stopped during backup to
|
||||||
|
prevent file corruption. This ensures data integrity but may
|
||||||
|
cause temporary service interruption.
|
||||||
|
</FormDescription>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="enabled"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel className="flex items-center gap-2">
|
||||||
|
<Switch
|
||||||
|
checked={field.value}
|
||||||
|
onCheckedChange={field.onChange}
|
||||||
|
/>
|
||||||
|
Enabled
|
||||||
|
</FormLabel>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button type="submit" isLoading={isLoading} className="w-full">
|
||||||
|
{volumeBackupId ? "Update" : "Create"} Volume Backup
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,411 @@
|
|||||||
|
import { DrawerLogs } from "@/components/shared/drawer-logs";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
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,
|
||||||
|
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 { cn } from "@/lib/utils";
|
||||||
|
import { api } from "@/utils/api";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import copy from "copy-to-clipboard";
|
||||||
|
import { debounce } from "lodash";
|
||||||
|
import { CheckIcon, ChevronsUpDown, Copy, RotateCcw } from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { type LogLine, parseLogs } from "../../docker/logs/utils";
|
||||||
|
import { formatBytes } from "../../database/backups/restore-backup";
|
||||||
|
import { AlertBlock } from "@/components/shared/alert-block";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
id: string;
|
||||||
|
type: "application" | "compose";
|
||||||
|
serverId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const RestoreBackupSchema = z.object({
|
||||||
|
destinationId: z
|
||||||
|
.string({
|
||||||
|
required_error: "Please select a destination",
|
||||||
|
})
|
||||||
|
.min(1, {
|
||||||
|
message: "Destination is required",
|
||||||
|
}),
|
||||||
|
backupFile: z
|
||||||
|
.string({
|
||||||
|
required_error: "Please select a backup file",
|
||||||
|
})
|
||||||
|
.min(1, {
|
||||||
|
message: "Backup file is required",
|
||||||
|
}),
|
||||||
|
volumeName: z
|
||||||
|
.string({
|
||||||
|
required_error: "Please enter a volume name",
|
||||||
|
})
|
||||||
|
.min(1, {
|
||||||
|
message: "Volume name is required",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const RestoreVolumeBackups = ({ id, type, serverId }: Props) => {
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
const [debouncedSearchTerm, setDebouncedSearchTerm] = useState("");
|
||||||
|
|
||||||
|
const { data: destinations = [] } = api.destination.all.useQuery();
|
||||||
|
|
||||||
|
const form = useForm<z.infer<typeof RestoreBackupSchema>>({
|
||||||
|
defaultValues: {
|
||||||
|
destinationId: "",
|
||||||
|
backupFile: "",
|
||||||
|
volumeName: "",
|
||||||
|
},
|
||||||
|
resolver: zodResolver(RestoreBackupSchema),
|
||||||
|
});
|
||||||
|
|
||||||
|
const destinationId = form.watch("destinationId");
|
||||||
|
const volumeName = form.watch("volumeName");
|
||||||
|
const backupFile = form.watch("backupFile");
|
||||||
|
|
||||||
|
const debouncedSetSearch = debounce((value: string) => {
|
||||||
|
setDebouncedSearchTerm(value);
|
||||||
|
}, 350);
|
||||||
|
|
||||||
|
const handleSearchChange = (value: string) => {
|
||||||
|
setSearch(value);
|
||||||
|
debouncedSetSearch(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const { data: files = [], isLoading } = api.backup.listBackupFiles.useQuery(
|
||||||
|
{
|
||||||
|
destinationId: destinationId,
|
||||||
|
search: debouncedSearchTerm,
|
||||||
|
serverId: serverId ?? "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
enabled: isOpen && !!destinationId,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const [isDrawerOpen, setIsDrawerOpen] = useState(false);
|
||||||
|
const [filteredLogs, setFilteredLogs] = useState<LogLine[]>([]);
|
||||||
|
const [isDeploying, setIsDeploying] = useState(false);
|
||||||
|
|
||||||
|
api.volumeBackups.restoreVolumeBackupWithLogs.useSubscription(
|
||||||
|
{
|
||||||
|
id,
|
||||||
|
serviceType: type,
|
||||||
|
serverId,
|
||||||
|
destinationId,
|
||||||
|
volumeName,
|
||||||
|
backupFileName: backupFile,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
enabled: isDeploying,
|
||||||
|
onData(log) {
|
||||||
|
if (!isDrawerOpen) {
|
||||||
|
setIsDrawerOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (log === "Restore completed successfully!") {
|
||||||
|
setIsDeploying(false);
|
||||||
|
}
|
||||||
|
const parsedLogs = parseLogs(log);
|
||||||
|
setFilteredLogs((prev) => [...prev, ...parsedLogs]);
|
||||||
|
},
|
||||||
|
onError(error) {
|
||||||
|
console.error("Restore logs error:", error);
|
||||||
|
setIsDeploying(false);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const onSubmit = async () => {
|
||||||
|
setIsDeploying(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button variant="outline">
|
||||||
|
<RotateCcw className="mr-2 size-4" />
|
||||||
|
Restore Volume Backup
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-lg">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center">
|
||||||
|
<RotateCcw className="mr-2 size-4" />
|
||||||
|
Restore Volume Backup
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Select a destination and search for volume backup files
|
||||||
|
</DialogDescription>
|
||||||
|
<AlertBlock>
|
||||||
|
Make sure the volume name is not being used by another container.
|
||||||
|
</AlertBlock>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<Form {...form}>
|
||||||
|
<form
|
||||||
|
id="hook-form-restore-backup"
|
||||||
|
onSubmit={form.handleSubmit(onSubmit)}
|
||||||
|
className="grid w-full 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",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{field.value
|
||||||
|
? destinations.find(
|
||||||
|
(d) => d.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 destinations..."
|
||||||
|
className="h-9"
|
||||||
|
/>
|
||||||
|
<CommandEmpty>No destinations found.</CommandEmpty>
|
||||||
|
<ScrollArea className="h-64">
|
||||||
|
<CommandGroup>
|
||||||
|
{destinations.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="backupFile"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="">
|
||||||
|
<FormLabel className="flex items-center">
|
||||||
|
Search Backup Files
|
||||||
|
{field.value && (
|
||||||
|
<Badge variant="outline" className="truncate w-52">
|
||||||
|
{field.value}
|
||||||
|
<Copy
|
||||||
|
className="ml-2 size-4 cursor-pointer"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
e.preventDefault();
|
||||||
|
copy(field.value);
|
||||||
|
toast.success("Backup file copied to clipboard");
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</FormLabel>
|
||||||
|
<Popover modal>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<FormControl>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className={cn(
|
||||||
|
"w-full justify-between !bg-input",
|
||||||
|
!field.value && "text-muted-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="truncate text-left flex-1 w-52">
|
||||||
|
{field.value || "Search and select a backup file"}
|
||||||
|
</span>
|
||||||
|
<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 backup files..."
|
||||||
|
value={search}
|
||||||
|
onValueChange={handleSearchChange}
|
||||||
|
className="h-9"
|
||||||
|
/>
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="py-6 text-center text-sm">
|
||||||
|
Loading backup files...
|
||||||
|
</div>
|
||||||
|
) : files.length === 0 && search ? (
|
||||||
|
<div className="py-6 text-center text-sm text-muted-foreground">
|
||||||
|
No backup files found for "{search}"
|
||||||
|
</div>
|
||||||
|
) : files.length === 0 ? (
|
||||||
|
<div className="py-6 text-center text-sm text-muted-foreground">
|
||||||
|
No backup files available
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ScrollArea className="h-64">
|
||||||
|
<CommandGroup className="w-96">
|
||||||
|
{files?.map((file) => (
|
||||||
|
<CommandItem
|
||||||
|
value={file.Path}
|
||||||
|
key={file.Path}
|
||||||
|
onSelect={() => {
|
||||||
|
form.setValue("backupFile", file.Path);
|
||||||
|
if (file.IsDir) {
|
||||||
|
setSearch(`${file.Path}/`);
|
||||||
|
setDebouncedSearchTerm(`${file.Path}/`);
|
||||||
|
} else {
|
||||||
|
setSearch(file.Path);
|
||||||
|
setDebouncedSearchTerm(file.Path);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex w-full flex-col gap-1">
|
||||||
|
<div className="flex w-full justify-between">
|
||||||
|
<span className="font-medium">
|
||||||
|
{file.Path}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<CheckIcon
|
||||||
|
className={cn(
|
||||||
|
"ml-auto h-4 w-4",
|
||||||
|
file.Path === field.value
|
||||||
|
? "opacity-100"
|
||||||
|
: "opacity-0",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-4 text-xs text-muted-foreground">
|
||||||
|
<span>
|
||||||
|
Size: {formatBytes(file.Size)}
|
||||||
|
</span>
|
||||||
|
{file.IsDir && (
|
||||||
|
<span className="text-blue-500">
|
||||||
|
Directory
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{file.Hashes?.MD5 && (
|
||||||
|
<span>MD5: {file.Hashes.MD5}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
</ScrollArea>
|
||||||
|
)}
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="volumeName"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Volume Name</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="Enter volume name" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
isLoading={isDeploying}
|
||||||
|
form="hook-form-restore-backup"
|
||||||
|
type="submit"
|
||||||
|
// disabled={
|
||||||
|
// !form.watch("backupFile") ||
|
||||||
|
// (backupType === "compose" && !form.watch("databaseType"))
|
||||||
|
// }
|
||||||
|
>
|
||||||
|
Restore
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
|
||||||
|
<DrawerLogs
|
||||||
|
isOpen={isDrawerOpen}
|
||||||
|
onClose={() => {
|
||||||
|
setIsDrawerOpen(false);
|
||||||
|
setFilteredLogs([]);
|
||||||
|
setIsDeploying(false);
|
||||||
|
// refetch();
|
||||||
|
}}
|
||||||
|
filteredLogs={filteredLogs}
|
||||||
|
/>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,250 @@
|
|||||||
|
import { DialogAction } from "@/components/shared/dialog-action";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipProvider,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from "@/components/ui/tooltip";
|
||||||
|
import { api } from "@/utils/api";
|
||||||
|
import {
|
||||||
|
ClipboardList,
|
||||||
|
DatabaseBackup,
|
||||||
|
Loader2,
|
||||||
|
Play,
|
||||||
|
Trash2,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { HandleVolumeBackups } from "./handle-volume-backups";
|
||||||
|
import { ShowDeploymentsModal } from "../deployments/show-deployments-modal";
|
||||||
|
import { RestoreVolumeBackups } from "./restore-volume-backups";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
id: string;
|
||||||
|
type?: "application" | "compose";
|
||||||
|
serverId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ShowVolumeBackups = ({
|
||||||
|
id,
|
||||||
|
type = "application",
|
||||||
|
serverId,
|
||||||
|
}: Props) => {
|
||||||
|
const {
|
||||||
|
data: volumeBackups,
|
||||||
|
isLoading: isLoadingVolumeBackups,
|
||||||
|
refetch: refetchVolumeBackups,
|
||||||
|
} = api.volumeBackups.list.useQuery(
|
||||||
|
{
|
||||||
|
id: id || "",
|
||||||
|
volumeBackupType: type,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
enabled: !!id,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const utils = api.useUtils();
|
||||||
|
|
||||||
|
const { mutateAsync: deleteVolumeBackup, isLoading: isDeleting } =
|
||||||
|
api.volumeBackups.delete.useMutation();
|
||||||
|
|
||||||
|
const { mutateAsync: runManually, isLoading } =
|
||||||
|
api.volumeBackups.runManually.useMutation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="border px-6 shadow-none bg-transparent h-full min-h-[50vh]">
|
||||||
|
<CardHeader className="px-0">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<CardTitle className="text-xl font-bold flex items-center gap-2">
|
||||||
|
Volume Backups
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Schedule volume backups to run automatically at specified
|
||||||
|
intervals.
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{volumeBackups && volumeBackups.length > 0 && (
|
||||||
|
<>
|
||||||
|
<HandleVolumeBackups id={id} volumeBackupType={type} />
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<RestoreVolumeBackups
|
||||||
|
id={id}
|
||||||
|
type={type}
|
||||||
|
serverId={serverId}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="px-0">
|
||||||
|
{isLoadingVolumeBackups ? (
|
||||||
|
<div className="flex gap-4 w-full items-center justify-center text-center mx-auto min-h-[45vh]">
|
||||||
|
<Loader2 className="size-4 text-muted-foreground/70 transition-colors animate-spin self-center" />
|
||||||
|
<span className="text-sm text-muted-foreground/70">
|
||||||
|
Loading volume backups...
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : volumeBackups && volumeBackups.length > 0 ? (
|
||||||
|
<div className="grid xl:grid-cols-2 gap-4 grid-cols-1 h-full">
|
||||||
|
{volumeBackups.map((volumeBackup) => {
|
||||||
|
const serverId =
|
||||||
|
volumeBackup.application?.serverId ||
|
||||||
|
volumeBackup.postgres?.serverId ||
|
||||||
|
volumeBackup.mysql?.serverId ||
|
||||||
|
volumeBackup.mariadb?.serverId ||
|
||||||
|
volumeBackup.mongo?.serverId ||
|
||||||
|
volumeBackup.redis?.serverId ||
|
||||||
|
volumeBackup.compose?.serverId;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={volumeBackup.volumeBackupId}
|
||||||
|
className=" flex items-center justify-between rounded-lg border p-3 transition-colors bg-muted/50"
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className="flex h-9 w-9 items-center justify-center rounded-full bg-primary/5">
|
||||||
|
<DatabaseBackup className="size-4 text-primary/70" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<h3 className="text-sm font-medium leading-none">
|
||||||
|
{volumeBackup.name}
|
||||||
|
</h3>
|
||||||
|
<Badge
|
||||||
|
variant={
|
||||||
|
volumeBackup.enabled ? "default" : "secondary"
|
||||||
|
}
|
||||||
|
className="text-[10px] px-1 py-0"
|
||||||
|
>
|
||||||
|
{volumeBackup.enabled ? "Enabled" : "Disabled"}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className="font-mono text-[10px] bg-transparent"
|
||||||
|
>
|
||||||
|
Cron: {volumeBackup.cronExpression}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<ShowDeploymentsModal
|
||||||
|
id={volumeBackup.volumeBackupId}
|
||||||
|
type="volumeBackup"
|
||||||
|
serverId={serverId || undefined}
|
||||||
|
>
|
||||||
|
<Button variant="ghost" size="icon">
|
||||||
|
<ClipboardList className="size-4 transition-colors " />
|
||||||
|
</Button>
|
||||||
|
</ShowDeploymentsModal>
|
||||||
|
|
||||||
|
<TooltipProvider delayDuration={0}>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
isLoading={isLoading}
|
||||||
|
onClick={async () => {
|
||||||
|
toast.success("Volume backup run successfully");
|
||||||
|
|
||||||
|
await runManually({
|
||||||
|
volumeBackupId: volumeBackup.volumeBackupId,
|
||||||
|
})
|
||||||
|
.then(async () => {
|
||||||
|
await new Promise((resolve) =>
|
||||||
|
setTimeout(resolve, 1500),
|
||||||
|
);
|
||||||
|
refetchVolumeBackups();
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
toast.error("Error running volume backup");
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Play className="size-4 transition-colors" />
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
Run Manual Volume Backup
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
|
||||||
|
<HandleVolumeBackups
|
||||||
|
volumeBackupId={volumeBackup.volumeBackupId}
|
||||||
|
id={id}
|
||||||
|
volumeBackupType={type}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DialogAction
|
||||||
|
title="Delete Volume Backup"
|
||||||
|
description="Are you sure you want to delete this volume backup?"
|
||||||
|
type="destructive"
|
||||||
|
onClick={async () => {
|
||||||
|
await deleteVolumeBackup({
|
||||||
|
volumeBackupId: volumeBackup.volumeBackupId,
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
utils.volumeBackups.list.invalidate({
|
||||||
|
id,
|
||||||
|
volumeBackupType: type,
|
||||||
|
});
|
||||||
|
toast.success("Volume backup deleted successfully");
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
toast.error("Error deleting volume backup");
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="group hover:bg-red-500/10 "
|
||||||
|
isLoading={isDeleting}
|
||||||
|
>
|
||||||
|
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
|
||||||
|
</Button>
|
||||||
|
</DialogAction>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-2 items-center justify-center py-12 rounded-lg">
|
||||||
|
<DatabaseBackup className="size-8 mb-4 text-muted-foreground" />
|
||||||
|
<p className="text-lg font-medium text-muted-foreground">
|
||||||
|
No volume backups
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
|
Create your first volume backup to automate your workflows
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<HandleVolumeBackups id={id} volumeBackupType={type} />
|
||||||
|
<RestoreVolumeBackups id={id} type={type} serverId={serverId} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -280,7 +280,7 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
|
|||||||
{repositories?.map((repo) => {
|
{repositories?.map((repo) => {
|
||||||
return (
|
return (
|
||||||
<CommandItem
|
<CommandItem
|
||||||
value={repo.name}
|
value={repo.url}
|
||||||
key={repo.url}
|
key={repo.url}
|
||||||
onSelect={() => {
|
onSelect={() => {
|
||||||
form.setValue("repository", {
|
form.setValue("repository", {
|
||||||
@@ -301,7 +301,8 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
|
|||||||
<CheckIcon
|
<CheckIcon
|
||||||
className={cn(
|
className={cn(
|
||||||
"ml-auto h-4 w-4",
|
"ml-auto h-4 w-4",
|
||||||
repo.name === field.value.repo
|
repo.url ===
|
||||||
|
field.value.gitlabPathNamespace
|
||||||
? "opacity-100"
|
? "opacity-100"
|
||||||
: "opacity-0",
|
: "opacity-0",
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -199,7 +199,7 @@ const RestoreBackupSchema = z
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const formatBytes = (bytes: number): string => {
|
export const formatBytes = (bytes: number): string => {
|
||||||
if (bytes === 0) return "0 Bytes";
|
if (bytes === 0) return "0 Bytes";
|
||||||
const k = 1024;
|
const k = 1024;
|
||||||
const sizes = ["Bytes", "KB", "MB", "GB", "TB"];
|
const sizes = ["Bytes", "KB", "MB", "GB", "TB"];
|
||||||
@@ -415,7 +415,7 @@ export const RestoreBackup = ({
|
|||||||
<FormLabel className="flex items-center justify-between">
|
<FormLabel className="flex items-center justify-between">
|
||||||
Search Backup Files
|
Search Backup Files
|
||||||
{field.value && (
|
{field.value && (
|
||||||
<Badge variant="outline">
|
<Badge variant="outline" className="truncate">
|
||||||
{field.value}
|
{field.value}
|
||||||
<Copy
|
<Copy
|
||||||
className="ml-2 size-4 cursor-pointer"
|
className="ml-2 size-4 cursor-pointer"
|
||||||
@@ -439,7 +439,9 @@ export const RestoreBackup = ({
|
|||||||
!field.value && "text-muted-foreground",
|
!field.value && "text-muted-foreground",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{field.value || "Search and select a backup file"}
|
<span className="truncate text-left flex-1 w-52">
|
||||||
|
{field.value || "Search and select a backup file"}
|
||||||
|
</span>
|
||||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
</Button>
|
</Button>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ export const ShowNodeApplications = ({ serverId }: Props) => {
|
|||||||
Services
|
Services
|
||||||
</Button>
|
</Button>
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
<DialogContent className={"sm:max-w-6xl overflow-y-auto max-h-screen"}>
|
<DialogContent className={"sm:max-w-10xl overflow-y-auto max-h-screen"}>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Node Applications</DialogTitle>
|
<DialogTitle>Node Applications</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
|
|||||||
@@ -246,7 +246,9 @@ const Leaf = React.forwardRef<
|
|||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<p className=" text-sm whitespace-normal font-mono">{item.name}</p>
|
<p className=" text-sm whitespace-normal font-mono text-left">
|
||||||
|
{item.name}
|
||||||
|
</p>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
2
apps/dokploy/drizzle/0100_purple_rogue.sql
Normal file
2
apps/dokploy/drizzle/0100_purple_rogue.sql
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE "domain" ADD COLUMN "internalPath" text DEFAULT '/';--> statement-breakpoint
|
||||||
|
ALTER TABLE "domain" ADD COLUMN "stripPath" boolean DEFAULT false NOT NULL;
|
||||||
33
apps/dokploy/drizzle/0101_moaning_blazing_skull.sql
Normal file
33
apps/dokploy/drizzle/0101_moaning_blazing_skull.sql
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
CREATE TABLE "volume_backup" (
|
||||||
|
"volumeBackupId" text PRIMARY KEY NOT NULL,
|
||||||
|
"name" text NOT NULL,
|
||||||
|
"volumeName" text NOT NULL,
|
||||||
|
"prefix" text NOT NULL,
|
||||||
|
"serviceType" "serviceType" DEFAULT 'application' NOT NULL,
|
||||||
|
"appName" text NOT NULL,
|
||||||
|
"serviceName" text,
|
||||||
|
"turnOff" boolean DEFAULT false NOT NULL,
|
||||||
|
"cronExpression" text NOT NULL,
|
||||||
|
"keepLatestCount" integer,
|
||||||
|
"enabled" boolean,
|
||||||
|
"applicationId" text,
|
||||||
|
"postgresId" text,
|
||||||
|
"mariadbId" text,
|
||||||
|
"mongoId" text,
|
||||||
|
"mysqlId" text,
|
||||||
|
"redisId" text,
|
||||||
|
"composeId" text,
|
||||||
|
"createdAt" text NOT NULL,
|
||||||
|
"destinationId" text NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "deployment" ADD COLUMN "volumeBackupId" text;--> statement-breakpoint
|
||||||
|
ALTER TABLE "volume_backup" ADD CONSTRAINT "volume_backup_applicationId_application_applicationId_fk" FOREIGN KEY ("applicationId") REFERENCES "public"."application"("applicationId") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "volume_backup" ADD CONSTRAINT "volume_backup_postgresId_postgres_postgresId_fk" FOREIGN KEY ("postgresId") REFERENCES "public"."postgres"("postgresId") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "volume_backup" ADD CONSTRAINT "volume_backup_mariadbId_mariadb_mariadbId_fk" FOREIGN KEY ("mariadbId") REFERENCES "public"."mariadb"("mariadbId") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "volume_backup" ADD CONSTRAINT "volume_backup_mongoId_mongo_mongoId_fk" FOREIGN KEY ("mongoId") REFERENCES "public"."mongo"("mongoId") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "volume_backup" ADD CONSTRAINT "volume_backup_mysqlId_mysql_mysqlId_fk" FOREIGN KEY ("mysqlId") REFERENCES "public"."mysql"("mysqlId") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "volume_backup" ADD CONSTRAINT "volume_backup_redisId_redis_redisId_fk" FOREIGN KEY ("redisId") REFERENCES "public"."redis"("redisId") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "volume_backup" ADD CONSTRAINT "volume_backup_composeId_compose_composeId_fk" FOREIGN KEY ("composeId") REFERENCES "public"."compose"("composeId") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "volume_backup" ADD CONSTRAINT "volume_backup_destinationId_destination_destinationId_fk" FOREIGN KEY ("destinationId") REFERENCES "public"."destination"("destinationId") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "deployment" ADD CONSTRAINT "deployment_volumeBackupId_volume_backup_volumeBackupId_fk" FOREIGN KEY ("volumeBackupId") REFERENCES "public"."volume_backup"("volumeBackupId") ON DELETE cascade ON UPDATE no action;
|
||||||
3
apps/dokploy/drizzle/0102_opposite_grandmaster.sql
Normal file
3
apps/dokploy/drizzle/0102_opposite_grandmaster.sql
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
ALTER TABLE "compose" ADD COLUMN "isolatedDeploymentsVolume" boolean DEFAULT false NOT NULL;
|
||||||
|
|
||||||
|
UPDATE "compose" SET "isolatedDeploymentsVolume" = true;
|
||||||
5862
apps/dokploy/drizzle/meta/0100_snapshot.json
Normal file
5862
apps/dokploy/drizzle/meta/0100_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
6122
apps/dokploy/drizzle/meta/0101_snapshot.json
Normal file
6122
apps/dokploy/drizzle/meta/0101_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
6129
apps/dokploy/drizzle/meta/0102_snapshot.json
Normal file
6129
apps/dokploy/drizzle/meta/0102_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -701,6 +701,27 @@
|
|||||||
"when": 1751693569786,
|
"when": 1751693569786,
|
||||||
"tag": "0099_wise_golden_guardian",
|
"tag": "0099_wise_golden_guardian",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 100,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1751741736144,
|
||||||
|
"tag": "0100_purple_rogue",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 101,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1751751631943,
|
||||||
|
"tag": "0101_moaning_blazing_skull",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 102,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1751848685503,
|
||||||
|
"tag": "0102_opposite_grandmaster",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "dokploy",
|
"name": "dokploy",
|
||||||
"version": "v0.23.7",
|
"version": "v0.24.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { ShowDockerLogs } from "@/components/dashboard/application/logs/show";
|
|||||||
import { ShowPreviewDeployments } from "@/components/dashboard/application/preview-deployments/show-preview-deployments";
|
import { ShowPreviewDeployments } from "@/components/dashboard/application/preview-deployments/show-preview-deployments";
|
||||||
import { ShowSchedules } from "@/components/dashboard/application/schedules/show-schedules";
|
import { ShowSchedules } from "@/components/dashboard/application/schedules/show-schedules";
|
||||||
import { UpdateApplication } from "@/components/dashboard/application/update-application";
|
import { UpdateApplication } from "@/components/dashboard/application/update-application";
|
||||||
|
import { ShowVolumeBackups } from "@/components/dashboard/application/volume-backups/show-volume-backups";
|
||||||
import { DeleteService } from "@/components/dashboard/compose/delete-service";
|
import { DeleteService } from "@/components/dashboard/compose/delete-service";
|
||||||
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 { ContainerPaidMonitoring } from "@/components/dashboard/monitoring/paid/container/show-paid-container-monitoring";
|
import { ContainerPaidMonitoring } from "@/components/dashboard/monitoring/paid/container/show-paid-container-monitoring";
|
||||||
@@ -61,7 +62,8 @@ type TabState =
|
|||||||
| "deployments"
|
| "deployments"
|
||||||
| "domains"
|
| "domains"
|
||||||
| "monitoring"
|
| "monitoring"
|
||||||
| "preview-deployments";
|
| "preview-deployments"
|
||||||
|
| "volume-backups";
|
||||||
|
|
||||||
const Service = (
|
const Service = (
|
||||||
props: InferGetServerSidePropsType<typeof getServerSideProps>,
|
props: InferGetServerSidePropsType<typeof getServerSideProps>,
|
||||||
@@ -234,6 +236,9 @@ const Service = (
|
|||||||
Preview Deployments
|
Preview Deployments
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger value="schedules">Schedules</TabsTrigger>
|
<TabsTrigger value="schedules">Schedules</TabsTrigger>
|
||||||
|
<TabsTrigger value="volume-backups">
|
||||||
|
Volume Backups
|
||||||
|
</TabsTrigger>
|
||||||
<TabsTrigger value="deployments">Deployments</TabsTrigger>
|
<TabsTrigger value="deployments">Deployments</TabsTrigger>
|
||||||
<TabsTrigger value="logs">Logs</TabsTrigger>
|
<TabsTrigger value="logs">Logs</TabsTrigger>
|
||||||
{((data?.serverId && isCloud) || !data?.server) && (
|
{((data?.serverId && isCloud) || !data?.server) && (
|
||||||
@@ -328,6 +333,15 @@ const Service = (
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
<TabsContent value="volume-backups" className="w-full pt-2.5">
|
||||||
|
<div className="flex flex-col gap-4 border rounded-lg">
|
||||||
|
<ShowVolumeBackups
|
||||||
|
id={applicationId}
|
||||||
|
type="application"
|
||||||
|
serverId={data?.serverId || ""}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
<TabsContent value="preview-deployments" className="w-full">
|
<TabsContent value="preview-deployments" className="w-full">
|
||||||
<div className="flex flex-col gap-4 pt-2.5">
|
<div className="flex flex-col gap-4 pt-2.5">
|
||||||
<ShowPreviewDeployments applicationId={applicationId} />
|
<ShowPreviewDeployments applicationId={applicationId} />
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { ShowDeployments } from "@/components/dashboard/application/deployments/
|
|||||||
import { ShowDomains } from "@/components/dashboard/application/domains/show-domains";
|
import { ShowDomains } from "@/components/dashboard/application/domains/show-domains";
|
||||||
import { ShowEnvironment } from "@/components/dashboard/application/environment/show-enviroment";
|
import { ShowEnvironment } from "@/components/dashboard/application/environment/show-enviroment";
|
||||||
import { ShowSchedules } from "@/components/dashboard/application/schedules/show-schedules";
|
import { ShowSchedules } from "@/components/dashboard/application/schedules/show-schedules";
|
||||||
|
import { ShowVolumeBackups } from "@/components/dashboard/application/volume-backups/show-volume-backups";
|
||||||
import { AddCommandCompose } from "@/components/dashboard/compose/advanced/add-command";
|
import { AddCommandCompose } from "@/components/dashboard/compose/advanced/add-command";
|
||||||
import { DeleteService } from "@/components/dashboard/compose/delete-service";
|
import { DeleteService } from "@/components/dashboard/compose/delete-service";
|
||||||
import { ShowGeneralCompose } from "@/components/dashboard/compose/general/show";
|
import { ShowGeneralCompose } from "@/components/dashboard/compose/general/show";
|
||||||
@@ -57,7 +58,8 @@ type TabState =
|
|||||||
| "advanced"
|
| "advanced"
|
||||||
| "deployments"
|
| "deployments"
|
||||||
| "domains"
|
| "domains"
|
||||||
| "monitoring";
|
| "monitoring"
|
||||||
|
| "volumeBackups";
|
||||||
|
|
||||||
const Service = (
|
const Service = (
|
||||||
props: InferGetServerSidePropsType<typeof getServerSideProps>,
|
props: InferGetServerSidePropsType<typeof getServerSideProps>,
|
||||||
@@ -214,10 +216,10 @@ const Service = (
|
|||||||
className={cn(
|
className={cn(
|
||||||
"xl:grid xl:w-fit max-md:overflow-y-scroll justify-start",
|
"xl:grid xl:w-fit max-md:overflow-y-scroll justify-start",
|
||||||
isCloud && data?.serverId
|
isCloud && data?.serverId
|
||||||
? "xl:grid-cols-9"
|
? "xl:grid-cols-10"
|
||||||
: data?.serverId
|
: data?.serverId
|
||||||
? "xl:grid-cols-8"
|
? "xl:grid-cols-9"
|
||||||
: "xl:grid-cols-9",
|
: "xl:grid-cols-10",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<TabsTrigger value="general">General</TabsTrigger>
|
<TabsTrigger value="general">General</TabsTrigger>
|
||||||
@@ -226,6 +228,9 @@ const Service = (
|
|||||||
<TabsTrigger value="deployments">Deployments</TabsTrigger>
|
<TabsTrigger value="deployments">Deployments</TabsTrigger>
|
||||||
<TabsTrigger value="backups">Backups</TabsTrigger>
|
<TabsTrigger value="backups">Backups</TabsTrigger>
|
||||||
<TabsTrigger value="schedules">Schedules</TabsTrigger>
|
<TabsTrigger value="schedules">Schedules</TabsTrigger>
|
||||||
|
<TabsTrigger value="volumeBackups">
|
||||||
|
Volume 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>
|
||||||
@@ -255,7 +260,15 @@ const Service = (
|
|||||||
<ShowSchedules id={composeId} scheduleType="compose" />
|
<ShowSchedules id={composeId} scheduleType="compose" />
|
||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
<TabsContent value="volumeBackups">
|
||||||
|
<div className="flex flex-col gap-4 pt-2.5">
|
||||||
|
<ShowVolumeBackups
|
||||||
|
id={composeId}
|
||||||
|
type="compose"
|
||||||
|
serverId={data?.serverId || ""}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
<TabsContent value="monitoring">
|
<TabsContent value="monitoring">
|
||||||
<div className="pt-2.5">
|
<div className="pt-2.5">
|
||||||
<div className="flex flex-col border rounded-lg ">
|
<div className="flex flex-col border rounded-lg ">
|
||||||
@@ -342,6 +355,7 @@ const Service = (
|
|||||||
<ShowDomains id={composeId} type="compose" />
|
<ShowDomains id={composeId} type="compose" />
|
||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="advanced">
|
<TabsContent value="advanced">
|
||||||
<div className="flex flex-col gap-4 pt-2.5">
|
<div className="flex flex-col gap-4 pt-2.5">
|
||||||
<AddCommandCompose composeId={composeId} />
|
<AddCommandCompose composeId={composeId} />
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ import { swarmRouter } from "./routers/swarm";
|
|||||||
import { userRouter } from "./routers/user";
|
import { userRouter } from "./routers/user";
|
||||||
import { scheduleRouter } from "./routers/schedule";
|
import { scheduleRouter } from "./routers/schedule";
|
||||||
import { rollbackRouter } from "./routers/rollbacks";
|
import { rollbackRouter } from "./routers/rollbacks";
|
||||||
|
import { volumeBackupsRouter } from "./routers/volume-backups";
|
||||||
/**
|
/**
|
||||||
* This is the primary router for your server.
|
* This is the primary router for your server.
|
||||||
*
|
*
|
||||||
@@ -82,6 +83,7 @@ export const appRouter = createTRPCRouter({
|
|||||||
organization: organizationRouter,
|
organization: organizationRouter,
|
||||||
schedule: scheduleRouter,
|
schedule: scheduleRouter,
|
||||||
rollback: rollbackRouter,
|
rollback: rollbackRouter,
|
||||||
|
volumeBackups: volumeBackupsRouter,
|
||||||
});
|
});
|
||||||
|
|
||||||
// export type definition of API
|
// export type definition of API
|
||||||
|
|||||||
@@ -184,12 +184,6 @@ export const applicationRouter = createTRPCRouter({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (application.serverId) {
|
|
||||||
await stopServiceRemote(application.serverId, input.appName);
|
|
||||||
} else {
|
|
||||||
await stopService(input.appName);
|
|
||||||
}
|
|
||||||
|
|
||||||
await updateApplicationStatus(input.applicationId, "idle");
|
await updateApplicationStatus(input.applicationId, "idle");
|
||||||
await mechanizeDockerContainer(application);
|
await mechanizeDockerContainer(application);
|
||||||
await updateApplicationStatus(input.applicationId, "done");
|
await updateApplicationStatus(input.applicationId, "done");
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ import {
|
|||||||
findProjectById,
|
findProjectById,
|
||||||
findServerById,
|
findServerById,
|
||||||
findUserById,
|
findUserById,
|
||||||
|
getComposeContainer,
|
||||||
loadServices,
|
loadServices,
|
||||||
randomizeComposeFile,
|
randomizeComposeFile,
|
||||||
randomizeIsolatedDeploymentComposeFile,
|
randomizeIsolatedDeploymentComposeFile,
|
||||||
@@ -241,6 +242,27 @@ export const composeRouter = createTRPCRouter({
|
|||||||
}
|
}
|
||||||
return await loadServices(input.composeId, input.type);
|
return await loadServices(input.composeId, input.type);
|
||||||
}),
|
}),
|
||||||
|
loadMountsByService: protectedProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
composeId: z.string().min(1),
|
||||||
|
serviceName: z.string().min(1),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.query(async ({ input, ctx }) => {
|
||||||
|
const compose = await findComposeById(input.composeId);
|
||||||
|
if (compose.project.organizationId !== ctx.session.activeOrganizationId) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "UNAUTHORIZED",
|
||||||
|
message: "You are not authorized to load this compose",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const container = await getComposeContainer(compose, input.serviceName);
|
||||||
|
const mounts = container?.Mounts.filter(
|
||||||
|
(mount) => mount.Type === "volume" && mount.Source !== "",
|
||||||
|
);
|
||||||
|
return mounts;
|
||||||
|
}),
|
||||||
fetchSourceType: protectedProcedure
|
fetchSourceType: protectedProcedure
|
||||||
.input(apiFindCompose)
|
.input(apiFindCompose)
|
||||||
.mutation(async ({ input, ctx }) => {
|
.mutation(async ({ input, ctx }) => {
|
||||||
|
|||||||
@@ -7,10 +7,13 @@ import {
|
|||||||
import {
|
import {
|
||||||
createMount,
|
createMount,
|
||||||
deleteMount,
|
deleteMount,
|
||||||
|
findApplicationById,
|
||||||
findMountById,
|
findMountById,
|
||||||
|
getServiceContainer,
|
||||||
updateMount,
|
updateMount,
|
||||||
} from "@dokploy/server";
|
} from "@dokploy/server";
|
||||||
import { createTRPCRouter, protectedProcedure } from "../trpc";
|
import { createTRPCRouter, protectedProcedure } from "../trpc";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
export const mountRouter = createTRPCRouter({
|
export const mountRouter = createTRPCRouter({
|
||||||
create: protectedProcedure
|
create: protectedProcedure
|
||||||
@@ -33,4 +36,14 @@ export const mountRouter = createTRPCRouter({
|
|||||||
.mutation(async ({ input }) => {
|
.mutation(async ({ input }) => {
|
||||||
return await updateMount(input.mountId, input);
|
return await updateMount(input.mountId, input);
|
||||||
}),
|
}),
|
||||||
|
allNamedByApplicationId: protectedProcedure
|
||||||
|
.input(z.object({ applicationId: z.string().min(1) }))
|
||||||
|
.query(async ({ input }) => {
|
||||||
|
const app = await findApplicationById(input.applicationId);
|
||||||
|
const container = await getServiceContainer(app.appName, app.serverId);
|
||||||
|
const mounts = container?.Mounts.filter(
|
||||||
|
(mount) => mount.Type === "volume" && mount.Source !== "",
|
||||||
|
);
|
||||||
|
return mounts;
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -609,14 +609,14 @@ export const settingsRouter = createTRPCRouter({
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
.input(apiReadStatsLogs)
|
.input(apiReadStatsLogs)
|
||||||
.query(({ input }) => {
|
.query(async ({ input }) => {
|
||||||
if (IS_CLOUD) {
|
if (IS_CLOUD) {
|
||||||
return {
|
return {
|
||||||
data: [],
|
data: [],
|
||||||
totalCount: 0,
|
totalCount: 0,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
const rawConfig = readMonitoringConfig(
|
const rawConfig = await readMonitoringConfig(
|
||||||
!!input.dateRange?.start && !!input.dateRange?.end,
|
!!input.dateRange?.start && !!input.dateRange?.end,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -652,11 +652,11 @@ export const settingsRouter = createTRPCRouter({
|
|||||||
})
|
})
|
||||||
.optional(),
|
.optional(),
|
||||||
)
|
)
|
||||||
.query(({ input }) => {
|
.query(async ({ input }) => {
|
||||||
if (IS_CLOUD) {
|
if (IS_CLOUD) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
const rawConfig = readMonitoringConfig(
|
const rawConfig = await readMonitoringConfig(
|
||||||
!!input?.dateRange?.start || !!input?.dateRange?.end,
|
!!input?.dateRange?.start || !!input?.dateRange?.end,
|
||||||
);
|
);
|
||||||
const processedLogs = processLogs(rawConfig as string, input?.dateRange);
|
const processedLogs = processLogs(rawConfig as string, input?.dateRange);
|
||||||
|
|||||||
218
apps/dokploy/server/api/routers/volume-backups.ts
Normal file
218
apps/dokploy/server/api/routers/volume-backups.ts
Normal file
@@ -0,0 +1,218 @@
|
|||||||
|
import {
|
||||||
|
IS_CLOUD,
|
||||||
|
updateVolumeBackup,
|
||||||
|
removeVolumeBackup,
|
||||||
|
createVolumeBackup,
|
||||||
|
runVolumeBackup,
|
||||||
|
findVolumeBackupById,
|
||||||
|
restoreVolume,
|
||||||
|
scheduleVolumeBackup,
|
||||||
|
removeVolumeBackupJob,
|
||||||
|
} from "@dokploy/server";
|
||||||
|
import {
|
||||||
|
createVolumeBackupSchema,
|
||||||
|
updateVolumeBackupSchema,
|
||||||
|
volumeBackups,
|
||||||
|
} from "@dokploy/server/db/schema";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { createTRPCRouter, protectedProcedure } from "../trpc";
|
||||||
|
import { db } from "@dokploy/server/db";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import { observable } from "@trpc/server/observable";
|
||||||
|
import {
|
||||||
|
execAsyncRemote,
|
||||||
|
execAsyncStream,
|
||||||
|
} from "@dokploy/server/utils/process/execAsync";
|
||||||
|
import { removeJob, schedule, updateJob } from "@/server/utils/backup";
|
||||||
|
import { TRPCError } from "@trpc/server";
|
||||||
|
|
||||||
|
export const volumeBackupsRouter = createTRPCRouter({
|
||||||
|
list: protectedProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
id: z.string().min(1),
|
||||||
|
volumeBackupType: z.enum([
|
||||||
|
"application",
|
||||||
|
"postgres",
|
||||||
|
"mysql",
|
||||||
|
"mariadb",
|
||||||
|
"mongo",
|
||||||
|
"redis",
|
||||||
|
"compose",
|
||||||
|
]),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.query(async ({ input }) => {
|
||||||
|
return await db.query.volumeBackups.findMany({
|
||||||
|
where: eq(volumeBackups[`${input.volumeBackupType}Id`], input.id),
|
||||||
|
with: {
|
||||||
|
application: true,
|
||||||
|
postgres: true,
|
||||||
|
mysql: true,
|
||||||
|
mariadb: true,
|
||||||
|
mongo: true,
|
||||||
|
redis: true,
|
||||||
|
compose: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
create: protectedProcedure
|
||||||
|
.input(createVolumeBackupSchema)
|
||||||
|
.mutation(async ({ input }) => {
|
||||||
|
const newVolumeBackup = await createVolumeBackup(input);
|
||||||
|
|
||||||
|
if (newVolumeBackup?.enabled) {
|
||||||
|
if (IS_CLOUD) {
|
||||||
|
await schedule({
|
||||||
|
cronSchedule: newVolumeBackup.cronExpression,
|
||||||
|
volumeBackupId: newVolumeBackup.volumeBackupId,
|
||||||
|
type: "volume-backup",
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await scheduleVolumeBackup(newVolumeBackup.volumeBackupId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return newVolumeBackup;
|
||||||
|
}),
|
||||||
|
one: protectedProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
volumeBackupId: z.string().min(1),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.query(async ({ input }) => {
|
||||||
|
return await findVolumeBackupById(input.volumeBackupId);
|
||||||
|
}),
|
||||||
|
delete: protectedProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
volumeBackupId: z.string().min(1),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.mutation(async ({ input }) => {
|
||||||
|
return await removeVolumeBackup(input.volumeBackupId);
|
||||||
|
}),
|
||||||
|
update: protectedProcedure
|
||||||
|
.input(updateVolumeBackupSchema)
|
||||||
|
.mutation(async ({ input }) => {
|
||||||
|
const updatedVolumeBackup = await updateVolumeBackup(
|
||||||
|
input.volumeBackupId,
|
||||||
|
input,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!updatedVolumeBackup) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "NOT_FOUND",
|
||||||
|
message: "Volume backup not found",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (IS_CLOUD) {
|
||||||
|
if (updatedVolumeBackup.enabled) {
|
||||||
|
await updateJob({
|
||||||
|
cronSchedule: updatedVolumeBackup.cronExpression,
|
||||||
|
volumeBackupId: updatedVolumeBackup.volumeBackupId,
|
||||||
|
type: "volume-backup",
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await removeJob({
|
||||||
|
cronSchedule: updatedVolumeBackup.cronExpression,
|
||||||
|
volumeBackupId: updatedVolumeBackup.volumeBackupId,
|
||||||
|
type: "volume-backup",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (updatedVolumeBackup?.enabled) {
|
||||||
|
removeVolumeBackupJob(updatedVolumeBackup.volumeBackupId);
|
||||||
|
scheduleVolumeBackup(updatedVolumeBackup.volumeBackupId);
|
||||||
|
} else {
|
||||||
|
removeVolumeBackupJob(updatedVolumeBackup.volumeBackupId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return updatedVolumeBackup;
|
||||||
|
}),
|
||||||
|
|
||||||
|
runManually: protectedProcedure
|
||||||
|
.input(z.object({ volumeBackupId: z.string().min(1) }))
|
||||||
|
.mutation(async ({ input }) => {
|
||||||
|
try {
|
||||||
|
return await runVolumeBackup(input.volumeBackupId);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
restoreVolumeBackupWithLogs: protectedProcedure
|
||||||
|
.meta({
|
||||||
|
openapi: {
|
||||||
|
enabled: false,
|
||||||
|
path: "/restore-volume-backup-with-logs",
|
||||||
|
method: "POST",
|
||||||
|
override: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
backupFileName: z.string().min(1),
|
||||||
|
destinationId: z.string().min(1),
|
||||||
|
volumeName: z.string().min(1),
|
||||||
|
id: z.string().min(1),
|
||||||
|
serviceType: z.enum(["application", "compose"]),
|
||||||
|
serverId: z.string().optional(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.subscription(async ({ input }) => {
|
||||||
|
return observable<string>((emit) => {
|
||||||
|
const runRestore = async () => {
|
||||||
|
try {
|
||||||
|
emit.next("🚀 Starting volume restore process...");
|
||||||
|
emit.next(`📂 Backup File: ${input.backupFileName}`);
|
||||||
|
emit.next(`🔧 Volume Name: ${input.volumeName}`);
|
||||||
|
emit.next(`🏷️ Service Type: ${input.serviceType}`);
|
||||||
|
emit.next(""); // Empty line for better readability
|
||||||
|
|
||||||
|
// Generate the restore command
|
||||||
|
const restoreCommand = await restoreVolume(
|
||||||
|
input.id,
|
||||||
|
input.destinationId,
|
||||||
|
input.volumeName,
|
||||||
|
input.backupFileName,
|
||||||
|
input.serverId || "",
|
||||||
|
input.serviceType,
|
||||||
|
);
|
||||||
|
|
||||||
|
emit.next("📋 Generated restore command:");
|
||||||
|
emit.next("▶️ Executing restore...");
|
||||||
|
emit.next(""); // Empty line
|
||||||
|
|
||||||
|
// Execute the restore command with real-time output
|
||||||
|
if (input.serverId) {
|
||||||
|
emit.next(`🌐 Executing on remote server: ${input.serverId}`);
|
||||||
|
await execAsyncRemote(input.serverId, restoreCommand, (data) => {
|
||||||
|
emit.next(data);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
emit.next("🖥️ Executing on local server");
|
||||||
|
await execAsyncStream(restoreCommand, (data) => {
|
||||||
|
emit.next(data);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
emit.next("");
|
||||||
|
emit.next("✅ Volume restore completed successfully!");
|
||||||
|
emit.next(
|
||||||
|
"🎉 All containers/services have been restarted with the restored volume.",
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
emit.next("");
|
||||||
|
emit.next("❌ Volume restore failed!");
|
||||||
|
} finally {
|
||||||
|
emit.complete();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Start the restore process
|
||||||
|
runRestore();
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
});
|
||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
createDefaultTraefikConfig,
|
createDefaultTraefikConfig,
|
||||||
initCronJobs,
|
initCronJobs,
|
||||||
initSchedules,
|
initSchedules,
|
||||||
|
initVolumeBackupsCronJobs,
|
||||||
initializeNetwork,
|
initializeNetwork,
|
||||||
sendDokployRestartNotifications,
|
sendDokployRestartNotifications,
|
||||||
setupDirectories,
|
setupDirectories,
|
||||||
@@ -51,6 +52,7 @@ void app.prepare().then(async () => {
|
|||||||
await migration();
|
await migration();
|
||||||
await initCronJobs();
|
await initCronJobs();
|
||||||
await initSchedules();
|
await initSchedules();
|
||||||
|
await initVolumeBackupsCronJobs();
|
||||||
await sendDokployRestartNotifications();
|
await sendDokployRestartNotifications();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,11 @@ type QueueJob =
|
|||||||
type: "schedule";
|
type: "schedule";
|
||||||
cronSchedule: string;
|
cronSchedule: string;
|
||||||
scheduleId: string;
|
scheduleId: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: "volume-backup";
|
||||||
|
cronSchedule: string;
|
||||||
|
volumeBackupId: string;
|
||||||
};
|
};
|
||||||
export const schedule = async (job: QueueJob) => {
|
export const schedule = async (job: QueueJob) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -30,12 +30,41 @@ const getWsUrl = () => {
|
|||||||
return `${protocol}${host}/drawer-logs`;
|
return `${protocol}${host}/drawer-logs`;
|
||||||
};
|
};
|
||||||
|
|
||||||
const wsClient =
|
// Create WebSocket client with delayed connection
|
||||||
typeof window !== "undefined"
|
const createLazyWSClient = () => {
|
||||||
? createWSClient({
|
if (typeof window === "undefined") return null;
|
||||||
url: getWsUrl() || "",
|
|
||||||
})
|
let actualClient: ReturnType<typeof createWSClient> | null = null;
|
||||||
: null;
|
|
||||||
|
return {
|
||||||
|
request: (op: any, callbacks: any) => {
|
||||||
|
if (!actualClient) {
|
||||||
|
const wsUrl = getWsUrl();
|
||||||
|
if (wsUrl) {
|
||||||
|
actualClient = createWSClient({ url: wsUrl });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return actualClient?.request(op, callbacks) || (() => {});
|
||||||
|
},
|
||||||
|
close: () => {
|
||||||
|
if (actualClient) {
|
||||||
|
actualClient.close();
|
||||||
|
actualClient = null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
getConnection: () => {
|
||||||
|
if (!actualClient) {
|
||||||
|
const wsUrl = getWsUrl();
|
||||||
|
if (wsUrl) {
|
||||||
|
actualClient = createWSClient({ url: wsUrl });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return actualClient!.getConnection();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const wsClient = createLazyWSClient();
|
||||||
|
|
||||||
/** A set of type-safe react-query hooks for your tRPC API. */
|
/** A set of type-safe react-query hooks for your tRPC API. */
|
||||||
export const api = createTRPCNext<AppRouter>({
|
export const api = createTRPCNext<AppRouter>({
|
||||||
|
|||||||
@@ -61,6 +61,12 @@ app.post("/update-backup", zValidator("json", jobQueueSchema), async (c) => {
|
|||||||
type: "schedule",
|
type: "schedule",
|
||||||
cronSchedule: job.pattern,
|
cronSchedule: job.pattern,
|
||||||
});
|
});
|
||||||
|
} else if (data.type === "volume-backup") {
|
||||||
|
result = await removeJob({
|
||||||
|
volumeBackupId: data.volumeBackupId,
|
||||||
|
type: "volume-backup",
|
||||||
|
cronSchedule: job.pattern,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
logger.info({ result }, "Job removed");
|
logger.info({ result }, "Job removed");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,6 +42,12 @@ export const scheduleJob = (job: QueueJob) => {
|
|||||||
pattern: job.cronSchedule,
|
pattern: job.cronSchedule,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
} else if (job.type === "volume-backup") {
|
||||||
|
jobQueue.add(job.volumeBackupId, job, {
|
||||||
|
repeat: {
|
||||||
|
pattern: job.cronSchedule,
|
||||||
|
},
|
||||||
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -67,6 +73,13 @@ export const removeJob = async (data: QueueJob) => {
|
|||||||
});
|
});
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
if (data.type === "volume-backup") {
|
||||||
|
const { volumeBackupId, cronSchedule } = data;
|
||||||
|
const result = await jobQueue.removeRepeatable(volumeBackupId, {
|
||||||
|
pattern: cronSchedule,
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -89,6 +102,10 @@ export const getJobRepeatable = async (
|
|||||||
const job = repeatableJobs.find((j) => j.name === scheduleId);
|
const job = repeatableJobs.find((j) => j.name === scheduleId);
|
||||||
return job ? job : null;
|
return job ? job : null;
|
||||||
}
|
}
|
||||||
|
if (data.type === "volume-backup") {
|
||||||
|
const { volumeBackupId } = data;
|
||||||
|
const job = repeatableJobs.find((j) => j.name === volumeBackupId);
|
||||||
|
return job ? job : null;
|
||||||
|
}
|
||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -16,6 +16,11 @@ export const jobQueueSchema = z.discriminatedUnion("type", [
|
|||||||
type: z.literal("schedule"),
|
type: z.literal("schedule"),
|
||||||
scheduleId: z.string(),
|
scheduleId: z.string(),
|
||||||
}),
|
}),
|
||||||
|
z.object({
|
||||||
|
cronSchedule: z.string(),
|
||||||
|
type: z.literal("volume-backup"),
|
||||||
|
volumeBackupId: z.string(),
|
||||||
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export type QueueJob = z.infer<typeof jobQueueSchema>;
|
export type QueueJob = z.infer<typeof jobQueueSchema>;
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
findBackupById,
|
findBackupById,
|
||||||
findScheduleById,
|
findScheduleById,
|
||||||
findServerById,
|
findServerById,
|
||||||
|
findVolumeBackupById,
|
||||||
keepLatestNBackups,
|
keepLatestNBackups,
|
||||||
runCommand,
|
runCommand,
|
||||||
runComposeBackup,
|
runComposeBackup,
|
||||||
@@ -12,9 +13,15 @@ import {
|
|||||||
runMongoBackup,
|
runMongoBackup,
|
||||||
runMySqlBackup,
|
runMySqlBackup,
|
||||||
runPostgresBackup,
|
runPostgresBackup,
|
||||||
|
runVolumeBackup,
|
||||||
} from "@dokploy/server";
|
} from "@dokploy/server";
|
||||||
import { db } from "@dokploy/server/dist/db";
|
import { db } from "@dokploy/server/dist/db";
|
||||||
import { backups, schedules, server } from "@dokploy/server/dist/db/schema";
|
import {
|
||||||
|
backups,
|
||||||
|
schedules,
|
||||||
|
server,
|
||||||
|
volumeBackups,
|
||||||
|
} from "@dokploy/server/dist/db/schema";
|
||||||
import { and, 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";
|
||||||
@@ -93,6 +100,12 @@ export const runJobs = async (job: QueueJob) => {
|
|||||||
if (schedule.enabled) {
|
if (schedule.enabled) {
|
||||||
await runCommand(schedule.scheduleId);
|
await runCommand(schedule.scheduleId);
|
||||||
}
|
}
|
||||||
|
} else if (job.type === "volume-backup") {
|
||||||
|
const { volumeBackupId } = job;
|
||||||
|
const volumeBackup = await findVolumeBackupById(volumeBackupId);
|
||||||
|
if (volumeBackup.enabled) {
|
||||||
|
await runVolumeBackup(volumeBackupId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(error);
|
logger.error(error);
|
||||||
@@ -184,4 +197,44 @@ export const initializeJobs = async () => {
|
|||||||
{ Quantity: filteredSchedulesBasedOnServerStatus.length },
|
{ Quantity: filteredSchedulesBasedOnServerStatus.length },
|
||||||
"Schedules Initialized",
|
"Schedules Initialized",
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const volumeBackupsResult = await db.query.volumeBackups.findMany({
|
||||||
|
where: eq(volumeBackups.enabled, true),
|
||||||
|
with: {
|
||||||
|
application: {
|
||||||
|
with: {
|
||||||
|
server: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
compose: {
|
||||||
|
with: {
|
||||||
|
server: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const filteredVolumeBackupsBasedOnServerStatus = volumeBackupsResult.filter(
|
||||||
|
(volumeBackup) => {
|
||||||
|
if (volumeBackup.application) {
|
||||||
|
return volumeBackup.application.server?.serverStatus === "active";
|
||||||
|
}
|
||||||
|
if (volumeBackup.compose) {
|
||||||
|
return volumeBackup.compose.server?.serverStatus === "active";
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const volumeBackup of filteredVolumeBackupsBasedOnServerStatus) {
|
||||||
|
scheduleJob({
|
||||||
|
volumeBackupId: volumeBackup.volumeBackupId,
|
||||||
|
type: "volume-backup",
|
||||||
|
cronSchedule: volumeBackup.cronExpression,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
{ Quantity: filteredVolumeBackupsBasedOnServerStatus.length },
|
||||||
|
"Volume Backups Initialized",
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -24,5 +24,6 @@ export const paths = (isServer = false) => {
|
|||||||
MONITORING_PATH: `${BASE_PATH}/monitoring`,
|
MONITORING_PATH: `${BASE_PATH}/monitoring`,
|
||||||
REGISTRY_PATH: `${BASE_PATH}/registry`,
|
REGISTRY_PATH: `${BASE_PATH}/registry`,
|
||||||
SCHEDULES_PATH: `${BASE_PATH}/schedules`,
|
SCHEDULES_PATH: `${BASE_PATH}/schedules`,
|
||||||
|
VOLUME_BACKUPS_PATH: `${BASE_PATH}/volume-backups`,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -79,6 +79,10 @@ 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),
|
||||||
|
// Keep this for backward compatibility since we will not add the prefix anymore to volumes
|
||||||
|
isolatedDeploymentsVolume: boolean("isolatedDeploymentsVolume")
|
||||||
|
.notNull()
|
||||||
|
.default(false),
|
||||||
triggerType: triggerType("triggerType").default("push"),
|
triggerType: triggerType("triggerType").default("push"),
|
||||||
composeStatus: applicationStatus("composeStatus").notNull().default("idle"),
|
composeStatus: applicationStatus("composeStatus").notNull().default("idle"),
|
||||||
projectId: text("projectId")
|
projectId: text("projectId")
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { previewDeployments } from "./preview-deployments";
|
|||||||
import { schedules } from "./schedule";
|
import { schedules } from "./schedule";
|
||||||
import { server } from "./server";
|
import { server } from "./server";
|
||||||
import { rollbacks } from "./rollbacks";
|
import { rollbacks } from "./rollbacks";
|
||||||
|
import { volumeBackups } from "./volume-backups";
|
||||||
export const deploymentStatus = pgEnum("deploymentStatus", [
|
export const deploymentStatus = pgEnum("deploymentStatus", [
|
||||||
"running",
|
"running",
|
||||||
"done",
|
"done",
|
||||||
@@ -64,6 +65,10 @@ export const deployments = pgTable("deployment", {
|
|||||||
(): AnyPgColumn => rollbacks.rollbackId,
|
(): AnyPgColumn => rollbacks.rollbackId,
|
||||||
{ onDelete: "cascade" },
|
{ onDelete: "cascade" },
|
||||||
),
|
),
|
||||||
|
volumeBackupId: text("volumeBackupId").references(
|
||||||
|
(): AnyPgColumn => volumeBackups.volumeBackupId,
|
||||||
|
{ onDelete: "cascade" },
|
||||||
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const deploymentsRelations = relations(deployments, ({ one }) => ({
|
export const deploymentsRelations = relations(deployments, ({ one }) => ({
|
||||||
@@ -95,6 +100,10 @@ export const deploymentsRelations = relations(deployments, ({ one }) => ({
|
|||||||
fields: [deployments.deploymentId],
|
fields: [deployments.deploymentId],
|
||||||
references: [rollbacks.deploymentId],
|
references: [rollbacks.deploymentId],
|
||||||
}),
|
}),
|
||||||
|
volumeBackup: one(volumeBackups, {
|
||||||
|
fields: [deployments.volumeBackupId],
|
||||||
|
references: [volumeBackups.volumeBackupId],
|
||||||
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const schema = createInsertSchema(deployments, {
|
const schema = createInsertSchema(deployments, {
|
||||||
@@ -179,6 +188,17 @@ export const apiCreateDeploymentSchedule = schema
|
|||||||
scheduleId: z.string().min(1),
|
scheduleId: z.string().min(1),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const apiCreateDeploymentVolumeBackup = schema
|
||||||
|
.pick({
|
||||||
|
title: true,
|
||||||
|
status: true,
|
||||||
|
logPath: true,
|
||||||
|
description: true,
|
||||||
|
})
|
||||||
|
.extend({
|
||||||
|
volumeBackupId: z.string().min(1),
|
||||||
|
});
|
||||||
|
|
||||||
export const apiFindAllByApplication = schema
|
export const apiFindAllByApplication = schema
|
||||||
.pick({
|
.pick({
|
||||||
applicationId: true,
|
applicationId: true,
|
||||||
@@ -216,6 +236,7 @@ export const apiFindAllByType = z
|
|||||||
"schedule",
|
"schedule",
|
||||||
"previewDeployment",
|
"previewDeployment",
|
||||||
"backup",
|
"backup",
|
||||||
|
"volumeBackup",
|
||||||
]),
|
]),
|
||||||
})
|
})
|
||||||
.required();
|
.required();
|
||||||
|
|||||||
@@ -51,6 +51,8 @@ export const domains = pgTable("domain", {
|
|||||||
{ onDelete: "cascade" },
|
{ onDelete: "cascade" },
|
||||||
),
|
),
|
||||||
certificateType: certificateType("certificateType").notNull().default("none"),
|
certificateType: certificateType("certificateType").notNull().default("none"),
|
||||||
|
internalPath: text("internalPath").default("/"),
|
||||||
|
stripPath: boolean("stripPath").notNull().default(false),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const domainsRelations = relations(domains, ({ one }) => ({
|
export const domainsRelations = relations(domains, ({ one }) => ({
|
||||||
@@ -82,6 +84,8 @@ export const apiCreateDomain = createSchema.pick({
|
|||||||
serviceName: true,
|
serviceName: true,
|
||||||
domainType: true,
|
domainType: true,
|
||||||
previewDeploymentId: true,
|
previewDeploymentId: true,
|
||||||
|
internalPath: true,
|
||||||
|
stripPath: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const apiFindDomain = createSchema
|
export const apiFindDomain = createSchema
|
||||||
@@ -112,5 +116,7 @@ export const apiUpdateDomain = createSchema
|
|||||||
customCertResolver: true,
|
customCertResolver: true,
|
||||||
serviceName: true,
|
serviceName: true,
|
||||||
domainType: true,
|
domainType: true,
|
||||||
|
internalPath: true,
|
||||||
|
stripPath: true,
|
||||||
})
|
})
|
||||||
.merge(createSchema.pick({ domainId: true }).required());
|
.merge(createSchema.pick({ domainId: true }).required());
|
||||||
|
|||||||
@@ -33,3 +33,4 @@ export * from "./ai";
|
|||||||
export * from "./account";
|
export * from "./account";
|
||||||
export * from "./schedule";
|
export * from "./schedule";
|
||||||
export * from "./rollbacks";
|
export * from "./rollbacks";
|
||||||
|
export * from "./volume-backups";
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ import { z } from "zod";
|
|||||||
import { deployments } from "./deployment";
|
import { deployments } from "./deployment";
|
||||||
import type { Application } from "@dokploy/server/services/application";
|
import type { Application } from "@dokploy/server/services/application";
|
||||||
import type { Project } from "@dokploy/server/services/project";
|
import type { Project } from "@dokploy/server/services/project";
|
||||||
|
import type { Mount } from "@dokploy/server/services/mount";
|
||||||
|
import type { Port } from "@dokploy/server/services/port";
|
||||||
|
import type { Registry } from "@dokploy/server/services/registry";
|
||||||
|
|
||||||
export const rollbacks = pgTable("rollback", {
|
export const rollbacks = pgTable("rollback", {
|
||||||
rollbackId: text("rollbackId")
|
rollbackId: text("rollbackId")
|
||||||
@@ -22,7 +25,14 @@ export const rollbacks = pgTable("rollback", {
|
|||||||
createdAt: text("createdAt")
|
createdAt: text("createdAt")
|
||||||
.notNull()
|
.notNull()
|
||||||
.$defaultFn(() => new Date().toISOString()),
|
.$defaultFn(() => new Date().toISOString()),
|
||||||
fullContext: jsonb("fullContext").$type<Application & { project: Project }>(),
|
fullContext: jsonb("fullContext").$type<
|
||||||
|
Application & {
|
||||||
|
project: Project;
|
||||||
|
mounts: Mount[];
|
||||||
|
ports: Port[];
|
||||||
|
registry?: Registry | null;
|
||||||
|
}
|
||||||
|
>(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type Rollback = typeof rollbacks.$inferSelect;
|
export type Rollback = typeof rollbacks.$inferSelect;
|
||||||
|
|||||||
118
packages/server/src/db/schema/volume-backups.ts
Normal file
118
packages/server/src/db/schema/volume-backups.ts
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
import { relations } from "drizzle-orm";
|
||||||
|
import { boolean, integer, pgTable, text } from "drizzle-orm/pg-core";
|
||||||
|
import { createInsertSchema } from "drizzle-zod";
|
||||||
|
import { nanoid } from "nanoid";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { serviceType } from "./mount";
|
||||||
|
import { applications } from "./application";
|
||||||
|
import { mongo } from "./mongo";
|
||||||
|
import { mysql } from "./mysql";
|
||||||
|
import { redis } from "./redis";
|
||||||
|
import { compose } from "./compose";
|
||||||
|
import { postgres } from "./postgres";
|
||||||
|
import { mariadb } from "./mariadb";
|
||||||
|
import { destinations } from "./destination";
|
||||||
|
import { deployments } from "./deployment";
|
||||||
|
import { generateAppName } from "./utils";
|
||||||
|
|
||||||
|
export const volumeBackups = pgTable("volume_backup", {
|
||||||
|
volumeBackupId: text("volumeBackupId")
|
||||||
|
.notNull()
|
||||||
|
.primaryKey()
|
||||||
|
.$defaultFn(() => nanoid()),
|
||||||
|
name: text("name").notNull(),
|
||||||
|
volumeName: text("volumeName").notNull(),
|
||||||
|
prefix: text("prefix").notNull(),
|
||||||
|
serviceType: serviceType("serviceType").notNull().default("application"),
|
||||||
|
appName: text("appName")
|
||||||
|
.notNull()
|
||||||
|
.$defaultFn(() => generateAppName("volumeBackup")),
|
||||||
|
serviceName: text("serviceName"),
|
||||||
|
turnOff: boolean("turnOff").notNull().default(false),
|
||||||
|
cronExpression: text("cronExpression").notNull(),
|
||||||
|
keepLatestCount: integer("keepLatestCount"),
|
||||||
|
enabled: boolean("enabled"),
|
||||||
|
applicationId: text("applicationId").references(
|
||||||
|
() => applications.applicationId,
|
||||||
|
{
|
||||||
|
onDelete: "cascade",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
postgresId: text("postgresId").references(() => postgres.postgresId, {
|
||||||
|
onDelete: "cascade",
|
||||||
|
}),
|
||||||
|
mariadbId: text("mariadbId").references(() => mariadb.mariadbId, {
|
||||||
|
onDelete: "cascade",
|
||||||
|
}),
|
||||||
|
mongoId: text("mongoId").references(() => mongo.mongoId, {
|
||||||
|
onDelete: "cascade",
|
||||||
|
}),
|
||||||
|
mysqlId: text("mysqlId").references(() => mysql.mysqlId, {
|
||||||
|
onDelete: "cascade",
|
||||||
|
}),
|
||||||
|
redisId: text("redisId").references(() => redis.redisId, {
|
||||||
|
onDelete: "cascade",
|
||||||
|
}),
|
||||||
|
composeId: text("composeId").references(() => compose.composeId, {
|
||||||
|
onDelete: "cascade",
|
||||||
|
}),
|
||||||
|
createdAt: text("createdAt")
|
||||||
|
.notNull()
|
||||||
|
.$defaultFn(() => new Date().toISOString()),
|
||||||
|
destinationId: text("destinationId")
|
||||||
|
.notNull()
|
||||||
|
.references(() => destinations.destinationId, { onDelete: "cascade" }),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type VolumeBackup = typeof volumeBackups.$inferSelect;
|
||||||
|
|
||||||
|
export const volumeBackupsRelations = relations(
|
||||||
|
volumeBackups,
|
||||||
|
({ one, many }) => ({
|
||||||
|
application: one(applications, {
|
||||||
|
fields: [volumeBackups.applicationId],
|
||||||
|
references: [applications.applicationId],
|
||||||
|
}),
|
||||||
|
postgres: one(postgres, {
|
||||||
|
fields: [volumeBackups.postgresId],
|
||||||
|
references: [postgres.postgresId],
|
||||||
|
}),
|
||||||
|
mariadb: one(mariadb, {
|
||||||
|
fields: [volumeBackups.mariadbId],
|
||||||
|
references: [mariadb.mariadbId],
|
||||||
|
}),
|
||||||
|
mongo: one(mongo, {
|
||||||
|
fields: [volumeBackups.mongoId],
|
||||||
|
references: [mongo.mongoId],
|
||||||
|
}),
|
||||||
|
mysql: one(mysql, {
|
||||||
|
fields: [volumeBackups.mysqlId],
|
||||||
|
references: [mysql.mysqlId],
|
||||||
|
}),
|
||||||
|
redis: one(redis, {
|
||||||
|
fields: [volumeBackups.redisId],
|
||||||
|
references: [redis.redisId],
|
||||||
|
}),
|
||||||
|
compose: one(compose, {
|
||||||
|
fields: [volumeBackups.composeId],
|
||||||
|
references: [compose.composeId],
|
||||||
|
}),
|
||||||
|
destination: one(destinations, {
|
||||||
|
fields: [volumeBackups.destinationId],
|
||||||
|
references: [destinations.destinationId],
|
||||||
|
}),
|
||||||
|
deployments: many(deployments),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
export const createVolumeBackupSchema = createInsertSchema(volumeBackups).omit({
|
||||||
|
volumeBackupId: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const updateVolumeBackupSchema = createVolumeBackupSchema.extend({
|
||||||
|
volumeBackupId: z.string().min(1),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const apiFindOneVolumeBackup = z.object({
|
||||||
|
volumeBackupId: z.string().min(1),
|
||||||
|
});
|
||||||
@@ -4,6 +4,8 @@ export const domain = z
|
|||||||
.object({
|
.object({
|
||||||
host: z.string().min(1, { message: "Add a hostname" }),
|
host: z.string().min(1, { message: "Add a hostname" }),
|
||||||
path: z.string().min(1).optional(),
|
path: z.string().min(1).optional(),
|
||||||
|
internalPath: z.string().optional(),
|
||||||
|
stripPath: z.boolean().optional(),
|
||||||
port: z
|
port: z
|
||||||
.number()
|
.number()
|
||||||
.min(1, { message: "Port must be at least 1" })
|
.min(1, { message: "Port must be at least 1" })
|
||||||
@@ -29,12 +31,37 @@ export const domain = z
|
|||||||
message: "Required when certificate type is custom",
|
message: "Required when certificate type is custom",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Validate stripPath requires a valid path
|
||||||
|
if (input.stripPath && (!input.path || input.path === "/")) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
path: ["stripPath"],
|
||||||
|
message:
|
||||||
|
"Strip path can only be enabled when a path other than '/' is specified",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate internalPath starts with /
|
||||||
|
if (
|
||||||
|
input.internalPath &&
|
||||||
|
input.internalPath !== "/" &&
|
||||||
|
!input.internalPath.startsWith("/")
|
||||||
|
) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
path: ["internalPath"],
|
||||||
|
message: "Internal path must start with '/'",
|
||||||
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
export const domainCompose = z
|
export const domainCompose = z
|
||||||
.object({
|
.object({
|
||||||
host: z.string().min(1, { message: "Host is required" }),
|
host: z.string().min(1, { message: "Host is required" }),
|
||||||
path: z.string().min(1).optional(),
|
path: z.string().min(1).optional(),
|
||||||
|
internalPath: z.string().optional(),
|
||||||
|
stripPath: z.boolean().optional(),
|
||||||
port: z
|
port: z
|
||||||
.number()
|
.number()
|
||||||
.min(1, { message: "Port must be at least 1" })
|
.min(1, { message: "Port must be at least 1" })
|
||||||
@@ -61,4 +88,27 @@ export const domainCompose = z
|
|||||||
message: "Required when certificate type is custom",
|
message: "Required when certificate type is custom",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Validate stripPath requires a valid path
|
||||||
|
if (input.stripPath && (!input.path || input.path === "/")) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
path: ["stripPath"],
|
||||||
|
message:
|
||||||
|
"Strip path can only be enabled when a path other than '/' is specified",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate internalPath starts with /
|
||||||
|
if (
|
||||||
|
input.internalPath &&
|
||||||
|
input.internalPath !== "/" &&
|
||||||
|
!input.internalPath.startsWith("/")
|
||||||
|
) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
path: ["internalPath"],
|
||||||
|
message: "Internal path must start with '/'",
|
||||||
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ export * from "./services/mysql";
|
|||||||
export * from "./services/backup";
|
export * from "./services/backup";
|
||||||
export * from "./services/cluster";
|
export * from "./services/cluster";
|
||||||
export * from "./services/settings";
|
export * from "./services/settings";
|
||||||
|
export * from "./services/volume-backups";
|
||||||
export * from "./services/docker";
|
export * from "./services/docker";
|
||||||
export * from "./services/destination";
|
export * from "./services/destination";
|
||||||
export * from "./services/deployment";
|
export * from "./services/deployment";
|
||||||
@@ -132,5 +133,6 @@ export {
|
|||||||
|
|
||||||
export * from "./utils/schedules/utils";
|
export * from "./utils/schedules/utils";
|
||||||
export * from "./utils/schedules/index";
|
export * from "./utils/schedules/index";
|
||||||
|
export * from "./utils/volume-backups/index";
|
||||||
|
|
||||||
export * from "./lib/logger";
|
export * from "./lib/logger";
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
type apiCreateDeploymentPreview,
|
type apiCreateDeploymentPreview,
|
||||||
type apiCreateDeploymentSchedule,
|
type apiCreateDeploymentSchedule,
|
||||||
type apiCreateDeploymentServer,
|
type apiCreateDeploymentServer,
|
||||||
|
type apiCreateDeploymentVolumeBackup,
|
||||||
deployments,
|
deployments,
|
||||||
} from "@dokploy/server/db/schema";
|
} from "@dokploy/server/db/schema";
|
||||||
import { removeDirectoryIfExistsContent } from "@dokploy/server/utils/filesystem/directory";
|
import { removeDirectoryIfExistsContent } from "@dokploy/server/utils/filesystem/directory";
|
||||||
@@ -32,6 +33,7 @@ import {
|
|||||||
} from "./preview-deployment";
|
} from "./preview-deployment";
|
||||||
import { findScheduleById } from "./schedule";
|
import { findScheduleById } from "./schedule";
|
||||||
import { removeRollbackById } from "./rollbacks";
|
import { removeRollbackById } from "./rollbacks";
|
||||||
|
import { findVolumeBackupById } from "./volume-backups";
|
||||||
|
|
||||||
export type Deployment = typeof deployments.$inferSelect;
|
export type Deployment = typeof deployments.$inferSelect;
|
||||||
|
|
||||||
@@ -458,6 +460,91 @@ export const createDeploymentSchedule = async (
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const createDeploymentVolumeBackup = async (
|
||||||
|
deployment: Omit<
|
||||||
|
typeof apiCreateDeploymentVolumeBackup._type,
|
||||||
|
"deploymentId" | "createdAt" | "status" | "logPath"
|
||||||
|
>,
|
||||||
|
) => {
|
||||||
|
const volumeBackup = await findVolumeBackupById(deployment.volumeBackupId);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const serverId =
|
||||||
|
volumeBackup.application?.serverId || volumeBackup.compose?.serverId;
|
||||||
|
await removeLastTenDeployments(
|
||||||
|
deployment.volumeBackupId,
|
||||||
|
"volumeBackup",
|
||||||
|
serverId,
|
||||||
|
);
|
||||||
|
const { VOLUME_BACKUPS_PATH } = paths(!!serverId);
|
||||||
|
const formattedDateTime = format(new Date(), "yyyy-MM-dd:HH:mm:ss");
|
||||||
|
const fileName = `${volumeBackup.appName}-${formattedDateTime}.log`;
|
||||||
|
const logFilePath = path.join(
|
||||||
|
VOLUME_BACKUPS_PATH,
|
||||||
|
volumeBackup.appName,
|
||||||
|
fileName,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (serverId) {
|
||||||
|
const server = await findServerById(serverId);
|
||||||
|
|
||||||
|
const command = `
|
||||||
|
mkdir -p ${VOLUME_BACKUPS_PATH}/${volumeBackup.appName};
|
||||||
|
echo "Initializing volume backup" >> ${logFilePath};
|
||||||
|
`;
|
||||||
|
|
||||||
|
await execAsyncRemote(server.serverId, command);
|
||||||
|
} else {
|
||||||
|
await fsPromises.mkdir(
|
||||||
|
path.join(VOLUME_BACKUPS_PATH, volumeBackup.appName),
|
||||||
|
{
|
||||||
|
recursive: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
await fsPromises.writeFile(logFilePath, "Initializing volume backup\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
const deploymentCreate = await db
|
||||||
|
.insert(deployments)
|
||||||
|
.values({
|
||||||
|
volumeBackupId: deployment.volumeBackupId,
|
||||||
|
title: deployment.title || "Deployment",
|
||||||
|
status: "running",
|
||||||
|
logPath: logFilePath,
|
||||||
|
description: deployment.description || "",
|
||||||
|
startedAt: new Date().toISOString(),
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
if (deploymentCreate.length === 0 || !deploymentCreate[0]) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "BAD_REQUEST",
|
||||||
|
message: "Error creating the deployment",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return deploymentCreate[0];
|
||||||
|
} catch (error) {
|
||||||
|
console.log(error);
|
||||||
|
await db
|
||||||
|
.insert(deployments)
|
||||||
|
.values({
|
||||||
|
volumeBackupId: deployment.volumeBackupId,
|
||||||
|
title: deployment.title || "Deployment",
|
||||||
|
status: "error",
|
||||||
|
logPath: "",
|
||||||
|
description: deployment.description || "",
|
||||||
|
errorMessage: `An error have occured: ${error instanceof Error ? error.message : error}`,
|
||||||
|
startedAt: new Date().toISOString(),
|
||||||
|
finishedAt: new Date().toISOString(),
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "BAD_REQUEST",
|
||||||
|
message: "Error creating the deployment",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
export const removeDeployment = async (deploymentId: string) => {
|
export const removeDeployment = async (deploymentId: string) => {
|
||||||
try {
|
try {
|
||||||
const deployment = await db
|
const deployment = await db
|
||||||
@@ -492,7 +579,8 @@ const getDeploymentsByType = async (
|
|||||||
| "server"
|
| "server"
|
||||||
| "schedule"
|
| "schedule"
|
||||||
| "previewDeployment"
|
| "previewDeployment"
|
||||||
| "backup",
|
| "backup"
|
||||||
|
| "volumeBackup",
|
||||||
) => {
|
) => {
|
||||||
const deploymentList = await db.query.deployments.findMany({
|
const deploymentList = await db.query.deployments.findMany({
|
||||||
where: eq(deployments[`${type}Id`], id),
|
where: eq(deployments[`${type}Id`], id),
|
||||||
@@ -524,7 +612,8 @@ const removeLastTenDeployments = async (
|
|||||||
| "server"
|
| "server"
|
||||||
| "schedule"
|
| "schedule"
|
||||||
| "previewDeployment"
|
| "previewDeployment"
|
||||||
| "backup",
|
| "backup"
|
||||||
|
| "volumeBackup",
|
||||||
serverId?: string | null,
|
serverId?: string | null,
|
||||||
) => {
|
) => {
|
||||||
const deploymentList = await getDeploymentsByType(id, type);
|
const deploymentList = await getDeploymentsByType(id, type);
|
||||||
|
|||||||
@@ -6,13 +6,22 @@ import {
|
|||||||
deployments as deploymentsSchema,
|
deployments as deploymentsSchema,
|
||||||
} from "../db/schema";
|
} from "../db/schema";
|
||||||
import type { z } from "zod";
|
import type { z } from "zod";
|
||||||
import { findApplicationById } from "./application";
|
import { type Application, findApplicationById } from "./application";
|
||||||
import { getRemoteDocker } from "../utils/servers/remote-docker";
|
import { getRemoteDocker } from "../utils/servers/remote-docker";
|
||||||
import type { ApplicationNested } from "../utils/builders";
|
import { getAuthConfig, type ApplicationNested } from "../utils/builders";
|
||||||
import { execAsync, execAsyncRemote } from "../utils/process/execAsync";
|
import { execAsync, execAsyncRemote } from "../utils/process/execAsync";
|
||||||
import type { CreateServiceOptions } from "dockerode";
|
import type { CreateServiceOptions } from "dockerode";
|
||||||
import { findDeploymentById } from "./deployment";
|
import { findDeploymentById } from "./deployment";
|
||||||
import { prepareEnvironmentVariables } from "../utils/docker/utils";
|
import {
|
||||||
|
prepareEnvironmentVariables,
|
||||||
|
calculateResources,
|
||||||
|
generateBindMounts,
|
||||||
|
generateConfigContainer,
|
||||||
|
generateVolumeMounts,
|
||||||
|
} from "../utils/docker/utils";
|
||||||
|
import type { Project } from "./project";
|
||||||
|
import type { Mount } from "./mount";
|
||||||
|
import type { Port } from "./port";
|
||||||
|
|
||||||
export const createRollback = async (
|
export const createRollback = async (
|
||||||
input: z.infer<typeof createRollbackSchema>,
|
input: z.infer<typeof createRollbackSchema>,
|
||||||
@@ -152,16 +161,16 @@ export const rollback = async (rollbackId: string) => {
|
|||||||
|
|
||||||
const application = await findApplicationById(deployment.applicationId);
|
const application = await findApplicationById(deployment.applicationId);
|
||||||
|
|
||||||
const envVariables = prepareEnvironmentVariables(
|
if (!result.fullContext) {
|
||||||
result?.fullContext?.env || "",
|
throw new Error("Rollback context not found");
|
||||||
result.fullContext?.project?.env || "",
|
}
|
||||||
);
|
|
||||||
|
|
||||||
|
// Use the full context for rollback
|
||||||
await rollbackApplication(
|
await rollbackApplication(
|
||||||
application.appName,
|
application.appName,
|
||||||
result.image || "",
|
result.image || "",
|
||||||
application.serverId,
|
application.serverId,
|
||||||
envVariables,
|
result.fullContext,
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -169,18 +178,94 @@ const rollbackApplication = async (
|
|||||||
appName: string,
|
appName: string,
|
||||||
image: string,
|
image: string,
|
||||||
serverId?: string | null,
|
serverId?: string | null,
|
||||||
env: string[] = [],
|
fullContext?: Application & {
|
||||||
|
project: Project;
|
||||||
|
mounts: Mount[];
|
||||||
|
ports: Port[];
|
||||||
|
},
|
||||||
) => {
|
) => {
|
||||||
|
if (!fullContext) {
|
||||||
|
throw new Error("Full context is required for rollback");
|
||||||
|
}
|
||||||
|
|
||||||
const docker = await getRemoteDocker(serverId);
|
const docker = await getRemoteDocker(serverId);
|
||||||
|
|
||||||
|
// Use the same configuration as mechanizeDockerContainer
|
||||||
|
const {
|
||||||
|
env,
|
||||||
|
mounts,
|
||||||
|
cpuLimit,
|
||||||
|
memoryLimit,
|
||||||
|
memoryReservation,
|
||||||
|
cpuReservation,
|
||||||
|
command,
|
||||||
|
ports,
|
||||||
|
} = fullContext;
|
||||||
|
|
||||||
|
const resources = calculateResources({
|
||||||
|
memoryLimit,
|
||||||
|
memoryReservation,
|
||||||
|
cpuLimit,
|
||||||
|
cpuReservation,
|
||||||
|
});
|
||||||
|
|
||||||
|
const volumesMount = generateVolumeMounts(mounts);
|
||||||
|
|
||||||
|
const {
|
||||||
|
HealthCheck,
|
||||||
|
RestartPolicy,
|
||||||
|
Placement,
|
||||||
|
Labels,
|
||||||
|
Mode,
|
||||||
|
RollbackConfig,
|
||||||
|
UpdateConfig,
|
||||||
|
Networks,
|
||||||
|
} = generateConfigContainer(fullContext as ApplicationNested);
|
||||||
|
|
||||||
|
const bindsMount = generateBindMounts(mounts);
|
||||||
|
const envVariables = prepareEnvironmentVariables(
|
||||||
|
env,
|
||||||
|
fullContext.project.env,
|
||||||
|
);
|
||||||
|
|
||||||
|
// For rollback, we use the provided image instead of calculating it
|
||||||
|
const authConfig = getAuthConfig(fullContext as ApplicationNested);
|
||||||
|
|
||||||
const settings: CreateServiceOptions = {
|
const settings: CreateServiceOptions = {
|
||||||
|
authconfig: authConfig,
|
||||||
Name: appName,
|
Name: appName,
|
||||||
TaskTemplate: {
|
TaskTemplate: {
|
||||||
ContainerSpec: {
|
ContainerSpec: {
|
||||||
|
HealthCheck,
|
||||||
Image: image,
|
Image: image,
|
||||||
Env: env,
|
Env: envVariables,
|
||||||
|
Mounts: [...volumesMount, ...bindsMount],
|
||||||
|
...(command
|
||||||
|
? {
|
||||||
|
Command: ["/bin/sh"],
|
||||||
|
Args: ["-c", command],
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
Labels,
|
||||||
|
},
|
||||||
|
Networks,
|
||||||
|
RestartPolicy,
|
||||||
|
Placement,
|
||||||
|
Resources: {
|
||||||
|
...resources,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
Mode,
|
||||||
|
RollbackConfig,
|
||||||
|
EndpointSpec: {
|
||||||
|
Ports: ports.map((port) => ({
|
||||||
|
PublishMode: port.publishMode,
|
||||||
|
Protocol: port.protocol,
|
||||||
|
TargetPort: port.targetPort,
|
||||||
|
PublishedPort: port.publishedPort,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
UpdateConfig,
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
64
packages/server/src/services/volume-backups.ts
Normal file
64
packages/server/src/services/volume-backups.ts
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import {
|
||||||
|
type createVolumeBackupSchema,
|
||||||
|
type updateVolumeBackupSchema,
|
||||||
|
volumeBackups,
|
||||||
|
} from "../db/schema";
|
||||||
|
import { db } from "../db";
|
||||||
|
import { TRPCError } from "@trpc/server";
|
||||||
|
import type { z } from "zod";
|
||||||
|
|
||||||
|
export const findVolumeBackupById = async (volumeBackupId: string) => {
|
||||||
|
const volumeBackup = await db.query.volumeBackups.findFirst({
|
||||||
|
where: eq(volumeBackups.volumeBackupId, volumeBackupId),
|
||||||
|
with: {
|
||||||
|
application: true,
|
||||||
|
postgres: true,
|
||||||
|
mysql: true,
|
||||||
|
mariadb: true,
|
||||||
|
mongo: true,
|
||||||
|
redis: true,
|
||||||
|
compose: true,
|
||||||
|
destination: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!volumeBackup) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "NOT_FOUND",
|
||||||
|
message: "Volume backup not found",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return volumeBackup;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createVolumeBackup = async (
|
||||||
|
volumeBackup: z.infer<typeof createVolumeBackupSchema>,
|
||||||
|
) => {
|
||||||
|
const newVolumeBackup = await db
|
||||||
|
.insert(volumeBackups)
|
||||||
|
.values(volumeBackup)
|
||||||
|
.returning()
|
||||||
|
.then((e) => e[0]);
|
||||||
|
|
||||||
|
return newVolumeBackup;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const removeVolumeBackup = async (volumeBackupId: string) => {
|
||||||
|
await db
|
||||||
|
.delete(volumeBackups)
|
||||||
|
.where(eq(volumeBackups.volumeBackupId, volumeBackupId));
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updateVolumeBackup = async (
|
||||||
|
volumeBackupId: string,
|
||||||
|
volumeBackup: z.infer<typeof updateVolumeBackupSchema>,
|
||||||
|
) => {
|
||||||
|
return await db
|
||||||
|
.update(volumeBackups)
|
||||||
|
.set(volumeBackup)
|
||||||
|
.where(eq(volumeBackups.volumeBackupId, volumeBackupId))
|
||||||
|
.returning()
|
||||||
|
.then((e) => e[0]);
|
||||||
|
};
|
||||||
@@ -19,6 +19,7 @@ export const setupDirectories = () => {
|
|||||||
MONITORING_PATH,
|
MONITORING_PATH,
|
||||||
SSH_PATH,
|
SSH_PATH,
|
||||||
SCHEDULES_PATH,
|
SCHEDULES_PATH,
|
||||||
|
VOLUME_BACKUPS_PATH,
|
||||||
} = paths();
|
} = paths();
|
||||||
const directories = [
|
const directories = [
|
||||||
BASE_PATH,
|
BASE_PATH,
|
||||||
@@ -30,6 +31,7 @@ export const setupDirectories = () => {
|
|||||||
CERTIFICATES_PATH,
|
CERTIFICATES_PATH,
|
||||||
MONITORING_PATH,
|
MONITORING_PATH,
|
||||||
SCHEDULES_PATH,
|
SCHEDULES_PATH,
|
||||||
|
VOLUME_BACKUPS_PATH,
|
||||||
];
|
];
|
||||||
|
|
||||||
for (const dir of directories) {
|
for (const dir of directories) {
|
||||||
|
|||||||
@@ -227,7 +227,7 @@ const getImageName = (application: ApplicationNested) => {
|
|||||||
return imageName;
|
return imageName;
|
||||||
};
|
};
|
||||||
|
|
||||||
const getAuthConfig = (application: ApplicationNested) => {
|
export const getAuthConfig = (application: ApplicationNested) => {
|
||||||
const { registry, username, password, sourceType, registryUrl } = application;
|
const { registry, username, password, sourceType, registryUrl } = application;
|
||||||
|
|
||||||
if (sourceType === "docker") {
|
if (sourceType === "docker") {
|
||||||
|
|||||||
@@ -1,18 +1,21 @@
|
|||||||
import { findComposeById } from "@dokploy/server/services/compose";
|
import { findComposeById } from "@dokploy/server/services/compose";
|
||||||
import { dump, load } from "js-yaml";
|
import { dump, load } from "js-yaml";
|
||||||
import { addAppNameToAllServiceNames } from "./collision/root-network";
|
import { addAppNameToAllServiceNames } from "./collision/root-network";
|
||||||
import { generateRandomHash } from "./compose";
|
|
||||||
import { addSuffixToAllVolumes } from "./compose/volume";
|
import { addSuffixToAllVolumes } from "./compose/volume";
|
||||||
|
import { generateRandomHash } from "./compose";
|
||||||
import type { ComposeSpecification } from "./types";
|
import type { ComposeSpecification } from "./types";
|
||||||
|
|
||||||
export const addAppNameToPreventCollision = (
|
export const addAppNameToPreventCollision = (
|
||||||
composeData: ComposeSpecification,
|
composeData: ComposeSpecification,
|
||||||
appName: string,
|
appName: string,
|
||||||
|
isolatedDeploymentsVolume: boolean,
|
||||||
): ComposeSpecification => {
|
): ComposeSpecification => {
|
||||||
let updatedComposeData = { ...composeData };
|
let updatedComposeData = { ...composeData };
|
||||||
|
|
||||||
updatedComposeData = addAppNameToAllServiceNames(updatedComposeData, appName);
|
updatedComposeData = addAppNameToAllServiceNames(updatedComposeData, appName);
|
||||||
updatedComposeData = addSuffixToAllVolumes(updatedComposeData, appName);
|
if (isolatedDeploymentsVolume) {
|
||||||
|
updatedComposeData = addSuffixToAllVolumes(updatedComposeData, appName);
|
||||||
|
}
|
||||||
return updatedComposeData;
|
return updatedComposeData;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -29,6 +32,7 @@ export const randomizeIsolatedDeploymentComposeFile = async (
|
|||||||
const newComposeFile = addAppNameToPreventCollision(
|
const newComposeFile = addAppNameToPreventCollision(
|
||||||
composeData,
|
composeData,
|
||||||
randomSuffix,
|
randomSuffix,
|
||||||
|
compose.isolatedDeploymentsVolume,
|
||||||
);
|
);
|
||||||
|
|
||||||
return dump(newComposeFile);
|
return dump(newComposeFile);
|
||||||
@@ -36,11 +40,16 @@ export const randomizeIsolatedDeploymentComposeFile = async (
|
|||||||
|
|
||||||
export const randomizeDeployableSpecificationFile = (
|
export const randomizeDeployableSpecificationFile = (
|
||||||
composeSpec: ComposeSpecification,
|
composeSpec: ComposeSpecification,
|
||||||
|
isolatedDeploymentsVolume: boolean,
|
||||||
suffix?: string,
|
suffix?: string,
|
||||||
) => {
|
) => {
|
||||||
if (!suffix) {
|
if (!suffix) {
|
||||||
return composeSpec;
|
return composeSpec;
|
||||||
}
|
}
|
||||||
const newComposeFile = addAppNameToPreventCollision(composeSpec, suffix);
|
const newComposeFile = addAppNameToPreventCollision(
|
||||||
|
composeSpec,
|
||||||
|
suffix,
|
||||||
|
isolatedDeploymentsVolume,
|
||||||
|
);
|
||||||
return newComposeFile;
|
return newComposeFile;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -202,6 +202,7 @@ export const addDomainToCompose = async (
|
|||||||
if (compose.isolatedDeployment) {
|
if (compose.isolatedDeployment) {
|
||||||
const randomized = randomizeDeployableSpecificationFile(
|
const randomized = randomizeDeployableSpecificationFile(
|
||||||
result,
|
result,
|
||||||
|
compose.isolatedDeploymentsVolume,
|
||||||
compose.suffix || compose.appName,
|
compose.suffix || compose.appName,
|
||||||
);
|
);
|
||||||
result = randomized;
|
result = randomized;
|
||||||
@@ -301,6 +302,8 @@ export const createDomainLabels = (
|
|||||||
certificateType,
|
certificateType,
|
||||||
path,
|
path,
|
||||||
customCertResolver,
|
customCertResolver,
|
||||||
|
stripPath,
|
||||||
|
internalPath,
|
||||||
} = domain;
|
} = domain;
|
||||||
const routerName = `${appName}-${uniqueConfigKey}-${entrypoint}`;
|
const routerName = `${appName}-${uniqueConfigKey}-${entrypoint}`;
|
||||||
const labels = [
|
const labels = [
|
||||||
@@ -310,6 +313,34 @@ export const createDomainLabels = (
|
|||||||
`traefik.http.routers.${routerName}.service=${routerName}`,
|
`traefik.http.routers.${routerName}.service=${routerName}`,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// Validate stripPath - it should only be used when path is defined and not "/"
|
||||||
|
if (stripPath) {
|
||||||
|
if (!path || path === "/") {
|
||||||
|
console.warn(
|
||||||
|
`stripPath is enabled but path is not defined or is "/" for domain ${host}`,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
const middlewareName = `stripprefix-${appName}-${uniqueConfigKey}`;
|
||||||
|
labels.push(
|
||||||
|
`traefik.http.middlewares.${middlewareName}.stripprefix.prefixes=${path}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate internalPath - ensure it's a valid path format
|
||||||
|
if (internalPath && internalPath !== "/") {
|
||||||
|
if (!internalPath.startsWith("/")) {
|
||||||
|
console.warn(
|
||||||
|
`internalPath "${internalPath}" should start with "/" and not be empty for domain ${host}`,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
const middlewareName = `addprefix-${appName}-${uniqueConfigKey}`;
|
||||||
|
labels.push(
|
||||||
|
`traefik.http.middlewares.${middlewareName}.addprefix.prefix=${internalPath}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (entrypoint === "web" && https) {
|
if (entrypoint === "web" && https) {
|
||||||
labels.push(
|
labels.push(
|
||||||
`traefik.http.routers.${routerName}.middlewares=redirect-to-https@file`,
|
`traefik.http.routers.${routerName}.middlewares=redirect-to-https@file`,
|
||||||
|
|||||||
@@ -287,7 +287,7 @@ export const parseEnvironmentKeyValuePair = (
|
|||||||
throw new Error(`Invalid environment variable pair: ${pair}`);
|
throw new Error(`Invalid environment variable pair: ${pair}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return [key, valueParts.join("")];
|
return [key, valueParts.join("=")];
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getEnviromentVariablesObject = (
|
export const getEnviromentVariablesObject = (
|
||||||
|
|||||||
@@ -253,13 +253,11 @@ export const getGitlabRepositories = async (gitlabId?: string) => {
|
|||||||
const groupName = gitlabProvider.groupName?.toLowerCase();
|
const groupName = gitlabProvider.groupName?.toLowerCase();
|
||||||
|
|
||||||
if (groupName) {
|
if (groupName) {
|
||||||
const isIncluded = groupName
|
return groupName
|
||||||
.split(",")
|
.split(",")
|
||||||
.some((name) =>
|
.some((name) =>
|
||||||
full_path.toLowerCase().startsWith(name.trim().toLowerCase()),
|
full_path.toLowerCase().startsWith(name.trim().toLowerCase()),
|
||||||
);
|
);
|
||||||
|
|
||||||
return isIncluded && kind === "group";
|
|
||||||
}
|
}
|
||||||
return kind === "user";
|
return kind === "user";
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import fs, { writeFileSync } from "node:fs";
|
import fs, { writeFileSync } from "node:fs";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
import { createReadStream } from "node:fs";
|
||||||
|
import { createInterface } from "node:readline";
|
||||||
import { paths } from "@dokploy/server/constants";
|
import { paths } from "@dokploy/server/constants";
|
||||||
import type { Domain } from "@dokploy/server/services/domain";
|
import type { Domain } from "@dokploy/server/services/domain";
|
||||||
import { dump, load } from "js-yaml";
|
import { dump, load } from "js-yaml";
|
||||||
@@ -137,39 +139,40 @@ export const readRemoteConfig = async (serverId: string, appName: string) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const readMonitoringConfig = (readAll = false) => {
|
export const readMonitoringConfig = async (readAll = false) => {
|
||||||
const { DYNAMIC_TRAEFIK_PATH } = paths();
|
const { DYNAMIC_TRAEFIK_PATH } = paths();
|
||||||
const configPath = path.join(DYNAMIC_TRAEFIK_PATH, "access.log");
|
const configPath = path.join(DYNAMIC_TRAEFIK_PATH, "access.log");
|
||||||
if (fs.existsSync(configPath)) {
|
if (fs.existsSync(configPath)) {
|
||||||
if (!readAll) {
|
if (!readAll) {
|
||||||
// Read first 500 lines
|
// Read first 500 lines using streams
|
||||||
let content = "";
|
let content = "";
|
||||||
let chunk = "";
|
|
||||||
let validCount = 0;
|
let validCount = 0;
|
||||||
|
|
||||||
for (const char of fs.readFileSync(configPath, "utf8")) {
|
const fileStream = createReadStream(configPath, { encoding: "utf8" });
|
||||||
chunk += char;
|
const readline = createInterface({
|
||||||
if (char === "\n") {
|
input: fileStream,
|
||||||
try {
|
crlfDelay: Number.POSITIVE_INFINITY,
|
||||||
const trimmed = chunk.trim();
|
});
|
||||||
if (
|
|
||||||
trimmed !== "" &&
|
for await (const line of readline) {
|
||||||
trimmed.startsWith("{") &&
|
try {
|
||||||
trimmed.endsWith("}")
|
const trimmed = line.trim();
|
||||||
) {
|
if (
|
||||||
const log = JSON.parse(trimmed);
|
trimmed !== "" &&
|
||||||
if (log.ServiceName !== "dokploy-service-app@file") {
|
trimmed.startsWith("{") &&
|
||||||
content += chunk;
|
trimmed.endsWith("}")
|
||||||
validCount++;
|
) {
|
||||||
if (validCount >= 500) {
|
const log = JSON.parse(trimmed);
|
||||||
break;
|
if (log.ServiceName !== "dokploy-service-app@file") {
|
||||||
}
|
content += `${line}\n`;
|
||||||
|
validCount++;
|
||||||
|
if (validCount >= 500) {
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
|
||||||
// Ignore invalid JSON
|
|
||||||
}
|
}
|
||||||
chunk = "";
|
} catch {
|
||||||
|
// Ignore invalid JSON
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return content;
|
return content;
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
writeTraefikConfigRemote,
|
writeTraefikConfigRemote,
|
||||||
} from "./application";
|
} from "./application";
|
||||||
import type { FileConfig, HttpRouter } from "./file-types";
|
import type { FileConfig, HttpRouter } from "./file-types";
|
||||||
|
import { createPathMiddlewares, removePathMiddlewares } from "./middleware";
|
||||||
|
|
||||||
export const manageDomain = async (app: ApplicationNested, domain: Domain) => {
|
export const manageDomain = async (app: ApplicationNested, domain: Domain) => {
|
||||||
const { appName } = app;
|
const { appName } = app;
|
||||||
@@ -46,6 +47,8 @@ export const manageDomain = async (app: ApplicationNested, domain: Domain) => {
|
|||||||
|
|
||||||
config.http.services[serviceName] = createServiceConfig(appName, domain);
|
config.http.services[serviceName] = createServiceConfig(appName, domain);
|
||||||
|
|
||||||
|
await createPathMiddlewares(app, domain);
|
||||||
|
|
||||||
if (app.serverId) {
|
if (app.serverId) {
|
||||||
await writeTraefikConfigRemote(config, appName, app.serverId);
|
await writeTraefikConfigRemote(config, appName, app.serverId);
|
||||||
} else {
|
} else {
|
||||||
@@ -80,6 +83,8 @@ export const removeDomain = async (
|
|||||||
delete config.http.services[serviceKey];
|
delete config.http.services[serviceKey];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await removePathMiddlewares(application, uniqueKey);
|
||||||
|
|
||||||
// verify if is the last router if so we delete the router
|
// verify if is the last router if so we delete the router
|
||||||
if (
|
if (
|
||||||
config?.http?.routers &&
|
config?.http?.routers &&
|
||||||
@@ -107,7 +112,8 @@ export const createRouterConfig = async (
|
|||||||
const { appName, redirects, security } = app;
|
const { appName, redirects, security } = app;
|
||||||
const { certificateType } = domain;
|
const { certificateType } = domain;
|
||||||
|
|
||||||
const { host, path, https, uniqueConfigKey } = domain;
|
const { host, path, https, uniqueConfigKey, internalPath, stripPath } =
|
||||||
|
domain;
|
||||||
const routerConfig: HttpRouter = {
|
const routerConfig: HttpRouter = {
|
||||||
rule: `Host(\`${host}\`)${path !== null && path !== "/" ? ` && PathPrefix(\`${path}\`)` : ""}`,
|
rule: `Host(\`${host}\`)${path !== null && path !== "/" ? ` && PathPrefix(\`${path}\`)` : ""}`,
|
||||||
service: `${appName}-service-${uniqueConfigKey}`,
|
service: `${appName}-service-${uniqueConfigKey}`,
|
||||||
@@ -115,6 +121,17 @@ export const createRouterConfig = async (
|
|||||||
entryPoints: [entryPoint],
|
entryPoints: [entryPoint],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Add path rewriting middleware if needed
|
||||||
|
if (internalPath && internalPath !== "/" && internalPath !== path) {
|
||||||
|
const pathMiddleware = `addprefix-${appName}-${uniqueConfigKey}`;
|
||||||
|
routerConfig.middlewares?.push(pathMiddleware);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stripPath && path && path !== "/") {
|
||||||
|
const stripMiddleware = `stripprefix-${appName}-${uniqueConfigKey}`;
|
||||||
|
routerConfig.middlewares?.push(stripMiddleware);
|
||||||
|
}
|
||||||
|
|
||||||
if (entryPoint === "web" && https) {
|
if (entryPoint === "web" && https) {
|
||||||
routerConfig.middlewares = ["redirect-to-https"];
|
routerConfig.middlewares = ["redirect-to-https"];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import type { ApplicationNested } from "../builders";
|
|||||||
import { execAsyncRemote } from "../process/execAsync";
|
import { execAsyncRemote } from "../process/execAsync";
|
||||||
import { writeTraefikConfigRemote } from "./application";
|
import { writeTraefikConfigRemote } from "./application";
|
||||||
import type { FileConfig } from "./file-types";
|
import type { FileConfig } from "./file-types";
|
||||||
|
import type { Domain } from "@dokploy/server/services/domain";
|
||||||
|
|
||||||
export const addMiddleware = (config: FileConfig, middlewareName: string) => {
|
export const addMiddleware = (config: FileConfig, middlewareName: string) => {
|
||||||
if (config.http?.routers) {
|
if (config.http?.routers) {
|
||||||
@@ -105,3 +106,97 @@ export const writeMiddleware = <T>(config: T) => {
|
|||||||
const newYamlContent = dump(config);
|
const newYamlContent = dump(config);
|
||||||
writeFileSync(configPath, newYamlContent, "utf8");
|
writeFileSync(configPath, newYamlContent, "utf8");
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const createPathMiddlewares = async (
|
||||||
|
app: ApplicationNested,
|
||||||
|
domain: Domain,
|
||||||
|
) => {
|
||||||
|
let config: FileConfig;
|
||||||
|
|
||||||
|
if (app.serverId) {
|
||||||
|
try {
|
||||||
|
config = await loadRemoteMiddlewares(app.serverId);
|
||||||
|
} catch {
|
||||||
|
config = { http: { middlewares: {} } };
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
config = loadMiddlewares<FileConfig>();
|
||||||
|
} catch {
|
||||||
|
config = { http: { middlewares: {} } };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const { appName } = app;
|
||||||
|
const { uniqueConfigKey, internalPath, stripPath, path } = domain;
|
||||||
|
|
||||||
|
if (!config.http) {
|
||||||
|
config.http = { middlewares: {} };
|
||||||
|
}
|
||||||
|
if (!config.http.middlewares) {
|
||||||
|
config.http.middlewares = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add internal path prefix middleware
|
||||||
|
if (internalPath && internalPath !== "/" && internalPath !== path) {
|
||||||
|
const middlewareName = `addprefix-${appName}-${uniqueConfigKey}`;
|
||||||
|
config.http.middlewares[middlewareName] = {
|
||||||
|
addPrefix: {
|
||||||
|
prefix: internalPath,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Strip external path middleware if needed
|
||||||
|
if (stripPath && path && path !== "/") {
|
||||||
|
const middlewareName = `stripprefix-${appName}-${uniqueConfigKey}`;
|
||||||
|
config.http.middlewares[middlewareName] = {
|
||||||
|
stripPrefix: {
|
||||||
|
prefixes: [path],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (app.serverId) {
|
||||||
|
await writeTraefikConfigRemote(config, "middlewares", app.serverId);
|
||||||
|
} else {
|
||||||
|
writeMiddleware(config);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const removePathMiddlewares = async (
|
||||||
|
app: ApplicationNested,
|
||||||
|
uniqueConfigKey: number,
|
||||||
|
) => {
|
||||||
|
let config: FileConfig;
|
||||||
|
|
||||||
|
if (app.serverId) {
|
||||||
|
try {
|
||||||
|
config = await loadRemoteMiddlewares(app.serverId);
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
config = loadMiddlewares<FileConfig>();
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const { appName } = app;
|
||||||
|
|
||||||
|
if (config.http?.middlewares) {
|
||||||
|
const addPrefixMiddleware = `addprefix-${appName}-${uniqueConfigKey}`;
|
||||||
|
const stripPrefixMiddleware = `stripprefix-${appName}-${uniqueConfigKey}`;
|
||||||
|
|
||||||
|
delete config.http.middlewares[addPrefixMiddleware];
|
||||||
|
delete config.http.middlewares[stripPrefixMiddleware];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (app.serverId) {
|
||||||
|
await writeTraefikConfigRemote(config, "middlewares", app.serverId);
|
||||||
|
} else {
|
||||||
|
writeMiddleware(config);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|||||||
91
packages/server/src/utils/volume-backups/backup.ts
Normal file
91
packages/server/src/utils/volume-backups/backup.ts
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
import type { findVolumeBackupById } from "@dokploy/server/services/volume-backups";
|
||||||
|
import { normalizeS3Path } from "../backups/utils";
|
||||||
|
import { getS3Credentials } from "../backups/utils";
|
||||||
|
import path from "node:path";
|
||||||
|
import { paths } from "@dokploy/server/constants";
|
||||||
|
import { findComposeById } from "@dokploy/server/services/compose";
|
||||||
|
|
||||||
|
export const backupVolume = async (
|
||||||
|
volumeBackup: Awaited<ReturnType<typeof findVolumeBackupById>>,
|
||||||
|
) => {
|
||||||
|
const { serviceType, volumeName, turnOff, prefix } = volumeBackup;
|
||||||
|
const serverId =
|
||||||
|
volumeBackup.application?.serverId || volumeBackup.compose?.serverId;
|
||||||
|
const { VOLUME_BACKUPS_PATH } = paths(!!serverId);
|
||||||
|
const destination = volumeBackup.destination;
|
||||||
|
const backupFileName = `${volumeName}-${new Date().toISOString()}.tar`;
|
||||||
|
const bucketDestination = `${normalizeS3Path(prefix)}${backupFileName}`;
|
||||||
|
const rcloneFlags = getS3Credentials(volumeBackup.destination);
|
||||||
|
const rcloneDestination = `:s3:${destination.bucket}/${bucketDestination}`;
|
||||||
|
const volumeBackupPath = path.join(VOLUME_BACKUPS_PATH, volumeBackup.appName);
|
||||||
|
|
||||||
|
const rcloneCommand = `rclone copyto ${rcloneFlags.join(" ")} "${volumeBackupPath}/${backupFileName}" "${rcloneDestination}"`;
|
||||||
|
|
||||||
|
const baseCommand = `
|
||||||
|
set -e
|
||||||
|
echo "Volume name: ${volumeName}"
|
||||||
|
echo "Backup file name: ${backupFileName}"
|
||||||
|
echo "Turning off volume backup: ${turnOff ? "Yes" : "No"}"
|
||||||
|
echo "Starting volume backup"
|
||||||
|
echo "Dir: ${volumeBackupPath}"
|
||||||
|
docker run --rm \
|
||||||
|
-v ${volumeName}:/volume_data \
|
||||||
|
-v ${volumeBackupPath}:/backup \
|
||||||
|
ubuntu \
|
||||||
|
bash -c "cd /volume_data && tar cvf /backup/${backupFileName} ."
|
||||||
|
echo "Volume backup done ✅"
|
||||||
|
echo "Starting upload to S3..."
|
||||||
|
${rcloneCommand}
|
||||||
|
echo "Upload to S3 done ✅"
|
||||||
|
`;
|
||||||
|
|
||||||
|
if (!turnOff) {
|
||||||
|
return baseCommand;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (serviceType === "application") {
|
||||||
|
return `
|
||||||
|
echo "Stopping application to 0 replicas"
|
||||||
|
ACTUAL_REPLICAS=$(docker service inspect ${volumeBackup.application?.appName} --format "{{.Spec.Mode.Replicated.Replicas}}")
|
||||||
|
echo "Actual replicas: $ACTUAL_REPLICAS"
|
||||||
|
docker service scale ${volumeBackup.application?.appName}=0
|
||||||
|
${baseCommand}
|
||||||
|
echo "Starting application to $ACTUAL_REPLICAS replicas"
|
||||||
|
docker service scale ${volumeBackup.application?.appName}=$ACTUAL_REPLICAS
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
if (serviceType === "compose") {
|
||||||
|
const compose = await findComposeById(
|
||||||
|
volumeBackup.compose?.composeId || "",
|
||||||
|
);
|
||||||
|
let stopCommand = "";
|
||||||
|
let startCommand = "";
|
||||||
|
|
||||||
|
if (compose.composeType === "stack") {
|
||||||
|
stopCommand = `
|
||||||
|
echo "Stopping compose to 0 replicas"
|
||||||
|
echo "Service name: ${compose.appName}_${volumeBackup.serviceName}"
|
||||||
|
ACTUAL_REPLICAS=$(docker service inspect ${compose.appName}_${volumeBackup.serviceName} --format "{{.Spec.Mode.Replicated.Replicas}}")
|
||||||
|
echo "Actual replicas: $ACTUAL_REPLICAS"
|
||||||
|
docker service scale ${compose.appName}_${volumeBackup.serviceName}=0`;
|
||||||
|
startCommand = `
|
||||||
|
echo "Starting compose to $ACTUAL_REPLICAS replicas"
|
||||||
|
docker service scale ${compose.appName}_${volumeBackup.serviceName}=$ACTUAL_REPLICAS`;
|
||||||
|
} else {
|
||||||
|
stopCommand = `
|
||||||
|
echo "Stopping compose container"
|
||||||
|
ID=$(docker ps -q --filter "label=com.docker.compose.project=${compose.appName}" --filter "label=com.docker.compose.service=${volumeBackup.serviceName}")
|
||||||
|
docker stop $ID`;
|
||||||
|
startCommand = `
|
||||||
|
echo "Starting compose container"
|
||||||
|
docker start $ID
|
||||||
|
echo "Compose container started"
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
return `
|
||||||
|
${stopCommand}
|
||||||
|
${baseCommand}
|
||||||
|
${startCommand}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
};
|
||||||
30
packages/server/src/utils/volume-backups/index.ts
Normal file
30
packages/server/src/utils/volume-backups/index.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
export * from "./backup";
|
||||||
|
export * from "./restore";
|
||||||
|
export * from "./utils";
|
||||||
|
import { volumeBackups } from "@dokploy/server/db/schema";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import { db } from "../../db/index";
|
||||||
|
import { scheduleVolumeBackup } from "./utils";
|
||||||
|
|
||||||
|
export const initVolumeBackupsCronJobs = async () => {
|
||||||
|
console.log("Setting up volume backups cron jobs....");
|
||||||
|
try {
|
||||||
|
const volumeBackupsResult = await db.query.volumeBackups.findMany({
|
||||||
|
where: eq(volumeBackups.enabled, true),
|
||||||
|
with: {
|
||||||
|
application: true,
|
||||||
|
compose: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`Initializing ${volumeBackupsResult.length} volume backups`);
|
||||||
|
for (const volumeBackup of volumeBackupsResult) {
|
||||||
|
scheduleVolumeBackup(volumeBackup.volumeBackupId);
|
||||||
|
console.log(
|
||||||
|
`Initialized volume backup: ${volumeBackup.name} ${volumeBackup.serviceType} ✅`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.log(`Error initializing volume backups: ${error}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
126
packages/server/src/utils/volume-backups/restore.ts
Normal file
126
packages/server/src/utils/volume-backups/restore.ts
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
import {
|
||||||
|
findApplicationById,
|
||||||
|
findComposeById,
|
||||||
|
findDestinationById,
|
||||||
|
getS3Credentials,
|
||||||
|
paths,
|
||||||
|
} from "../..";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
export const restoreVolume = async (
|
||||||
|
id: string,
|
||||||
|
destinationId: string,
|
||||||
|
volumeName: string,
|
||||||
|
backupFileName: string,
|
||||||
|
serverId: string,
|
||||||
|
serviceType: "application" | "compose",
|
||||||
|
) => {
|
||||||
|
const destination = await findDestinationById(destinationId);
|
||||||
|
const { VOLUME_BACKUPS_PATH } = paths(!!serverId);
|
||||||
|
const volumeBackupPath = path.join(VOLUME_BACKUPS_PATH, volumeName);
|
||||||
|
const rcloneFlags = getS3Credentials(destination);
|
||||||
|
const bucketPath = `:s3:${destination.bucket}`;
|
||||||
|
const backupPath = `${bucketPath}/${backupFileName}`;
|
||||||
|
|
||||||
|
// Command to download backup file from S3
|
||||||
|
const downloadCommand = `rclone copyto ${rcloneFlags.join(" ")} "${backupPath}" "${volumeBackupPath}/${backupFileName}"`;
|
||||||
|
|
||||||
|
// Base restore command that creates the volume and restores data
|
||||||
|
const baseRestoreCommand = `
|
||||||
|
set -e
|
||||||
|
echo "Volume name: ${volumeName}"
|
||||||
|
echo "Backup file name: ${backupFileName}"
|
||||||
|
echo "Volume backup path: ${volumeBackupPath}"
|
||||||
|
echo "Downloading backup from S3..."
|
||||||
|
mkdir -p ${volumeBackupPath}
|
||||||
|
${downloadCommand}
|
||||||
|
echo "Download completed ✅"
|
||||||
|
echo "Creating new volume and restoring data..."
|
||||||
|
docker run --rm \
|
||||||
|
-v ${volumeName}:/volume_data \
|
||||||
|
-v ${volumeBackupPath}:/backup \
|
||||||
|
ubuntu \
|
||||||
|
bash -c "cd /volume_data && tar xvf /backup/${backupFileName} ."
|
||||||
|
echo "Volume restore completed ✅"
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Function to check if volume exists and get containers using it
|
||||||
|
const checkVolumeCommand = `
|
||||||
|
# Check if volume exists
|
||||||
|
VOLUME_EXISTS=$(docker volume ls -q --filter name="^${volumeName}$" | wc -l)
|
||||||
|
echo "Volume exists: $VOLUME_EXISTS"
|
||||||
|
|
||||||
|
if [ "$VOLUME_EXISTS" = "0" ]; then
|
||||||
|
echo "Volume doesn't exist, proceeding with direct restore"
|
||||||
|
${baseRestoreCommand}
|
||||||
|
else
|
||||||
|
echo "Volume exists, checking for containers using it (including stopped ones)..."
|
||||||
|
|
||||||
|
# Get ALL containers (running and stopped) using this volume - much simpler with native filter!
|
||||||
|
CONTAINERS_USING_VOLUME=$(docker ps -a --filter "volume=${volumeName}" --format "{{.ID}}|{{.Names}}|{{.State}}|{{.Labels}}")
|
||||||
|
|
||||||
|
if [ -z "$CONTAINERS_USING_VOLUME" ]; then
|
||||||
|
echo "Volume exists but no containers are using it"
|
||||||
|
echo "Removing existing volume and proceeding with restore"
|
||||||
|
docker volume rm ${volumeName} --force
|
||||||
|
${baseRestoreCommand}
|
||||||
|
else
|
||||||
|
echo ""
|
||||||
|
echo "⚠️ WARNING: Cannot restore volume as it is currently in use!"
|
||||||
|
echo ""
|
||||||
|
echo "📋 The following containers are using volume '${volumeName}':"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
echo "$CONTAINERS_USING_VOLUME" | while IFS='|' read container_id container_name container_state labels; do
|
||||||
|
echo " 🐳 Container: $container_name ($container_id)"
|
||||||
|
echo " Status: $container_state"
|
||||||
|
|
||||||
|
# Determine container type
|
||||||
|
if echo "$labels" | grep -q "com.docker.swarm.service.name="; then
|
||||||
|
SERVICE_NAME=$(echo "$labels" | grep -o "com.docker.swarm.service.name=[^,]*" | cut -d'=' -f2)
|
||||||
|
echo " Type: Docker Swarm Service ($SERVICE_NAME)"
|
||||||
|
elif echo "$labels" | grep -q "com.docker.compose.project="; then
|
||||||
|
PROJECT_NAME=$(echo "$labels" | grep -o "com.docker.compose.project=[^,]*" | cut -d'=' -f2)
|
||||||
|
echo " Type: Docker Compose ($PROJECT_NAME)"
|
||||||
|
else
|
||||||
|
echo " Type: Regular Container"
|
||||||
|
fi
|
||||||
|
echo ""
|
||||||
|
done
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "🔧 To restore this volume, please:"
|
||||||
|
echo " 1. Stop all containers/services using this volume"
|
||||||
|
echo " 2. Remove the existing volume: docker volume rm ${volumeName}"
|
||||||
|
echo " 3. Run the restore operation again"
|
||||||
|
echo ""
|
||||||
|
echo "❌ Volume restore aborted - volume is in use"
|
||||||
|
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
`;
|
||||||
|
|
||||||
|
if (serviceType === "application") {
|
||||||
|
const application = await findApplicationById(id);
|
||||||
|
return `
|
||||||
|
echo "=== VOLUME RESTORE FOR APPLICATION ==="
|
||||||
|
echo "Application: ${application.appName}"
|
||||||
|
${checkVolumeCommand}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (serviceType === "compose") {
|
||||||
|
const compose = await findComposeById(id);
|
||||||
|
|
||||||
|
return `
|
||||||
|
echo "=== VOLUME RESTORE FOR COMPOSE ==="
|
||||||
|
echo "Compose: ${compose.appName}"
|
||||||
|
echo "Compose Type: ${compose.composeType}"
|
||||||
|
${checkVolumeCommand}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback for unknown service types
|
||||||
|
return checkVolumeCommand;
|
||||||
|
};
|
||||||
48
packages/server/src/utils/volume-backups/utils.ts
Normal file
48
packages/server/src/utils/volume-backups/utils.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import { findVolumeBackupById } from "@dokploy/server/services/volume-backups";
|
||||||
|
import {
|
||||||
|
createDeploymentVolumeBackup,
|
||||||
|
execAsync,
|
||||||
|
execAsyncRemote,
|
||||||
|
updateDeploymentStatus,
|
||||||
|
} from "../..";
|
||||||
|
import { backupVolume } from "./backup";
|
||||||
|
import { scheduleJob, scheduledJobs } from "node-schedule";
|
||||||
|
|
||||||
|
export const scheduleVolumeBackup = async (volumeBackupId: string) => {
|
||||||
|
const volumeBackup = await findVolumeBackupById(volumeBackupId);
|
||||||
|
scheduleJob(volumeBackupId, volumeBackup.cronExpression, async () => {
|
||||||
|
await runVolumeBackup(volumeBackupId);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const removeVolumeBackupJob = async (volumeBackupId: string) => {
|
||||||
|
const currentJob = scheduledJobs[volumeBackupId];
|
||||||
|
currentJob?.cancel();
|
||||||
|
};
|
||||||
|
|
||||||
|
export const runVolumeBackup = async (volumeBackupId: string) => {
|
||||||
|
const volumeBackup = await findVolumeBackupById(volumeBackupId);
|
||||||
|
const serverId =
|
||||||
|
volumeBackup.application?.serverId || volumeBackup.compose?.serverId;
|
||||||
|
const deployment = await createDeploymentVolumeBackup({
|
||||||
|
volumeBackupId: volumeBackup.volumeBackupId,
|
||||||
|
title: "Volume Backup",
|
||||||
|
description: "Volume Backup",
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const command = await backupVolume(volumeBackup);
|
||||||
|
|
||||||
|
const commandWithLog = `(${command}) >> ${deployment.logPath} 2>&1`;
|
||||||
|
if (serverId) {
|
||||||
|
await execAsyncRemote(serverId, commandWithLog);
|
||||||
|
} else {
|
||||||
|
await execAsync(commandWithLog);
|
||||||
|
}
|
||||||
|
|
||||||
|
await updateDeploymentStatus(deployment.deploymentId, "done");
|
||||||
|
} catch (error) {
|
||||||
|
await updateDeploymentStatus(deployment.deploymentId, "error");
|
||||||
|
console.error(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user