mirror of
https://github.com/Dokploy/dokploy.git
synced 2026-06-16 20:55:21 +02:00
Compare commits
154 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 | ||
|
|
5c73ced500 | ||
|
|
b1450d14ac | ||
|
|
c5e3b00990 | ||
|
|
cf3f44f686 | ||
|
|
119883e746 | ||
|
|
2918868166 | ||
|
|
715e44116d | ||
|
|
c15ee721ff | ||
|
|
71befc3a4b | ||
|
|
bca32f077b | ||
|
|
1afcecaec0 | ||
|
|
80b72dc75e | ||
|
|
5973d7b9b8 | ||
|
|
ab3a1504cf | ||
|
|
f51c51958f | ||
|
|
f3703e6f5e | ||
|
|
14cb6cecae | ||
|
|
6885b140eb | ||
|
|
8e8712e33d | ||
|
|
56fcaa8ccd | ||
|
|
d229284e5e | ||
|
|
3561b5cae6 | ||
|
|
a3192d6584 | ||
|
|
24ea8b7fbd | ||
|
|
e12df7b32e | ||
|
|
8001c9cfc2 | ||
|
|
a762b4b4ae | ||
|
|
9cfbd664c5 | ||
|
|
f9972bee60 | ||
|
|
3970cd452b | ||
|
|
6fc51e02a7 | ||
|
|
fa7db0dc75 | ||
|
|
107cdcee49 | ||
|
|
6521491e2f | ||
|
|
c5311f2a9f | ||
|
|
7726c8db21 | ||
|
|
b272f01a18 | ||
|
|
bddb07898e | ||
|
|
2fe7349889 | ||
|
|
5c1c969873 | ||
|
|
2f6f1b19e7 | ||
|
|
934ec9b16a | ||
|
|
c6d760a904 | ||
|
|
4f021a3f79 | ||
|
|
0c861585ed | ||
|
|
d15ccfe505 | ||
|
|
e158e05ad6 | ||
|
|
e21605030a | ||
|
|
f1c46f0d19 | ||
|
|
fbf3776548 | ||
|
|
46d84eaa71 | ||
|
|
2a2b947998 | ||
|
|
9974b2326f | ||
|
|
c042c8c0c5 | ||
|
|
819a310d48 | ||
|
|
12860a0736 | ||
|
|
392e2d66ec | ||
|
|
1f4ce2daf3 | ||
|
|
49edf17463 | ||
|
|
6eea02c098 | ||
|
|
a5bba9a11b | ||
|
|
37c7507507 | ||
|
|
ce88a0a5f2 | ||
|
|
40f28705cb | ||
|
|
03e04b7bce | ||
|
|
b920e7c0f1 | ||
|
|
15fde820a3 | ||
|
|
64293fce79 | ||
|
|
526f249d0e | ||
|
|
17c5a42d8e | ||
|
|
fac96b5db5 | ||
|
|
4796b0cf4e | ||
|
|
159a055bc6 | ||
|
|
cfade317f1 | ||
|
|
36ebefff16 | ||
|
|
7cc0603078 | ||
|
|
e0e42ac554 | ||
|
|
e004d8bd52 | ||
|
|
0c0912f606 | ||
|
|
8d81440d9b | ||
|
|
9ede3bd71b | ||
|
|
df6a72ea50 | ||
|
|
e79f8c4b72 | ||
|
|
26e2a24f63 | ||
|
|
830feabd70 | ||
|
|
122a3d110d | ||
|
|
c05edb313f | ||
|
|
1ec2853862 | ||
|
|
5c2709248c | ||
|
|
bc79074441 | ||
|
|
3b5428697b | ||
|
|
5ada451916 | ||
|
|
6b0d9240dd | ||
|
|
475c452451 | ||
|
|
1e31ebb9c2 | ||
|
|
207fe6f477 | ||
|
|
0b34676336 | ||
|
|
499022a328 | ||
|
|
fb5d2bd5b6 | ||
|
|
e42f6bc610 | ||
|
|
61cf426615 | ||
|
|
dde12e132a | ||
|
|
fd0f679d0f | ||
|
|
2a89be6efc | ||
|
|
412bb9e874 | ||
|
|
6290c217f1 | ||
|
|
4babdd45ea | ||
|
|
24bff96898 | ||
|
|
df8f1252a0 | ||
|
|
8599f519a4 | ||
|
|
113e4ae4b5 | ||
|
|
7f0bdc7e00 | ||
|
|
b685a817fd | ||
|
|
6061a443d1 | ||
|
|
4c9835d1f3 | ||
|
|
f2671f9369 | ||
|
|
bb904bb011 | ||
|
|
9fb6ca2b3b | ||
|
|
9f146d7d80 | ||
|
|
cad628d155 | ||
|
|
cd8230b0e5 |
23
GUIDES.md
23
GUIDES.md
@@ -16,28 +16,29 @@ Here's how to install docker on different operating systems:
|
||||
### Ubuntu
|
||||
|
||||
```bash
|
||||
# Uninstall old versions
|
||||
for pkg in docker.io docker-doc docker-compose docker-compose-v2 podman-docker containerd runc; do sudo apt-get remove $pkg; done
|
||||
|
||||
# Update package index
|
||||
sudo apt-get update
|
||||
|
||||
# Install prerequisites
|
||||
sudo apt-get install \
|
||||
apt-transport-https \
|
||||
ca-certificates \
|
||||
curl \
|
||||
gnupg \
|
||||
lsb-release
|
||||
sudo apt-get install ca-certificates curl
|
||||
sudo install -m 0755 -d /etc/apt/keyrings
|
||||
|
||||
# Add Docker's official GPG key
|
||||
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /usr/share/keyrings/docker-archive-keyring.gpg
|
||||
sudo curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc
|
||||
sudo chmod a+r /etc/apt/keyrings/docker.asc
|
||||
|
||||
# Set up stable repository
|
||||
# Add the repository to Apt sources
|
||||
echo \
|
||||
"deb [arch=amd64 signed-by=/usr/share/keyrings/docker-archive-keyring.gpg] https://download.docker.com/linux/ubuntu \
|
||||
$(lsb_release -cs) stable" | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
|
||||
"deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu \
|
||||
$(. /etc/os-release && echo "${UBUNTU_CODENAME:-$VERSION_CODENAME}") stable" | \
|
||||
sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
|
||||
|
||||
# Install Docker Engine
|
||||
sudo apt-get update
|
||||
sudo apt-get install docker-ce docker-ce-cli containerd.io
|
||||
sudo apt-get install docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
|
||||
```
|
||||
|
||||
## Windows
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
## Core License (Apache License 2.0)
|
||||
|
||||
Copyright 2024 Mauricio Siu.
|
||||
Copyright 2025 Mauricio Siu.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
|
||||
99
README.md
99
README.md
@@ -1,20 +1,16 @@
|
||||
<div align="center">
|
||||
<div>
|
||||
<a href="https://dokploy.com" target="_blank" rel="noopener">
|
||||
<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>
|
||||
</div>
|
||||
|
||||
</br>
|
||||
<div align="center">
|
||||
<div>Join us on Discord for help, feedback, and discussions!</div>
|
||||
<a href="https://dokploy.com">
|
||||
<img src=".github/sponsors/logo.png" alt="Dokploy - Open Source Alternative to Vercel, Heroku and Netlify." align="center" width="100%" />
|
||||
</a>
|
||||
</br>
|
||||
</br>
|
||||
<p>Join us on Discord for help, feedback, and discussions!</p>
|
||||
<a href="https://discord.gg/2tBnJ3jDJc">
|
||||
<img src="https://discordapp.com/api/guilds/1234073262418563112/widget.png?style=banner2" alt="Discord Shield"/>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<br />
|
||||
|
||||
Dokploy is a free, self-hostable Platform as a Service (PaaS) that simplifies the deployment and management of applications and databases.
|
||||
|
||||
### Features
|
||||
@@ -61,71 +57,52 @@ For detailed documentation, visit [docs.dokploy.com](https://docs.dokploy.com).
|
||||
|
||||
### Hero Sponsors 🎖
|
||||
|
||||
<div style="display: flex; align-items: center; gap: 20px;">
|
||||
<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.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://mandarin3d.com/?ref=dokploy" target="_blank" style="display: inline-block;">
|
||||
<img src=".github/sponsors/mandarin.png" alt="Mandarin" height="50"/>
|
||||
</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>
|
||||
|
||||
|
||||
<div>
|
||||
<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"><img src=".github/sponsors/lxaer.png" alt="LX Aer" width="100"/></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"><img src=".github/sponsors/light-node.webp" alt="Lightnode" width="300"/></a>
|
||||
</div>
|
||||
|
||||
<!-- Premium Supporters 🥇 -->
|
||||
|
||||
<!-- Add Premium Supporters here -->
|
||||
|
||||
### Premium Supporters 🥇
|
||||
|
||||
<div style="display: flex; align-items: center; gap: 20px;">
|
||||
<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://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>
|
||||
|
||||
<div>
|
||||
<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"><img src=".github/sponsors/agentdock.png" alt="agentdock.ai" width="100"/></a>
|
||||
</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>
|
||||
|
||||
</div>
|
||||
<!-- Elite Contributors 🥈 -->
|
||||
|
||||
|
||||
|
||||
<!-- Add Elite Contributors here -->
|
||||
|
||||
### Supporting Members 🥉
|
||||
|
||||
<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>
|
||||
### Elite Contributors 🥈
|
||||
|
||||
<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>
|
||||
|
||||
### 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 🤝
|
||||
|
||||
<div style="display: flex; gap: 30px; flex-wrap: wrap;">
|
||||
<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://photoquest.wedding/?ref=dokploy"><img src="https://photoquest.wedding/favicon/android-chrome-512x512.png" width="60px" alt="Rivo.gg"/></a>
|
||||
|
||||
<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://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>
|
||||
</div>
|
||||
|
||||
#### Organizations:
|
||||
@@ -140,12 +117,12 @@ For detailed documentation, visit [docs.dokploy.com](https://docs.dokploy.com).
|
||||
|
||||
<a href="https://github.com/dokploy/dokploy/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=dokploy/dokploy" />
|
||||
</a>
|
||||
</a>
|
||||
|
||||
## Video Tutorial
|
||||
|
||||
<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>
|
||||
|
||||
## Contributing
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
# License
|
||||
|
||||
## Core License (Apache License 2.0)
|
||||
|
||||
Copyright 2024 Mauricio Siu.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and limitations under the License.
|
||||
|
||||
## Additional Terms for Specific Features
|
||||
|
||||
The following additional terms apply to the multi-node support, Docker Compose file, Preview Deployments and Multi Server features of Dokploy. In the event of a conflict, these provisions shall take precedence over those in the Apache License:
|
||||
|
||||
- **Self-Hosted Version Free**: All features of Dokploy, including multi-node support, Docker Compose file support, Preview Deployments and Multi Server, will always be free to use in the self-hosted version.
|
||||
- **Restriction on Resale**: The multi-node support, Docker Compose file support, Preview Deployments and Multi Server features cannot be sold or offered as a service by any party other than the copyright holder without prior written consent.
|
||||
- **Modification Distribution**: Any modifications to the multi-node support, Docker Compose file support, Preview Deployments and Multi Server features must be distributed freely and cannot be sold or offered as a service.
|
||||
|
||||
For further inquiries or permissions, please contact us directly.
|
||||
@@ -19,6 +19,8 @@ describe("createDomainLabels", () => {
|
||||
path: "/",
|
||||
createdAt: "",
|
||||
previewDeploymentId: "",
|
||||
internalPath: "/",
|
||||
stripPath: false,
|
||||
};
|
||||
|
||||
it("should create basic labels for web entrypoint", async () => {
|
||||
|
||||
@@ -119,6 +119,8 @@ const baseDomain: Domain = {
|
||||
domainType: "application",
|
||||
uniqueConfigKey: 1,
|
||||
previewDeploymentId: "",
|
||||
internalPath: "/",
|
||||
stripPath: false,
|
||||
};
|
||||
|
||||
const baseRedirect: Redirect = {
|
||||
|
||||
@@ -130,7 +130,7 @@ const createStringToJSONSchema = (schema: z.ZodTypeAny) => {
|
||||
}
|
||||
try {
|
||||
return JSON.parse(str);
|
||||
} catch (_e) {
|
||||
} catch {
|
||||
ctx.addIssue({ code: "custom", message: "Invalid JSON format" });
|
||||
return z.NEVER;
|
||||
}
|
||||
|
||||
@@ -107,7 +107,7 @@ export const ShowImport = ({ composeId }: Props) => {
|
||||
composeId,
|
||||
});
|
||||
setShowModal(false);
|
||||
} catch (_error) {
|
||||
} catch {
|
||||
toast.error("Error importing template");
|
||||
}
|
||||
};
|
||||
@@ -126,7 +126,7 @@ export const ShowImport = ({ composeId }: Props) => {
|
||||
});
|
||||
setTemplateInfo(result);
|
||||
setShowModal(true);
|
||||
} catch (_error) {
|
||||
} catch {
|
||||
toast.error("Error processing template");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -35,6 +35,9 @@ import { z } from "zod";
|
||||
|
||||
const AddPortSchema = z.object({
|
||||
publishedPort: z.number().int().min(1).max(65535),
|
||||
publishMode: z.enum(["ingress", "host"], {
|
||||
required_error: "Publish mode is required",
|
||||
}),
|
||||
targetPort: z.number().int().min(1).max(65535),
|
||||
protocol: z.enum(["tcp", "udp"], {
|
||||
required_error: "Protocol is required",
|
||||
@@ -80,6 +83,7 @@ export const HandlePorts = ({
|
||||
useEffect(() => {
|
||||
form.reset({
|
||||
publishedPort: data?.publishedPort ?? 0,
|
||||
publishMode: data?.publishMode ?? "ingress",
|
||||
targetPort: data?.targetPort ?? 0,
|
||||
protocol: data?.protocol ?? "tcp",
|
||||
});
|
||||
@@ -165,6 +169,32 @@ export const HandlePorts = ({
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="publishMode"
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<FormItem className="md:col-span-2">
|
||||
<FormLabel>Published Port Mode</FormLabel>
|
||||
<Select
|
||||
onValueChange={field.onChange}
|
||||
value={field.value}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a publish mode for the port" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value={"ingress"}>Ingress</SelectItem>
|
||||
<SelectItem value={"host"}>Host</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="targetPort"
|
||||
|
||||
@@ -60,7 +60,7 @@ export const ShowPorts = ({ applicationId }: Props) => {
|
||||
{data?.ports.map((port) => (
|
||||
<div key={port.portId}>
|
||||
<div className="flex w-full flex-col sm:flex-row sm:items-center justify-between gap-4 sm:gap-10 border rounded-lg p-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 flex-col gap-4 sm:gap-8">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 flex-col gap-4 sm:gap-8">
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="font-medium">Published Port</span>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
@@ -68,7 +68,13 @@ export const ShowPorts = ({ applicationId }: Props) => {
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="font-medium"> Target Port</span>
|
||||
<span className="font-medium">Published Port Mode</span>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{port?.publishMode?.toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="font-medium">Target Port</span>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{port.targetPort}
|
||||
</span>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { AlertBlock } from "@/components/shared/alert-block";
|
||||
import { CodeEditor } from "@/components/shared/code-editor";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
@@ -169,6 +170,23 @@ export const AddVolumes = ({
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
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
|
||||
control={form.control}
|
||||
defaultValue={form.control._defaultValues.type}
|
||||
|
||||
@@ -14,7 +14,8 @@ interface Props {
|
||||
| "schedule"
|
||||
| "server"
|
||||
| "backup"
|
||||
| "previewDeployment";
|
||||
| "previewDeployment"
|
||||
| "volumeBackup";
|
||||
serverId?: string;
|
||||
refreshToken?: string;
|
||||
children?: React.ReactNode;
|
||||
|
||||
@@ -27,7 +27,8 @@ interface Props {
|
||||
| "schedule"
|
||||
| "server"
|
||||
| "backup"
|
||||
| "previewDeployment";
|
||||
| "previewDeployment"
|
||||
| "volumeBackup";
|
||||
refreshToken?: string;
|
||||
serverId?: string;
|
||||
}
|
||||
@@ -62,6 +63,8 @@ export const ShowDeployments = ({
|
||||
|
||||
const { mutateAsync: rollback, isLoading: isRollingBack } =
|
||||
api.rollback.rollback.useMutation();
|
||||
const { mutateAsync: killProcess, isLoading: isKillingProcess } =
|
||||
api.deployment.killProcess.useMutation();
|
||||
|
||||
const [url, setUrl] = React.useState("");
|
||||
useEffect(() => {
|
||||
@@ -170,6 +173,32 @@ export const ShowDeployments = ({
|
||||
</div>
|
||||
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
{deployment.pid && deployment.status === "running" && (
|
||||
<DialogAction
|
||||
title="Kill Process"
|
||||
description="Are you sure you want to kill the process?"
|
||||
type="default"
|
||||
onClick={async () => {
|
||||
await killProcess({
|
||||
deploymentId: deployment.deploymentId,
|
||||
})
|
||||
.then(() => {
|
||||
toast.success("Process killed successfully");
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error killing process");
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
isLoading={isKillingProcess}
|
||||
>
|
||||
Kill Process
|
||||
</Button>
|
||||
</DialogAction>
|
||||
)}
|
||||
<Button
|
||||
onClick={() => {
|
||||
setActiveLog(deployment);
|
||||
|
||||
@@ -49,6 +49,8 @@ export const domain = z
|
||||
.object({
|
||||
host: z.string().min(1, { message: "Add a hostname" }),
|
||||
path: z.string().min(1).optional(),
|
||||
internalPath: z.string().optional(),
|
||||
stripPath: z.boolean().optional(),
|
||||
port: z
|
||||
.number()
|
||||
.min(1, { message: "Port must be at least 1" })
|
||||
@@ -84,6 +86,29 @@ export const domain = z
|
||||
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>;
|
||||
@@ -162,6 +187,8 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
|
||||
defaultValues: {
|
||||
host: "",
|
||||
path: undefined,
|
||||
internalPath: undefined,
|
||||
stripPath: false,
|
||||
port: undefined,
|
||||
https: false,
|
||||
certificateType: undefined,
|
||||
@@ -182,6 +209,8 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
|
||||
...data,
|
||||
/* Convert null to undefined */
|
||||
path: data?.path || undefined,
|
||||
internalPath: data?.internalPath || undefined,
|
||||
stripPath: data?.stripPath || false,
|
||||
port: data?.port || undefined,
|
||||
certificateType: data?.certificateType || undefined,
|
||||
customCertResolver: data?.customCertResolver || undefined,
|
||||
@@ -194,6 +223,8 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
|
||||
form.reset({
|
||||
host: "",
|
||||
path: undefined,
|
||||
internalPath: undefined,
|
||||
stripPath: false,
|
||||
port: undefined,
|
||||
https: false,
|
||||
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
|
||||
control={form.control}
|
||||
name="port"
|
||||
|
||||
@@ -278,7 +278,7 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
|
||||
{repositories?.map((repo) => {
|
||||
return (
|
||||
<CommandItem
|
||||
value={repo.name}
|
||||
value={repo.url}
|
||||
key={repo.url}
|
||||
onSelect={() => {
|
||||
form.setValue("repository", {
|
||||
@@ -299,7 +299,8 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
|
||||
<CheckIcon
|
||||
className={cn(
|
||||
"ml-auto h-4 w-4",
|
||||
repo.name === field.value.repo
|
||||
repo.url ===
|
||||
field.value.gitlabPathNamespace
|
||||
? "opacity-100"
|
||||
: "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')
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { AlertBlock } from "@/components/shared/alert-block";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
@@ -79,6 +80,11 @@ export const ShowRollbackSettings = ({ applicationId, children }: Props) => {
|
||||
<DialogDescription>
|
||||
Configure how rollbacks work for this application
|
||||
</DialogDescription>
|
||||
<AlertBlock>
|
||||
Having rollbacks enabled increases storage usage. Be careful with
|
||||
this option. Note that manually cleaning the cache may delete
|
||||
rollback images, making them unavailable for future rollbacks.
|
||||
</AlertBlock>
|
||||
</DialogHeader>
|
||||
|
||||
<Form {...form}>
|
||||
|
||||
@@ -166,12 +166,16 @@ export const ShowSchedules = ({ id, scheduleType = "application" }: Props) => {
|
||||
|
||||
await runManually({
|
||||
scheduleId: schedule.scheduleId,
|
||||
}).then(async () => {
|
||||
await new Promise((resolve) =>
|
||||
setTimeout(resolve, 1500),
|
||||
);
|
||||
refetchSchedules();
|
||||
});
|
||||
})
|
||||
.then(async () => {
|
||||
await new Promise((resolve) =>
|
||||
setTimeout(resolve, 1500),
|
||||
);
|
||||
refetchSchedules();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error running schedule");
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Play className="size-4 transition-colors" />
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -77,7 +77,7 @@ export const ComposeFileEditor = ({ composeId }: Props) => {
|
||||
composeId,
|
||||
});
|
||||
})
|
||||
.catch((_e) => {
|
||||
.catch(() => {
|
||||
toast.error("Error updating the Compose config");
|
||||
});
|
||||
};
|
||||
|
||||
@@ -280,7 +280,7 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
|
||||
{repositories?.map((repo) => {
|
||||
return (
|
||||
<CommandItem
|
||||
value={repo.name}
|
||||
value={repo.url}
|
||||
key={repo.url}
|
||||
onSelect={() => {
|
||||
form.setValue("repository", {
|
||||
@@ -301,7 +301,8 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
|
||||
<CheckIcon
|
||||
className={cn(
|
||||
"ml-auto h-4 w-4",
|
||||
repo.name === field.value.repo
|
||||
repo.url ===
|
||||
field.value.gitlabPathNamespace
|
||||
? "opacity-100"
|
||||
: "opacity-0",
|
||||
)}
|
||||
|
||||
@@ -40,7 +40,7 @@ export const ShowConvertedCompose = ({ composeId }: Props) => {
|
||||
.then(() => {
|
||||
refetch();
|
||||
})
|
||||
.catch((_err) => {});
|
||||
.catch(() => {});
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
|
||||
@@ -199,7 +199,7 @@ const RestoreBackupSchema = z
|
||||
}
|
||||
});
|
||||
|
||||
const formatBytes = (bytes: number): string => {
|
||||
export const formatBytes = (bytes: number): string => {
|
||||
if (bytes === 0) return "0 Bytes";
|
||||
const k = 1024;
|
||||
const sizes = ["Bytes", "KB", "MB", "GB", "TB"];
|
||||
@@ -415,7 +415,7 @@ export const RestoreBackup = ({
|
||||
<FormLabel className="flex items-center justify-between">
|
||||
Search Backup Files
|
||||
{field.value && (
|
||||
<Badge variant="outline">
|
||||
<Badge variant="outline" className="truncate">
|
||||
{field.value}
|
||||
<Copy
|
||||
className="ml-2 size-4 cursor-pointer"
|
||||
@@ -439,7 +439,9 @@ export const RestoreBackup = ({
|
||||
!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" />
|
||||
</Button>
|
||||
</FormControl>
|
||||
|
||||
@@ -103,7 +103,7 @@ export const AddApplication = ({ projectId, projectName }: Props) => {
|
||||
projectId,
|
||||
});
|
||||
})
|
||||
.catch((_e) => {
|
||||
.catch(() => {
|
||||
toast.error("Error creating the service");
|
||||
});
|
||||
};
|
||||
|
||||
@@ -20,7 +20,7 @@ export const ShowNodesModal = ({ serverId }: Props) => {
|
||||
Show Swarm Nodes
|
||||
</DropdownMenuItem>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-5xl overflow-y-auto max-h-screen ">
|
||||
<DialogContent className="min-w-[70vw] overflow-y-auto max-h-screen">
|
||||
<div className="grid w-full gap-1">
|
||||
<ShowNodes serverId={serverId} />
|
||||
</div>
|
||||
|
||||
@@ -87,7 +87,7 @@ export const ShowNodes = ({ serverId }: Props) => {
|
||||
</TableCaption>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[100px]">Hostname</TableHead>
|
||||
<TableHead className="text-left">Hostname</TableHead>
|
||||
<TableHead className="text-right">Status</TableHead>
|
||||
<TableHead className="text-right">Role</TableHead>
|
||||
<TableHead className="text-right">Availability</TableHead>
|
||||
@@ -104,7 +104,7 @@ export const ShowNodes = ({ serverId }: Props) => {
|
||||
const isManager = node.Spec.Role === "manager";
|
||||
return (
|
||||
<TableRow key={node.ID}>
|
||||
<TableCell className="w-[100px]">
|
||||
<TableCell className="text-left">
|
||||
{node.Description.Hostname}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
|
||||
@@ -1063,7 +1063,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
||||
});
|
||||
}
|
||||
toast.success("Connection Success");
|
||||
} catch (_err) {
|
||||
} catch {
|
||||
toast.error("Error testing the provider");
|
||||
}
|
||||
}}
|
||||
|
||||
@@ -63,7 +63,7 @@ export const Disable2FA = () => {
|
||||
toast.success("2FA disabled successfully");
|
||||
utils.user.get.invalidate();
|
||||
setIsOpen(false);
|
||||
} catch (_error) {
|
||||
} catch {
|
||||
form.setError("password", {
|
||||
message: "Connection error. Please try again.",
|
||||
});
|
||||
|
||||
@@ -36,7 +36,7 @@ export const ToggleDockerCleanup = ({ serverId }: Props) => {
|
||||
await refetch();
|
||||
}
|
||||
toast.success("Docker Cleanup updated");
|
||||
} catch (_error) {
|
||||
} catch {
|
||||
toast.error("Docker Cleanup Error");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -56,7 +56,7 @@ export function GPUSupport({ serverId }: GPUSupportProps) {
|
||||
try {
|
||||
await utils.settings.checkGPUStatus.invalidate({ serverId });
|
||||
await refetch();
|
||||
} catch (_error) {
|
||||
} catch {
|
||||
toast.error("Failed to refresh GPU status");
|
||||
} finally {
|
||||
setIsRefreshing(false);
|
||||
@@ -74,7 +74,7 @@ export function GPUSupport({ serverId }: GPUSupportProps) {
|
||||
|
||||
try {
|
||||
await setupGPU.mutateAsync({ serverId });
|
||||
} catch (_error) {
|
||||
} catch {
|
||||
// Error handling is done in mutation's onError
|
||||
}
|
||||
};
|
||||
|
||||
@@ -141,7 +141,7 @@ export const ShowServers = () => {
|
||||
</TableCaption>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[100px]">Name</TableHead>
|
||||
<TableHead className="text-left">Name</TableHead>
|
||||
{isCloud && (
|
||||
<TableHead className="text-center">
|
||||
Status
|
||||
@@ -173,7 +173,7 @@ export const ShowServers = () => {
|
||||
const isActive = server.serverStatus === "active";
|
||||
return (
|
||||
<TableRow key={server.serverId}>
|
||||
<TableCell className="w-[100px]">
|
||||
<TableCell className="text-left">
|
||||
{server.name}
|
||||
</TableCell>
|
||||
{isCloud && (
|
||||
|
||||
@@ -146,7 +146,7 @@ export const ShowInvitations = () => {
|
||||
{invitation.status === "pending" && (
|
||||
<DropdownMenuItem
|
||||
className="w-full cursor-pointer"
|
||||
onSelect={(_e) => {
|
||||
onSelect={() => {
|
||||
copy(
|
||||
`${origin}/invitation?token=${invitation.id}`,
|
||||
);
|
||||
@@ -162,7 +162,7 @@ export const ShowInvitations = () => {
|
||||
{invitation.status === "pending" && (
|
||||
<DropdownMenuItem
|
||||
className="w-full cursor-pointer"
|
||||
onSelect={async (_e) => {
|
||||
onSelect={async () => {
|
||||
const result =
|
||||
await authClient.organization.cancelInvitation(
|
||||
{
|
||||
@@ -189,7 +189,7 @@ export const ShowInvitations = () => {
|
||||
)}
|
||||
<DropdownMenuItem
|
||||
className="w-full cursor-pointer"
|
||||
onSelect={async (_e) => {
|
||||
onSelect={async () => {
|
||||
await removeInvitation({
|
||||
invitationId: invitation.id,
|
||||
}).then(() => {
|
||||
|
||||
@@ -91,7 +91,7 @@ export const ManageTraefikPorts = ({ children, serverId }: Props) => {
|
||||
});
|
||||
toast.success(t("settings.server.webServer.traefik.portsUpdated"));
|
||||
setOpen(false);
|
||||
} catch (_error) {}
|
||||
} catch {}
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -87,7 +87,7 @@ export const ShowNodeApplications = ({ serverId }: Props) => {
|
||||
Services
|
||||
</Button>
|
||||
</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>
|
||||
<DialogTitle>Node Applications</DialogTitle>
|
||||
<DialogDescription>
|
||||
|
||||
@@ -4,7 +4,7 @@ import type * as React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
||||
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold whitespace-nowrap transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
|
||||
@@ -246,7 +246,9 @@ const Leaf = React.forwardRef<
|
||||
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>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -17,7 +17,7 @@ const PopoverContent = React.forwardRef<
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
"z-50 w-full rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
1
apps/dokploy/drizzle/0098_conscious_chat.sql
Normal file
1
apps/dokploy/drizzle/0098_conscious_chat.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE "deployment" ADD COLUMN "pid" text;
|
||||
2
apps/dokploy/drizzle/0099_wise_golden_guardian.sql
Normal file
2
apps/dokploy/drizzle/0099_wise_golden_guardian.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
CREATE TYPE "public"."publishModeType" AS ENUM('ingress', 'host');--> statement-breakpoint
|
||||
ALTER TABLE "port" ADD COLUMN "publishMode" "publishModeType" DEFAULT 'host' NOT NULL;
|
||||
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;
|
||||
5832
apps/dokploy/drizzle/meta/0098_snapshot.json
Normal file
5832
apps/dokploy/drizzle/meta/0098_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
5848
apps/dokploy/drizzle/meta/0099_snapshot.json
Normal file
5848
apps/dokploy/drizzle/meta/0099_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
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
@@ -687,6 +687,41 @@
|
||||
"when": 1750567641441,
|
||||
"tag": "0097_hard_lizard",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 98,
|
||||
"version": "7",
|
||||
"when": 1751233265357,
|
||||
"tag": "0098_conscious_chat",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 99,
|
||||
"version": "7",
|
||||
"when": 1751693569786,
|
||||
"tag": "0099_wise_golden_guardian",
|
||||
"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",
|
||||
"version": "v0.23.2",
|
||||
"version": "v0.24.0",
|
||||
"private": true,
|
||||
"license": "Apache-2.0",
|
||||
"type": "module",
|
||||
|
||||
@@ -72,7 +72,7 @@ export async function getServerSideProps(
|
||||
trpcState: helpers.dehydrate(),
|
||||
},
|
||||
};
|
||||
} catch (_error) {
|
||||
} catch {
|
||||
return {
|
||||
props: {},
|
||||
};
|
||||
|
||||
@@ -390,7 +390,7 @@ const Project = (
|
||||
break;
|
||||
}
|
||||
success++;
|
||||
} catch (_error) {
|
||||
} catch {
|
||||
toast.error(`Error starting service ${serviceId}`);
|
||||
}
|
||||
}
|
||||
@@ -437,7 +437,7 @@ const Project = (
|
||||
break;
|
||||
}
|
||||
success++;
|
||||
} catch (_error) {
|
||||
} catch {
|
||||
toast.error(`Error stopping service ${serviceId}`);
|
||||
}
|
||||
}
|
||||
@@ -1107,7 +1107,7 @@ export async function getServerSideProps(
|
||||
projectId: params?.projectId,
|
||||
},
|
||||
};
|
||||
} catch (_error) {
|
||||
} catch {
|
||||
return {
|
||||
redirect: {
|
||||
permanent: false,
|
||||
|
||||
@@ -14,6 +14,7 @@ import { ShowDockerLogs } from "@/components/dashboard/application/logs/show";
|
||||
import { ShowPreviewDeployments } from "@/components/dashboard/application/preview-deployments/show-preview-deployments";
|
||||
import { ShowSchedules } from "@/components/dashboard/application/schedules/show-schedules";
|
||||
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 { ContainerFreeMonitoring } from "@/components/dashboard/monitoring/free/container/show-free-container-monitoring";
|
||||
import { ContainerPaidMonitoring } from "@/components/dashboard/monitoring/paid/container/show-paid-container-monitoring";
|
||||
@@ -61,7 +62,8 @@ type TabState =
|
||||
| "deployments"
|
||||
| "domains"
|
||||
| "monitoring"
|
||||
| "preview-deployments";
|
||||
| "preview-deployments"
|
||||
| "volume-backups";
|
||||
|
||||
const Service = (
|
||||
props: InferGetServerSidePropsType<typeof getServerSideProps>,
|
||||
@@ -234,6 +236,9 @@ const Service = (
|
||||
Preview Deployments
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="schedules">Schedules</TabsTrigger>
|
||||
<TabsTrigger value="volume-backups">
|
||||
Volume Backups
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="deployments">Deployments</TabsTrigger>
|
||||
<TabsTrigger value="logs">Logs</TabsTrigger>
|
||||
{((data?.serverId && isCloud) || !data?.server) && (
|
||||
@@ -328,6 +333,15 @@ const Service = (
|
||||
/>
|
||||
</div>
|
||||
</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">
|
||||
<div className="flex flex-col gap-4 pt-2.5">
|
||||
<ShowPreviewDeployments applicationId={applicationId} />
|
||||
@@ -413,7 +427,7 @@ export async function getServerSideProps(
|
||||
activeTab: (activeTab || "general") as TabState,
|
||||
},
|
||||
};
|
||||
} catch (_error) {
|
||||
} catch {
|
||||
return {
|
||||
redirect: {
|
||||
permanent: false,
|
||||
|
||||
@@ -4,6 +4,7 @@ import { ShowDeployments } from "@/components/dashboard/application/deployments/
|
||||
import { ShowDomains } from "@/components/dashboard/application/domains/show-domains";
|
||||
import { ShowEnvironment } from "@/components/dashboard/application/environment/show-enviroment";
|
||||
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 { DeleteService } from "@/components/dashboard/compose/delete-service";
|
||||
import { ShowGeneralCompose } from "@/components/dashboard/compose/general/show";
|
||||
@@ -57,7 +58,8 @@ type TabState =
|
||||
| "advanced"
|
||||
| "deployments"
|
||||
| "domains"
|
||||
| "monitoring";
|
||||
| "monitoring"
|
||||
| "volumeBackups";
|
||||
|
||||
const Service = (
|
||||
props: InferGetServerSidePropsType<typeof getServerSideProps>,
|
||||
@@ -214,10 +216,10 @@ const Service = (
|
||||
className={cn(
|
||||
"xl:grid xl:w-fit max-md:overflow-y-scroll justify-start",
|
||||
isCloud && data?.serverId
|
||||
? "xl:grid-cols-9"
|
||||
? "xl:grid-cols-10"
|
||||
: data?.serverId
|
||||
? "xl:grid-cols-8"
|
||||
: "xl:grid-cols-9",
|
||||
? "xl:grid-cols-9"
|
||||
: "xl:grid-cols-10",
|
||||
)}
|
||||
>
|
||||
<TabsTrigger value="general">General</TabsTrigger>
|
||||
@@ -226,6 +228,9 @@ const Service = (
|
||||
<TabsTrigger value="deployments">Deployments</TabsTrigger>
|
||||
<TabsTrigger value="backups">Backups</TabsTrigger>
|
||||
<TabsTrigger value="schedules">Schedules</TabsTrigger>
|
||||
<TabsTrigger value="volumeBackups">
|
||||
Volume Backups
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="logs">Logs</TabsTrigger>
|
||||
{((data?.serverId && isCloud) || !data?.server) && (
|
||||
<TabsTrigger value="monitoring">Monitoring</TabsTrigger>
|
||||
@@ -255,7 +260,15 @@ const Service = (
|
||||
<ShowSchedules id={composeId} scheduleType="compose" />
|
||||
</div>
|
||||
</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">
|
||||
<div className="pt-2.5">
|
||||
<div className="flex flex-col border rounded-lg ">
|
||||
@@ -342,6 +355,7 @@ const Service = (
|
||||
<ShowDomains id={composeId} type="compose" />
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="advanced">
|
||||
<div className="flex flex-col gap-4 pt-2.5">
|
||||
<AddCommandCompose composeId={composeId} />
|
||||
@@ -409,7 +423,7 @@ export async function getServerSideProps(
|
||||
activeTab: (activeTab || "general") as TabState,
|
||||
},
|
||||
};
|
||||
} catch (_error) {
|
||||
} catch {
|
||||
return {
|
||||
redirect: {
|
||||
permanent: false,
|
||||
|
||||
@@ -338,7 +338,7 @@ export async function getServerSideProps(
|
||||
activeTab: (activeTab || "general") as TabState,
|
||||
},
|
||||
};
|
||||
} catch (_error) {
|
||||
} catch {
|
||||
return {
|
||||
redirect: {
|
||||
permanent: false,
|
||||
|
||||
@@ -340,7 +340,7 @@ export async function getServerSideProps(
|
||||
activeTab: (activeTab || "general") as TabState,
|
||||
},
|
||||
};
|
||||
} catch (_error) {
|
||||
} catch {
|
||||
return {
|
||||
redirect: {
|
||||
permanent: false,
|
||||
|
||||
@@ -324,7 +324,7 @@ export async function getServerSideProps(
|
||||
activeTab: (activeTab || "general") as TabState,
|
||||
},
|
||||
};
|
||||
} catch (_error) {
|
||||
} catch {
|
||||
return {
|
||||
redirect: {
|
||||
permanent: false,
|
||||
|
||||
@@ -321,7 +321,7 @@ export async function getServerSideProps(
|
||||
activeTab: (activeTab || "general") as TabState,
|
||||
},
|
||||
};
|
||||
} catch (_error) {
|
||||
} catch {
|
||||
return {
|
||||
redirect: {
|
||||
permanent: false,
|
||||
|
||||
@@ -328,7 +328,7 @@ export async function getServerSideProps(
|
||||
activeTab: (activeTab || "general") as TabState,
|
||||
},
|
||||
};
|
||||
} catch (_error) {
|
||||
} catch {
|
||||
return {
|
||||
redirect: {
|
||||
permanent: false,
|
||||
|
||||
@@ -68,7 +68,7 @@ export async function getServerSideProps(
|
||||
trpcState: helpers.dehydrate(),
|
||||
},
|
||||
};
|
||||
} catch (_error) {
|
||||
} catch {
|
||||
return {
|
||||
props: {},
|
||||
};
|
||||
|
||||
@@ -69,7 +69,7 @@ export async function getServerSideProps(
|
||||
trpcState: helpers.dehydrate(),
|
||||
},
|
||||
};
|
||||
} catch (_error) {
|
||||
} catch {
|
||||
return {
|
||||
props: {},
|
||||
};
|
||||
|
||||
@@ -72,7 +72,7 @@ export async function getServerSideProps(
|
||||
trpcState: helpers.dehydrate(),
|
||||
},
|
||||
};
|
||||
} catch (_error) {
|
||||
} catch {
|
||||
return {
|
||||
props: {},
|
||||
};
|
||||
|
||||
@@ -72,7 +72,7 @@ export async function getServerSideProps(
|
||||
trpcState: helpers.dehydrate(),
|
||||
},
|
||||
};
|
||||
} catch (_error) {
|
||||
} catch {
|
||||
return {
|
||||
props: {},
|
||||
};
|
||||
|
||||
@@ -96,7 +96,7 @@ export default function Home({ IS_CLOUD }: Props) {
|
||||
|
||||
toast.success("Logged in successfully");
|
||||
router.push("/dashboard/projects");
|
||||
} catch (_error) {
|
||||
} catch {
|
||||
toast.error("An error occurred while logging in");
|
||||
} finally {
|
||||
setIsLoginLoading(false);
|
||||
@@ -124,7 +124,7 @@ export default function Home({ IS_CLOUD }: Props) {
|
||||
|
||||
toast.success("Logged in successfully");
|
||||
router.push("/dashboard/projects");
|
||||
} catch (_error) {
|
||||
} catch {
|
||||
toast.error("An error occurred while verifying 2FA code");
|
||||
} finally {
|
||||
setIsTwoFactorLoading(false);
|
||||
@@ -154,7 +154,7 @@ export default function Home({ IS_CLOUD }: Props) {
|
||||
|
||||
toast.success("Logged in successfully");
|
||||
router.push("/dashboard/projects");
|
||||
} catch (_error) {
|
||||
} catch {
|
||||
toast.error("An error occurred while verifying backup code");
|
||||
} finally {
|
||||
setIsBackupCodeLoading(false);
|
||||
@@ -478,7 +478,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||
},
|
||||
};
|
||||
}
|
||||
} catch (_error) {}
|
||||
} catch {}
|
||||
|
||||
return {
|
||||
props: {
|
||||
|
||||
@@ -133,7 +133,7 @@ const Invitation = ({
|
||||
|
||||
toast.success("Account created successfully");
|
||||
router.push("/dashboard/projects");
|
||||
} catch (_error) {
|
||||
} catch {
|
||||
toast.error("An error occurred while creating your account");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -4,24 +4,24 @@ import { users_temp } from "@dokploy/server/db/schema";
|
||||
import { eq } from "drizzle-orm";
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const result = await findAdmin();
|
||||
try {
|
||||
const result = await findAdmin();
|
||||
|
||||
const update = await db
|
||||
.update(users_temp)
|
||||
.set({
|
||||
twoFactorEnabled: false,
|
||||
})
|
||||
.where(eq(users_temp.id, result.userId));
|
||||
const update = await db
|
||||
.update(users_temp)
|
||||
.set({
|
||||
twoFactorEnabled: false,
|
||||
})
|
||||
.where(eq(users_temp.id, result.userId));
|
||||
|
||||
if (update) {
|
||||
console.log("2FA reset successful");
|
||||
} else {
|
||||
console.log("Password reset failed");
|
||||
}
|
||||
if (update) {
|
||||
console.log("2FA reset successful");
|
||||
} else {
|
||||
console.log("Password reset failed");
|
||||
}
|
||||
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
console.log("Error resetting 2FA", error);
|
||||
}
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
console.log("Error resetting 2FA", error);
|
||||
}
|
||||
})();
|
||||
|
||||
@@ -37,6 +37,7 @@ import { swarmRouter } from "./routers/swarm";
|
||||
import { userRouter } from "./routers/user";
|
||||
import { scheduleRouter } from "./routers/schedule";
|
||||
import { rollbackRouter } from "./routers/rollbacks";
|
||||
import { volumeBackupsRouter } from "./routers/volume-backups";
|
||||
/**
|
||||
* This is the primary router for your server.
|
||||
*
|
||||
@@ -82,6 +83,7 @@ export const appRouter = createTRPCRouter({
|
||||
organization: organizationRouter,
|
||||
schedule: scheduleRouter,
|
||||
rollback: rollbackRouter,
|
||||
volumeBackups: volumeBackupsRouter,
|
||||
});
|
||||
|
||||
// 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 mechanizeDockerContainer(application);
|
||||
await updateApplicationStatus(input.applicationId, "done");
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
findProjectById,
|
||||
findServerById,
|
||||
findUserById,
|
||||
getComposeContainer,
|
||||
loadServices,
|
||||
randomizeComposeFile,
|
||||
randomizeIsolatedDeploymentComposeFile,
|
||||
@@ -241,6 +242,27 @@ export const composeRouter = createTRPCRouter({
|
||||
}
|
||||
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
|
||||
.input(apiFindCompose)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
|
||||
@@ -7,16 +7,21 @@ import {
|
||||
deployments,
|
||||
} from "@/server/db/schema";
|
||||
import {
|
||||
execAsync,
|
||||
execAsyncRemote,
|
||||
findAllDeploymentsByApplicationId,
|
||||
findAllDeploymentsByComposeId,
|
||||
findAllDeploymentsByServerId,
|
||||
findApplicationById,
|
||||
findComposeById,
|
||||
findDeploymentById,
|
||||
findServerById,
|
||||
updateDeploymentStatus,
|
||||
} from "@dokploy/server";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { desc, eq } from "drizzle-orm";
|
||||
import { createTRPCRouter, protectedProcedure } from "../trpc";
|
||||
import { z } from "zod";
|
||||
|
||||
export const deploymentRouter = createTRPCRouter({
|
||||
all: protectedProcedure
|
||||
@@ -72,4 +77,30 @@ export const deploymentRouter = createTRPCRouter({
|
||||
|
||||
return deploymentsList;
|
||||
}),
|
||||
|
||||
killProcess: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
deploymentId: z.string().min(1),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ input }) => {
|
||||
const deployment = await findDeploymentById(input.deploymentId);
|
||||
|
||||
if (!deployment.pid) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Deployment is not running",
|
||||
});
|
||||
}
|
||||
|
||||
const command = `kill -9 ${deployment.pid}`;
|
||||
if (deployment.schedule?.serverId) {
|
||||
await execAsyncRemote(deployment.schedule.serverId, command);
|
||||
} else {
|
||||
await execAsync(command);
|
||||
}
|
||||
|
||||
await updateDeploymentStatus(deployment.deploymentId, "error");
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
containerRestart,
|
||||
findServerById,
|
||||
getConfig,
|
||||
getContainers,
|
||||
getContainersByAppLabel,
|
||||
@@ -9,6 +10,9 @@ import {
|
||||
} from "@dokploy/server";
|
||||
import { z } from "zod";
|
||||
import { createTRPCRouter, protectedProcedure } from "../trpc";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
|
||||
export const containerIdRegex = /^[a-zA-Z0-9.\-_]+$/;
|
||||
|
||||
export const dockerRouter = createTRPCRouter({
|
||||
getContainers: protectedProcedure
|
||||
@@ -17,14 +21,23 @@ export const dockerRouter = createTRPCRouter({
|
||||
serverId: z.string().optional(),
|
||||
}),
|
||||
)
|
||||
.query(async ({ input }) => {
|
||||
.query(async ({ input, ctx }) => {
|
||||
if (input.serverId) {
|
||||
const server = await findServerById(input.serverId);
|
||||
if (server.organizationId !== ctx.session?.activeOrganizationId) {
|
||||
throw new TRPCError({ code: "UNAUTHORIZED" });
|
||||
}
|
||||
}
|
||||
return await getContainers(input.serverId);
|
||||
}),
|
||||
|
||||
restartContainer: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
containerId: z.string().min(1),
|
||||
containerId: z
|
||||
.string()
|
||||
.min(1)
|
||||
.regex(containerIdRegex, "Invalid container id."),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ input }) => {
|
||||
@@ -34,11 +47,20 @@ export const dockerRouter = createTRPCRouter({
|
||||
getConfig: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
containerId: z.string().min(1),
|
||||
containerId: z
|
||||
.string()
|
||||
.min(1)
|
||||
.regex(containerIdRegex, "Invalid container id."),
|
||||
serverId: z.string().optional(),
|
||||
}),
|
||||
)
|
||||
.query(async ({ input }) => {
|
||||
.query(async ({ input, ctx }) => {
|
||||
if (input.serverId) {
|
||||
const server = await findServerById(input.serverId);
|
||||
if (server.organizationId !== ctx.session?.activeOrganizationId) {
|
||||
throw new TRPCError({ code: "UNAUTHORIZED" });
|
||||
}
|
||||
}
|
||||
return await getConfig(input.containerId, input.serverId);
|
||||
}),
|
||||
|
||||
@@ -48,11 +70,17 @@ export const dockerRouter = createTRPCRouter({
|
||||
appType: z
|
||||
.union([z.literal("stack"), z.literal("docker-compose")])
|
||||
.optional(),
|
||||
appName: z.string().min(1),
|
||||
appName: z.string().min(1).regex(containerIdRegex, "Invalid app name."),
|
||||
serverId: z.string().optional(),
|
||||
}),
|
||||
)
|
||||
.query(async ({ input }) => {
|
||||
.query(async ({ input, ctx }) => {
|
||||
if (input.serverId) {
|
||||
const server = await findServerById(input.serverId);
|
||||
if (server.organizationId !== ctx.session?.activeOrganizationId) {
|
||||
throw new TRPCError({ code: "UNAUTHORIZED" });
|
||||
}
|
||||
}
|
||||
return await getContainersByAppNameMatch(
|
||||
input.appName,
|
||||
input.appType,
|
||||
@@ -63,12 +91,18 @@ export const dockerRouter = createTRPCRouter({
|
||||
getContainersByAppLabel: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
appName: z.string().min(1),
|
||||
appName: z.string().min(1).regex(containerIdRegex, "Invalid app name."),
|
||||
serverId: z.string().optional(),
|
||||
type: z.enum(["standalone", "swarm"]),
|
||||
}),
|
||||
)
|
||||
.query(async ({ input }) => {
|
||||
.query(async ({ input, ctx }) => {
|
||||
if (input.serverId) {
|
||||
const server = await findServerById(input.serverId);
|
||||
if (server.organizationId !== ctx.session?.activeOrganizationId) {
|
||||
throw new TRPCError({ code: "UNAUTHORIZED" });
|
||||
}
|
||||
}
|
||||
return await getContainersByAppLabel(
|
||||
input.appName,
|
||||
input.type,
|
||||
@@ -79,22 +113,34 @@ export const dockerRouter = createTRPCRouter({
|
||||
getStackContainersByAppName: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
appName: z.string().min(1),
|
||||
appName: z.string().min(1).regex(containerIdRegex, "Invalid app name."),
|
||||
serverId: z.string().optional(),
|
||||
}),
|
||||
)
|
||||
.query(async ({ input }) => {
|
||||
.query(async ({ input, ctx }) => {
|
||||
if (input.serverId) {
|
||||
const server = await findServerById(input.serverId);
|
||||
if (server.organizationId !== ctx.session?.activeOrganizationId) {
|
||||
throw new TRPCError({ code: "UNAUTHORIZED" });
|
||||
}
|
||||
}
|
||||
return await getStackContainersByAppName(input.appName, input.serverId);
|
||||
}),
|
||||
|
||||
getServiceContainersByAppName: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
appName: z.string().min(1),
|
||||
appName: z.string().min(1).regex(containerIdRegex, "Invalid app name."),
|
||||
serverId: z.string().optional(),
|
||||
}),
|
||||
)
|
||||
.query(async ({ input }) => {
|
||||
.query(async ({ input, ctx }) => {
|
||||
if (input.serverId) {
|
||||
const server = await findServerById(input.serverId);
|
||||
if (server.organizationId !== ctx.session?.activeOrganizationId) {
|
||||
throw new TRPCError({ code: "UNAUTHORIZED" });
|
||||
}
|
||||
}
|
||||
return await getServiceContainersByAppName(input.appName, input.serverId);
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -7,10 +7,13 @@ import {
|
||||
import {
|
||||
createMount,
|
||||
deleteMount,
|
||||
findApplicationById,
|
||||
findMountById,
|
||||
getServiceContainer,
|
||||
updateMount,
|
||||
} from "@dokploy/server";
|
||||
import { createTRPCRouter, protectedProcedure } from "../trpc";
|
||||
import { z } from "zod";
|
||||
|
||||
export const mountRouter = createTRPCRouter({
|
||||
create: protectedProcedure
|
||||
@@ -33,4 +36,14 @@ export const mountRouter = createTRPCRouter({
|
||||
.mutation(async ({ 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;
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -459,6 +459,15 @@ export const settingsRouter = createTRPCRouter({
|
||||
throw new TRPCError({ code: "UNAUTHORIZED" });
|
||||
}
|
||||
}
|
||||
|
||||
if (input.serverId) {
|
||||
const server = await findServerById(input.serverId);
|
||||
|
||||
if (server.organizationId !== ctx.session?.activeOrganizationId) {
|
||||
throw new TRPCError({ code: "UNAUTHORIZED" });
|
||||
}
|
||||
}
|
||||
|
||||
return readConfigInPath(input.path, input.serverId);
|
||||
}),
|
||||
getIp: protectedProcedure.query(async ({ ctx }) => {
|
||||
@@ -600,14 +609,14 @@ export const settingsRouter = createTRPCRouter({
|
||||
},
|
||||
})
|
||||
.input(apiReadStatsLogs)
|
||||
.query(({ input }) => {
|
||||
.query(async ({ input }) => {
|
||||
if (IS_CLOUD) {
|
||||
return {
|
||||
data: [],
|
||||
totalCount: 0,
|
||||
};
|
||||
}
|
||||
const rawConfig = readMonitoringConfig(
|
||||
const rawConfig = await readMonitoringConfig(
|
||||
!!input.dateRange?.start && !!input.dateRange?.end,
|
||||
);
|
||||
|
||||
@@ -643,11 +652,11 @@ export const settingsRouter = createTRPCRouter({
|
||||
})
|
||||
.optional(),
|
||||
)
|
||||
.query(({ input }) => {
|
||||
.query(async ({ input }) => {
|
||||
if (IS_CLOUD) {
|
||||
return [];
|
||||
}
|
||||
const rawConfig = readMonitoringConfig(
|
||||
const rawConfig = await readMonitoringConfig(
|
||||
!!input?.dateRange?.start || !!input?.dateRange?.end,
|
||||
);
|
||||
const processedLogs = processLogs(rawConfig as string, input?.dateRange);
|
||||
@@ -837,6 +846,14 @@ export const settingsRouter = createTRPCRouter({
|
||||
getLogCleanupStatus: adminProcedure.query(async () => {
|
||||
return getLogCleanupStatus();
|
||||
}),
|
||||
|
||||
getDokployCloudIps: adminProcedure.query(async () => {
|
||||
if (!IS_CLOUD) {
|
||||
return [];
|
||||
}
|
||||
const ips = process.env.DOKPLOY_CLOUD_IPS?.split(",");
|
||||
return ips;
|
||||
}),
|
||||
});
|
||||
|
||||
export const getTraefikPorts = async (serverId?: string) => {
|
||||
|
||||
@@ -6,6 +6,9 @@ import {
|
||||
} from "@dokploy/server";
|
||||
import { z } from "zod";
|
||||
import { createTRPCRouter, protectedProcedure } from "../trpc";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { findServerById } from "@dokploy/server";
|
||||
import { containerIdRegex } from "./docker";
|
||||
|
||||
export const swarmRouter = createTRPCRouter({
|
||||
getNodes: protectedProcedure
|
||||
@@ -14,12 +17,24 @@ export const swarmRouter = createTRPCRouter({
|
||||
serverId: z.string().optional(),
|
||||
}),
|
||||
)
|
||||
.query(async ({ input }) => {
|
||||
.query(async ({ input, ctx }) => {
|
||||
if (input.serverId) {
|
||||
const server = await findServerById(input.serverId);
|
||||
if (server.organizationId !== ctx.session?.activeOrganizationId) {
|
||||
throw new TRPCError({ code: "UNAUTHORIZED" });
|
||||
}
|
||||
}
|
||||
return await getSwarmNodes(input.serverId);
|
||||
}),
|
||||
getNodeInfo: protectedProcedure
|
||||
.input(z.object({ nodeId: z.string(), serverId: z.string().optional() }))
|
||||
.query(async ({ input }) => {
|
||||
.query(async ({ input, ctx }) => {
|
||||
if (input.serverId) {
|
||||
const server = await findServerById(input.serverId);
|
||||
if (server.organizationId !== ctx.session?.activeOrganizationId) {
|
||||
throw new TRPCError({ code: "UNAUTHORIZED" });
|
||||
}
|
||||
}
|
||||
return await getNodeInfo(input.nodeId, input.serverId);
|
||||
}),
|
||||
getNodeApps: protectedProcedure
|
||||
@@ -28,17 +43,29 @@ export const swarmRouter = createTRPCRouter({
|
||||
serverId: z.string().optional(),
|
||||
}),
|
||||
)
|
||||
.query(async ({ input }) => {
|
||||
.query(async ({ input, ctx }) => {
|
||||
if (input.serverId) {
|
||||
const server = await findServerById(input.serverId);
|
||||
if (server.organizationId !== ctx.session?.activeOrganizationId) {
|
||||
throw new TRPCError({ code: "UNAUTHORIZED" });
|
||||
}
|
||||
}
|
||||
return getNodeApplications(input.serverId);
|
||||
}),
|
||||
getAppInfos: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
appName: z.string(),
|
||||
appName: z.string().min(1).regex(containerIdRegex, "Invalid app name."),
|
||||
serverId: z.string().optional(),
|
||||
}),
|
||||
)
|
||||
.query(async ({ input }) => {
|
||||
.query(async ({ input, ctx }) => {
|
||||
if (input.serverId) {
|
||||
const server = await findServerById(input.serverId);
|
||||
if (server.organizationId !== ctx.session?.activeOrganizationId) {
|
||||
throw new TRPCError({ code: "UNAUTHORIZED" });
|
||||
}
|
||||
}
|
||||
return await getApplicationInfo(input.appName, input.serverId);
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -75,6 +75,24 @@ export const userRouter = createTRPCRouter({
|
||||
},
|
||||
});
|
||||
|
||||
// If user not found in the organization, deny access
|
||||
if (!memberResult) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "User not found in this organization",
|
||||
});
|
||||
}
|
||||
|
||||
// Allow access if:
|
||||
// 1. User is requesting their own information
|
||||
// 2. User has owner role (admin permissions) AND user is in the same organization
|
||||
if (memberResult.userId !== ctx.user.id && ctx.user.role !== "owner") {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "You are not authorized to access this user",
|
||||
});
|
||||
}
|
||||
|
||||
return memberResult;
|
||||
}),
|
||||
get: protectedProcedure.query(async ({ ctx }) => {
|
||||
|
||||
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,
|
||||
initCronJobs,
|
||||
initSchedules,
|
||||
initVolumeBackupsCronJobs,
|
||||
initializeNetwork,
|
||||
sendDokployRestartNotifications,
|
||||
setupDirectories,
|
||||
@@ -51,6 +52,7 @@ void app.prepare().then(async () => {
|
||||
await migration();
|
||||
await initCronJobs();
|
||||
await initSchedules();
|
||||
await initVolumeBackupsCronJobs();
|
||||
await sendDokployRestartNotifications();
|
||||
}
|
||||
|
||||
|
||||
@@ -19,6 +19,11 @@ type QueueJob =
|
||||
type: "schedule";
|
||||
cronSchedule: string;
|
||||
scheduleId: string;
|
||||
}
|
||||
| {
|
||||
type: "volume-backup";
|
||||
cronSchedule: string;
|
||||
volumeBackupId: string;
|
||||
};
|
||||
export const schedule = async (job: QueueJob) => {
|
||||
try {
|
||||
|
||||
@@ -6,7 +6,7 @@ export const isWSL = async () => {
|
||||
const { stdout } = await execAsync("uname -r");
|
||||
const isWSL = stdout.includes("microsoft");
|
||||
return isWSL;
|
||||
} catch (_error) {
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -101,7 +101,7 @@ export const setupDeploymentLogsWebSocketServer = (
|
||||
ws.close();
|
||||
});
|
||||
}
|
||||
} catch (_error) {
|
||||
} catch {
|
||||
// @ts-ignore
|
||||
// const errorMessage = error?.message as unknown as string;
|
||||
// ws.send(errorMessage);
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
initializeNetwork,
|
||||
initializeSwarm,
|
||||
} from "@dokploy/server/setup/setup";
|
||||
import { execAsync } from "@dokploy/server";
|
||||
(async () => {
|
||||
try {
|
||||
setupDirectories();
|
||||
@@ -20,6 +21,7 @@ import {
|
||||
await initializeNetwork();
|
||||
createDefaultTraefikConfig();
|
||||
createDefaultServerTraefikConfig();
|
||||
await execAsync("docker pull traefik:v3.1.2");
|
||||
await initializeTraefik();
|
||||
await initializeRedis();
|
||||
await initializePostgres();
|
||||
|
||||
@@ -242,3 +242,8 @@
|
||||
background-color: var(--terminal-paste) !important;
|
||||
color: currentColor !important;
|
||||
}
|
||||
|
||||
.cm-content,
|
||||
.cm-lineWrapping {
|
||||
@apply font-mono;
|
||||
}
|
||||
|
||||
@@ -30,12 +30,41 @@ const getWsUrl = () => {
|
||||
return `${protocol}${host}/drawer-logs`;
|
||||
};
|
||||
|
||||
const wsClient =
|
||||
typeof window !== "undefined"
|
||||
? createWSClient({
|
||||
url: getWsUrl() || "",
|
||||
})
|
||||
: null;
|
||||
// Create WebSocket client with delayed connection
|
||||
const createLazyWSClient = () => {
|
||||
if (typeof window === "undefined") return null;
|
||||
|
||||
let actualClient: ReturnType<typeof createWSClient> | 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. */
|
||||
export const api = createTRPCNext<AppRouter>({
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright 2025 Mauricio Siu.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -61,6 +61,12 @@ app.post("/update-backup", zValidator("json", jobQueueSchema), async (c) => {
|
||||
type: "schedule",
|
||||
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");
|
||||
}
|
||||
|
||||
@@ -42,6 +42,12 @@ export const scheduleJob = (job: QueueJob) => {
|
||||
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;
|
||||
}
|
||||
if (data.type === "volume-backup") {
|
||||
const { volumeBackupId, cronSchedule } = data;
|
||||
const result = await jobQueue.removeRepeatable(volumeBackupId, {
|
||||
pattern: cronSchedule,
|
||||
});
|
||||
return result;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
@@ -89,6 +102,10 @@ export const getJobRepeatable = async (
|
||||
const job = repeatableJobs.find((j) => j.name === scheduleId);
|
||||
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;
|
||||
};
|
||||
|
||||
@@ -16,6 +16,11 @@ export const jobQueueSchema = z.discriminatedUnion("type", [
|
||||
type: z.literal("schedule"),
|
||||
scheduleId: z.string(),
|
||||
}),
|
||||
z.object({
|
||||
cronSchedule: z.string(),
|
||||
type: z.literal("volume-backup"),
|
||||
volumeBackupId: z.string(),
|
||||
}),
|
||||
]);
|
||||
|
||||
export type QueueJob = z.infer<typeof jobQueueSchema>;
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
findBackupById,
|
||||
findScheduleById,
|
||||
findServerById,
|
||||
findVolumeBackupById,
|
||||
keepLatestNBackups,
|
||||
runCommand,
|
||||
runComposeBackup,
|
||||
@@ -12,9 +13,15 @@ import {
|
||||
runMongoBackup,
|
||||
runMySqlBackup,
|
||||
runPostgresBackup,
|
||||
runVolumeBackup,
|
||||
} from "@dokploy/server";
|
||||
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 { logger } from "./logger.js";
|
||||
import { scheduleJob } from "./queue.js";
|
||||
@@ -93,6 +100,12 @@ export const runJobs = async (job: QueueJob) => {
|
||||
if (schedule.enabled) {
|
||||
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) {
|
||||
logger.error(error);
|
||||
@@ -184,4 +197,44 @@ export const initializeJobs = async () => {
|
||||
{ Quantity: filteredSchedulesBasedOnServerStatus.length },
|
||||
"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`,
|
||||
REGISTRY_PATH: `${BASE_PATH}/registry`,
|
||||
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(""),
|
||||
randomize: boolean("randomize").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"),
|
||||
composeStatus: applicationStatus("composeStatus").notNull().default("idle"),
|
||||
projectId: text("projectId")
|
||||
|
||||
@@ -16,6 +16,7 @@ import { previewDeployments } from "./preview-deployments";
|
||||
import { schedules } from "./schedule";
|
||||
import { server } from "./server";
|
||||
import { rollbacks } from "./rollbacks";
|
||||
import { volumeBackups } from "./volume-backups";
|
||||
export const deploymentStatus = pgEnum("deploymentStatus", [
|
||||
"running",
|
||||
"done",
|
||||
@@ -31,6 +32,7 @@ export const deployments = pgTable("deployment", {
|
||||
description: text("description"),
|
||||
status: deploymentStatus("status").default("running"),
|
||||
logPath: text("logPath").notNull(),
|
||||
pid: text("pid"),
|
||||
applicationId: text("applicationId").references(
|
||||
() => applications.applicationId,
|
||||
{ onDelete: "cascade" },
|
||||
@@ -63,6 +65,10 @@ export const deployments = pgTable("deployment", {
|
||||
(): AnyPgColumn => rollbacks.rollbackId,
|
||||
{ onDelete: "cascade" },
|
||||
),
|
||||
volumeBackupId: text("volumeBackupId").references(
|
||||
(): AnyPgColumn => volumeBackups.volumeBackupId,
|
||||
{ onDelete: "cascade" },
|
||||
),
|
||||
});
|
||||
|
||||
export const deploymentsRelations = relations(deployments, ({ one }) => ({
|
||||
@@ -94,6 +100,10 @@ export const deploymentsRelations = relations(deployments, ({ one }) => ({
|
||||
fields: [deployments.deploymentId],
|
||||
references: [rollbacks.deploymentId],
|
||||
}),
|
||||
volumeBackup: one(volumeBackups, {
|
||||
fields: [deployments.volumeBackupId],
|
||||
references: [volumeBackups.volumeBackupId],
|
||||
}),
|
||||
}));
|
||||
|
||||
const schema = createInsertSchema(deployments, {
|
||||
@@ -178,6 +188,17 @@ export const apiCreateDeploymentSchedule = schema
|
||||
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
|
||||
.pick({
|
||||
applicationId: true,
|
||||
@@ -215,6 +236,7 @@ export const apiFindAllByType = z
|
||||
"schedule",
|
||||
"previewDeployment",
|
||||
"backup",
|
||||
"volumeBackup",
|
||||
]),
|
||||
})
|
||||
.required();
|
||||
|
||||
@@ -51,6 +51,8 @@ export const domains = pgTable("domain", {
|
||||
{ onDelete: "cascade" },
|
||||
),
|
||||
certificateType: certificateType("certificateType").notNull().default("none"),
|
||||
internalPath: text("internalPath").default("/"),
|
||||
stripPath: boolean("stripPath").notNull().default(false),
|
||||
});
|
||||
|
||||
export const domainsRelations = relations(domains, ({ one }) => ({
|
||||
@@ -82,6 +84,8 @@ export const apiCreateDomain = createSchema.pick({
|
||||
serviceName: true,
|
||||
domainType: true,
|
||||
previewDeploymentId: true,
|
||||
internalPath: true,
|
||||
stripPath: true,
|
||||
});
|
||||
|
||||
export const apiFindDomain = createSchema
|
||||
@@ -112,5 +116,7 @@ export const apiUpdateDomain = createSchema
|
||||
customCertResolver: true,
|
||||
serviceName: true,
|
||||
domainType: true,
|
||||
internalPath: true,
|
||||
stripPath: true,
|
||||
})
|
||||
.merge(createSchema.pick({ domainId: true }).required());
|
||||
|
||||
@@ -33,3 +33,4 @@ export * from "./ai";
|
||||
export * from "./account";
|
||||
export * from "./schedule";
|
||||
export * from "./rollbacks";
|
||||
export * from "./volume-backups";
|
||||
|
||||
@@ -6,6 +6,7 @@ import { z } from "zod";
|
||||
import { applications } from "./application";
|
||||
|
||||
export const protocolType = pgEnum("protocolType", ["tcp", "udp"]);
|
||||
export const publishModeType = pgEnum("publishModeType", ["ingress", "host"]);
|
||||
|
||||
export const ports = pgTable("port", {
|
||||
portId: text("portId")
|
||||
@@ -13,6 +14,7 @@ export const ports = pgTable("port", {
|
||||
.primaryKey()
|
||||
.$defaultFn(() => nanoid()),
|
||||
publishedPort: integer("publishedPort").notNull(),
|
||||
publishMode: publishModeType("publishMode").notNull().default("host"),
|
||||
targetPort: integer("targetPort").notNull(),
|
||||
protocol: protocolType("protocol").notNull(),
|
||||
|
||||
@@ -32,6 +34,7 @@ const createSchema = createInsertSchema(ports, {
|
||||
portId: z.string().min(1),
|
||||
applicationId: z.string().min(1),
|
||||
publishedPort: z.number(),
|
||||
publishMode: z.enum(["ingress", "host"]).default("ingress"),
|
||||
targetPort: z.number(),
|
||||
protocol: z.enum(["tcp", "udp"]).default("tcp"),
|
||||
});
|
||||
@@ -39,6 +42,7 @@ const createSchema = createInsertSchema(ports, {
|
||||
export const apiCreatePort = createSchema
|
||||
.pick({
|
||||
publishedPort: true,
|
||||
publishMode: true,
|
||||
targetPort: true,
|
||||
protocol: true,
|
||||
applicationId: true,
|
||||
@@ -55,6 +59,7 @@ export const apiUpdatePort = createSchema
|
||||
.pick({
|
||||
portId: true,
|
||||
publishedPort: true,
|
||||
publishMode: true,
|
||||
targetPort: true,
|
||||
protocol: true,
|
||||
})
|
||||
|
||||
@@ -4,6 +4,11 @@ import { createInsertSchema } from "drizzle-zod";
|
||||
import { nanoid } from "nanoid";
|
||||
import { z } from "zod";
|
||||
import { deployments } from "./deployment";
|
||||
import type { Application } from "@dokploy/server/services/application";
|
||||
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", {
|
||||
rollbackId: text("rollbackId")
|
||||
@@ -20,7 +25,14 @@ export const rollbacks = pgTable("rollback", {
|
||||
createdAt: text("createdAt")
|
||||
.notNull()
|
||||
.$defaultFn(() => new Date().toISOString()),
|
||||
fullContext: jsonb("fullContext"),
|
||||
fullContext: jsonb("fullContext").$type<
|
||||
Application & {
|
||||
project: Project;
|
||||
mounts: Mount[];
|
||||
ports: Port[];
|
||||
registry?: Registry | null;
|
||||
}
|
||||
>(),
|
||||
});
|
||||
|
||||
export type Rollback = typeof rollbacks.$inferSelect;
|
||||
|
||||
@@ -15,6 +15,7 @@ import { backups } from "./backups";
|
||||
import { projects } from "./project";
|
||||
import { schedules } from "./schedule";
|
||||
import { certificateType } from "./shared";
|
||||
import { paths } from "@dokploy/server/constants";
|
||||
/**
|
||||
* This is an example of how to use the multi-project schema feature of Drizzle ORM. Use the same
|
||||
* database instance for multiple projects.
|
||||
@@ -236,7 +237,31 @@ export const apiModifyTraefikConfig = z.object({
|
||||
serverId: z.string().optional(),
|
||||
});
|
||||
export const apiReadTraefikConfig = z.object({
|
||||
path: z.string().min(1),
|
||||
path: z
|
||||
.string()
|
||||
.min(1)
|
||||
.refine(
|
||||
(path) => {
|
||||
// Prevent directory traversal attacks
|
||||
if (path.includes("../") || path.includes("..\\")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const { MAIN_TRAEFIK_PATH } = paths();
|
||||
if (path.startsWith("/") && !path.startsWith(MAIN_TRAEFIK_PATH)) {
|
||||
return false;
|
||||
}
|
||||
// Prevent null bytes and other dangerous characters
|
||||
if (path.includes("\0") || path.includes("\x00")) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
{
|
||||
message:
|
||||
"Invalid path: path traversal or unauthorized directory access detected",
|
||||
},
|
||||
),
|
||||
serverId: z.string().optional(),
|
||||
});
|
||||
|
||||
|
||||
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({
|
||||
host: z.string().min(1, { message: "Add a hostname" }),
|
||||
path: z.string().min(1).optional(),
|
||||
internalPath: z.string().optional(),
|
||||
stripPath: z.boolean().optional(),
|
||||
port: z
|
||||
.number()
|
||||
.min(1, { message: "Port must be at least 1" })
|
||||
@@ -29,12 +31,37 @@ export const domain = z
|
||||
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
|
||||
.object({
|
||||
host: z.string().min(1, { message: "Host is required" }),
|
||||
path: z.string().min(1).optional(),
|
||||
internalPath: z.string().optional(),
|
||||
stripPath: z.boolean().optional(),
|
||||
port: z
|
||||
.number()
|
||||
.min(1, { message: "Port must be at least 1" })
|
||||
@@ -61,4 +88,27 @@ export const domainCompose = z
|
||||
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/cluster";
|
||||
export * from "./services/settings";
|
||||
export * from "./services/volume-backups";
|
||||
export * from "./services/docker";
|
||||
export * from "./services/destination";
|
||||
export * from "./services/deployment";
|
||||
@@ -132,5 +133,6 @@ export {
|
||||
|
||||
export * from "./utils/schedules/utils";
|
||||
export * from "./utils/schedules/index";
|
||||
export * from "./utils/volume-backups/index";
|
||||
|
||||
export * from "./lib/logger";
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user