mirror of
https://github.com/Dokploy/dokploy.git
synced 2026-07-08 23:45:22 +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
|
### Ubuntu
|
||||||
|
|
||||||
```bash
|
```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
|
# Update package index
|
||||||
sudo apt-get update
|
sudo apt-get update
|
||||||
|
|
||||||
# Install prerequisites
|
# Install prerequisites
|
||||||
sudo apt-get install \
|
sudo apt-get install ca-certificates curl
|
||||||
apt-transport-https \
|
sudo install -m 0755 -d /etc/apt/keyrings
|
||||||
ca-certificates \
|
|
||||||
curl \
|
|
||||||
gnupg \
|
|
||||||
lsb-release
|
|
||||||
|
|
||||||
# Add Docker's official GPG key
|
# 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 \
|
echo \
|
||||||
"deb [arch=amd64 signed-by=/usr/share/keyrings/docker-archive-keyring.gpg] https://download.docker.com/linux/ubuntu \
|
"deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu \
|
||||||
$(lsb_release -cs) stable" | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
|
$(. /etc/os-release && echo "${UBUNTU_CODENAME:-$VERSION_CODENAME}") stable" | \
|
||||||
|
sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
|
||||||
|
|
||||||
# Install Docker Engine
|
# Install Docker Engine
|
||||||
sudo apt-get update
|
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
|
## Windows
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
## Core License (Apache License 2.0)
|
## Core License (Apache License 2.0)
|
||||||
|
|
||||||
Copyright 2024 Mauricio Siu.
|
Copyright 2025 Mauricio Siu.
|
||||||
|
|
||||||
Licensed under the Apache License, Version 2.0 (the "License");
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
you may not use this file except in compliance with 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 align="center">
|
||||||
<div>
|
<a href="https://dokploy.com">
|
||||||
<a href="https://dokploy.com" target="_blank" rel="noopener">
|
<img src=".github/sponsors/logo.png" alt="Dokploy - Open Source Alternative to Vercel, Heroku and Netlify." align="center" width="100%" />
|
||||||
<img style="object-fit: cover;" align="center" width="100%"src=".github/sponsors/logo.png" alt="Dokploy - Open Source Alternative to Vercel, Heroku and Netlify." />
|
</a>
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</br>
|
|
||||||
<div align="center">
|
|
||||||
<div>Join us on Discord for help, feedback, and discussions!</div>
|
|
||||||
</br>
|
</br>
|
||||||
|
</br>
|
||||||
|
<p>Join us on Discord for help, feedback, and discussions!</p>
|
||||||
<a href="https://discord.gg/2tBnJ3jDJc">
|
<a href="https://discord.gg/2tBnJ3jDJc">
|
||||||
<img src="https://discordapp.com/api/guilds/1234073262418563112/widget.png?style=banner2" alt="Discord Shield"/>
|
<img src="https://discordapp.com/api/guilds/1234073262418563112/widget.png?style=banner2" alt="Discord Shield"/>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<br />
|
<br />
|
||||||
|
|
||||||
Dokploy is a free, self-hostable Platform as a Service (PaaS) that simplifies the deployment and management of applications and databases.
|
Dokploy is a free, self-hostable Platform as a Service (PaaS) that simplifies the deployment and management of applications and databases.
|
||||||
|
|
||||||
### Features
|
### Features
|
||||||
@@ -61,71 +57,52 @@ For detailed documentation, visit [docs.dokploy.com](https://docs.dokploy.com).
|
|||||||
|
|
||||||
### Hero Sponsors 🎖
|
### Hero Sponsors 🎖
|
||||||
|
|
||||||
<div style="display: flex; align-items: center; gap: 20px;">
|
<div>
|
||||||
<a href="https://www.hostinger.com/vps-hosting?ref=dokploy" target="_blank" style="display: inline-block; margin-right: 10px;">
|
<a href="https://www.hostinger.com/vps-hosting?ref=dokploy"><img src=".github/sponsors/hostinger.jpg" alt="Hostinger" width="300"/></a>
|
||||||
<img src=".github/sponsors/hostinger.jpg" alt="Hostinger" height="50"/>
|
<a href="https://www.lxaer.com/?ref=dokploy"><img src=".github/sponsors/lxaer.png" alt="LX Aer" width="100"/></a>
|
||||||
</a>
|
<a href="https://mandarin3d.com/?ref=dokploy"><img src=".github/sponsors/mandarin.png" alt="Mandarin" width="100"/></a>
|
||||||
<a href="https://www.lxaer.com/?ref=dokploy" target="_blank" style="display: inline-block; margin-right: 10px;">
|
<a href="https://lightnode.com/?ref=dokploy"><img src=".github/sponsors/light-node.webp" alt="Lightnode" width="300"/></a>
|
||||||
<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>
|
</div>
|
||||||
|
|
||||||
|
<!-- Premium Supporters 🥇 -->
|
||||||
|
|
||||||
|
<!-- Add Premium Supporters here -->
|
||||||
|
|
||||||
### Premium Supporters 🥇
|
### Premium Supporters 🥇
|
||||||
|
|
||||||
<div style="display: flex; align-items: center; gap: 20px;">
|
<div>
|
||||||
<a href="https://supafort.com/?ref=dokploy" target="_blank" style="display: inline-block; margin-right: 20px;">
|
<a href="https://supafort.com/?ref=dokploy"><img src="https://supafort.com/build/q-4Ht4rBZR.webp" alt="Supafort.com" width="300"/></a>
|
||||||
<img src="https://supafort.com/build/q-4Ht4rBZR.webp" alt="Supafort.com" height="50"/>
|
<a href="https://agentdock.ai/?ref=dokploy"><img src=".github/sponsors/agentdock.png" alt="agentdock.ai" width="100"/></a>
|
||||||
</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>
|
</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 🥈 -->
|
<!-- Elite Contributors 🥈 -->
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<!-- Add Elite Contributors here -->
|
<!-- Add Elite Contributors here -->
|
||||||
|
|
||||||
### Supporting Members 🥉
|
### Elite Contributors 🥈
|
||||||
|
|
||||||
<div style="display: flex; gap: 30px; flex-wrap: wrap;">
|
|
||||||
<a href="https://lightspeed.run/?ref=dokploy"><img src="https://github.com/lightspeedrun.png" width="60px" alt="Lightspeed.run"/></a>
|
|
||||||
<a href="https://cloudblast.io/?ref=dokploy "><img src="https://cloudblast.io/img/logo-icon.193cf13e.svg" width="250px" alt="Cloudblast.io"/></a>
|
|
||||||
<a href="https://startupfa.me/?ref=dokploy "><img src=".github/sponsors/startupfame.png" width="65px" alt="Startupfame"/></a>
|
|
||||||
<a href="https://itsdb-center.com?ref=dokploy "><img src=".github/sponsors/its.png" width="65px" alt="Itsdb-center"/></a>
|
|
||||||
<a href="https://openalternative.co/?ref=dokploy "><img src=".github/sponsors/openalternative.png" width="65px" alt="Openalternative"/></a>
|
|
||||||
<a href="https://synexa.ai/?ref=dokploy"><img src=".github/sponsors/synexa.png" width="65px" alt="Synexa"/></a>
|
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<a href="https://americancloud.com/?ref=dokploy"><img src=".github/sponsors/american-cloud.png" alt="AmericanCloud" width="300"/></a>
|
||||||
|
<a href="https://tolgee.io/?utm_source=github_dokploy&utm_medium=banner&utm_campaign=dokploy"><img src="https://dokploy.com/tolgee-logo.png" alt="Tolgee" width="100"/></a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
### Supporting Members 🥉
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<a href="https://lightspeed.run/?ref=dokploy"><img src="https://github.com/lightspeedrun.png" width="60px" alt="Lightspeed.run"/></a>
|
||||||
|
<a href="https://cloudblast.io/?ref=dokploy"><img src="https://cloudblast.io/img/logo-icon.193cf13e.svg" width="250px" alt="Cloudblast.io"/></a>
|
||||||
|
<a href="https://startupfa.me/?ref=dokploy"><img src=".github/sponsors/startupfame.png" width="65px" alt="Startupfame"/></a>
|
||||||
|
<a href="https://itsdb-center.com?ref=dokploy"><img src=".github/sponsors/its.png" width="65px" alt="Itsdb-center"/></a>
|
||||||
|
<a href="https://openalternative.co/?ref=dokploy"><img src=".github/sponsors/openalternative.png" width="65px" alt="Openalternative"/></a>
|
||||||
|
<a href="https://synexa.ai/?ref=dokploy"><img src=".github/sponsors/synexa.png" width="65px" alt="Synexa"/></a>
|
||||||
|
</div>
|
||||||
|
|
||||||
### Community Backers 🤝
|
### Community Backers 🤝
|
||||||
|
|
||||||
<div style="display: flex; gap: 30px; flex-wrap: wrap;">
|
<div>
|
||||||
<a href="https://steamsets.com/?ref=dokploy"><img src="https://avatars.githubusercontent.com/u/111978405?s=200&v=4" width="60px" alt="Steamsets.com"/></a>
|
<a href="https://steamsets.com/?ref=dokploy"><img src="https://avatars.githubusercontent.com/u/111978405?s=200&v=4" width="60px" alt="Steamsets.com"/></a>
|
||||||
<a href="https://rivo.gg/?ref=dokploy"><img src="https://avatars.githubusercontent.com/u/126797452?s=200&v=4" width="60px" alt="Rivo.gg"/></a>
|
<a href="https://rivo.gg/?ref=dokploy"><img src="https://avatars.githubusercontent.com/u/126797452?s=200&v=4" width="60px" alt="Rivo.gg"/></a>
|
||||||
<a href="https://photoquest.wedding/?ref=dokploy"><img src="https://photoquest.wedding/favicon/android-chrome-512x512.png" width="60px" alt="Rivo.gg"/></a>
|
<a href="https://photoquest.wedding/?ref=dokploy"><img src="https://photoquest.wedding/favicon/android-chrome-512x512.png" width="60px" alt="Rivo.gg"/></a>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
#### Organizations:
|
#### Organizations:
|
||||||
@@ -140,12 +117,12 @@ For detailed documentation, visit [docs.dokploy.com](https://docs.dokploy.com).
|
|||||||
|
|
||||||
<a href="https://github.com/dokploy/dokploy/graphs/contributors">
|
<a href="https://github.com/dokploy/dokploy/graphs/contributors">
|
||||||
<img src="https://contrib.rocks/image?repo=dokploy/dokploy" />
|
<img src="https://contrib.rocks/image?repo=dokploy/dokploy" />
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
## Video Tutorial
|
## Video Tutorial
|
||||||
|
|
||||||
<a href="https://youtu.be/mznYKPvhcfw">
|
<a href="https://youtu.be/mznYKPvhcfw">
|
||||||
<img src="https://dokploy.com/banner.png" alt="Watch the video" width="400" style="border-radius:20px;"/>
|
<img src="https://dokploy.com/banner.png" alt="Watch the video" width="400"/>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|||||||
@@ -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: "/",
|
path: "/",
|
||||||
createdAt: "",
|
createdAt: "",
|
||||||
previewDeploymentId: "",
|
previewDeploymentId: "",
|
||||||
|
internalPath: "/",
|
||||||
|
stripPath: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
it("should create basic labels for web entrypoint", async () => {
|
it("should create basic labels for web entrypoint", async () => {
|
||||||
|
|||||||
@@ -119,6 +119,8 @@ const baseDomain: Domain = {
|
|||||||
domainType: "application",
|
domainType: "application",
|
||||||
uniqueConfigKey: 1,
|
uniqueConfigKey: 1,
|
||||||
previewDeploymentId: "",
|
previewDeploymentId: "",
|
||||||
|
internalPath: "/",
|
||||||
|
stripPath: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
const baseRedirect: Redirect = {
|
const baseRedirect: Redirect = {
|
||||||
|
|||||||
@@ -130,7 +130,7 @@ const createStringToJSONSchema = (schema: z.ZodTypeAny) => {
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
return JSON.parse(str);
|
return JSON.parse(str);
|
||||||
} catch (_e) {
|
} catch {
|
||||||
ctx.addIssue({ code: "custom", message: "Invalid JSON format" });
|
ctx.addIssue({ code: "custom", message: "Invalid JSON format" });
|
||||||
return z.NEVER;
|
return z.NEVER;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -107,7 +107,7 @@ export const ShowImport = ({ composeId }: Props) => {
|
|||||||
composeId,
|
composeId,
|
||||||
});
|
});
|
||||||
setShowModal(false);
|
setShowModal(false);
|
||||||
} catch (_error) {
|
} catch {
|
||||||
toast.error("Error importing template");
|
toast.error("Error importing template");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -126,7 +126,7 @@ export const ShowImport = ({ composeId }: Props) => {
|
|||||||
});
|
});
|
||||||
setTemplateInfo(result);
|
setTemplateInfo(result);
|
||||||
setShowModal(true);
|
setShowModal(true);
|
||||||
} catch (_error) {
|
} catch {
|
||||||
toast.error("Error processing template");
|
toast.error("Error processing template");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -35,6 +35,9 @@ import { z } from "zod";
|
|||||||
|
|
||||||
const AddPortSchema = z.object({
|
const AddPortSchema = z.object({
|
||||||
publishedPort: z.number().int().min(1).max(65535),
|
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),
|
targetPort: z.number().int().min(1).max(65535),
|
||||||
protocol: z.enum(["tcp", "udp"], {
|
protocol: z.enum(["tcp", "udp"], {
|
||||||
required_error: "Protocol is required",
|
required_error: "Protocol is required",
|
||||||
@@ -80,6 +83,7 @@ export const HandlePorts = ({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
form.reset({
|
form.reset({
|
||||||
publishedPort: data?.publishedPort ?? 0,
|
publishedPort: data?.publishedPort ?? 0,
|
||||||
|
publishMode: data?.publishMode ?? "ingress",
|
||||||
targetPort: data?.targetPort ?? 0,
|
targetPort: data?.targetPort ?? 0,
|
||||||
protocol: data?.protocol ?? "tcp",
|
protocol: data?.protocol ?? "tcp",
|
||||||
});
|
});
|
||||||
@@ -165,6 +169,32 @@ export const HandlePorts = ({
|
|||||||
</FormItem>
|
</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
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="targetPort"
|
name="targetPort"
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ export const ShowPorts = ({ applicationId }: Props) => {
|
|||||||
{data?.ports.map((port) => (
|
{data?.ports.map((port) => (
|
||||||
<div key={port.portId}>
|
<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="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">
|
<div className="flex flex-col gap-1">
|
||||||
<span className="font-medium">Published Port</span>
|
<span className="font-medium">Published Port</span>
|
||||||
<span className="text-sm text-muted-foreground">
|
<span className="text-sm text-muted-foreground">
|
||||||
@@ -68,7 +68,13 @@ export const ShowPorts = ({ applicationId }: Props) => {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-1">
|
<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">
|
<span className="text-sm text-muted-foreground">
|
||||||
{port.targetPort}
|
{port.targetPort}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { AlertBlock } from "@/components/shared/alert-block";
|
||||||
import { CodeEditor } from "@/components/shared/code-editor";
|
import { CodeEditor } from "@/components/shared/code-editor";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
@@ -169,6 +170,23 @@ export const AddVolumes = ({
|
|||||||
onSubmit={form.handleSubmit(onSubmit)}
|
onSubmit={form.handleSubmit(onSubmit)}
|
||||||
className="grid w-full gap-8 "
|
className="grid w-full gap-8 "
|
||||||
>
|
>
|
||||||
|
{type === "bind" && (
|
||||||
|
<AlertBlock>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p>
|
||||||
|
Make sure the host path is a valid path and exists in the
|
||||||
|
host machine.
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
<strong>Cluster Warning:</strong> If you're using cluster
|
||||||
|
features, bind mounts may cause deployment failures since
|
||||||
|
the path must exist on all worker/manager nodes. Consider
|
||||||
|
using external tools to distribute the folder across nodes
|
||||||
|
or use named volumes instead.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</AlertBlock>
|
||||||
|
)}
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
defaultValue={form.control._defaultValues.type}
|
defaultValue={form.control._defaultValues.type}
|
||||||
|
|||||||
@@ -14,7 +14,8 @@ interface Props {
|
|||||||
| "schedule"
|
| "schedule"
|
||||||
| "server"
|
| "server"
|
||||||
| "backup"
|
| "backup"
|
||||||
| "previewDeployment";
|
| "previewDeployment"
|
||||||
|
| "volumeBackup";
|
||||||
serverId?: string;
|
serverId?: string;
|
||||||
refreshToken?: string;
|
refreshToken?: string;
|
||||||
children?: React.ReactNode;
|
children?: React.ReactNode;
|
||||||
|
|||||||
@@ -27,7 +27,8 @@ interface Props {
|
|||||||
| "schedule"
|
| "schedule"
|
||||||
| "server"
|
| "server"
|
||||||
| "backup"
|
| "backup"
|
||||||
| "previewDeployment";
|
| "previewDeployment"
|
||||||
|
| "volumeBackup";
|
||||||
refreshToken?: string;
|
refreshToken?: string;
|
||||||
serverId?: string;
|
serverId?: string;
|
||||||
}
|
}
|
||||||
@@ -62,6 +63,8 @@ export const ShowDeployments = ({
|
|||||||
|
|
||||||
const { mutateAsync: rollback, isLoading: isRollingBack } =
|
const { mutateAsync: rollback, isLoading: isRollingBack } =
|
||||||
api.rollback.rollback.useMutation();
|
api.rollback.rollback.useMutation();
|
||||||
|
const { mutateAsync: killProcess, isLoading: isKillingProcess } =
|
||||||
|
api.deployment.killProcess.useMutation();
|
||||||
|
|
||||||
const [url, setUrl] = React.useState("");
|
const [url, setUrl] = React.useState("");
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -170,6 +173,32 @@ export const ShowDeployments = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-row items-center gap-2">
|
<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
|
<Button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setActiveLog(deployment);
|
setActiveLog(deployment);
|
||||||
|
|||||||
@@ -49,6 +49,8 @@ export const domain = z
|
|||||||
.object({
|
.object({
|
||||||
host: z.string().min(1, { message: "Add a hostname" }),
|
host: z.string().min(1, { message: "Add a hostname" }),
|
||||||
path: z.string().min(1).optional(),
|
path: z.string().min(1).optional(),
|
||||||
|
internalPath: z.string().optional(),
|
||||||
|
stripPath: z.boolean().optional(),
|
||||||
port: z
|
port: z
|
||||||
.number()
|
.number()
|
||||||
.min(1, { message: "Port must be at least 1" })
|
.min(1, { message: "Port must be at least 1" })
|
||||||
@@ -84,6 +86,29 @@ export const domain = z
|
|||||||
message: "Required",
|
message: "Required",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Validate stripPath requires a valid path
|
||||||
|
if (input.stripPath && (!input.path || input.path === "/")) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
path: ["stripPath"],
|
||||||
|
message:
|
||||||
|
"Strip path can only be enabled when a path other than '/' is specified",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate internalPath starts with /
|
||||||
|
if (
|
||||||
|
input.internalPath &&
|
||||||
|
input.internalPath !== "/" &&
|
||||||
|
!input.internalPath.startsWith("/")
|
||||||
|
) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
path: ["internalPath"],
|
||||||
|
message: "Internal path must start with '/'",
|
||||||
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
type Domain = z.infer<typeof domain>;
|
type Domain = z.infer<typeof domain>;
|
||||||
@@ -162,6 +187,8 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
|
|||||||
defaultValues: {
|
defaultValues: {
|
||||||
host: "",
|
host: "",
|
||||||
path: undefined,
|
path: undefined,
|
||||||
|
internalPath: undefined,
|
||||||
|
stripPath: false,
|
||||||
port: undefined,
|
port: undefined,
|
||||||
https: false,
|
https: false,
|
||||||
certificateType: undefined,
|
certificateType: undefined,
|
||||||
@@ -182,6 +209,8 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
|
|||||||
...data,
|
...data,
|
||||||
/* Convert null to undefined */
|
/* Convert null to undefined */
|
||||||
path: data?.path || undefined,
|
path: data?.path || undefined,
|
||||||
|
internalPath: data?.internalPath || undefined,
|
||||||
|
stripPath: data?.stripPath || false,
|
||||||
port: data?.port || undefined,
|
port: data?.port || undefined,
|
||||||
certificateType: data?.certificateType || undefined,
|
certificateType: data?.certificateType || undefined,
|
||||||
customCertResolver: data?.customCertResolver || undefined,
|
customCertResolver: data?.customCertResolver || undefined,
|
||||||
@@ -194,6 +223,8 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
|
|||||||
form.reset({
|
form.reset({
|
||||||
host: "",
|
host: "",
|
||||||
path: undefined,
|
path: undefined,
|
||||||
|
internalPath: undefined,
|
||||||
|
stripPath: false,
|
||||||
port: undefined,
|
port: undefined,
|
||||||
https: false,
|
https: false,
|
||||||
certificateType: undefined,
|
certificateType: undefined,
|
||||||
@@ -469,6 +500,49 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="internalPath"
|
||||||
|
render={({ field }) => {
|
||||||
|
return (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Internal Path</FormLabel>
|
||||||
|
<FormDescription>
|
||||||
|
The path where your application expects to receive
|
||||||
|
requests internally (defaults to "/")
|
||||||
|
</FormDescription>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder={"/"} {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="stripPath"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="flex flex-row items-center justify-between p-3 border rounded-lg shadow-sm">
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<FormLabel>Strip Path</FormLabel>
|
||||||
|
<FormDescription>
|
||||||
|
Remove the external path from the request before
|
||||||
|
forwarding to the application
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</div>
|
||||||
|
<FormControl>
|
||||||
|
<Switch
|
||||||
|
checked={field.value}
|
||||||
|
onCheckedChange={field.onChange}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="port"
|
name="port"
|
||||||
|
|||||||
@@ -278,7 +278,7 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
|
|||||||
{repositories?.map((repo) => {
|
{repositories?.map((repo) => {
|
||||||
return (
|
return (
|
||||||
<CommandItem
|
<CommandItem
|
||||||
value={repo.name}
|
value={repo.url}
|
||||||
key={repo.url}
|
key={repo.url}
|
||||||
onSelect={() => {
|
onSelect={() => {
|
||||||
form.setValue("repository", {
|
form.setValue("repository", {
|
||||||
@@ -299,7 +299,8 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
|
|||||||
<CheckIcon
|
<CheckIcon
|
||||||
className={cn(
|
className={cn(
|
||||||
"ml-auto h-4 w-4",
|
"ml-auto h-4 w-4",
|
||||||
repo.name === field.value.repo
|
repo.url ===
|
||||||
|
field.value.gitlabPathNamespace
|
||||||
? "opacity-100"
|
? "opacity-100"
|
||||||
: "opacity-0",
|
: "opacity-0",
|
||||||
)}
|
)}
|
||||||
|
|||||||
108
apps/dokploy/components/dashboard/application/rollbacks/Backup
Normal file
108
apps/dokploy/components/dashboard/application/rollbacks/Backup
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
Backup
|
||||||
|
# license-namedbackups-abxelc
|
||||||
|
1. docker ps --filter "label=com.docker.swarm.service.name=license-namedbackups-abxelc" --format "{{.Names}}"
|
||||||
|
2. docker run --rm \
|
||||||
|
--volumes-from "license-namedbackups-abxelc.1.m3cxy78ocj3w0zu42kmgamc5y" \
|
||||||
|
-v $(pwd):/backup \
|
||||||
|
ubuntu \
|
||||||
|
tar cvf /backup/backup.tar /var/lib/postgresql/data
|
||||||
|
|
||||||
|
|
||||||
|
# Official Command Backup
|
||||||
|
|
||||||
|
1. Backup
|
||||||
|
|
||||||
|
docker run --rm \
|
||||||
|
-v license-namedbackups-abxelc-data:/volume_data \
|
||||||
|
-v $(pwd):/backup \
|
||||||
|
ubuntu \
|
||||||
|
bash -c "cd /volume_data && tar cvf /backup/generic_backup.tar ."
|
||||||
|
|
||||||
|
|
||||||
|
2. Restore
|
||||||
|
|
||||||
|
docker service scale license-namedbackups-abxelc=0
|
||||||
|
|
||||||
|
docker volume rm license-namedbackups-abxelc-data
|
||||||
|
|
||||||
|
2. docker run --rm \
|
||||||
|
-v license-namedbackups-abxelc-data:/volume_data \
|
||||||
|
-v $(pwd):/backup \
|
||||||
|
ubuntu \
|
||||||
|
bash -c "cd /volume_data && tar xvf /backup/generic_backup.tar ."
|
||||||
|
|
||||||
|
docker service scale license-namedbackups-abxelc=1
|
||||||
|
|
||||||
|
|
||||||
|
root@srv594061:~# docker volume inspect n8n_data-data
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"CreatedAt": "2025-06-28T18:07:44Z",
|
||||||
|
"Driver": "local",
|
||||||
|
"Labels": null,
|
||||||
|
"Mountpoint": "/var/lib/docker/volumes/n8n_data-data/_data",
|
||||||
|
"Name": "n8n_data-data",
|
||||||
|
"Options": null,
|
||||||
|
"Scope": "local"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
Archivos funcuionando creados por N8N
|
||||||
|
|
||||||
|
# root@srv594061:~# cd /var/lib/docker/volumes/n8n_data-data/_data
|
||||||
|
# root@srv594061:/var/lib/docker/volumes/n8n_data-data/_data# ls
|
||||||
|
# binaryData config crash.journal database.sqlite git n8nEventLog.log ssh
|
||||||
|
|
||||||
|
Luego que intente hacer el backup con el comando de backup
|
||||||
|
|
||||||
|
|
||||||
|
root@srv594061:~# docker run --rm -v n8n_data-data:/volume_data -v $(pwd):/backup ubuntu bash -c "cd /volume_data && tar cvf /backup/generic_backup6.tar ."
|
||||||
|
./
|
||||||
|
./config
|
||||||
|
./crash.journal
|
||||||
|
./binaryData/
|
||||||
|
./git/
|
||||||
|
./database.sqlite
|
||||||
|
./ssh/
|
||||||
|
./n8nEventLog.log
|
||||||
|
root@srv594061:~#
|
||||||
|
|
||||||
|
# Paramos la aplicacion
|
||||||
|
docker service scale n8n=0
|
||||||
|
|
||||||
|
# Haciendo el restore
|
||||||
|
root@srv594061:~# docker volume rm n8n_data-data
|
||||||
|
n8n_data-data
|
||||||
|
root@srv594061:~# docker run --rm -v n8n_data-data:/volume_data -v $(pwd):/backup ubuntu bash -c "cd /volume_data && tar xvf /backup/generic_backup6.tar && chown -R 999:999 ."
|
||||||
|
./
|
||||||
|
./config
|
||||||
|
./crash.journal
|
||||||
|
./binaryData/
|
||||||
|
./git/
|
||||||
|
./database.sqlite
|
||||||
|
./ssh/
|
||||||
|
./n8nEventLog.log
|
||||||
|
|
||||||
|
# Tenemos los archivos en el volumen
|
||||||
|
root@srv594061:~# ls /var/lib/docker/volumes/n8n_data-data/_data
|
||||||
|
binaryData config crash.journal database.sqlite git n8nEventLog.log ssh
|
||||||
|
root@srv594061:~#
|
||||||
|
|
||||||
|
docker service scale n8n=1
|
||||||
|
|
||||||
|
# Luego en N8N Cuando se que el volumen tiene la data
|
||||||
|
Permissions 0644 for n8n settings file /home/node/.n8n/config are too wide. This is ignored for now, but in the future n8n will attempt to change the permissions automatically. To automatically enforce correct permissions now set N8N_ENFORCE_SETTINGS_FILE_PERMISSIONS=true (recommended), or turn this check off set N8N_ENFORCE_SETTINGS_FILE_PERMISSIONS=false.
|
||||||
|
User settings loaded from: /home/node/.n8n/config
|
||||||
|
Last session crashed
|
||||||
|
Error: EACCES: permission denied, open '/home/node/.n8n/crash.journal'
|
||||||
|
at open (node:internal/fs/promises:639:25)
|
||||||
|
at touchFile (/usr/local/lib/node_modules/n8n/dist/crash-journal.js:18:20)
|
||||||
|
at Object.init (/usr/local/lib/node_modules/n8n/dist/crash-journal.js:32:5)
|
||||||
|
at Start.initCrashJournal (/usr/local/lib/node_modules/n8n/dist/commands/base-command.js:113:9)
|
||||||
|
at Start.init (/usr/local/lib/node_modules/n8n/dist/commands/start.js:141:9)
|
||||||
|
at Start._run (/usr/local/lib/node_modules/n8n/node_modules/@oclif/core/lib/command.js:301:13)
|
||||||
|
at Config.runCommand (/usr/local/lib/node_modules/n8n/node_modules/@oclif/core/lib/config/config.js:424:25)
|
||||||
|
at run (/usr/local/lib/node_modules/n8n/node_modules/@oclif/core/lib/main.js:94:16)
|
||||||
|
at /usr/local/lib/node_modules/n8n/bin/n8n:71:2
|
||||||
|
TypeError: Cannot read properties of undefined (reading 'error')
|
||||||
|
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { AlertBlock } from "@/components/shared/alert-block";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@@ -79,6 +80,11 @@ export const ShowRollbackSettings = ({ applicationId, children }: Props) => {
|
|||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
Configure how rollbacks work for this application
|
Configure how rollbacks work for this application
|
||||||
</DialogDescription>
|
</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>
|
</DialogHeader>
|
||||||
|
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
|
|||||||
@@ -166,12 +166,16 @@ export const ShowSchedules = ({ id, scheduleType = "application" }: Props) => {
|
|||||||
|
|
||||||
await runManually({
|
await runManually({
|
||||||
scheduleId: schedule.scheduleId,
|
scheduleId: schedule.scheduleId,
|
||||||
}).then(async () => {
|
})
|
||||||
await new Promise((resolve) =>
|
.then(async () => {
|
||||||
setTimeout(resolve, 1500),
|
await new Promise((resolve) =>
|
||||||
);
|
setTimeout(resolve, 1500),
|
||||||
refetchSchedules();
|
);
|
||||||
});
|
refetchSchedules();
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
toast.error("Error running schedule");
|
||||||
|
});
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Play className="size-4 transition-colors" />
|
<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,
|
composeId,
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
.catch((_e) => {
|
.catch(() => {
|
||||||
toast.error("Error updating the Compose config");
|
toast.error("Error updating the Compose config");
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -280,7 +280,7 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
|
|||||||
{repositories?.map((repo) => {
|
{repositories?.map((repo) => {
|
||||||
return (
|
return (
|
||||||
<CommandItem
|
<CommandItem
|
||||||
value={repo.name}
|
value={repo.url}
|
||||||
key={repo.url}
|
key={repo.url}
|
||||||
onSelect={() => {
|
onSelect={() => {
|
||||||
form.setValue("repository", {
|
form.setValue("repository", {
|
||||||
@@ -301,7 +301,8 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
|
|||||||
<CheckIcon
|
<CheckIcon
|
||||||
className={cn(
|
className={cn(
|
||||||
"ml-auto h-4 w-4",
|
"ml-auto h-4 w-4",
|
||||||
repo.name === field.value.repo
|
repo.url ===
|
||||||
|
field.value.gitlabPathNamespace
|
||||||
? "opacity-100"
|
? "opacity-100"
|
||||||
: "opacity-0",
|
: "opacity-0",
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ export const ShowConvertedCompose = ({ composeId }: Props) => {
|
|||||||
.then(() => {
|
.then(() => {
|
||||||
refetch();
|
refetch();
|
||||||
})
|
})
|
||||||
.catch((_err) => {});
|
.catch(() => {});
|
||||||
}
|
}
|
||||||
}, [isOpen]);
|
}, [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";
|
if (bytes === 0) return "0 Bytes";
|
||||||
const k = 1024;
|
const k = 1024;
|
||||||
const sizes = ["Bytes", "KB", "MB", "GB", "TB"];
|
const sizes = ["Bytes", "KB", "MB", "GB", "TB"];
|
||||||
@@ -415,7 +415,7 @@ export const RestoreBackup = ({
|
|||||||
<FormLabel className="flex items-center justify-between">
|
<FormLabel className="flex items-center justify-between">
|
||||||
Search Backup Files
|
Search Backup Files
|
||||||
{field.value && (
|
{field.value && (
|
||||||
<Badge variant="outline">
|
<Badge variant="outline" className="truncate">
|
||||||
{field.value}
|
{field.value}
|
||||||
<Copy
|
<Copy
|
||||||
className="ml-2 size-4 cursor-pointer"
|
className="ml-2 size-4 cursor-pointer"
|
||||||
@@ -439,7 +439,9 @@ export const RestoreBackup = ({
|
|||||||
!field.value && "text-muted-foreground",
|
!field.value && "text-muted-foreground",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{field.value || "Search and select a backup file"}
|
<span className="truncate text-left flex-1 w-52">
|
||||||
|
{field.value || "Search and select a backup file"}
|
||||||
|
</span>
|
||||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
</Button>
|
</Button>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
|||||||
@@ -103,7 +103,7 @@ export const AddApplication = ({ projectId, projectName }: Props) => {
|
|||||||
projectId,
|
projectId,
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
.catch((_e) => {
|
.catch(() => {
|
||||||
toast.error("Error creating the service");
|
toast.error("Error creating the service");
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ export const ShowNodesModal = ({ serverId }: Props) => {
|
|||||||
Show Swarm Nodes
|
Show Swarm Nodes
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DialogTrigger>
|
</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">
|
<div className="grid w-full gap-1">
|
||||||
<ShowNodes serverId={serverId} />
|
<ShowNodes serverId={serverId} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ export const ShowNodes = ({ serverId }: Props) => {
|
|||||||
</TableCaption>
|
</TableCaption>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead className="w-[100px]">Hostname</TableHead>
|
<TableHead className="text-left">Hostname</TableHead>
|
||||||
<TableHead className="text-right">Status</TableHead>
|
<TableHead className="text-right">Status</TableHead>
|
||||||
<TableHead className="text-right">Role</TableHead>
|
<TableHead className="text-right">Role</TableHead>
|
||||||
<TableHead className="text-right">Availability</TableHead>
|
<TableHead className="text-right">Availability</TableHead>
|
||||||
@@ -104,7 +104,7 @@ export const ShowNodes = ({ serverId }: Props) => {
|
|||||||
const isManager = node.Spec.Role === "manager";
|
const isManager = node.Spec.Role === "manager";
|
||||||
return (
|
return (
|
||||||
<TableRow key={node.ID}>
|
<TableRow key={node.ID}>
|
||||||
<TableCell className="w-[100px]">
|
<TableCell className="text-left">
|
||||||
{node.Description.Hostname}
|
{node.Description.Hostname}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-right">
|
<TableCell className="text-right">
|
||||||
|
|||||||
@@ -1063,7 +1063,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
toast.success("Connection Success");
|
toast.success("Connection Success");
|
||||||
} catch (_err) {
|
} catch {
|
||||||
toast.error("Error testing the provider");
|
toast.error("Error testing the provider");
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ export const Disable2FA = () => {
|
|||||||
toast.success("2FA disabled successfully");
|
toast.success("2FA disabled successfully");
|
||||||
utils.user.get.invalidate();
|
utils.user.get.invalidate();
|
||||||
setIsOpen(false);
|
setIsOpen(false);
|
||||||
} catch (_error) {
|
} catch {
|
||||||
form.setError("password", {
|
form.setError("password", {
|
||||||
message: "Connection error. Please try again.",
|
message: "Connection error. Please try again.",
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ export const ToggleDockerCleanup = ({ serverId }: Props) => {
|
|||||||
await refetch();
|
await refetch();
|
||||||
}
|
}
|
||||||
toast.success("Docker Cleanup updated");
|
toast.success("Docker Cleanup updated");
|
||||||
} catch (_error) {
|
} catch {
|
||||||
toast.error("Docker Cleanup Error");
|
toast.error("Docker Cleanup Error");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ export function GPUSupport({ serverId }: GPUSupportProps) {
|
|||||||
try {
|
try {
|
||||||
await utils.settings.checkGPUStatus.invalidate({ serverId });
|
await utils.settings.checkGPUStatus.invalidate({ serverId });
|
||||||
await refetch();
|
await refetch();
|
||||||
} catch (_error) {
|
} catch {
|
||||||
toast.error("Failed to refresh GPU status");
|
toast.error("Failed to refresh GPU status");
|
||||||
} finally {
|
} finally {
|
||||||
setIsRefreshing(false);
|
setIsRefreshing(false);
|
||||||
@@ -74,7 +74,7 @@ export function GPUSupport({ serverId }: GPUSupportProps) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await setupGPU.mutateAsync({ serverId });
|
await setupGPU.mutateAsync({ serverId });
|
||||||
} catch (_error) {
|
} catch {
|
||||||
// Error handling is done in mutation's onError
|
// Error handling is done in mutation's onError
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -141,7 +141,7 @@ export const ShowServers = () => {
|
|||||||
</TableCaption>
|
</TableCaption>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead className="w-[100px]">Name</TableHead>
|
<TableHead className="text-left">Name</TableHead>
|
||||||
{isCloud && (
|
{isCloud && (
|
||||||
<TableHead className="text-center">
|
<TableHead className="text-center">
|
||||||
Status
|
Status
|
||||||
@@ -173,7 +173,7 @@ export const ShowServers = () => {
|
|||||||
const isActive = server.serverStatus === "active";
|
const isActive = server.serverStatus === "active";
|
||||||
return (
|
return (
|
||||||
<TableRow key={server.serverId}>
|
<TableRow key={server.serverId}>
|
||||||
<TableCell className="w-[100px]">
|
<TableCell className="text-left">
|
||||||
{server.name}
|
{server.name}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
{isCloud && (
|
{isCloud && (
|
||||||
|
|||||||
@@ -146,7 +146,7 @@ export const ShowInvitations = () => {
|
|||||||
{invitation.status === "pending" && (
|
{invitation.status === "pending" && (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
className="w-full cursor-pointer"
|
className="w-full cursor-pointer"
|
||||||
onSelect={(_e) => {
|
onSelect={() => {
|
||||||
copy(
|
copy(
|
||||||
`${origin}/invitation?token=${invitation.id}`,
|
`${origin}/invitation?token=${invitation.id}`,
|
||||||
);
|
);
|
||||||
@@ -162,7 +162,7 @@ export const ShowInvitations = () => {
|
|||||||
{invitation.status === "pending" && (
|
{invitation.status === "pending" && (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
className="w-full cursor-pointer"
|
className="w-full cursor-pointer"
|
||||||
onSelect={async (_e) => {
|
onSelect={async () => {
|
||||||
const result =
|
const result =
|
||||||
await authClient.organization.cancelInvitation(
|
await authClient.organization.cancelInvitation(
|
||||||
{
|
{
|
||||||
@@ -189,7 +189,7 @@ export const ShowInvitations = () => {
|
|||||||
)}
|
)}
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
className="w-full cursor-pointer"
|
className="w-full cursor-pointer"
|
||||||
onSelect={async (_e) => {
|
onSelect={async () => {
|
||||||
await removeInvitation({
|
await removeInvitation({
|
||||||
invitationId: invitation.id,
|
invitationId: invitation.id,
|
||||||
}).then(() => {
|
}).then(() => {
|
||||||
|
|||||||
@@ -91,7 +91,7 @@ export const ManageTraefikPorts = ({ children, serverId }: Props) => {
|
|||||||
});
|
});
|
||||||
toast.success(t("settings.server.webServer.traefik.portsUpdated"));
|
toast.success(t("settings.server.webServer.traefik.portsUpdated"));
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
} catch (_error) {}
|
} catch {}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ export const ShowNodeApplications = ({ serverId }: Props) => {
|
|||||||
Services
|
Services
|
||||||
</Button>
|
</Button>
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
<DialogContent className={"sm:max-w-6xl overflow-y-auto max-h-screen"}>
|
<DialogContent className={"sm:max-w-10xl overflow-y-auto max-h-screen"}>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Node Applications</DialogTitle>
|
<DialogTitle>Node Applications</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import type * as React from "react";
|
|||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
const badgeVariants = cva(
|
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: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
|
|||||||
@@ -246,7 +246,9 @@ const Leaf = React.forwardRef<
|
|||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<p className=" text-sm whitespace-normal font-mono">{item.name}</p>
|
<p className=" text-sm whitespace-normal font-mono text-left">
|
||||||
|
{item.name}
|
||||||
|
</p>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ const PopoverContent = React.forwardRef<
|
|||||||
align={align}
|
align={align}
|
||||||
sideOffset={sideOffset}
|
sideOffset={sideOffset}
|
||||||
className={cn(
|
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,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...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,
|
"when": 1750567641441,
|
||||||
"tag": "0097_hard_lizard",
|
"tag": "0097_hard_lizard",
|
||||||
"breakpoints": true
|
"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",
|
"name": "dokploy",
|
||||||
"version": "v0.23.2",
|
"version": "v0.24.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ export async function getServerSideProps(
|
|||||||
trpcState: helpers.dehydrate(),
|
trpcState: helpers.dehydrate(),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
} catch (_error) {
|
} catch {
|
||||||
return {
|
return {
|
||||||
props: {},
|
props: {},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -390,7 +390,7 @@ const Project = (
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
success++;
|
success++;
|
||||||
} catch (_error) {
|
} catch {
|
||||||
toast.error(`Error starting service ${serviceId}`);
|
toast.error(`Error starting service ${serviceId}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -437,7 +437,7 @@ const Project = (
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
success++;
|
success++;
|
||||||
} catch (_error) {
|
} catch {
|
||||||
toast.error(`Error stopping service ${serviceId}`);
|
toast.error(`Error stopping service ${serviceId}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1107,7 +1107,7 @@ export async function getServerSideProps(
|
|||||||
projectId: params?.projectId,
|
projectId: params?.projectId,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
} catch (_error) {
|
} catch {
|
||||||
return {
|
return {
|
||||||
redirect: {
|
redirect: {
|
||||||
permanent: false,
|
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 { ShowPreviewDeployments } from "@/components/dashboard/application/preview-deployments/show-preview-deployments";
|
||||||
import { ShowSchedules } from "@/components/dashboard/application/schedules/show-schedules";
|
import { ShowSchedules } from "@/components/dashboard/application/schedules/show-schedules";
|
||||||
import { UpdateApplication } from "@/components/dashboard/application/update-application";
|
import { UpdateApplication } from "@/components/dashboard/application/update-application";
|
||||||
|
import { ShowVolumeBackups } from "@/components/dashboard/application/volume-backups/show-volume-backups";
|
||||||
import { DeleteService } from "@/components/dashboard/compose/delete-service";
|
import { DeleteService } from "@/components/dashboard/compose/delete-service";
|
||||||
import { ContainerFreeMonitoring } from "@/components/dashboard/monitoring/free/container/show-free-container-monitoring";
|
import { ContainerFreeMonitoring } from "@/components/dashboard/monitoring/free/container/show-free-container-monitoring";
|
||||||
import { ContainerPaidMonitoring } from "@/components/dashboard/monitoring/paid/container/show-paid-container-monitoring";
|
import { ContainerPaidMonitoring } from "@/components/dashboard/monitoring/paid/container/show-paid-container-monitoring";
|
||||||
@@ -61,7 +62,8 @@ type TabState =
|
|||||||
| "deployments"
|
| "deployments"
|
||||||
| "domains"
|
| "domains"
|
||||||
| "monitoring"
|
| "monitoring"
|
||||||
| "preview-deployments";
|
| "preview-deployments"
|
||||||
|
| "volume-backups";
|
||||||
|
|
||||||
const Service = (
|
const Service = (
|
||||||
props: InferGetServerSidePropsType<typeof getServerSideProps>,
|
props: InferGetServerSidePropsType<typeof getServerSideProps>,
|
||||||
@@ -234,6 +236,9 @@ const Service = (
|
|||||||
Preview Deployments
|
Preview Deployments
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger value="schedules">Schedules</TabsTrigger>
|
<TabsTrigger value="schedules">Schedules</TabsTrigger>
|
||||||
|
<TabsTrigger value="volume-backups">
|
||||||
|
Volume Backups
|
||||||
|
</TabsTrigger>
|
||||||
<TabsTrigger value="deployments">Deployments</TabsTrigger>
|
<TabsTrigger value="deployments">Deployments</TabsTrigger>
|
||||||
<TabsTrigger value="logs">Logs</TabsTrigger>
|
<TabsTrigger value="logs">Logs</TabsTrigger>
|
||||||
{((data?.serverId && isCloud) || !data?.server) && (
|
{((data?.serverId && isCloud) || !data?.server) && (
|
||||||
@@ -328,6 +333,15 @@ const Service = (
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
<TabsContent value="volume-backups" className="w-full pt-2.5">
|
||||||
|
<div className="flex flex-col gap-4 border rounded-lg">
|
||||||
|
<ShowVolumeBackups
|
||||||
|
id={applicationId}
|
||||||
|
type="application"
|
||||||
|
serverId={data?.serverId || ""}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
<TabsContent value="preview-deployments" className="w-full">
|
<TabsContent value="preview-deployments" className="w-full">
|
||||||
<div className="flex flex-col gap-4 pt-2.5">
|
<div className="flex flex-col gap-4 pt-2.5">
|
||||||
<ShowPreviewDeployments applicationId={applicationId} />
|
<ShowPreviewDeployments applicationId={applicationId} />
|
||||||
@@ -413,7 +427,7 @@ export async function getServerSideProps(
|
|||||||
activeTab: (activeTab || "general") as TabState,
|
activeTab: (activeTab || "general") as TabState,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
} catch (_error) {
|
} catch {
|
||||||
return {
|
return {
|
||||||
redirect: {
|
redirect: {
|
||||||
permanent: false,
|
permanent: false,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { ShowDeployments } from "@/components/dashboard/application/deployments/
|
|||||||
import { ShowDomains } from "@/components/dashboard/application/domains/show-domains";
|
import { ShowDomains } from "@/components/dashboard/application/domains/show-domains";
|
||||||
import { ShowEnvironment } from "@/components/dashboard/application/environment/show-enviroment";
|
import { ShowEnvironment } from "@/components/dashboard/application/environment/show-enviroment";
|
||||||
import { ShowSchedules } from "@/components/dashboard/application/schedules/show-schedules";
|
import { ShowSchedules } from "@/components/dashboard/application/schedules/show-schedules";
|
||||||
|
import { ShowVolumeBackups } from "@/components/dashboard/application/volume-backups/show-volume-backups";
|
||||||
import { AddCommandCompose } from "@/components/dashboard/compose/advanced/add-command";
|
import { AddCommandCompose } from "@/components/dashboard/compose/advanced/add-command";
|
||||||
import { DeleteService } from "@/components/dashboard/compose/delete-service";
|
import { DeleteService } from "@/components/dashboard/compose/delete-service";
|
||||||
import { ShowGeneralCompose } from "@/components/dashboard/compose/general/show";
|
import { ShowGeneralCompose } from "@/components/dashboard/compose/general/show";
|
||||||
@@ -57,7 +58,8 @@ type TabState =
|
|||||||
| "advanced"
|
| "advanced"
|
||||||
| "deployments"
|
| "deployments"
|
||||||
| "domains"
|
| "domains"
|
||||||
| "monitoring";
|
| "monitoring"
|
||||||
|
| "volumeBackups";
|
||||||
|
|
||||||
const Service = (
|
const Service = (
|
||||||
props: InferGetServerSidePropsType<typeof getServerSideProps>,
|
props: InferGetServerSidePropsType<typeof getServerSideProps>,
|
||||||
@@ -214,10 +216,10 @@ const Service = (
|
|||||||
className={cn(
|
className={cn(
|
||||||
"xl:grid xl:w-fit max-md:overflow-y-scroll justify-start",
|
"xl:grid xl:w-fit max-md:overflow-y-scroll justify-start",
|
||||||
isCloud && data?.serverId
|
isCloud && data?.serverId
|
||||||
? "xl:grid-cols-9"
|
? "xl:grid-cols-10"
|
||||||
: data?.serverId
|
: data?.serverId
|
||||||
? "xl:grid-cols-8"
|
? "xl:grid-cols-9"
|
||||||
: "xl:grid-cols-9",
|
: "xl:grid-cols-10",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<TabsTrigger value="general">General</TabsTrigger>
|
<TabsTrigger value="general">General</TabsTrigger>
|
||||||
@@ -226,6 +228,9 @@ const Service = (
|
|||||||
<TabsTrigger value="deployments">Deployments</TabsTrigger>
|
<TabsTrigger value="deployments">Deployments</TabsTrigger>
|
||||||
<TabsTrigger value="backups">Backups</TabsTrigger>
|
<TabsTrigger value="backups">Backups</TabsTrigger>
|
||||||
<TabsTrigger value="schedules">Schedules</TabsTrigger>
|
<TabsTrigger value="schedules">Schedules</TabsTrigger>
|
||||||
|
<TabsTrigger value="volumeBackups">
|
||||||
|
Volume Backups
|
||||||
|
</TabsTrigger>
|
||||||
<TabsTrigger value="logs">Logs</TabsTrigger>
|
<TabsTrigger value="logs">Logs</TabsTrigger>
|
||||||
{((data?.serverId && isCloud) || !data?.server) && (
|
{((data?.serverId && isCloud) || !data?.server) && (
|
||||||
<TabsTrigger value="monitoring">Monitoring</TabsTrigger>
|
<TabsTrigger value="monitoring">Monitoring</TabsTrigger>
|
||||||
@@ -255,7 +260,15 @@ const Service = (
|
|||||||
<ShowSchedules id={composeId} scheduleType="compose" />
|
<ShowSchedules id={composeId} scheduleType="compose" />
|
||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
<TabsContent value="volumeBackups">
|
||||||
|
<div className="flex flex-col gap-4 pt-2.5">
|
||||||
|
<ShowVolumeBackups
|
||||||
|
id={composeId}
|
||||||
|
type="compose"
|
||||||
|
serverId={data?.serverId || ""}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
<TabsContent value="monitoring">
|
<TabsContent value="monitoring">
|
||||||
<div className="pt-2.5">
|
<div className="pt-2.5">
|
||||||
<div className="flex flex-col border rounded-lg ">
|
<div className="flex flex-col border rounded-lg ">
|
||||||
@@ -342,6 +355,7 @@ const Service = (
|
|||||||
<ShowDomains id={composeId} type="compose" />
|
<ShowDomains id={composeId} type="compose" />
|
||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="advanced">
|
<TabsContent value="advanced">
|
||||||
<div className="flex flex-col gap-4 pt-2.5">
|
<div className="flex flex-col gap-4 pt-2.5">
|
||||||
<AddCommandCompose composeId={composeId} />
|
<AddCommandCompose composeId={composeId} />
|
||||||
@@ -409,7 +423,7 @@ export async function getServerSideProps(
|
|||||||
activeTab: (activeTab || "general") as TabState,
|
activeTab: (activeTab || "general") as TabState,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
} catch (_error) {
|
} catch {
|
||||||
return {
|
return {
|
||||||
redirect: {
|
redirect: {
|
||||||
permanent: false,
|
permanent: false,
|
||||||
|
|||||||
@@ -338,7 +338,7 @@ export async function getServerSideProps(
|
|||||||
activeTab: (activeTab || "general") as TabState,
|
activeTab: (activeTab || "general") as TabState,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
} catch (_error) {
|
} catch {
|
||||||
return {
|
return {
|
||||||
redirect: {
|
redirect: {
|
||||||
permanent: false,
|
permanent: false,
|
||||||
|
|||||||
@@ -340,7 +340,7 @@ export async function getServerSideProps(
|
|||||||
activeTab: (activeTab || "general") as TabState,
|
activeTab: (activeTab || "general") as TabState,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
} catch (_error) {
|
} catch {
|
||||||
return {
|
return {
|
||||||
redirect: {
|
redirect: {
|
||||||
permanent: false,
|
permanent: false,
|
||||||
|
|||||||
@@ -324,7 +324,7 @@ export async function getServerSideProps(
|
|||||||
activeTab: (activeTab || "general") as TabState,
|
activeTab: (activeTab || "general") as TabState,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
} catch (_error) {
|
} catch {
|
||||||
return {
|
return {
|
||||||
redirect: {
|
redirect: {
|
||||||
permanent: false,
|
permanent: false,
|
||||||
|
|||||||
@@ -321,7 +321,7 @@ export async function getServerSideProps(
|
|||||||
activeTab: (activeTab || "general") as TabState,
|
activeTab: (activeTab || "general") as TabState,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
} catch (_error) {
|
} catch {
|
||||||
return {
|
return {
|
||||||
redirect: {
|
redirect: {
|
||||||
permanent: false,
|
permanent: false,
|
||||||
|
|||||||
@@ -328,7 +328,7 @@ export async function getServerSideProps(
|
|||||||
activeTab: (activeTab || "general") as TabState,
|
activeTab: (activeTab || "general") as TabState,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
} catch (_error) {
|
} catch {
|
||||||
return {
|
return {
|
||||||
redirect: {
|
redirect: {
|
||||||
permanent: false,
|
permanent: false,
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ export async function getServerSideProps(
|
|||||||
trpcState: helpers.dehydrate(),
|
trpcState: helpers.dehydrate(),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
} catch (_error) {
|
} catch {
|
||||||
return {
|
return {
|
||||||
props: {},
|
props: {},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ export async function getServerSideProps(
|
|||||||
trpcState: helpers.dehydrate(),
|
trpcState: helpers.dehydrate(),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
} catch (_error) {
|
} catch {
|
||||||
return {
|
return {
|
||||||
props: {},
|
props: {},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ export async function getServerSideProps(
|
|||||||
trpcState: helpers.dehydrate(),
|
trpcState: helpers.dehydrate(),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
} catch (_error) {
|
} catch {
|
||||||
return {
|
return {
|
||||||
props: {},
|
props: {},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ export async function getServerSideProps(
|
|||||||
trpcState: helpers.dehydrate(),
|
trpcState: helpers.dehydrate(),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
} catch (_error) {
|
} catch {
|
||||||
return {
|
return {
|
||||||
props: {},
|
props: {},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -96,7 +96,7 @@ export default function Home({ IS_CLOUD }: Props) {
|
|||||||
|
|
||||||
toast.success("Logged in successfully");
|
toast.success("Logged in successfully");
|
||||||
router.push("/dashboard/projects");
|
router.push("/dashboard/projects");
|
||||||
} catch (_error) {
|
} catch {
|
||||||
toast.error("An error occurred while logging in");
|
toast.error("An error occurred while logging in");
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoginLoading(false);
|
setIsLoginLoading(false);
|
||||||
@@ -124,7 +124,7 @@ export default function Home({ IS_CLOUD }: Props) {
|
|||||||
|
|
||||||
toast.success("Logged in successfully");
|
toast.success("Logged in successfully");
|
||||||
router.push("/dashboard/projects");
|
router.push("/dashboard/projects");
|
||||||
} catch (_error) {
|
} catch {
|
||||||
toast.error("An error occurred while verifying 2FA code");
|
toast.error("An error occurred while verifying 2FA code");
|
||||||
} finally {
|
} finally {
|
||||||
setIsTwoFactorLoading(false);
|
setIsTwoFactorLoading(false);
|
||||||
@@ -154,7 +154,7 @@ export default function Home({ IS_CLOUD }: Props) {
|
|||||||
|
|
||||||
toast.success("Logged in successfully");
|
toast.success("Logged in successfully");
|
||||||
router.push("/dashboard/projects");
|
router.push("/dashboard/projects");
|
||||||
} catch (_error) {
|
} catch {
|
||||||
toast.error("An error occurred while verifying backup code");
|
toast.error("An error occurred while verifying backup code");
|
||||||
} finally {
|
} finally {
|
||||||
setIsBackupCodeLoading(false);
|
setIsBackupCodeLoading(false);
|
||||||
@@ -478,7 +478,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
} catch (_error) {}
|
} catch {}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
props: {
|
props: {
|
||||||
|
|||||||
@@ -133,7 +133,7 @@ const Invitation = ({
|
|||||||
|
|
||||||
toast.success("Account created successfully");
|
toast.success("Account created successfully");
|
||||||
router.push("/dashboard/projects");
|
router.push("/dashboard/projects");
|
||||||
} catch (_error) {
|
} catch {
|
||||||
toast.error("An error occurred while creating your account");
|
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";
|
import { eq } from "drizzle-orm";
|
||||||
|
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
const result = await findAdmin();
|
const result = await findAdmin();
|
||||||
|
|
||||||
const update = await db
|
const update = await db
|
||||||
.update(users_temp)
|
.update(users_temp)
|
||||||
.set({
|
.set({
|
||||||
twoFactorEnabled: false,
|
twoFactorEnabled: false,
|
||||||
})
|
})
|
||||||
.where(eq(users_temp.id, result.userId));
|
.where(eq(users_temp.id, result.userId));
|
||||||
|
|
||||||
if (update) {
|
if (update) {
|
||||||
console.log("2FA reset successful");
|
console.log("2FA reset successful");
|
||||||
} else {
|
} else {
|
||||||
console.log("Password reset failed");
|
console.log("Password reset failed");
|
||||||
}
|
}
|
||||||
|
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log("Error resetting 2FA", error);
|
console.log("Error resetting 2FA", error);
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ import { swarmRouter } from "./routers/swarm";
|
|||||||
import { userRouter } from "./routers/user";
|
import { userRouter } from "./routers/user";
|
||||||
import { scheduleRouter } from "./routers/schedule";
|
import { scheduleRouter } from "./routers/schedule";
|
||||||
import { rollbackRouter } from "./routers/rollbacks";
|
import { rollbackRouter } from "./routers/rollbacks";
|
||||||
|
import { volumeBackupsRouter } from "./routers/volume-backups";
|
||||||
/**
|
/**
|
||||||
* This is the primary router for your server.
|
* This is the primary router for your server.
|
||||||
*
|
*
|
||||||
@@ -82,6 +83,7 @@ export const appRouter = createTRPCRouter({
|
|||||||
organization: organizationRouter,
|
organization: organizationRouter,
|
||||||
schedule: scheduleRouter,
|
schedule: scheduleRouter,
|
||||||
rollback: rollbackRouter,
|
rollback: rollbackRouter,
|
||||||
|
volumeBackups: volumeBackupsRouter,
|
||||||
});
|
});
|
||||||
|
|
||||||
// export type definition of API
|
// export type definition of API
|
||||||
|
|||||||
@@ -184,12 +184,6 @@ export const applicationRouter = createTRPCRouter({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (application.serverId) {
|
|
||||||
await stopServiceRemote(application.serverId, input.appName);
|
|
||||||
} else {
|
|
||||||
await stopService(input.appName);
|
|
||||||
}
|
|
||||||
|
|
||||||
await updateApplicationStatus(input.applicationId, "idle");
|
await updateApplicationStatus(input.applicationId, "idle");
|
||||||
await mechanizeDockerContainer(application);
|
await mechanizeDockerContainer(application);
|
||||||
await updateApplicationStatus(input.applicationId, "done");
|
await updateApplicationStatus(input.applicationId, "done");
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ import {
|
|||||||
findProjectById,
|
findProjectById,
|
||||||
findServerById,
|
findServerById,
|
||||||
findUserById,
|
findUserById,
|
||||||
|
getComposeContainer,
|
||||||
loadServices,
|
loadServices,
|
||||||
randomizeComposeFile,
|
randomizeComposeFile,
|
||||||
randomizeIsolatedDeploymentComposeFile,
|
randomizeIsolatedDeploymentComposeFile,
|
||||||
@@ -241,6 +242,27 @@ export const composeRouter = createTRPCRouter({
|
|||||||
}
|
}
|
||||||
return await loadServices(input.composeId, input.type);
|
return await loadServices(input.composeId, input.type);
|
||||||
}),
|
}),
|
||||||
|
loadMountsByService: protectedProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
composeId: z.string().min(1),
|
||||||
|
serviceName: z.string().min(1),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.query(async ({ input, ctx }) => {
|
||||||
|
const compose = await findComposeById(input.composeId);
|
||||||
|
if (compose.project.organizationId !== ctx.session.activeOrganizationId) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "UNAUTHORIZED",
|
||||||
|
message: "You are not authorized to load this compose",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const container = await getComposeContainer(compose, input.serviceName);
|
||||||
|
const mounts = container?.Mounts.filter(
|
||||||
|
(mount) => mount.Type === "volume" && mount.Source !== "",
|
||||||
|
);
|
||||||
|
return mounts;
|
||||||
|
}),
|
||||||
fetchSourceType: protectedProcedure
|
fetchSourceType: protectedProcedure
|
||||||
.input(apiFindCompose)
|
.input(apiFindCompose)
|
||||||
.mutation(async ({ input, ctx }) => {
|
.mutation(async ({ input, ctx }) => {
|
||||||
|
|||||||
@@ -7,16 +7,21 @@ import {
|
|||||||
deployments,
|
deployments,
|
||||||
} from "@/server/db/schema";
|
} from "@/server/db/schema";
|
||||||
import {
|
import {
|
||||||
|
execAsync,
|
||||||
|
execAsyncRemote,
|
||||||
findAllDeploymentsByApplicationId,
|
findAllDeploymentsByApplicationId,
|
||||||
findAllDeploymentsByComposeId,
|
findAllDeploymentsByComposeId,
|
||||||
findAllDeploymentsByServerId,
|
findAllDeploymentsByServerId,
|
||||||
findApplicationById,
|
findApplicationById,
|
||||||
findComposeById,
|
findComposeById,
|
||||||
|
findDeploymentById,
|
||||||
findServerById,
|
findServerById,
|
||||||
|
updateDeploymentStatus,
|
||||||
} from "@dokploy/server";
|
} from "@dokploy/server";
|
||||||
import { TRPCError } from "@trpc/server";
|
import { TRPCError } from "@trpc/server";
|
||||||
import { desc, eq } from "drizzle-orm";
|
import { desc, eq } from "drizzle-orm";
|
||||||
import { createTRPCRouter, protectedProcedure } from "../trpc";
|
import { createTRPCRouter, protectedProcedure } from "../trpc";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
export const deploymentRouter = createTRPCRouter({
|
export const deploymentRouter = createTRPCRouter({
|
||||||
all: protectedProcedure
|
all: protectedProcedure
|
||||||
@@ -72,4 +77,30 @@ export const deploymentRouter = createTRPCRouter({
|
|||||||
|
|
||||||
return deploymentsList;
|
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 {
|
import {
|
||||||
containerRestart,
|
containerRestart,
|
||||||
|
findServerById,
|
||||||
getConfig,
|
getConfig,
|
||||||
getContainers,
|
getContainers,
|
||||||
getContainersByAppLabel,
|
getContainersByAppLabel,
|
||||||
@@ -9,6 +10,9 @@ import {
|
|||||||
} from "@dokploy/server";
|
} from "@dokploy/server";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { createTRPCRouter, protectedProcedure } from "../trpc";
|
import { createTRPCRouter, protectedProcedure } from "../trpc";
|
||||||
|
import { TRPCError } from "@trpc/server";
|
||||||
|
|
||||||
|
export const containerIdRegex = /^[a-zA-Z0-9.\-_]+$/;
|
||||||
|
|
||||||
export const dockerRouter = createTRPCRouter({
|
export const dockerRouter = createTRPCRouter({
|
||||||
getContainers: protectedProcedure
|
getContainers: protectedProcedure
|
||||||
@@ -17,14 +21,23 @@ export const dockerRouter = createTRPCRouter({
|
|||||||
serverId: z.string().optional(),
|
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);
|
return await getContainers(input.serverId);
|
||||||
}),
|
}),
|
||||||
|
|
||||||
restartContainer: protectedProcedure
|
restartContainer: protectedProcedure
|
||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
containerId: z.string().min(1),
|
containerId: z
|
||||||
|
.string()
|
||||||
|
.min(1)
|
||||||
|
.regex(containerIdRegex, "Invalid container id."),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.mutation(async ({ input }) => {
|
.mutation(async ({ input }) => {
|
||||||
@@ -34,11 +47,20 @@ export const dockerRouter = createTRPCRouter({
|
|||||||
getConfig: protectedProcedure
|
getConfig: protectedProcedure
|
||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
containerId: z.string().min(1),
|
containerId: z
|
||||||
|
.string()
|
||||||
|
.min(1)
|
||||||
|
.regex(containerIdRegex, "Invalid container id."),
|
||||||
serverId: z.string().optional(),
|
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);
|
return await getConfig(input.containerId, input.serverId);
|
||||||
}),
|
}),
|
||||||
|
|
||||||
@@ -48,11 +70,17 @@ export const dockerRouter = createTRPCRouter({
|
|||||||
appType: z
|
appType: z
|
||||||
.union([z.literal("stack"), z.literal("docker-compose")])
|
.union([z.literal("stack"), z.literal("docker-compose")])
|
||||||
.optional(),
|
.optional(),
|
||||||
appName: z.string().min(1),
|
appName: z.string().min(1).regex(containerIdRegex, "Invalid app name."),
|
||||||
serverId: z.string().optional(),
|
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(
|
return await getContainersByAppNameMatch(
|
||||||
input.appName,
|
input.appName,
|
||||||
input.appType,
|
input.appType,
|
||||||
@@ -63,12 +91,18 @@ export const dockerRouter = createTRPCRouter({
|
|||||||
getContainersByAppLabel: protectedProcedure
|
getContainersByAppLabel: protectedProcedure
|
||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
appName: z.string().min(1),
|
appName: z.string().min(1).regex(containerIdRegex, "Invalid app name."),
|
||||||
serverId: z.string().optional(),
|
serverId: z.string().optional(),
|
||||||
type: z.enum(["standalone", "swarm"]),
|
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(
|
return await getContainersByAppLabel(
|
||||||
input.appName,
|
input.appName,
|
||||||
input.type,
|
input.type,
|
||||||
@@ -79,22 +113,34 @@ export const dockerRouter = createTRPCRouter({
|
|||||||
getStackContainersByAppName: protectedProcedure
|
getStackContainersByAppName: protectedProcedure
|
||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
appName: z.string().min(1),
|
appName: z.string().min(1).regex(containerIdRegex, "Invalid app name."),
|
||||||
serverId: z.string().optional(),
|
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);
|
return await getStackContainersByAppName(input.appName, input.serverId);
|
||||||
}),
|
}),
|
||||||
|
|
||||||
getServiceContainersByAppName: protectedProcedure
|
getServiceContainersByAppName: protectedProcedure
|
||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
appName: z.string().min(1),
|
appName: z.string().min(1).regex(containerIdRegex, "Invalid app name."),
|
||||||
serverId: z.string().optional(),
|
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);
|
return await getServiceContainersByAppName(input.appName, input.serverId);
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -7,10 +7,13 @@ import {
|
|||||||
import {
|
import {
|
||||||
createMount,
|
createMount,
|
||||||
deleteMount,
|
deleteMount,
|
||||||
|
findApplicationById,
|
||||||
findMountById,
|
findMountById,
|
||||||
|
getServiceContainer,
|
||||||
updateMount,
|
updateMount,
|
||||||
} from "@dokploy/server";
|
} from "@dokploy/server";
|
||||||
import { createTRPCRouter, protectedProcedure } from "../trpc";
|
import { createTRPCRouter, protectedProcedure } from "../trpc";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
export const mountRouter = createTRPCRouter({
|
export const mountRouter = createTRPCRouter({
|
||||||
create: protectedProcedure
|
create: protectedProcedure
|
||||||
@@ -33,4 +36,14 @@ export const mountRouter = createTRPCRouter({
|
|||||||
.mutation(async ({ input }) => {
|
.mutation(async ({ input }) => {
|
||||||
return await updateMount(input.mountId, input);
|
return await updateMount(input.mountId, input);
|
||||||
}),
|
}),
|
||||||
|
allNamedByApplicationId: protectedProcedure
|
||||||
|
.input(z.object({ applicationId: z.string().min(1) }))
|
||||||
|
.query(async ({ input }) => {
|
||||||
|
const app = await findApplicationById(input.applicationId);
|
||||||
|
const container = await getServiceContainer(app.appName, app.serverId);
|
||||||
|
const mounts = container?.Mounts.filter(
|
||||||
|
(mount) => mount.Type === "volume" && mount.Source !== "",
|
||||||
|
);
|
||||||
|
return mounts;
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -459,6 +459,15 @@ export const settingsRouter = createTRPCRouter({
|
|||||||
throw new TRPCError({ code: "UNAUTHORIZED" });
|
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);
|
return readConfigInPath(input.path, input.serverId);
|
||||||
}),
|
}),
|
||||||
getIp: protectedProcedure.query(async ({ ctx }) => {
|
getIp: protectedProcedure.query(async ({ ctx }) => {
|
||||||
@@ -600,14 +609,14 @@ export const settingsRouter = createTRPCRouter({
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
.input(apiReadStatsLogs)
|
.input(apiReadStatsLogs)
|
||||||
.query(({ input }) => {
|
.query(async ({ input }) => {
|
||||||
if (IS_CLOUD) {
|
if (IS_CLOUD) {
|
||||||
return {
|
return {
|
||||||
data: [],
|
data: [],
|
||||||
totalCount: 0,
|
totalCount: 0,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
const rawConfig = readMonitoringConfig(
|
const rawConfig = await readMonitoringConfig(
|
||||||
!!input.dateRange?.start && !!input.dateRange?.end,
|
!!input.dateRange?.start && !!input.dateRange?.end,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -643,11 +652,11 @@ export const settingsRouter = createTRPCRouter({
|
|||||||
})
|
})
|
||||||
.optional(),
|
.optional(),
|
||||||
)
|
)
|
||||||
.query(({ input }) => {
|
.query(async ({ input }) => {
|
||||||
if (IS_CLOUD) {
|
if (IS_CLOUD) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
const rawConfig = readMonitoringConfig(
|
const rawConfig = await readMonitoringConfig(
|
||||||
!!input?.dateRange?.start || !!input?.dateRange?.end,
|
!!input?.dateRange?.start || !!input?.dateRange?.end,
|
||||||
);
|
);
|
||||||
const processedLogs = processLogs(rawConfig as string, input?.dateRange);
|
const processedLogs = processLogs(rawConfig as string, input?.dateRange);
|
||||||
@@ -837,6 +846,14 @@ export const settingsRouter = createTRPCRouter({
|
|||||||
getLogCleanupStatus: adminProcedure.query(async () => {
|
getLogCleanupStatus: adminProcedure.query(async () => {
|
||||||
return getLogCleanupStatus();
|
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) => {
|
export const getTraefikPorts = async (serverId?: string) => {
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ import {
|
|||||||
} from "@dokploy/server";
|
} from "@dokploy/server";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { createTRPCRouter, protectedProcedure } from "../trpc";
|
import { createTRPCRouter, protectedProcedure } from "../trpc";
|
||||||
|
import { TRPCError } from "@trpc/server";
|
||||||
|
import { findServerById } from "@dokploy/server";
|
||||||
|
import { containerIdRegex } from "./docker";
|
||||||
|
|
||||||
export const swarmRouter = createTRPCRouter({
|
export const swarmRouter = createTRPCRouter({
|
||||||
getNodes: protectedProcedure
|
getNodes: protectedProcedure
|
||||||
@@ -14,12 +17,24 @@ export const swarmRouter = createTRPCRouter({
|
|||||||
serverId: z.string().optional(),
|
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);
|
return await getSwarmNodes(input.serverId);
|
||||||
}),
|
}),
|
||||||
getNodeInfo: protectedProcedure
|
getNodeInfo: protectedProcedure
|
||||||
.input(z.object({ nodeId: z.string(), serverId: z.string().optional() }))
|
.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);
|
return await getNodeInfo(input.nodeId, input.serverId);
|
||||||
}),
|
}),
|
||||||
getNodeApps: protectedProcedure
|
getNodeApps: protectedProcedure
|
||||||
@@ -28,17 +43,29 @@ export const swarmRouter = createTRPCRouter({
|
|||||||
serverId: z.string().optional(),
|
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);
|
return getNodeApplications(input.serverId);
|
||||||
}),
|
}),
|
||||||
getAppInfos: protectedProcedure
|
getAppInfos: protectedProcedure
|
||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
appName: z.string(),
|
appName: z.string().min(1).regex(containerIdRegex, "Invalid app name."),
|
||||||
serverId: z.string().optional(),
|
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);
|
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;
|
return memberResult;
|
||||||
}),
|
}),
|
||||||
get: protectedProcedure.query(async ({ ctx }) => {
|
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,
|
createDefaultTraefikConfig,
|
||||||
initCronJobs,
|
initCronJobs,
|
||||||
initSchedules,
|
initSchedules,
|
||||||
|
initVolumeBackupsCronJobs,
|
||||||
initializeNetwork,
|
initializeNetwork,
|
||||||
sendDokployRestartNotifications,
|
sendDokployRestartNotifications,
|
||||||
setupDirectories,
|
setupDirectories,
|
||||||
@@ -51,6 +52,7 @@ void app.prepare().then(async () => {
|
|||||||
await migration();
|
await migration();
|
||||||
await initCronJobs();
|
await initCronJobs();
|
||||||
await initSchedules();
|
await initSchedules();
|
||||||
|
await initVolumeBackupsCronJobs();
|
||||||
await sendDokployRestartNotifications();
|
await sendDokployRestartNotifications();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,11 @@ type QueueJob =
|
|||||||
type: "schedule";
|
type: "schedule";
|
||||||
cronSchedule: string;
|
cronSchedule: string;
|
||||||
scheduleId: string;
|
scheduleId: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: "volume-backup";
|
||||||
|
cronSchedule: string;
|
||||||
|
volumeBackupId: string;
|
||||||
};
|
};
|
||||||
export const schedule = async (job: QueueJob) => {
|
export const schedule = async (job: QueueJob) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ export const isWSL = async () => {
|
|||||||
const { stdout } = await execAsync("uname -r");
|
const { stdout } = await execAsync("uname -r");
|
||||||
const isWSL = stdout.includes("microsoft");
|
const isWSL = stdout.includes("microsoft");
|
||||||
return isWSL;
|
return isWSL;
|
||||||
} catch (_error) {
|
} catch {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -101,7 +101,7 @@ export const setupDeploymentLogsWebSocketServer = (
|
|||||||
ws.close();
|
ws.close();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (_error) {
|
} catch {
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
// const errorMessage = error?.message as unknown as string;
|
// const errorMessage = error?.message as unknown as string;
|
||||||
// ws.send(errorMessage);
|
// ws.send(errorMessage);
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
initializeNetwork,
|
initializeNetwork,
|
||||||
initializeSwarm,
|
initializeSwarm,
|
||||||
} from "@dokploy/server/setup/setup";
|
} from "@dokploy/server/setup/setup";
|
||||||
|
import { execAsync } from "@dokploy/server";
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
setupDirectories();
|
setupDirectories();
|
||||||
@@ -20,6 +21,7 @@ import {
|
|||||||
await initializeNetwork();
|
await initializeNetwork();
|
||||||
createDefaultTraefikConfig();
|
createDefaultTraefikConfig();
|
||||||
createDefaultServerTraefikConfig();
|
createDefaultServerTraefikConfig();
|
||||||
|
await execAsync("docker pull traefik:v3.1.2");
|
||||||
await initializeTraefik();
|
await initializeTraefik();
|
||||||
await initializeRedis();
|
await initializeRedis();
|
||||||
await initializePostgres();
|
await initializePostgres();
|
||||||
|
|||||||
@@ -242,3 +242,8 @@
|
|||||||
background-color: var(--terminal-paste) !important;
|
background-color: var(--terminal-paste) !important;
|
||||||
color: currentColor !important;
|
color: currentColor !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.cm-content,
|
||||||
|
.cm-lineWrapping {
|
||||||
|
@apply font-mono;
|
||||||
|
}
|
||||||
|
|||||||
@@ -30,12 +30,41 @@ const getWsUrl = () => {
|
|||||||
return `${protocol}${host}/drawer-logs`;
|
return `${protocol}${host}/drawer-logs`;
|
||||||
};
|
};
|
||||||
|
|
||||||
const wsClient =
|
// Create WebSocket client with delayed connection
|
||||||
typeof window !== "undefined"
|
const createLazyWSClient = () => {
|
||||||
? createWSClient({
|
if (typeof window === "undefined") return null;
|
||||||
url: getWsUrl() || "",
|
|
||||||
})
|
let actualClient: ReturnType<typeof createWSClient> | null = null;
|
||||||
: null;
|
|
||||||
|
return {
|
||||||
|
request: (op: any, callbacks: any) => {
|
||||||
|
if (!actualClient) {
|
||||||
|
const wsUrl = getWsUrl();
|
||||||
|
if (wsUrl) {
|
||||||
|
actualClient = createWSClient({ url: wsUrl });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return actualClient?.request(op, callbacks) || (() => {});
|
||||||
|
},
|
||||||
|
close: () => {
|
||||||
|
if (actualClient) {
|
||||||
|
actualClient.close();
|
||||||
|
actualClient = null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
getConnection: () => {
|
||||||
|
if (!actualClient) {
|
||||||
|
const wsUrl = getWsUrl();
|
||||||
|
if (wsUrl) {
|
||||||
|
actualClient = createWSClient({ url: wsUrl });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return actualClient!.getConnection();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const wsClient = createLazyWSClient();
|
||||||
|
|
||||||
/** A set of type-safe react-query hooks for your tRPC API. */
|
/** A set of type-safe react-query hooks for your tRPC API. */
|
||||||
export const api = createTRPCNext<AppRouter>({
|
export const api = createTRPCNext<AppRouter>({
|
||||||
|
|||||||
@@ -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",
|
type: "schedule",
|
||||||
cronSchedule: job.pattern,
|
cronSchedule: job.pattern,
|
||||||
});
|
});
|
||||||
|
} else if (data.type === "volume-backup") {
|
||||||
|
result = await removeJob({
|
||||||
|
volumeBackupId: data.volumeBackupId,
|
||||||
|
type: "volume-backup",
|
||||||
|
cronSchedule: job.pattern,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
logger.info({ result }, "Job removed");
|
logger.info({ result }, "Job removed");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,6 +42,12 @@ export const scheduleJob = (job: QueueJob) => {
|
|||||||
pattern: job.cronSchedule,
|
pattern: job.cronSchedule,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
} else if (job.type === "volume-backup") {
|
||||||
|
jobQueue.add(job.volumeBackupId, job, {
|
||||||
|
repeat: {
|
||||||
|
pattern: job.cronSchedule,
|
||||||
|
},
|
||||||
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -67,6 +73,13 @@ export const removeJob = async (data: QueueJob) => {
|
|||||||
});
|
});
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
if (data.type === "volume-backup") {
|
||||||
|
const { volumeBackupId, cronSchedule } = data;
|
||||||
|
const result = await jobQueue.removeRepeatable(volumeBackupId, {
|
||||||
|
pattern: cronSchedule,
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -89,6 +102,10 @@ export const getJobRepeatable = async (
|
|||||||
const job = repeatableJobs.find((j) => j.name === scheduleId);
|
const job = repeatableJobs.find((j) => j.name === scheduleId);
|
||||||
return job ? job : null;
|
return job ? job : null;
|
||||||
}
|
}
|
||||||
|
if (data.type === "volume-backup") {
|
||||||
|
const { volumeBackupId } = data;
|
||||||
|
const job = repeatableJobs.find((j) => j.name === volumeBackupId);
|
||||||
|
return job ? job : null;
|
||||||
|
}
|
||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -16,6 +16,11 @@ export const jobQueueSchema = z.discriminatedUnion("type", [
|
|||||||
type: z.literal("schedule"),
|
type: z.literal("schedule"),
|
||||||
scheduleId: z.string(),
|
scheduleId: z.string(),
|
||||||
}),
|
}),
|
||||||
|
z.object({
|
||||||
|
cronSchedule: z.string(),
|
||||||
|
type: z.literal("volume-backup"),
|
||||||
|
volumeBackupId: z.string(),
|
||||||
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export type QueueJob = z.infer<typeof jobQueueSchema>;
|
export type QueueJob = z.infer<typeof jobQueueSchema>;
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
findBackupById,
|
findBackupById,
|
||||||
findScheduleById,
|
findScheduleById,
|
||||||
findServerById,
|
findServerById,
|
||||||
|
findVolumeBackupById,
|
||||||
keepLatestNBackups,
|
keepLatestNBackups,
|
||||||
runCommand,
|
runCommand,
|
||||||
runComposeBackup,
|
runComposeBackup,
|
||||||
@@ -12,9 +13,15 @@ import {
|
|||||||
runMongoBackup,
|
runMongoBackup,
|
||||||
runMySqlBackup,
|
runMySqlBackup,
|
||||||
runPostgresBackup,
|
runPostgresBackup,
|
||||||
|
runVolumeBackup,
|
||||||
} from "@dokploy/server";
|
} from "@dokploy/server";
|
||||||
import { db } from "@dokploy/server/dist/db";
|
import { db } from "@dokploy/server/dist/db";
|
||||||
import { backups, schedules, server } from "@dokploy/server/dist/db/schema";
|
import {
|
||||||
|
backups,
|
||||||
|
schedules,
|
||||||
|
server,
|
||||||
|
volumeBackups,
|
||||||
|
} from "@dokploy/server/dist/db/schema";
|
||||||
import { and, eq } from "drizzle-orm";
|
import { and, eq } from "drizzle-orm";
|
||||||
import { logger } from "./logger.js";
|
import { logger } from "./logger.js";
|
||||||
import { scheduleJob } from "./queue.js";
|
import { scheduleJob } from "./queue.js";
|
||||||
@@ -93,6 +100,12 @@ export const runJobs = async (job: QueueJob) => {
|
|||||||
if (schedule.enabled) {
|
if (schedule.enabled) {
|
||||||
await runCommand(schedule.scheduleId);
|
await runCommand(schedule.scheduleId);
|
||||||
}
|
}
|
||||||
|
} else if (job.type === "volume-backup") {
|
||||||
|
const { volumeBackupId } = job;
|
||||||
|
const volumeBackup = await findVolumeBackupById(volumeBackupId);
|
||||||
|
if (volumeBackup.enabled) {
|
||||||
|
await runVolumeBackup(volumeBackupId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(error);
|
logger.error(error);
|
||||||
@@ -184,4 +197,44 @@ export const initializeJobs = async () => {
|
|||||||
{ Quantity: filteredSchedulesBasedOnServerStatus.length },
|
{ Quantity: filteredSchedulesBasedOnServerStatus.length },
|
||||||
"Schedules Initialized",
|
"Schedules Initialized",
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const volumeBackupsResult = await db.query.volumeBackups.findMany({
|
||||||
|
where: eq(volumeBackups.enabled, true),
|
||||||
|
with: {
|
||||||
|
application: {
|
||||||
|
with: {
|
||||||
|
server: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
compose: {
|
||||||
|
with: {
|
||||||
|
server: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const filteredVolumeBackupsBasedOnServerStatus = volumeBackupsResult.filter(
|
||||||
|
(volumeBackup) => {
|
||||||
|
if (volumeBackup.application) {
|
||||||
|
return volumeBackup.application.server?.serverStatus === "active";
|
||||||
|
}
|
||||||
|
if (volumeBackup.compose) {
|
||||||
|
return volumeBackup.compose.server?.serverStatus === "active";
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const volumeBackup of filteredVolumeBackupsBasedOnServerStatus) {
|
||||||
|
scheduleJob({
|
||||||
|
volumeBackupId: volumeBackup.volumeBackupId,
|
||||||
|
type: "volume-backup",
|
||||||
|
cronSchedule: volumeBackup.cronExpression,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
{ Quantity: filteredVolumeBackupsBasedOnServerStatus.length },
|
||||||
|
"Volume Backups Initialized",
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -24,5 +24,6 @@ export const paths = (isServer = false) => {
|
|||||||
MONITORING_PATH: `${BASE_PATH}/monitoring`,
|
MONITORING_PATH: `${BASE_PATH}/monitoring`,
|
||||||
REGISTRY_PATH: `${BASE_PATH}/registry`,
|
REGISTRY_PATH: `${BASE_PATH}/registry`,
|
||||||
SCHEDULES_PATH: `${BASE_PATH}/schedules`,
|
SCHEDULES_PATH: `${BASE_PATH}/schedules`,
|
||||||
|
VOLUME_BACKUPS_PATH: `${BASE_PATH}/volume-backups`,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -79,6 +79,10 @@ export const compose = pgTable("compose", {
|
|||||||
suffix: text("suffix").notNull().default(""),
|
suffix: text("suffix").notNull().default(""),
|
||||||
randomize: boolean("randomize").notNull().default(false),
|
randomize: boolean("randomize").notNull().default(false),
|
||||||
isolatedDeployment: boolean("isolatedDeployment").notNull().default(false),
|
isolatedDeployment: boolean("isolatedDeployment").notNull().default(false),
|
||||||
|
// Keep this for backward compatibility since we will not add the prefix anymore to volumes
|
||||||
|
isolatedDeploymentsVolume: boolean("isolatedDeploymentsVolume")
|
||||||
|
.notNull()
|
||||||
|
.default(false),
|
||||||
triggerType: triggerType("triggerType").default("push"),
|
triggerType: triggerType("triggerType").default("push"),
|
||||||
composeStatus: applicationStatus("composeStatus").notNull().default("idle"),
|
composeStatus: applicationStatus("composeStatus").notNull().default("idle"),
|
||||||
projectId: text("projectId")
|
projectId: text("projectId")
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { previewDeployments } from "./preview-deployments";
|
|||||||
import { schedules } from "./schedule";
|
import { schedules } from "./schedule";
|
||||||
import { server } from "./server";
|
import { server } from "./server";
|
||||||
import { rollbacks } from "./rollbacks";
|
import { rollbacks } from "./rollbacks";
|
||||||
|
import { volumeBackups } from "./volume-backups";
|
||||||
export const deploymentStatus = pgEnum("deploymentStatus", [
|
export const deploymentStatus = pgEnum("deploymentStatus", [
|
||||||
"running",
|
"running",
|
||||||
"done",
|
"done",
|
||||||
@@ -31,6 +32,7 @@ export const deployments = pgTable("deployment", {
|
|||||||
description: text("description"),
|
description: text("description"),
|
||||||
status: deploymentStatus("status").default("running"),
|
status: deploymentStatus("status").default("running"),
|
||||||
logPath: text("logPath").notNull(),
|
logPath: text("logPath").notNull(),
|
||||||
|
pid: text("pid"),
|
||||||
applicationId: text("applicationId").references(
|
applicationId: text("applicationId").references(
|
||||||
() => applications.applicationId,
|
() => applications.applicationId,
|
||||||
{ onDelete: "cascade" },
|
{ onDelete: "cascade" },
|
||||||
@@ -63,6 +65,10 @@ export const deployments = pgTable("deployment", {
|
|||||||
(): AnyPgColumn => rollbacks.rollbackId,
|
(): AnyPgColumn => rollbacks.rollbackId,
|
||||||
{ onDelete: "cascade" },
|
{ onDelete: "cascade" },
|
||||||
),
|
),
|
||||||
|
volumeBackupId: text("volumeBackupId").references(
|
||||||
|
(): AnyPgColumn => volumeBackups.volumeBackupId,
|
||||||
|
{ onDelete: "cascade" },
|
||||||
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const deploymentsRelations = relations(deployments, ({ one }) => ({
|
export const deploymentsRelations = relations(deployments, ({ one }) => ({
|
||||||
@@ -94,6 +100,10 @@ export const deploymentsRelations = relations(deployments, ({ one }) => ({
|
|||||||
fields: [deployments.deploymentId],
|
fields: [deployments.deploymentId],
|
||||||
references: [rollbacks.deploymentId],
|
references: [rollbacks.deploymentId],
|
||||||
}),
|
}),
|
||||||
|
volumeBackup: one(volumeBackups, {
|
||||||
|
fields: [deployments.volumeBackupId],
|
||||||
|
references: [volumeBackups.volumeBackupId],
|
||||||
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const schema = createInsertSchema(deployments, {
|
const schema = createInsertSchema(deployments, {
|
||||||
@@ -178,6 +188,17 @@ export const apiCreateDeploymentSchedule = schema
|
|||||||
scheduleId: z.string().min(1),
|
scheduleId: z.string().min(1),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const apiCreateDeploymentVolumeBackup = schema
|
||||||
|
.pick({
|
||||||
|
title: true,
|
||||||
|
status: true,
|
||||||
|
logPath: true,
|
||||||
|
description: true,
|
||||||
|
})
|
||||||
|
.extend({
|
||||||
|
volumeBackupId: z.string().min(1),
|
||||||
|
});
|
||||||
|
|
||||||
export const apiFindAllByApplication = schema
|
export const apiFindAllByApplication = schema
|
||||||
.pick({
|
.pick({
|
||||||
applicationId: true,
|
applicationId: true,
|
||||||
@@ -215,6 +236,7 @@ export const apiFindAllByType = z
|
|||||||
"schedule",
|
"schedule",
|
||||||
"previewDeployment",
|
"previewDeployment",
|
||||||
"backup",
|
"backup",
|
||||||
|
"volumeBackup",
|
||||||
]),
|
]),
|
||||||
})
|
})
|
||||||
.required();
|
.required();
|
||||||
|
|||||||
@@ -51,6 +51,8 @@ export const domains = pgTable("domain", {
|
|||||||
{ onDelete: "cascade" },
|
{ onDelete: "cascade" },
|
||||||
),
|
),
|
||||||
certificateType: certificateType("certificateType").notNull().default("none"),
|
certificateType: certificateType("certificateType").notNull().default("none"),
|
||||||
|
internalPath: text("internalPath").default("/"),
|
||||||
|
stripPath: boolean("stripPath").notNull().default(false),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const domainsRelations = relations(domains, ({ one }) => ({
|
export const domainsRelations = relations(domains, ({ one }) => ({
|
||||||
@@ -82,6 +84,8 @@ export const apiCreateDomain = createSchema.pick({
|
|||||||
serviceName: true,
|
serviceName: true,
|
||||||
domainType: true,
|
domainType: true,
|
||||||
previewDeploymentId: true,
|
previewDeploymentId: true,
|
||||||
|
internalPath: true,
|
||||||
|
stripPath: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const apiFindDomain = createSchema
|
export const apiFindDomain = createSchema
|
||||||
@@ -112,5 +116,7 @@ export const apiUpdateDomain = createSchema
|
|||||||
customCertResolver: true,
|
customCertResolver: true,
|
||||||
serviceName: true,
|
serviceName: true,
|
||||||
domainType: true,
|
domainType: true,
|
||||||
|
internalPath: true,
|
||||||
|
stripPath: true,
|
||||||
})
|
})
|
||||||
.merge(createSchema.pick({ domainId: true }).required());
|
.merge(createSchema.pick({ domainId: true }).required());
|
||||||
|
|||||||
@@ -33,3 +33,4 @@ export * from "./ai";
|
|||||||
export * from "./account";
|
export * from "./account";
|
||||||
export * from "./schedule";
|
export * from "./schedule";
|
||||||
export * from "./rollbacks";
|
export * from "./rollbacks";
|
||||||
|
export * from "./volume-backups";
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { z } from "zod";
|
|||||||
import { applications } from "./application";
|
import { applications } from "./application";
|
||||||
|
|
||||||
export const protocolType = pgEnum("protocolType", ["tcp", "udp"]);
|
export const protocolType = pgEnum("protocolType", ["tcp", "udp"]);
|
||||||
|
export const publishModeType = pgEnum("publishModeType", ["ingress", "host"]);
|
||||||
|
|
||||||
export const ports = pgTable("port", {
|
export const ports = pgTable("port", {
|
||||||
portId: text("portId")
|
portId: text("portId")
|
||||||
@@ -13,6 +14,7 @@ export const ports = pgTable("port", {
|
|||||||
.primaryKey()
|
.primaryKey()
|
||||||
.$defaultFn(() => nanoid()),
|
.$defaultFn(() => nanoid()),
|
||||||
publishedPort: integer("publishedPort").notNull(),
|
publishedPort: integer("publishedPort").notNull(),
|
||||||
|
publishMode: publishModeType("publishMode").notNull().default("host"),
|
||||||
targetPort: integer("targetPort").notNull(),
|
targetPort: integer("targetPort").notNull(),
|
||||||
protocol: protocolType("protocol").notNull(),
|
protocol: protocolType("protocol").notNull(),
|
||||||
|
|
||||||
@@ -32,6 +34,7 @@ const createSchema = createInsertSchema(ports, {
|
|||||||
portId: z.string().min(1),
|
portId: z.string().min(1),
|
||||||
applicationId: z.string().min(1),
|
applicationId: z.string().min(1),
|
||||||
publishedPort: z.number(),
|
publishedPort: z.number(),
|
||||||
|
publishMode: z.enum(["ingress", "host"]).default("ingress"),
|
||||||
targetPort: z.number(),
|
targetPort: z.number(),
|
||||||
protocol: z.enum(["tcp", "udp"]).default("tcp"),
|
protocol: z.enum(["tcp", "udp"]).default("tcp"),
|
||||||
});
|
});
|
||||||
@@ -39,6 +42,7 @@ const createSchema = createInsertSchema(ports, {
|
|||||||
export const apiCreatePort = createSchema
|
export const apiCreatePort = createSchema
|
||||||
.pick({
|
.pick({
|
||||||
publishedPort: true,
|
publishedPort: true,
|
||||||
|
publishMode: true,
|
||||||
targetPort: true,
|
targetPort: true,
|
||||||
protocol: true,
|
protocol: true,
|
||||||
applicationId: true,
|
applicationId: true,
|
||||||
@@ -55,6 +59,7 @@ export const apiUpdatePort = createSchema
|
|||||||
.pick({
|
.pick({
|
||||||
portId: true,
|
portId: true,
|
||||||
publishedPort: true,
|
publishedPort: true,
|
||||||
|
publishMode: true,
|
||||||
targetPort: true,
|
targetPort: true,
|
||||||
protocol: true,
|
protocol: true,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -4,6 +4,11 @@ import { createInsertSchema } from "drizzle-zod";
|
|||||||
import { nanoid } from "nanoid";
|
import { nanoid } from "nanoid";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { deployments } from "./deployment";
|
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", {
|
export const rollbacks = pgTable("rollback", {
|
||||||
rollbackId: text("rollbackId")
|
rollbackId: text("rollbackId")
|
||||||
@@ -20,7 +25,14 @@ export const rollbacks = pgTable("rollback", {
|
|||||||
createdAt: text("createdAt")
|
createdAt: text("createdAt")
|
||||||
.notNull()
|
.notNull()
|
||||||
.$defaultFn(() => new Date().toISOString()),
|
.$defaultFn(() => new Date().toISOString()),
|
||||||
fullContext: jsonb("fullContext"),
|
fullContext: jsonb("fullContext").$type<
|
||||||
|
Application & {
|
||||||
|
project: Project;
|
||||||
|
mounts: Mount[];
|
||||||
|
ports: Port[];
|
||||||
|
registry?: Registry | null;
|
||||||
|
}
|
||||||
|
>(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type Rollback = typeof rollbacks.$inferSelect;
|
export type Rollback = typeof rollbacks.$inferSelect;
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { backups } from "./backups";
|
|||||||
import { projects } from "./project";
|
import { projects } from "./project";
|
||||||
import { schedules } from "./schedule";
|
import { schedules } from "./schedule";
|
||||||
import { certificateType } from "./shared";
|
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
|
* This is an example of how to use the multi-project schema feature of Drizzle ORM. Use the same
|
||||||
* database instance for multiple projects.
|
* database instance for multiple projects.
|
||||||
@@ -236,7 +237,31 @@ export const apiModifyTraefikConfig = z.object({
|
|||||||
serverId: z.string().optional(),
|
serverId: z.string().optional(),
|
||||||
});
|
});
|
||||||
export const apiReadTraefikConfig = z.object({
|
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(),
|
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({
|
.object({
|
||||||
host: z.string().min(1, { message: "Add a hostname" }),
|
host: z.string().min(1, { message: "Add a hostname" }),
|
||||||
path: z.string().min(1).optional(),
|
path: z.string().min(1).optional(),
|
||||||
|
internalPath: z.string().optional(),
|
||||||
|
stripPath: z.boolean().optional(),
|
||||||
port: z
|
port: z
|
||||||
.number()
|
.number()
|
||||||
.min(1, { message: "Port must be at least 1" })
|
.min(1, { message: "Port must be at least 1" })
|
||||||
@@ -29,12 +31,37 @@ export const domain = z
|
|||||||
message: "Required when certificate type is custom",
|
message: "Required when certificate type is custom",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Validate stripPath requires a valid path
|
||||||
|
if (input.stripPath && (!input.path || input.path === "/")) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
path: ["stripPath"],
|
||||||
|
message:
|
||||||
|
"Strip path can only be enabled when a path other than '/' is specified",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate internalPath starts with /
|
||||||
|
if (
|
||||||
|
input.internalPath &&
|
||||||
|
input.internalPath !== "/" &&
|
||||||
|
!input.internalPath.startsWith("/")
|
||||||
|
) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
path: ["internalPath"],
|
||||||
|
message: "Internal path must start with '/'",
|
||||||
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
export const domainCompose = z
|
export const domainCompose = z
|
||||||
.object({
|
.object({
|
||||||
host: z.string().min(1, { message: "Host is required" }),
|
host: z.string().min(1, { message: "Host is required" }),
|
||||||
path: z.string().min(1).optional(),
|
path: z.string().min(1).optional(),
|
||||||
|
internalPath: z.string().optional(),
|
||||||
|
stripPath: z.boolean().optional(),
|
||||||
port: z
|
port: z
|
||||||
.number()
|
.number()
|
||||||
.min(1, { message: "Port must be at least 1" })
|
.min(1, { message: "Port must be at least 1" })
|
||||||
@@ -61,4 +88,27 @@ export const domainCompose = z
|
|||||||
message: "Required when certificate type is custom",
|
message: "Required when certificate type is custom",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Validate stripPath requires a valid path
|
||||||
|
if (input.stripPath && (!input.path || input.path === "/")) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
path: ["stripPath"],
|
||||||
|
message:
|
||||||
|
"Strip path can only be enabled when a path other than '/' is specified",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate internalPath starts with /
|
||||||
|
if (
|
||||||
|
input.internalPath &&
|
||||||
|
input.internalPath !== "/" &&
|
||||||
|
!input.internalPath.startsWith("/")
|
||||||
|
) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
path: ["internalPath"],
|
||||||
|
message: "Internal path must start with '/'",
|
||||||
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ export * from "./services/mysql";
|
|||||||
export * from "./services/backup";
|
export * from "./services/backup";
|
||||||
export * from "./services/cluster";
|
export * from "./services/cluster";
|
||||||
export * from "./services/settings";
|
export * from "./services/settings";
|
||||||
|
export * from "./services/volume-backups";
|
||||||
export * from "./services/docker";
|
export * from "./services/docker";
|
||||||
export * from "./services/destination";
|
export * from "./services/destination";
|
||||||
export * from "./services/deployment";
|
export * from "./services/deployment";
|
||||||
@@ -132,5 +133,6 @@ export {
|
|||||||
|
|
||||||
export * from "./utils/schedules/utils";
|
export * from "./utils/schedules/utils";
|
||||||
export * from "./utils/schedules/index";
|
export * from "./utils/schedules/index";
|
||||||
|
export * from "./utils/volume-backups/index";
|
||||||
|
|
||||||
export * from "./lib/logger";
|
export * from "./lib/logger";
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user