Compare commits

..

1 Commits

Author SHA1 Message Date
autofix-ci[bot]
04a10146ac [autofix.ci] apply automated fixes 2025-05-17 05:16:30 +00:00
252 changed files with 4436 additions and 73716 deletions

View File

@@ -12,7 +12,7 @@ jobs:
- uses: pnpm/action-setup@v4 - uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4 - uses: actions/setup-node@v4
with: with:
node-version: 20.16.0 node-version: 20.9.0
cache: "pnpm" cache: "pnpm"
- run: pnpm install --frozen-lockfile - run: pnpm install --frozen-lockfile
- run: pnpm run server:build - run: pnpm run server:build
@@ -26,7 +26,7 @@ jobs:
- uses: pnpm/action-setup@v4 - uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4 - uses: actions/setup-node@v4
with: with:
node-version: 20.16.0 node-version: 20.9.0
cache: "pnpm" cache: "pnpm"
- run: pnpm install --frozen-lockfile - run: pnpm install --frozen-lockfile
- run: pnpm run server:build - run: pnpm run server:build
@@ -39,7 +39,7 @@ jobs:
- uses: pnpm/action-setup@v4 - uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4 - uses: actions/setup-node@v4
with: with:
node-version: 20.16.0 node-version: 20.9.0
cache: "pnpm" cache: "pnpm"
- run: pnpm install --frozen-lockfile - run: pnpm install --frozen-lockfile
- run: pnpm run server:build - run: pnpm run server:build

2
.nvmrc
View File

@@ -1 +1 @@
20.16.0 20.9.0

View File

@@ -52,7 +52,7 @@ feat: add new feature
Before you start, please make the clone based on the `canary` branch, since the `main` branch is the source of truth and should always reflect the latest stable release, also the PRs will be merged to the `canary` branch. Before you start, please make the clone based on the `canary` branch, since the `main` branch is the source of truth and should always reflect the latest stable release, also the PRs will be merged to the `canary` branch.
We use Node v20.16.0 and recommend this specific version. If you have nvm installed, you can run `nvm install 20.16.0 && nvm use` in the root directory. We use Node v20.9.0 and recommend this specific version. If you have nvm installed, you can run `nvm install 20.9.0 && nvm use` in the root directory.
```bash ```bash
git clone https://github.com/dokploy/dokploy.git git clone https://github.com/dokploy/dokploy.git

View File

@@ -1,4 +1,3 @@
# syntax=docker/dockerfile:1
FROM node:20.9-slim AS base FROM node:20.9-slim AS base
ENV PNPM_HOME="/pnpm" ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH" ENV PATH="$PNPM_HOME:$PATH"
@@ -30,7 +29,7 @@ WORKDIR /app
# Set production # Set production
ENV NODE_ENV=production ENV NODE_ENV=production
RUN apt-get update && apt-get install -y curl unzip zip apache2-utils iproute2 rsync git-lfs && git lfs install && rm -rf /var/lib/apt/lists/* RUN apt-get update && apt-get install -y curl unzip zip apache2-utils iproute2 rsync && rm -rf /var/lib/apt/lists/*
# Copy only the necessary files # Copy only the necessary files
COPY --from=build /prod/dokploy/.next ./.next COPY --from=build /prod/dokploy/.next ./.next
@@ -50,7 +49,7 @@ RUN curl -fsSL https://get.docker.com -o get-docker.sh && sh get-docker.sh && rm
# Install Nixpacks and tsx # Install Nixpacks and tsx
# | VERBOSE=1 VERSION=1.21.0 bash # | VERBOSE=1 VERSION=1.21.0 bash
ARG NIXPACKS_VERSION=1.39.0 ARG NIXPACKS_VERSION=1.35.0
RUN curl -sSL https://nixpacks.com/install.sh -o install.sh \ RUN curl -sSL https://nixpacks.com/install.sh -o install.sh \
&& chmod +x install.sh \ && chmod +x install.sh \
&& ./install.sh \ && ./install.sh \
@@ -64,4 +63,4 @@ RUN curl -sSL https://railpack.com/install.sh | bash
COPY --from=buildpacksio/pack:0.35.0 /usr/local/bin/pack /usr/local/bin/pack COPY --from=buildpacksio/pack:0.35.0 /usr/local/bin/pack /usr/local/bin/pack
EXPOSE 3000 EXPOSE 3000
CMD [ "pnpm", "start" ] CMD [ "pnpm", "start" ]

View File

@@ -1,4 +1,3 @@
# syntax=docker/dockerfile:1
FROM node:20.9-slim AS base FROM node:20.9-slim AS base
ENV PNPM_HOME="/pnpm" ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH" ENV PATH="$PNPM_HOME:$PATH"

View File

@@ -1,4 +1,3 @@
# syntax=docker/dockerfile:1
# Build stage # Build stage
FROM golang:1.21-alpine3.19 AS builder FROM golang:1.21-alpine3.19 AS builder

View File

@@ -1,4 +1,3 @@
# syntax=docker/dockerfile:1
FROM node:20.9-slim AS base FROM node:20.9-slim AS base
ENV PNPM_HOME="/pnpm" ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH" ENV PATH="$PNPM_HOME:$PATH"

View File

@@ -1,4 +1,3 @@
# syntax=docker/dockerfile:1
FROM node:20.9-slim AS base FROM node:20.9-slim AS base
ENV PNPM_HOME="/pnpm" ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH" ENV PATH="$PNPM_HOME:$PATH"

View File

@@ -16,29 +16,28 @@ 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 ca-certificates curl sudo apt-get install \
sudo install -m 0755 -d /etc/apt/keyrings apt-transport-https \
ca-certificates \
curl \
gnupg \
lsb-release
# Add Docker's official GPG key # Add Docker's official GPG key
sudo curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /usr/share/keyrings/docker-archive-keyring.gpg
sudo chmod a+r /etc/apt/keyrings/docker.asc
# Add the repository to Apt sources # Set up stable repository
echo \ echo \
"deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu \ "deb [arch=amd64 signed-by=/usr/share/keyrings/docker-archive-keyring.gpg] https://download.docker.com/linux/ubuntu \
$(. /etc/os-release && echo "${UBUNTU_CODENAME:-$VERSION_CODENAME}") stable" | \ $(lsb_release -cs) stable" | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
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 docker-buildx-plugin docker-compose-plugin sudo apt-get install docker-ce docker-ce-cli containerd.io
``` ```
## Windows ## Windows

View File

@@ -2,7 +2,7 @@
## Core License (Apache License 2.0) ## Core License (Apache License 2.0)
Copyright 2025 Mauricio Siu. Copyright 2024 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.

112
README.md
View File

@@ -1,16 +1,20 @@
<div align="center"> <div align="center">
<a href="https://dokploy.com"> <div>
<img src=".github/sponsors/logo.png" alt="Dokploy - Open Source Alternative to Vercel, Heroku and Netlify." align="center" width="100%" /> <a href="https://dokploy.com" target="_blank" rel="noopener">
</a> <img style="object-fit: cover;" align="center" width="100%"src=".github/sponsors/logo.png" alt="Dokploy - Open Source Alternative to Vercel, Heroku and Netlify." />
</a>
</div>
</br>
<div align="center">
<div>Join us on Discord for help, feedback, and discussions!</div>
</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
@@ -57,52 +61,71 @@ For detailed documentation, visit [docs.dokploy.com](https://docs.dokploy.com).
### Hero Sponsors 🎖 ### Hero Sponsors 🎖
<div> <div style="display: flex; align-items: center; gap: 20px;">
<a href="https://www.hostinger.com/vps-hosting?ref=dokploy"><img src=".github/sponsors/hostinger.jpg" alt="Hostinger" width="300"/></a> <a href="https://www.hostinger.com/vps-hosting?ref=dokploy" target="_blank" style="display: inline-block; margin-right: 10px;">
<a href="https://www.lxaer.com/?ref=dokploy"><img src=".github/sponsors/lxaer.png" alt="LX Aer" width="100"/></a> <img src=".github/sponsors/hostinger.jpg" alt="Hostinger" height="50"/>
<a href="https://mandarin3d.com/?ref=dokploy"><img src=".github/sponsors/mandarin.png" alt="Mandarin" width="100"/></a> </a>
<a href="https://lightnode.com/?ref=dokploy"><img src=".github/sponsors/light-node.webp" alt="Lightnode" width="300"/></a> <a href="https://www.lxaer.com/?ref=dokploy" target="_blank" style="display: inline-block; margin-right: 10px;">
<img src=".github/sponsors/lxaer.png" alt="LX Aer" height="50"/>
</a>
<a href="https://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> <div style="display: flex; align-items: center; gap: 20px;">
<a href="https://supafort.com/?ref=dokploy"><img src="https://supafort.com/build/q-4Ht4rBZR.webp" alt="Supafort.com" width="300"/></a> <a href="https://supafort.com/?ref=dokploy" target="_blank" style="display: inline-block; margin-right: 20px;">
<a href="https://agentdock.ai/?ref=dokploy"><img src=".github/sponsors/agentdock.png" alt="agentdock.ai" width="100"/></a> <img src="https://supafort.com/build/q-4Ht4rBZR.webp" alt="Supafort.com" height="50"/>
</a>
<a href="https://agentdock.ai/?ref=dokploy" target="_blank" style="display: inline-block; margin-right: 50px;">
<img src=".github/sponsors/agentdock.png" alt="agentdock.ai" height="70"/>
</a>
</div> </div>
<!-- Elite Contributors 🥈 -->
<!-- Add Elite Contributors here -->
### Elite Contributors 🥈 ### Elite Contributors 🥈
<div> <div style="display: flex; align-items: center; gap: 20px;">
<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> <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> </div>
<!-- Elite Contributors 🥈 -->
<!-- Add Elite Contributors here -->
### Supporting Members 🥉 ### Supporting Members 🥉
<div> <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://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://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://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://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://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> <a href="https://synexa.ai/?ref=dokploy"><img src=".github/sponsors/synexa.png" width="65px" alt="Synexa"/></a>
</div> </div>
### Community Backers 🤝 ### Community Backers 🤝
<div> <div style="display: flex; gap: 30px; flex-wrap: wrap;">
<a href="https://steamsets.com/?ref=dokploy"><img src="https://avatars.githubusercontent.com/u/111978405?s=200&v=4" width="60px" alt="Steamsets.com"/></a> <a href="https://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:
@@ -117,14 +140,27 @@ 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"/> <img src="https://dokploy.com/banner.png" alt="Watch the video" width="400" style="border-radius:20px;"/>
</a> </a>
<!-- ## Supported OS
- Ubuntu 24.04 LTS
- Ubuntu 23.10
- Ubuntu 22.04 LTS
- Ubuntu 20.04 LTS
- Ubuntu 18.04 LTS
- Debian 12
- Debian 11
- Fedora 40
- Centos 9
- Centos 8 -->
## Contributing ## Contributing
Check out the [Contributing Guide](CONTRIBUTING.md) for more information. Check out the [Contributing Guide](CONTRIBUTING.md) for more information.

View File

@@ -1,28 +0,0 @@
# Dokploy Security Policy
At Dokploy, security is a top priority. We appreciate the help of security researchers and the community in identifying and reporting vulnerabilities.
## How to Report a Vulnerability
If you have discovered a security vulnerability in Dokploy, we ask that you report it responsibly by following these guidelines:
1. **Contact us:** Send an email to [contact@dokploy.com](mailto:contact@dokploy.com).
2. **Provide clear details:** Include as much information as possible to help us understand and reproduce the vulnerability. This should include:
* A clear description of the vulnerability.
* Steps to reproduce the vulnerability.
* Any sample code, screenshots, or videos that might be helpful.
* The potential impact of the vulnerability.
3. **Do not make the vulnerability public:** Please refrain from publicly disclosing the vulnerability until we have had the opportunity to investigate and address it. This is crucial for protecting our users.
4. **Allow us time:** We will endeavor to acknowledge receipt of your report as soon as possible and keep you informed of our progress. The time to resolve the vulnerability may vary depending on its complexity and severity.
## What We Expect From You
* Do not access user data or systems beyond what is necessary to demonstrate the vulnerability.
* Do not perform denial-of-service (DoS) attacks, spamming, or social engineering.
* Do not modify or destroy data that does not belong to you.
## Our Commitment
We are committed to working with you quickly and responsibly to address any legitimate security vulnerability.
Thank you for helping us keep Dokploy secure for everyone.

View File

@@ -9,25 +9,25 @@
"typecheck": "tsc --noEmit" "typecheck": "tsc --noEmit"
}, },
"dependencies": { "dependencies": {
"@dokploy/server": "workspace:*",
"@hono/node-server": "^1.12.1",
"@hono/zod-validator": "0.3.0",
"@nerimity/mimiqueue": "1.2.3",
"dotenv": "^16.3.1",
"hono": "^4.5.8",
"pino": "9.4.0", "pino": "9.4.0",
"pino-pretty": "11.2.2", "pino-pretty": "11.2.2",
"@hono/zod-validator": "0.3.0",
"zod": "^3.23.4",
"react": "18.2.0", "react": "18.2.0",
"react-dom": "18.2.0", "react-dom": "18.2.0",
"@dokploy/server": "workspace:*",
"@hono/node-server": "^1.12.1",
"hono": "^4.5.8",
"dotenv": "^16.3.1",
"redis": "4.7.0", "redis": "4.7.0",
"zod": "^3.23.4" "@nerimity/mimiqueue": "1.2.3"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^20.11.17", "typescript": "^5.4.2",
"@types/react": "^18.2.37", "@types/react": "^18.2.37",
"@types/react-dom": "^18.2.15", "@types/react-dom": "^18.2.15",
"tsx": "^4.7.1", "@types/node": "^20.11.17",
"typescript": "^5.4.2" "tsx": "^4.7.1"
}, },
"packageManager": "pnpm@9.5.0" "packageManager": "pnpm@9.5.0"
} }

View File

@@ -1 +1 @@
20.16.0 20.9.0

26
apps/dokploy/Dockerfile Normal file
View File

@@ -0,0 +1,26 @@
FROM node:18-slim AS base
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN corepack enable
FROM base AS build
COPY . /usr/src/app
WORKDIR /usr/src/app
RUN apt-get update && apt-get install -y python3 make g++ git && rm -rf /var/lib/apt/lists/*
# Install dependencies
RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --frozen-lockfile
# Build only the dokploy app
RUN pnpm run dokploy:build
# Deploy only the dokploy app
RUN pnpm deploy --filter=dokploy --prod /prod/dokploy
FROM base AS dokploy
COPY --from=build /prod/dokploy /prod/dokploy
WORKDIR /prod/dokploy
EXPOSE 3000
CMD [ "pnpm", "start" ]

26
apps/dokploy/LICENSE.MD Normal file
View File

@@ -0,0 +1,26 @@
# 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.

View File

@@ -19,8 +19,6 @@ 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 () => {

View File

@@ -105,7 +105,6 @@ const baseApp: ApplicationNested = {
ports: [], ports: [],
projectId: "", projectId: "",
publishDirectory: null, publishDirectory: null,
isStaticSpa: null,
redirects: [], redirects: [],
refreshToken: "", refreshToken: "",
registry: null, registry: null,
@@ -121,7 +120,6 @@ const baseApp: ApplicationNested = {
updateConfigSwarm: null, updateConfigSwarm: null,
username: null, username: null,
dockerContextPath: null, dockerContextPath: null,
rollbackActive: false,
}; };
describe("unzipDrop using real zip files", () => { describe("unzipDrop using real zip files", () => {
@@ -151,68 +149,67 @@ describe("unzipDrop using real zip files", () => {
} finally { } finally {
} }
}); });
it("should correctly extract a zip with a single root folder and a subfolder", async () => {
baseApp.appName = "folderwithfile";
// const appName = "folderwithfile";
const outputPath = path.join(APPLICATIONS_PATH, baseApp.appName, "code");
const zip = new AdmZip("./__test__/drop/zips/folder-with-file.zip");
const zipBuffer = zip.toBuffer();
const file = new File([zipBuffer], "single.zip");
await unzipDrop(file, baseApp);
const files = await fs.readdir(outputPath, { withFileTypes: true });
expect(files.some((f) => f.name === "folder1.txt")).toBe(true);
});
it("should correctly extract a zip with multiple root folders", async () => {
baseApp.appName = "two-folders";
// const appName = "two-folders";
const outputPath = path.join(APPLICATIONS_PATH, baseApp.appName, "code");
const zip = new AdmZip("./__test__/drop/zips/two-folders.zip");
const zipBuffer = zip.toBuffer();
const file = new File([zipBuffer], "single.zip");
await unzipDrop(file, baseApp);
const files = await fs.readdir(outputPath, { withFileTypes: true });
expect(files.some((f) => f.name === "folder1")).toBe(true);
expect(files.some((f) => f.name === "folder2")).toBe(true);
});
it("should correctly extract a zip with a single root with a file", async () => {
baseApp.appName = "nested";
// const appName = "nested";
const outputPath = path.join(APPLICATIONS_PATH, baseApp.appName, "code");
const zip = new AdmZip("./__test__/drop/zips/nested.zip");
const zipBuffer = zip.toBuffer();
const file = new File([zipBuffer], "single.zip");
await unzipDrop(file, baseApp);
const files = await fs.readdir(outputPath, { withFileTypes: true });
expect(files.some((f) => f.name === "folder1")).toBe(true);
expect(files.some((f) => f.name === "folder2")).toBe(true);
expect(files.some((f) => f.name === "folder3")).toBe(true);
});
it("should correctly extract a zip with a single root with a folder", async () => {
baseApp.appName = "folder-with-sibling-file";
// const appName = "folder-with-sibling-file";
const outputPath = path.join(APPLICATIONS_PATH, baseApp.appName, "code");
const zip = new AdmZip("./__test__/drop/zips/folder-with-sibling-file.zip");
const zipBuffer = zip.toBuffer();
const file = new File([zipBuffer], "single.zip");
await unzipDrop(file, baseApp);
const files = await fs.readdir(outputPath, { withFileTypes: true });
expect(files.some((f) => f.name === "folder1")).toBe(true);
expect(files.some((f) => f.name === "test.txt")).toBe(true);
});
}); });
// it("should correctly extract a zip with a single root folder and a subfolder", async () => {
// baseApp.appName = "folderwithfile";
// // const appName = "folderwithfile";
// const outputPath = path.join(APPLICATIONS_PATH, baseApp.appName, "code");
// const zip = new AdmZip("./__test__/drop/zips/folder-with-file.zip");
// const zipBuffer = zip.toBuffer();
// const file = new File([zipBuffer], "single.zip");
// await unzipDrop(file, baseApp);
// const files = await fs.readdir(outputPath, { withFileTypes: true });
// expect(files.some((f) => f.name === "folder1.txt")).toBe(true);
// });
// it("should correctly extract a zip with multiple root folders", async () => {
// baseApp.appName = "two-folders";
// // const appName = "two-folders";
// const outputPath = path.join(APPLICATIONS_PATH, baseApp.appName, "code");
// const zip = new AdmZip("./__test__/drop/zips/two-folders.zip");
// const zipBuffer = zip.toBuffer();
// const file = new File([zipBuffer], "single.zip");
// await unzipDrop(file, baseApp);
// const files = await fs.readdir(outputPath, { withFileTypes: true });
// expect(files.some((f) => f.name === "folder1")).toBe(true);
// expect(files.some((f) => f.name === "folder2")).toBe(true);
// });
// it("should correctly extract a zip with a single root with a file", async () => {
// baseApp.appName = "nested";
// // const appName = "nested";
// const outputPath = path.join(APPLICATIONS_PATH, baseApp.appName, "code");
// const zip = new AdmZip("./__test__/drop/zips/nested.zip");
// const zipBuffer = zip.toBuffer();
// const file = new File([zipBuffer], "single.zip");
// await unzipDrop(file, baseApp);
// const files = await fs.readdir(outputPath, { withFileTypes: true });
// expect(files.some((f) => f.name === "folder1")).toBe(true);
// expect(files.some((f) => f.name === "folder2")).toBe(true);
// expect(files.some((f) => f.name === "folder3")).toBe(true);
// });
// it("should correctly extract a zip with a single root with a folder", async () => {
// baseApp.appName = "folder-with-sibling-file";
// // const appName = "folder-with-sibling-file";
// const outputPath = path.join(APPLICATIONS_PATH, baseApp.appName, "code");
// const zip = new AdmZip("./__test__/drop/zips/folder-with-sibling-file.zip");
// const zipBuffer = zip.toBuffer();
// const file = new File([zipBuffer], "single.zip");
// await unzipDrop(file, baseApp);
// const files = await fs.readdir(outputPath, { withFileTypes: true });
// expect(files.some((f) => f.name === "folder1")).toBe(true);
// expect(files.some((f) => f.name === "test.txt")).toBe(true);
// });
// });

View File

@@ -5,7 +5,6 @@ import { createRouterConfig } from "@dokploy/server";
import { expect, test } from "vitest"; import { expect, test } from "vitest";
const baseApp: ApplicationNested = { const baseApp: ApplicationNested = {
rollbackActive: false,
applicationId: "", applicationId: "",
herokuVersion: "", herokuVersion: "",
giteaRepository: "", giteaRepository: "",
@@ -86,7 +85,6 @@ const baseApp: ApplicationNested = {
ports: [], ports: [],
projectId: "", projectId: "",
publishDirectory: null, publishDirectory: null,
isStaticSpa: null,
redirects: [], redirects: [],
refreshToken: "", refreshToken: "",
registry: null, registry: null,
@@ -119,8 +117,6 @@ const baseDomain: Domain = {
domainType: "application", domainType: "application",
uniqueConfigKey: 1, uniqueConfigKey: 1,
previewDeploymentId: "", previewDeploymentId: "",
internalPath: "/",
stripPath: false,
}; };
const baseRedirect: Redirect = { const baseRedirect: Redirect = {

View File

@@ -1,5 +1,5 @@
import { normalizeS3Path } from "@dokploy/server/utils/backups/utils";
import { describe, expect, test } from "vitest"; import { describe, expect, test } from "vitest";
import { normalizeS3Path } from "@dokploy/server/utils/backups/utils";
describe("normalizeS3Path", () => { describe("normalizeS3Path", () => {
test("should handle empty and whitespace-only prefix", () => { test("should handle empty and whitespace-only prefix", () => {

View File

@@ -130,7 +130,7 @@ const createStringToJSONSchema = (schema: z.ZodTypeAny) => {
} }
try { try {
return JSON.parse(str); return JSON.parse(str);
} catch { } catch (_e) {
ctx.addIssue({ code: "custom", message: "Invalid JSON format" }); ctx.addIssue({ code: "custom", message: "Invalid JSON format" });
return z.NEVER; return z.NEVER;
} }

View File

@@ -107,7 +107,7 @@ export const ShowImport = ({ composeId }: Props) => {
composeId, composeId,
}); });
setShowModal(false); setShowModal(false);
} catch { } catch (_error) {
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 { } catch (_error) {
toast.error("Error processing template"); toast.error("Error processing template");
} }
}; };
@@ -263,7 +263,7 @@ export const ShowImport = ({ composeId }: Props) => {
{templateInfo.template.envs.map((env, index) => ( {templateInfo.template.envs.map((env, index) => (
<div <div
key={index} key={index}
className="rounded-lg truncate border bg-card p-2 font-mono text-sm" className="rounded-lg border bg-card p-2 font-mono text-sm"
> >
{env} {env}
</div> </div>
@@ -328,7 +328,7 @@ export const ShowImport = ({ composeId }: Props) => {
<DialogDescription>Mount File Content</DialogDescription> <DialogDescription>Mount File Content</DialogDescription>
</DialogHeader> </DialogHeader>
<ScrollArea className="h-[45vh] pr-4"> <ScrollArea className="h-[25vh] pr-4">
<CodeEditor <CodeEditor
language="yaml" language="yaml"
value={selectedMount?.content || ""} value={selectedMount?.content || ""}

View File

@@ -35,9 +35,6 @@ 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",
@@ -83,7 +80,6 @@ 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",
}); });
@@ -169,32 +165,6 @@ 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"

View File

@@ -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 lg:grid-cols-4 flex-col gap-4 sm:gap-8"> <div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 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,13 +68,7 @@ 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">Published Port Mode</span> <span className="font-medium"> Target Port</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>

View File

@@ -1,4 +1,3 @@
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 {
@@ -170,23 +169,6 @@ 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}

View File

@@ -247,7 +247,7 @@ export const UpdateVolume = ({
control={form.control} control={form.control}
name="content" name="content"
render={({ field }) => ( render={({ field }) => (
<FormItem className="max-w-full max-w-[45rem]"> <FormItem>
<FormLabel>Content</FormLabel> <FormLabel>Content</FormLabel>
<FormControl> <FormControl>
<FormControl> <FormControl>
@@ -256,7 +256,7 @@ export const UpdateVolume = ({
placeholder={`NODE_ENV=production placeholder={`NODE_ENV=production
PORT=3000 PORT=3000
`} `}
className="h-96 font-mono w-full" className="h-96 font-mono"
{...field} {...field}
/> />
</FormControl> </FormControl>

View File

@@ -2,7 +2,6 @@ import { AlertBlock } from "@/components/shared/alert-block";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox";
import { import {
Form, Form,
FormControl, FormControl,
@@ -64,11 +63,10 @@ const mySchema = z.discriminatedUnion("buildType", [
publishDirectory: z.string().optional(), publishDirectory: z.string().optional(),
}), }),
z.object({ z.object({
buildType: z.literal(BuildType.railpack), buildType: z.literal(BuildType.static),
}), }),
z.object({ z.object({
buildType: z.literal(BuildType.static), buildType: z.literal(BuildType.railpack),
isStaticSpa: z.boolean().default(false),
}), }),
]); ]);
@@ -85,7 +83,6 @@ interface ApplicationData {
dockerBuildStage?: string | null; dockerBuildStage?: string | null;
herokuVersion?: string | null; herokuVersion?: string | null;
publishDirectory?: string | null; publishDirectory?: string | null;
isStaticSpa?: boolean | null;
} }
function isValidBuildType(value: string): value is BuildType { function isValidBuildType(value: string): value is BuildType {
@@ -118,18 +115,16 @@ const resetData = (data: ApplicationData): AddTemplate => {
case BuildType.static: case BuildType.static:
return { return {
buildType: BuildType.static, buildType: BuildType.static,
isStaticSpa: data.isStaticSpa ?? false,
}; };
case BuildType.railpack: case BuildType.railpack:
return { return {
buildType: BuildType.railpack, buildType: BuildType.railpack,
}; };
default: { default:
const buildType = data.buildType as BuildType; const buildType = data.buildType as BuildType;
return { return {
buildType, buildType,
} as AddTemplate; } as AddTemplate;
}
} }
}; };
@@ -179,8 +174,6 @@ export const ShowBuildChooseForm = ({ applicationId }: Props) => {
data.buildType === BuildType.heroku_buildpacks data.buildType === BuildType.heroku_buildpacks
? data.herokuVersion ? data.herokuVersion
: null, : null,
isStaticSpa:
data.buildType === BuildType.static ? data.isStaticSpa : null,
}) })
.then(async () => { .then(async () => {
toast.success("Build type saved"); toast.success("Build type saved");
@@ -371,30 +364,6 @@ export const ShowBuildChooseForm = ({ applicationId }: Props) => {
)} )}
/> />
)} )}
{buildType === BuildType.static && (
<FormField
control={form.control}
name="isStaticSpa"
render={({ field }) => (
<FormItem>
<FormControl>
<div className="flex items-center gap-x-2 p-2">
<Checkbox
id="checkboxIsStaticSpa"
value={String(field.value)}
checked={field.value}
onCheckedChange={field.onChange}
/>
<FormLabel htmlFor="checkboxIsStaticSpa">
Single Page Application (SPA)
</FormLabel>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
<div className="flex w-full justify-end"> <div className="flex w-full justify-end">
<Button isLoading={isLoading} type="submit"> <Button isLoading={isLoading} type="submit">
Save Save

View File

@@ -14,8 +14,7 @@ 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;

View File

@@ -1,6 +1,5 @@
import { DateTooltip } from "@/components/shared/date-tooltip"; import { DateTooltip } from "@/components/shared/date-tooltip";
import { StatusTooltip } from "@/components/shared/status-tooltip"; import { StatusTooltip } from "@/components/shared/status-tooltip";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
Card, Card,
@@ -10,14 +9,12 @@ import {
CardTitle, CardTitle,
} from "@/components/ui/card"; } from "@/components/ui/card";
import { type RouterOutputs, api } from "@/utils/api"; import { type RouterOutputs, api } from "@/utils/api";
import { Clock, Loader2, RocketIcon, Settings, RefreshCcw } from "lucide-react"; import { RocketIcon, Clock, Loader2 } from "lucide-react";
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import { CancelQueues } from "./cancel-queues"; import { CancelQueues } from "./cancel-queues";
import { RefreshToken } from "./refresh-token"; import { RefreshToken } from "./refresh-token";
import { ShowDeployment } from "./show-deployment"; import { ShowDeployment } from "./show-deployment";
import { ShowRollbackSettings } from "../rollbacks/show-rollback-settings"; import { Badge } from "@/components/ui/badge";
import { DialogAction } from "@/components/shared/dialog-action";
import { toast } from "sonner";
interface Props { interface Props {
id: string; id: string;
@@ -27,8 +24,7 @@ interface Props {
| "schedule" | "schedule"
| "server" | "server"
| "backup" | "backup"
| "previewDeployment" | "previewDeployment";
| "volumeBackup";
refreshToken?: string; refreshToken?: string;
serverId?: string; serverId?: string;
} }
@@ -61,11 +57,6 @@ export const ShowDeployments = ({
}, },
); );
const { mutateAsync: rollback, isLoading: isRollingBack } =
api.rollback.rollback.useMutation();
const { mutateAsync: killProcess, isLoading: isKillingProcess } =
api.deployment.killProcess.useMutation();
const [url, setUrl] = React.useState(""); const [url, setUrl] = React.useState("");
useEffect(() => { useEffect(() => {
setUrl(document.location.origin); setUrl(document.location.origin);
@@ -80,18 +71,9 @@ export const ShowDeployments = ({
See all the 10 last deployments for this {type} See all the 10 last deployments for this {type}
</CardDescription> </CardDescription>
</div> </div>
<div className="flex flex-row items-center gap-2"> {(type === "application" || type === "compose") && (
{(type === "application" || type === "compose") && ( <CancelQueues id={id} type={type} />
<CancelQueues id={id} type={type} /> )}
)}
{type === "application" && (
<ShowRollbackSettings applicationId={id}>
<Button variant="outline">
Configure Rollbacks <Settings className="size-4" />
</Button>
</ShowRollbackSettings>
)}
</div>
</CardHeader> </CardHeader>
<CardContent className="flex flex-col gap-4"> <CardContent className="flex flex-col gap-4">
{refreshToken && ( {refreshToken && (
@@ -172,73 +154,13 @@ export const ShowDeployments = ({
)} )}
</div> </div>
<div className="flex flex-row items-center gap-2"> <Button
{deployment.pid && deployment.status === "running" && ( onClick={() => {
<DialogAction setActiveLog(deployment);
title="Kill Process" }}
description="Are you sure you want to kill the process?" >
type="default" View
onClick={async () => { </Button>
await killProcess({
deploymentId: deployment.deploymentId,
})
.then(() => {
toast.success("Process killed successfully");
})
.catch(() => {
toast.error("Error killing process");
});
}}
>
<Button
variant="destructive"
size="sm"
isLoading={isKillingProcess}
>
Kill Process
</Button>
</DialogAction>
)}
<Button
onClick={() => {
setActiveLog(deployment);
}}
>
View
</Button>
{deployment?.rollback &&
deployment.status === "done" &&
type === "application" && (
<DialogAction
title="Rollback to this deployment"
description="Are you sure you want to rollback to this deployment?"
type="default"
onClick={async () => {
await rollback({
rollbackId: deployment.rollback.rollbackId,
})
.then(() => {
toast.success(
"Rollback initiated successfully",
);
})
.catch(() => {
toast.error("Error initiating rollback");
});
}}
>
<Button
variant="secondary"
size="sm"
isLoading={isRollingBack}
>
<RefreshCcw className="size-4 text-primary group-hover:text-red-500" />
Rollback
</Button>
</DialogAction>
)}
</div>
</div> </div>
</div> </div>
))} ))}

View File

@@ -1,5 +1,3 @@
import { AlertBlock } from "@/components/shared/alert-block";
import { Button } from "@/components/ui/button";
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@@ -8,6 +6,8 @@ import {
DialogTitle, DialogTitle,
DialogTrigger, DialogTrigger,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { AlertBlock } from "@/components/shared/alert-block";
import { Copy, HelpCircle, Server } from "lucide-react"; import { Copy, HelpCircle, Server } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";

View File

@@ -49,8 +49,6 @@ 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" })
@@ -86,29 +84,6 @@ 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>;
@@ -187,8 +162,6 @@ 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,
@@ -209,8 +182,6 @@ 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,
@@ -223,8 +194,6 @@ 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,
@@ -500,49 +469,6 @@ 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"

View File

@@ -1,5 +1,4 @@
import { DialogAction } from "@/components/shared/dialog-action"; import { DialogAction } from "@/components/shared/dialog-action";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
Card, Card,
@@ -8,12 +7,6 @@ import {
CardHeader, CardHeader,
CardTitle, CardTitle,
} from "@/components/ui/card"; } from "@/components/ui/card";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { api } from "@/utils/api"; import { api } from "@/utils/api";
import { import {
CheckCircle2, CheckCircle2,
@@ -28,10 +21,17 @@ import {
XCircle, XCircle,
} from "lucide-react"; } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { DnsHelperModal } from "./dns-helper-modal";
import { AddDomain } from "./handle-domain"; import { AddDomain } from "./handle-domain";
import { useState } from "react";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { DnsHelperModal } from "./dns-helper-modal";
import { Badge } from "@/components/ui/badge";
export type ValidationState = { export type ValidationState = {
isLoading: boolean; isLoading: boolean;
@@ -39,7 +39,6 @@ export type ValidationState = {
error?: string; error?: string;
resolvedIp?: string; resolvedIp?: string;
message?: string; message?: string;
cdnProvider?: string;
}; };
export type ValidationStates = Record<string, ValidationState>; export type ValidationStates = Record<string, ValidationState>;
@@ -120,7 +119,6 @@ export const ShowDomains = ({ id, type }: Props) => {
isValid: result.isValid, isValid: result.isValid,
error: result.error, error: result.error,
resolvedIp: result.resolvedIp, resolvedIp: result.resolvedIp,
cdnProvider: result.cdnProvider,
message: result.error && result.isValid ? result.error : undefined, message: result.error && result.isValid ? result.error : undefined,
}, },
})); }));
@@ -356,9 +354,8 @@ export const ShowDomains = ({ id, type }: Props) => {
) : validationState?.isValid ? ( ) : validationState?.isValid ? (
<> <>
<CheckCircle2 className="size-3 mr-1" /> <CheckCircle2 className="size-3 mr-1" />
{validationState.message && {validationState.message
validationState.cdnProvider ? "Behind Cloudflare"
? `Behind ${validationState.cdnProvider}`
: "DNS Valid"} : "DNS Valid"}
</> </>
) : validationState?.error ? ( ) : validationState?.error ? (

View File

@@ -136,7 +136,7 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
enableSubmodules: data.enableSubmodules || false, enableSubmodules: data.enableSubmodules || false,
}); });
} }
}, [form.reset, data?.applicationId, form]); }, [form.reset, data, form]);
const onSubmit = async (data: BitbucketProvider) => { const onSubmit = async (data: BitbucketProvider) => {
await mutateAsync({ await mutateAsync({

View File

@@ -53,7 +53,7 @@ export const SaveDockerProvider = ({ applicationId }: Props) => {
registryURL: data.registryUrl || "", registryURL: data.registryUrl || "",
}); });
} }
}, [form.reset, data?.applicationId, form]); }, [form.reset, data, form]);
const onSubmit = async (values: DockerProvider) => { const onSubmit = async (values: DockerProvider) => {
await mutateAsync({ await mutateAsync({

View File

@@ -17,13 +17,13 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { import {
Tooltip, Tooltip,
TooltipContent, TooltipContent,
TooltipProvider, TooltipProvider,
TooltipTrigger, TooltipTrigger,
} from "@/components/ui/tooltip"; } from "@/components/ui/tooltip";
import { Switch } from "@/components/ui/switch";
import { api } from "@/utils/api"; import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { KeyRoundIcon, LockIcon, X } from "lucide-react"; import { KeyRoundIcon, LockIcon, X } from "lucide-react";

View File

@@ -158,7 +158,7 @@ export const SaveGiteaProvider = ({ applicationId }: Props) => {
enableSubmodules: data.enableSubmodules || false, enableSubmodules: data.enableSubmodules || false,
}); });
} }
}, [form.reset, data?.applicationId, form]); }, [form.reset, data, form]);
const onSubmit = async (data: GiteaProvider) => { const onSubmit = async (data: GiteaProvider) => {
await mutateAsync({ await mutateAsync({

View File

@@ -134,7 +134,7 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
enableSubmodules: data.enableSubmodules ?? false, enableSubmodules: data.enableSubmodules ?? false,
}); });
} }
}, [form.reset, data?.applicationId, form]); }, [form.reset, data, form]);
const onSubmit = async (data: GithubProvider) => { const onSubmit = async (data: GithubProvider) => {
await mutateAsync({ await mutateAsync({

View File

@@ -141,7 +141,7 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
enableSubmodules: data.enableSubmodules ?? false, enableSubmodules: data.enableSubmodules ?? false,
}); });
} }
}, [form.reset, data?.applicationId, form]); }, [form.reset, data, form]);
const onSubmit = async (data: GitlabProvider) => { const onSubmit = async (data: GitlabProvider) => {
await mutateAsync({ await mutateAsync({
@@ -278,7 +278,7 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
{repositories?.map((repo) => { {repositories?.map((repo) => {
return ( return (
<CommandItem <CommandItem
value={repo.url} value={repo.name}
key={repo.url} key={repo.url}
onSelect={() => { onSelect={() => {
form.setValue("repository", { form.setValue("repository", {
@@ -299,8 +299,7 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
<CheckIcon <CheckIcon
className={cn( className={cn(
"ml-auto h-4 w-4", "ml-auto h-4 w-4",
repo.url === repo.name === field.value.repo
field.value.gitlabPathNamespace
? "opacity-100" ? "opacity-100"
: "opacity-0", : "opacity-0",
)} )}

View File

@@ -16,11 +16,9 @@ import { api } from "@/utils/api";
import { GitBranch, Loader2, UploadCloud } from "lucide-react"; import { GitBranch, Loader2, UploadCloud } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { useState } from "react"; import { useState } from "react";
import { toast } from "sonner";
import { SaveBitbucketProvider } from "./save-bitbucket-provider"; import { SaveBitbucketProvider } from "./save-bitbucket-provider";
import { SaveDragNDrop } from "./save-drag-n-drop"; import { SaveDragNDrop } from "./save-drag-n-drop";
import { SaveGitlabProvider } from "./save-gitlab-provider"; import { SaveGitlabProvider } from "./save-gitlab-provider";
import { UnauthorizedGitProvider } from "./unauthorized-git-provider";
type TabState = type TabState =
| "github" | "github"
@@ -45,31 +43,12 @@ export const ShowProviderForm = ({ applicationId }: Props) => {
const { data: giteaProviders, isLoading: isLoadingGitea } = const { data: giteaProviders, isLoading: isLoadingGitea } =
api.gitea.giteaProviders.useQuery(); api.gitea.giteaProviders.useQuery();
const { data: application, refetch } = api.application.one.useQuery({ const { data: application } = api.application.one.useQuery({ applicationId });
applicationId,
});
const { mutateAsync: disconnectGitProvider } =
api.application.disconnectGitProvider.useMutation();
const [tab, setSab] = useState<TabState>(application?.sourceType || "github"); const [tab, setSab] = useState<TabState>(application?.sourceType || "github");
const isLoading = const isLoading =
isLoadingGithub || isLoadingGitlab || isLoadingBitbucket || isLoadingGitea; isLoadingGithub || isLoadingGitlab || isLoadingBitbucket || isLoadingGitea;
const handleDisconnect = async () => {
try {
await disconnectGitProvider({ applicationId });
toast.success("Repository disconnected successfully");
await refetch();
} catch (error) {
toast.error(
`Failed to disconnect repository: ${
error instanceof Error ? error.message : "Unknown error"
}`,
);
}
};
if (isLoading) { if (isLoading) {
return ( return (
<Card className="group relative w-full bg-transparent"> <Card className="group relative w-full bg-transparent">
@@ -98,38 +77,6 @@ export const ShowProviderForm = ({ applicationId }: Props) => {
); );
} }
// Check if user doesn't have access to the current git provider
if (
application &&
!application.hasGitProviderAccess &&
application.sourceType !== "docker" &&
application.sourceType !== "drop"
) {
return (
<Card className="group relative w-full bg-transparent">
<CardHeader>
<CardTitle className="flex items-start justify-between">
<div className="flex flex-col gap-2">
<span className="flex flex-col space-y-0.5">Provider</span>
<p className="flex items-center text-sm font-normal text-muted-foreground">
Repository connection through unauthorized provider
</p>
</div>
<div className="hidden space-y-1 text-sm font-normal md:block">
<GitBranch className="size-6 text-muted-foreground" />
</div>
</CardTitle>
</CardHeader>
<CardContent>
<UnauthorizedGitProvider
service={application}
onDisconnect={handleDisconnect}
/>
</CardContent>
</Card>
);
}
return ( return (
<Card className="group relative w-full bg-transparent"> <Card className="group relative w-full bg-transparent">
<CardHeader> <CardHeader>

View File

@@ -1,149 +0,0 @@
import {
BitbucketIcon,
GitIcon,
GiteaIcon,
GithubIcon,
GitlabIcon,
} from "@/components/icons/data-tools-icons";
import { DialogAction } from "@/components/shared/dialog-action";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import type { RouterOutputs } from "@/utils/api";
import { AlertCircle, GitBranch, Unlink } from "lucide-react";
interface Props {
service:
| RouterOutputs["application"]["one"]
| RouterOutputs["compose"]["one"];
onDisconnect: () => void;
}
export const UnauthorizedGitProvider = ({ service, onDisconnect }: Props) => {
const getProviderIcon = (sourceType: string) => {
switch (sourceType) {
case "github":
return <GithubIcon className="size-5 text-muted-foreground" />;
case "gitlab":
return <GitlabIcon className="size-5 text-muted-foreground" />;
case "bitbucket":
return <BitbucketIcon className="size-5 text-muted-foreground" />;
case "gitea":
return <GiteaIcon className="size-5 text-muted-foreground" />;
case "git":
return <GitIcon className="size-5 text-muted-foreground" />;
default:
return <GitBranch className="size-5 text-muted-foreground" />;
}
};
const getRepositoryInfo = () => {
switch (service.sourceType) {
case "github":
return {
repo: service.repository,
branch: service.branch,
owner: service.owner,
};
case "gitlab":
return {
repo: service.gitlabRepository,
branch: service.gitlabBranch,
owner: service.gitlabOwner,
};
case "bitbucket":
return {
repo: service.bitbucketRepository,
branch: service.bitbucketBranch,
owner: service.bitbucketOwner,
};
case "gitea":
return {
repo: service.giteaRepository,
branch: service.giteaBranch,
owner: service.giteaOwner,
};
case "git":
return {
repo: service.customGitUrl,
branch: service.customGitBranch,
owner: null,
};
default:
return { repo: null, branch: null, owner: null };
}
};
const { repo, branch, owner } = getRepositoryInfo();
return (
<div className="space-y-4">
<Alert>
<AlertCircle className="h-4 w-4" />
<AlertDescription>
This application is connected to a {service.sourceType} repository
through a git provider that you don't have access to. You can see
basic repository information below, but cannot modify the
configuration.
</AlertDescription>
</Alert>
<Card className="border-dashed border-2 border-muted-foreground/20 bg-transparent">
<CardHeader>
<CardTitle className="flex items-center gap-2">
{getProviderIcon(service.sourceType)}
<span className="capitalize text-sm font-medium">
{service.sourceType} Repository
</span>
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{owner && (
<div>
<span className="text-sm font-medium text-muted-foreground">
Owner:
</span>
<p className="text-sm">{owner}</p>
</div>
)}
{repo && (
<div>
<span className="text-sm font-medium text-muted-foreground">
Repository:
</span>
<p className="text-sm">{repo}</p>
</div>
)}
{branch && (
<div>
<span className="text-sm font-medium text-muted-foreground">
Branch:
</span>
<p className="text-sm">{branch}</p>
</div>
)}
<div className="pt-4 border-t">
<DialogAction
title="Disconnect Repository"
description="Are you sure you want to disconnect this repository?"
type="default"
onClick={async () => {
onDisconnect();
}}
>
<Button variant="secondary" className="w-full">
<Unlink className="size-4 mr-2" />
Disconnect Repository
</Button>
</DialogAction>
<p className="text-xs text-muted-foreground mt-2">
Disconnecting will allow you to configure a new repository with
your own git providers.
</p>
</div>
</CardContent>
</Card>
</div>
);
};

View File

@@ -24,9 +24,9 @@ import {
} from "lucide-react"; } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import { ShowModalLogs } from "../../settings/web-server/show-modal-logs"; import { ShowModalLogs } from "../../settings/web-server/show-modal-logs";
import { ShowDeploymentsModal } from "../deployments/show-deployments-modal";
import { AddPreviewDomain } from "./add-preview-domain"; import { AddPreviewDomain } from "./add-preview-domain";
import { ShowPreviewSettings } from "./show-preview-settings"; import { ShowPreviewSettings } from "./show-preview-settings";
import { ShowDeploymentsModal } from "../deployments/show-deployments-modal";
interface Props { interface Props {
applicationId: string; applicationId: string;

View File

@@ -1,108 +0,0 @@
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')

View File

@@ -1,123 +0,0 @@
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,
} from "@/components/ui/form";
import { Switch } from "@/components/ui/switch";
import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
const formSchema = z.object({
rollbackActive: z.boolean(),
});
type FormValues = z.infer<typeof formSchema>;
interface Props {
applicationId: string;
children?: React.ReactNode;
}
export const ShowRollbackSettings = ({ applicationId, children }: Props) => {
const [isOpen, setIsOpen] = useState(false);
const { data: application, refetch } = api.application.one.useQuery(
{
applicationId,
},
{
enabled: !!applicationId,
},
);
const { mutateAsync: updateApplication, isLoading } =
api.application.update.useMutation();
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: {
rollbackActive: application?.rollbackActive ?? false,
},
});
const onSubmit = async (data: FormValues) => {
await updateApplication({
applicationId,
rollbackActive: data.rollbackActive,
})
.then(() => {
toast.success("Rollback settings updated");
setIsOpen(false);
refetch();
})
.catch(() => {
toast.error("Failed to update rollback settings");
});
};
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>{children}</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Rollback Settings</DialogTitle>
<DialogDescription>
Configure how rollbacks work for this application
</DialogDescription>
<AlertBlock>
Having rollbacks enabled increases storage usage. Be careful with
this option. Note that manually cleaning the cache may delete
rollback images, making them unavailable for future rollbacks.
</AlertBlock>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
<FormField
control={form.control}
name="rollbackActive"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-4">
<div className="space-y-0.5">
<FormLabel className="text-base">
Enable Rollbacks
</FormLabel>
<FormDescription>
Allow rolling back to previous deployments
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
<Button type="submit" className="w-full" isLoading={isLoading}>
Save Settings
</Button>
</form>
</Form>
</DialogContent>
</Dialog>
);
};

View File

@@ -1,23 +1,25 @@
import { AlertBlock } from "@/components/shared/alert-block";
import { CodeEditor } from "@/components/shared/code-editor";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { import {
Form, Form,
FormControl, FormControl,
FormDescription,
FormField, FormField,
FormItem, FormItem,
FormLabel, FormLabel,
FormMessage, FormMessage,
FormDescription,
} from "@/components/ui/form"; } from "@/components/ui/form";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { z } from "zod";
import {
Info,
PlusCircle,
PenBoxIcon,
RefreshCw,
DatabaseZap,
} from "lucide-react";
import { import {
Select, Select,
SelectContent, SelectContent,
@@ -25,27 +27,25 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { import {
Tooltip, Tooltip,
TooltipContent, TooltipContent,
TooltipProvider, TooltipProvider,
TooltipTrigger, TooltipTrigger,
} from "@/components/ui/tooltip"; } from "@/components/ui/tooltip";
import { cn } from "@/lib/utils"; import { Switch } from "@/components/ui/switch";
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 { useEffect, useState } from "react";
import { useForm } from "react-hook-form"; import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { toast } from "sonner"; import { toast } from "sonner";
import { z } from "zod"; import { AlertBlock } from "@/components/shared/alert-block";
import { CodeEditor } from "@/components/shared/code-editor";
import { cn } from "@/lib/utils";
import type { CacheType } from "../domains/handle-domain"; import type { CacheType } from "../domains/handle-domain";
export const commonCronExpressions = [ export const commonCronExpressions = [

View File

@@ -1,6 +1,14 @@
import { DialogAction } from "@/components/shared/dialog-action";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { api } from "@/utils/api";
import { HandleSchedules } from "./handle-schedules";
import {
Clock,
Play,
Terminal,
Trash2,
ClipboardList,
Loader2,
} from "lucide-react";
import { import {
Card, Card,
CardContent, CardContent,
@@ -8,24 +16,16 @@ import {
CardHeader, CardHeader,
CardTitle, CardTitle,
} from "@/components/ui/card"; } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { toast } from "sonner";
import { import {
Tooltip, Tooltip,
TooltipContent, TooltipContent,
TooltipProvider, TooltipProvider,
TooltipTrigger, TooltipTrigger,
} from "@/components/ui/tooltip"; } from "@/components/ui/tooltip";
import { api } from "@/utils/api"; import { DialogAction } from "@/components/shared/dialog-action";
import {
ClipboardList,
Clock,
Loader2,
Play,
Terminal,
Trash2,
} from "lucide-react";
import { toast } from "sonner";
import { ShowDeploymentsModal } from "../deployments/show-deployments-modal"; import { ShowDeploymentsModal } from "../deployments/show-deployments-modal";
import { HandleSchedules } from "./handle-schedules";
interface Props { interface Props {
id: string; id: string;
@@ -166,16 +166,12 @@ export const ShowSchedules = ({ id, scheduleType = "application" }: Props) => {
await runManually({ await runManually({
scheduleId: schedule.scheduleId, scheduleId: schedule.scheduleId,
}) }).then(async () => {
.then(async () => { await new Promise((resolve) =>
await new Promise((resolve) => setTimeout(resolve, 1500),
setTimeout(resolve, 1500), );
); refetchSchedules();
refetchSchedules(); });
})
.catch(() => {
toast.error("Error running schedule");
});
}} }}
> >
<Play className="size-4 transition-colors" /> <Play className="size-4 transition-colors" />

View File

@@ -1,672 +0,0 @@
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>
);
};

View File

@@ -1,411 +0,0 @@
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>
);
};

View File

@@ -1,250 +0,0 @@
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>
);
};

View File

@@ -44,10 +44,8 @@ export const ComposeFileEditor = ({ composeId }: Props) => {
resolver: zodResolver(AddComposeFile), resolver: zodResolver(AddComposeFile),
}); });
const composeFile = form.watch("composeFile");
useEffect(() => { useEffect(() => {
if (data && !composeFile) { if (data) {
form.reset({ form.reset({
composeFile: data.composeFile || "", composeFile: data.composeFile || "",
}); });
@@ -77,7 +75,7 @@ export const ComposeFileEditor = ({ composeId }: Props) => {
composeId, composeId,
}); });
}) })
.catch(() => { .catch((_e) => {
toast.error("Error updating the Compose config"); toast.error("Error updating the Compose config");
}); });
}; };

View File

@@ -136,7 +136,7 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
enableSubmodules: data.enableSubmodules ?? false, enableSubmodules: data.enableSubmodules ?? false,
}); });
} }
}, [form.reset, data?.composeId, form]); }, [form.reset, data, form]);
const onSubmit = async (data: BitbucketProvider) => { const onSubmit = async (data: BitbucketProvider) => {
await mutateAsync({ await mutateAsync({

View File

@@ -142,7 +142,7 @@ export const SaveGiteaProviderCompose = ({ composeId }: Props) => {
enableSubmodules: data.enableSubmodules ?? false, enableSubmodules: data.enableSubmodules ?? false,
}); });
} }
}, [form.reset, data?.composeId, form]); }, [form.reset, data, form]);
const onSubmit = async (data: GiteaProvider) => { const onSubmit = async (data: GiteaProvider) => {
await mutateAsync({ await mutateAsync({

View File

@@ -134,7 +134,7 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
enableSubmodules: data.enableSubmodules ?? false, enableSubmodules: data.enableSubmodules ?? false,
}); });
} }
}, [form.reset, data?.composeId, form]); }, [form.reset, data, form]);
const onSubmit = async (data: GithubProvider) => { const onSubmit = async (data: GithubProvider) => {
await mutateAsync({ await mutateAsync({

View File

@@ -142,7 +142,7 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
enableSubmodules: data.enableSubmodules ?? false, enableSubmodules: data.enableSubmodules ?? false,
}); });
} }
}, [form.reset, data?.composeId, form]); }, [form.reset, data, form]);
const onSubmit = async (data: GitlabProvider) => { const onSubmit = async (data: GitlabProvider) => {
await mutateAsync({ await mutateAsync({
@@ -280,7 +280,7 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
{repositories?.map((repo) => { {repositories?.map((repo) => {
return ( return (
<CommandItem <CommandItem
value={repo.url} value={repo.name}
key={repo.url} key={repo.url}
onSelect={() => { onSelect={() => {
form.setValue("repository", { form.setValue("repository", {
@@ -301,8 +301,7 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
<CheckIcon <CheckIcon
className={cn( className={cn(
"ml-auto h-4 w-4", "ml-auto h-4 w-4",
repo.url === repo.name === field.value.repo
field.value.gitlabPathNamespace
? "opacity-100" ? "opacity-100"
: "opacity-0", : "opacity-0",
)} )}

View File

@@ -18,8 +18,6 @@ import { SaveGitProviderCompose } from "./save-git-provider-compose";
import { SaveGiteaProviderCompose } from "./save-gitea-provider-compose"; import { SaveGiteaProviderCompose } from "./save-gitea-provider-compose";
import { SaveGithubProviderCompose } from "./save-github-provider-compose"; import { SaveGithubProviderCompose } from "./save-github-provider-compose";
import { SaveGitlabProviderCompose } from "./save-gitlab-provider-compose"; import { SaveGitlabProviderCompose } from "./save-gitlab-provider-compose";
import { UnauthorizedGitProvider } from "@/components/dashboard/application/general/generic/unauthorized-git-provider";
import { toast } from "sonner";
type TabState = "github" | "git" | "raw" | "gitlab" | "bitbucket" | "gitea"; type TabState = "github" | "git" | "raw" | "gitlab" | "bitbucket" | "gitea";
interface Props { interface Props {
@@ -36,29 +34,12 @@ export const ShowProviderFormCompose = ({ composeId }: Props) => {
const { data: giteaProviders, isLoading: isLoadingGitea } = const { data: giteaProviders, isLoading: isLoadingGitea } =
api.gitea.giteaProviders.useQuery(); api.gitea.giteaProviders.useQuery();
const { mutateAsync: disconnectGitProvider } = const { data: compose } = api.compose.one.useQuery({ composeId });
api.compose.disconnectGitProvider.useMutation();
const { data: compose, refetch } = api.compose.one.useQuery({ composeId });
const [tab, setSab] = useState<TabState>(compose?.sourceType || "github"); const [tab, setSab] = useState<TabState>(compose?.sourceType || "github");
const isLoading = const isLoading =
isLoadingGithub || isLoadingGitlab || isLoadingBitbucket || isLoadingGitea; isLoadingGithub || isLoadingGitlab || isLoadingBitbucket || isLoadingGitea;
const handleDisconnect = async () => {
try {
await disconnectGitProvider({ composeId });
toast.success("Repository disconnected successfully");
await refetch();
} catch (error) {
toast.error(
`Failed to disconnect repository: ${
error instanceof Error ? error.message : "Unknown error"
}`,
);
}
};
if (isLoading) { if (isLoading) {
return ( return (
<Card className="group relative w-full bg-transparent"> <Card className="group relative w-full bg-transparent">
@@ -87,37 +68,6 @@ export const ShowProviderFormCompose = ({ composeId }: Props) => {
); );
} }
// Check if user doesn't have access to the current git provider
if (
compose &&
!compose.hasGitProviderAccess &&
compose.sourceType !== "raw"
) {
return (
<Card className="group relative w-full bg-transparent">
<CardHeader>
<CardTitle className="flex items-start justify-between">
<div className="flex flex-col gap-2">
<span className="flex flex-col space-y-0.5">Provider</span>
<p className="flex items-center text-sm font-normal text-muted-foreground">
Repository connection through unauthorized provider
</p>
</div>
<div className="hidden space-y-1 text-sm font-normal md:block">
<GitBranch className="size-6 text-muted-foreground" />
</div>
</CardTitle>
</CardHeader>
<CardContent>
<UnauthorizedGitProvider
service={compose}
onDisconnect={handleDisconnect}
/>
</CardContent>
</Card>
);
}
return ( return (
<Card className="group relative w-full bg-transparent"> <Card className="group relative w-full bg-transparent">
<CardHeader> <CardHeader>

View File

@@ -71,8 +71,8 @@ export const IsolatedDeployment = ({ composeId }: Props) => {
isolatedDeployment: formData?.isolatedDeployment || false, isolatedDeployment: formData?.isolatedDeployment || false,
}) })
.then(async (_data) => { .then(async (_data) => {
await randomizeCompose(); randomizeCompose();
await refetch(); refetch();
toast.success("Compose updated"); toast.success("Compose updated");
}) })
.catch(() => { .catch(() => {
@@ -84,10 +84,15 @@ export const IsolatedDeployment = ({ composeId }: Props) => {
await mutateAsync({ await mutateAsync({
composeId, composeId,
suffix: data?.appName || "", suffix: data?.appName || "",
}).then(async (data) => { })
await utils.project.all.invalidate(); .then(async (data) => {
setCompose(data); await utils.project.all.invalidate();
}); setCompose(data);
toast.success("Compose Isolated");
})
.catch(() => {
toast.error("Error isolating the compose");
});
}; };
return ( return (

View File

@@ -77,8 +77,8 @@ export const RandomizeCompose = ({ composeId }: Props) => {
randomize: formData?.randomize || false, randomize: formData?.randomize || false,
}) })
.then(async (_data) => { .then(async (_data) => {
await randomizeCompose(); randomizeCompose();
await refetch(); refetch();
toast.success("Compose updated"); toast.success("Compose updated");
}) })
.catch(() => { .catch(() => {
@@ -90,10 +90,15 @@ export const RandomizeCompose = ({ composeId }: Props) => {
await mutateAsync({ await mutateAsync({
composeId, composeId,
suffix, suffix,
}).then(async (data) => { })
await utils.project.all.invalidate(); .then(async (data) => {
setCompose(data); await utils.project.all.invalidate();
}); setCompose(data);
toast.success("Compose randomized");
})
.catch(() => {
toast.error("Error randomizing the compose");
});
}; };
return ( return (

View File

@@ -40,7 +40,7 @@ export const ShowConvertedCompose = ({ composeId }: Props) => {
.then(() => { .then(() => {
refetch(); refetch();
}) })
.catch(() => {}); .catch((_err) => {});
} }
}, [isOpen]); }, [isOpen]);

View File

@@ -39,12 +39,6 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { api } from "@/utils/api"; import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
@@ -54,9 +48,9 @@ import {
CheckIcon, CheckIcon,
ChevronsUpDown, ChevronsUpDown,
Copy, Copy,
DatabaseZap,
RefreshCw,
RotateCcw, RotateCcw,
RefreshCw,
DatabaseZap,
} from "lucide-react"; } from "lucide-react";
import { useState } from "react"; import { useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
@@ -64,6 +58,12 @@ import { toast } from "sonner";
import { z } from "zod"; import { z } from "zod";
import type { ServiceType } from "../../application/advanced/show-resources"; import type { ServiceType } from "../../application/advanced/show-resources";
import { type LogLine, parseLogs } from "../../docker/logs/utils"; import { type LogLine, parseLogs } from "../../docker/logs/utils";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
type DatabaseType = type DatabaseType =
| Exclude<ServiceType, "application" | "redis"> | Exclude<ServiceType, "application" | "redis">
@@ -199,7 +199,7 @@ const RestoreBackupSchema = z
} }
}); });
export const formatBytes = (bytes: number): string => { 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" className="truncate"> <Badge variant="outline">
{field.value} {field.value}
<Copy <Copy
className="ml-2 size-4 cursor-pointer" className="ml-2 size-4 cursor-pointer"
@@ -439,9 +439,7 @@ export const RestoreBackup = ({
!field.value && "text-muted-foreground", !field.value && "text-muted-foreground",
)} )}
> >
<span className="truncate text-left flex-1 w-52"> {field.value || "Search and select a backup file"}
{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>

View File

@@ -1,10 +1,3 @@
import {
MariadbIcon,
MongodbIcon,
MysqlIcon,
PostgresqlIcon,
} from "@/components/icons/data-tools-icons";
import { AlertBlock } from "@/components/shared/alert-block";
import { DialogAction } from "@/components/shared/dialog-action"; import { DialogAction } from "@/components/shared/dialog-action";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
@@ -20,7 +13,6 @@ import {
TooltipProvider, TooltipProvider,
TooltipTrigger, TooltipTrigger,
} from "@/components/ui/tooltip"; } from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import { api } from "@/utils/api"; import { api } from "@/utils/api";
import { import {
ClipboardList, ClipboardList,
@@ -33,9 +25,17 @@ import Link from "next/link";
import { useState } from "react"; import { useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import type { ServiceType } from "../../application/advanced/show-resources"; import type { ServiceType } from "../../application/advanced/show-resources";
import { ShowDeploymentsModal } from "../../application/deployments/show-deployments-modal";
import { HandleBackup } from "./handle-backup";
import { RestoreBackup } from "./restore-backup"; import { RestoreBackup } from "./restore-backup";
import { HandleBackup } from "./handle-backup";
import { cn } from "@/lib/utils";
import {
MariadbIcon,
MongodbIcon,
MysqlIcon,
PostgresqlIcon,
} from "@/components/icons/data-tools-icons";
import { AlertBlock } from "@/components/shared/alert-block";
import { ShowDeploymentsModal } from "../../application/deployments/show-deployments-modal";
interface Props { interface Props {
id: string; id: string;

View File

@@ -1,9 +1,24 @@
"use client"; "use client";
import { Logo } from "@/components/shared/logo"; import { authClient } from "@/lib/auth-client";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { useEffect, useState } from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import {
CheckIcon,
ChevronsUpDown,
Settings2,
UserIcon,
XIcon,
Shield,
Calendar,
Key,
Copy,
Fingerprint,
Building2,
CreditCard,
Server,
} from "lucide-react";
import { toast } from "sonner";
import { import {
Command, Command,
CommandEmpty, CommandEmpty,
@@ -17,34 +32,19 @@ import {
PopoverContent, PopoverContent,
PopoverTrigger, PopoverTrigger,
} from "@/components/ui/popover"; } from "@/components/ui/popover";
import { cn } from "@/lib/utils";
import { Logo } from "@/components/shared/logo";
import { Badge } from "@/components/ui/badge";
import { import {
Tooltip, Tooltip,
TooltipContent, TooltipContent,
TooltipProvider,
TooltipTrigger, TooltipTrigger,
TooltipProvider,
} from "@/components/ui/tooltip"; } from "@/components/ui/tooltip";
import { authClient } from "@/lib/auth-client"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { cn } from "@/lib/utils";
import { api } from "@/utils/api";
import copy from "copy-to-clipboard";
import { format } from "date-fns"; import { format } from "date-fns";
import { import copy from "copy-to-clipboard";
Building2, import { api } from "@/utils/api";
Calendar,
CheckIcon,
ChevronsUpDown,
Copy,
CreditCard,
Fingerprint,
Key,
Server,
Settings2,
Shield,
UserIcon,
XIcon,
} from "lucide-react";
import { useEffect, useState } from "react";
import { toast } from "sonner";
type User = typeof authClient.$Infer.Session.user; type User = typeof authClient.$Infer.Session.user;

View File

@@ -103,7 +103,7 @@ export const AddApplication = ({ projectId, projectName }: Props) => {
projectId, projectId,
}); });
}) })
.catch(() => { .catch((_e) => {
toast.error("Error creating the service"); toast.error("Error creating the service");
}); });
}; };

View File

@@ -10,7 +10,6 @@ import {
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { api } from "@/utils/api"; import { api } from "@/utils/api";
import { Copy, Loader2 } from "lucide-react"; import { Copy, Loader2 } from "lucide-react";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
@@ -49,7 +48,6 @@ export const DuplicateProject = ({
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [name, setName] = useState(""); const [name, setName] = useState("");
const [description, setDescription] = useState(""); const [description, setDescription] = useState("");
const [duplicateType, setDuplicateType] = useState("new-project"); // "new-project" or "same-project"
const utils = api.useUtils(); const utils = api.useUtils();
const router = useRouter(); const router = useRouter();
@@ -61,15 +59,9 @@ export const DuplicateProject = ({
api.project.duplicate.useMutation({ api.project.duplicate.useMutation({
onSuccess: async (newProject) => { onSuccess: async (newProject) => {
await utils.project.all.invalidate(); await utils.project.all.invalidate();
toast.success( toast.success("Project duplicated successfully");
duplicateType === "new-project"
? "Project duplicated successfully"
: "Services duplicated successfully",
);
setOpen(false); setOpen(false);
if (duplicateType === "new-project") { router.push(`/dashboard/project/${newProject.projectId}`);
router.push(`/dashboard/project/${newProject.projectId}`);
}
}, },
onError: (error) => { onError: (error) => {
toast.error(error.message); toast.error(error.message);
@@ -77,7 +69,7 @@ export const DuplicateProject = ({
}); });
const handleDuplicate = async () => { const handleDuplicate = async () => {
if (duplicateType === "new-project" && !name) { if (!name) {
toast.error("Project name is required"); toast.error("Project name is required");
return; return;
} }
@@ -91,7 +83,6 @@ export const DuplicateProject = ({
id: service.id, id: service.id,
type: service.type, type: service.type,
})), })),
duplicateInSameProject: duplicateType === "same-project",
}); });
}; };
@@ -104,7 +95,6 @@ export const DuplicateProject = ({
// Reset form when closing // Reset form when closing
setName(""); setName("");
setDescription(""); setDescription("");
setDuplicateType("new-project");
} }
}} }}
> >
@@ -116,54 +106,32 @@ export const DuplicateProject = ({
</DialogTrigger> </DialogTrigger>
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>
<DialogTitle>Duplicate Services</DialogTitle> <DialogTitle>Duplicate Project</DialogTitle>
<DialogDescription> <DialogDescription>
Choose where to duplicate the selected services Create a new project with the selected services
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className="grid gap-4 py-4"> <div className="grid gap-4 py-4">
<div className="grid gap-2"> <div className="grid gap-2">
<Label>Duplicate to</Label> <Label htmlFor="name">Name</Label>
<RadioGroup <Input
value={duplicateType} id="name"
onValueChange={setDuplicateType} value={name}
className="grid gap-2" onChange={(e) => setName(e.target.value)}
> placeholder="New project name"
<div className="flex items-center space-x-2"> />
<RadioGroupItem value="new-project" id="new-project" />
<Label htmlFor="new-project">New project</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="same-project" id="same-project" />
<Label htmlFor="same-project">Same project</Label>
</div>
</RadioGroup>
</div> </div>
{duplicateType === "new-project" && ( <div className="grid gap-2">
<> <Label htmlFor="description">Description</Label>
<div className="grid gap-2"> <Input
<Label htmlFor="name">Name</Label> id="description"
<Input value={description}
id="name" onChange={(e) => setDescription(e.target.value)}
value={name} placeholder="Project description (optional)"
onChange={(e) => setName(e.target.value)} />
placeholder="New project name" </div>
/>
</div>
<div className="grid gap-2">
<Label htmlFor="description">Description</Label>
<Input
id="description"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Project description (optional)"
/>
</div>
</>
)}
<div className="grid gap-2"> <div className="grid gap-2">
<Label>Selected services to duplicate</Label> <Label>Selected services to duplicate</Label>
@@ -191,14 +159,10 @@ export const DuplicateProject = ({
{isLoading ? ( {isLoading ? (
<> <>
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> <Loader2 className="mr-2 h-4 w-4 animate-spin" />
{duplicateType === "new-project" Duplicating...
? "Duplicating project..."
: "Duplicating services..."}
</> </>
) : duplicateType === "new-project" ? (
"Duplicate project"
) : ( ) : (
"Duplicate services" "Duplicate"
)} )}
</Button> </Button>
</DialogFooter> </DialogFooter>

View File

@@ -38,7 +38,7 @@ const AddProjectSchema = z.object({
(name) => { (name) => {
const trimmedName = name.trim(); const trimmedName = name.trim();
const validNameRegex = const validNameRegex =
/^[\p{L}\p{N}_-][\p{L}\p{N}\s_.-]*[\p{L}\p{N}_-]$/u; /^[\p{L}\p{N}_-][\p{L}\p{N}\s_-]*[\p{L}\p{N}_-]$/u;
return validNameRegex.test(trimmedName); return validNameRegex.test(trimmedName);
}, },
{ {

View File

@@ -1,93 +1,80 @@
// @ts-nocheck
export const extractExpirationDate = (certData: string): Date | null => { export const extractExpirationDate = (certData: string): Date | null => {
try { try {
// Decode PEM base64 to DER binary const match = certData.match(
const b64 = certData.replace(/-----[^-]+-----/g, "").replace(/\s+/g, ""); /-----BEGIN CERTIFICATE-----\s*([^-]+)\s*-----END CERTIFICATE-----/,
const binStr = atob(b64); );
const der = new Uint8Array(binStr.length); if (!match?.[1]) return null;
for (let i = 0; i < binStr.length; i++) {
der[i] = binStr.charCodeAt(i); const base64Cert = match[1].replace(/\s/g, "");
const binaryStr = window.atob(base64Cert);
const bytes = new Uint8Array(binaryStr.length);
for (let i = 0; i < binaryStr.length; i++) {
bytes[i] = binaryStr.charCodeAt(i);
} }
let offset = 0; // ASN.1 tag for UTCTime is 0x17, GeneralizedTime is 0x18
// We need to find the second occurrence of either tag as it's the "not after" (expiration) date
let dateFound = false;
for (let i = 0; i < bytes.length - 2; i++) {
// Look for sequence containing validity period (0x30)
if (bytes[i] === 0x30) {
// Check next bytes for UTCTime or GeneralizedTime
let j = i + 1;
while (j < bytes.length - 2) {
if (bytes[j] === 0x17 || bytes[j] === 0x18) {
const dateType = bytes[j];
const dateLength = bytes[j + 1];
if (typeof dateLength === "undefined") break;
// Helper: read ASN.1 length field if (!dateFound) {
function readLength(pos: number): { length: number; offset: number } { // Skip "not before" date
// biome-ignore lint/style/noParameterAssign: <explanation> dateFound = true;
let len = der[pos++]; j += dateLength + 2;
if (len & 0x80) { continue;
const bytes = len & 0x7f; }
len = 0;
for (let i = 0; i < bytes; i++) { // Found "not after" date
// biome-ignore lint/style/noParameterAssign: <explanation> let dateStr = "";
len = (len << 8) + der[pos++]; for (let k = 0; k < dateLength; k++) {
const charCode = bytes[j + 2 + k];
if (typeof charCode === "undefined") continue;
dateStr += String.fromCharCode(charCode);
}
if (dateType === 0x17) {
// UTCTime (YYMMDDhhmmssZ)
const year = Number.parseInt(dateStr.slice(0, 2));
const fullYear = year >= 50 ? 1900 + year : 2000 + year;
return new Date(
Date.UTC(
fullYear,
Number.parseInt(dateStr.slice(2, 4)) - 1,
Number.parseInt(dateStr.slice(4, 6)),
Number.parseInt(dateStr.slice(6, 8)),
Number.parseInt(dateStr.slice(8, 10)),
Number.parseInt(dateStr.slice(10, 12)),
),
);
}
// GeneralizedTime (YYYYMMDDhhmmssZ)
return new Date(
Date.UTC(
Number.parseInt(dateStr.slice(0, 4)),
Number.parseInt(dateStr.slice(4, 6)) - 1,
Number.parseInt(dateStr.slice(6, 8)),
Number.parseInt(dateStr.slice(8, 10)),
Number.parseInt(dateStr.slice(10, 12)),
Number.parseInt(dateStr.slice(12, 14)),
),
);
}
j++;
} }
} }
return { length: len, offset: pos };
} }
return null;
// Skip the outer certificate sequence
if (der[offset++] !== 0x30) throw new Error("Expected sequence");
({ offset } = readLength(offset));
// Skip tbsCertificate sequence
if (der[offset++] !== 0x30) throw new Error("Expected tbsCertificate");
({ offset } = readLength(offset));
// Check for optional version field (context-specific tag [0])
if (der[offset] === 0xa0) {
offset++;
const versionLen = readLength(offset);
offset = versionLen.offset + versionLen.length;
}
// Skip serialNumber, signature, issuer
for (let i = 0; i < 3; i++) {
if (der[offset] !== 0x30 && der[offset] !== 0x02)
throw new Error("Unexpected structure");
offset++;
const fieldLen = readLength(offset);
offset = fieldLen.offset + fieldLen.length;
}
// Validity sequence (notBefore and notAfter)
if (der[offset++] !== 0x30) throw new Error("Expected validity sequence");
const validityLen = readLength(offset);
offset = validityLen.offset;
// notBefore
offset++;
const notBeforeLen = readLength(offset);
offset = notBeforeLen.offset + notBeforeLen.length;
// notAfter
offset++;
const notAfterLen = readLength(offset);
const notAfterStr = new TextDecoder().decode(
der.slice(notAfterLen.offset, notAfterLen.offset + notAfterLen.length),
);
// Parse GeneralizedTime (15 chars) or UTCTime (13 chars)
function parseTime(str: string): Date {
if (str.length === 13) {
// UTCTime YYMMDDhhmmssZ
const year = Number.parseInt(str.slice(0, 2), 10);
const fullYear = year < 50 ? 2000 + year : 1900 + year;
return new Date(
`${fullYear}-${str.slice(2, 4)}-${str.slice(4, 6)}T${str.slice(6, 8)}:${str.slice(8, 10)}:${str.slice(10, 12)}Z`,
);
}
if (str.length === 15) {
// GeneralizedTime YYYYMMDDhhmmssZ
return new Date(
`${str.slice(0, 4)}-${str.slice(4, 6)}-${str.slice(6, 8)}T${str.slice(8, 10)}:${str.slice(10, 12)}:${str.slice(12, 14)}Z`,
);
}
throw new Error("Invalid ASN.1 time format");
}
return parseTime(notAfterStr);
} catch (error) { } catch (error) {
console.error("Error parsing certificate:", error); console.error("Error parsing certificate:", error);
return null; return null;

View File

@@ -20,7 +20,7 @@ export const ShowNodesModal = ({ serverId }: Props) => {
Show Swarm Nodes Show Swarm Nodes
</DropdownMenuItem> </DropdownMenuItem>
</DialogTrigger> </DialogTrigger>
<DialogContent className="min-w-[70vw] overflow-y-auto max-h-screen"> <DialogContent className="sm:max-w-5xl 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>

View File

@@ -87,7 +87,7 @@ export const ShowNodes = ({ serverId }: Props) => {
</TableCaption> </TableCaption>
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead className="text-left">Hostname</TableHead> <TableHead className="w-[100px]">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="text-left"> <TableCell className="w-[100px]">
{node.Description.Hostname} {node.Description.Hostname}
</TableCell> </TableCell>
<TableCell className="text-right"> <TableCell className="text-right">

View File

@@ -18,7 +18,6 @@ import { useEffect, useState } from "react";
export const AddGithubProvider = () => { export const AddGithubProvider = () => {
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const { data: activeOrganization } = authClient.useActiveOrganization(); const { data: activeOrganization } = authClient.useActiveOrganization();
const { data: session } = authClient.useSession();
const { data } = api.user.get.useQuery(); const { data } = api.user.get.useQuery();
const [manifest, setManifest] = useState(""); const [manifest, setManifest] = useState("");
const [isOrganization, setIsOrganization] = useState(false); const [isOrganization, setIsOrganization] = useState(false);
@@ -28,7 +27,7 @@ export const AddGithubProvider = () => {
const url = document.location.origin; const url = document.location.origin;
const manifest = JSON.stringify( const manifest = JSON.stringify(
{ {
redirect_url: `${origin}/api/providers/github/setup?organizationId=${activeOrganization?.id}&userId=${session?.user?.id}`, redirect_url: `${origin}/api/providers/github/setup?organizationId=${activeOrganization?.id}`,
name: `Dokploy-${format(new Date(), "yyyy-MM-dd")}`, name: `Dokploy-${format(new Date(), "yyyy-MM-dd")}`,
url: origin, url: origin,
hook_attributes: { hook_attributes: {

View File

@@ -1063,7 +1063,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
}); });
} }
toast.success("Connection Success"); toast.success("Connection Success");
} catch { } catch (_err) {
toast.error("Error testing the provider"); toast.error("Error testing the provider");
} }
}} }}

View File

@@ -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 { } catch (_error) {
form.setError("password", { form.setError("password", {
message: "Connection error. Please try again.", message: "Connection error. Please try again.",
}); });

View File

@@ -18,7 +18,6 @@ import {
} from "@/components/ui/form"; } from "@/components/ui/form";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { Switch } from "@/components/ui/switch";
import { generateSHA256Hash } from "@/lib/utils"; import { generateSHA256Hash } from "@/lib/utils";
import { api } from "@/utils/api"; import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
@@ -30,6 +29,7 @@ import { toast } from "sonner";
import { z } from "zod"; import { z } from "zod";
import { Disable2FA } from "./disable-2fa"; import { Disable2FA } from "./disable-2fa";
import { Enable2FA } from "./enable-2fa"; import { Enable2FA } from "./enable-2fa";
import { Switch } from "@/components/ui/switch";
const profileSchema = z.object({ const profileSchema = z.object({
email: z.string(), email: z.string(),

View File

@@ -36,7 +36,7 @@ export const ToggleDockerCleanup = ({ serverId }: Props) => {
await refetch(); await refetch();
} }
toast.success("Docker Cleanup updated"); toast.success("Docker Cleanup updated");
} catch { } catch (_error) {
toast.error("Docker Cleanup Error"); toast.error("Docker Cleanup Error");
} }
}; };

View File

@@ -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 { } catch (_error) {
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 { } catch (_error) {
// Error handling is done in mutation's onError // Error handling is done in mutation's onError
} }
}; };

View File

@@ -156,67 +156,6 @@ export const HandleServers = ({ serverId }: Props) => {
remotely. remotely.
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div>
<p className="text-primary text-sm font-medium">
You will need to purchase or rent a Virtual Private Server (VPS) to
proceed, we recommend to use one of these providers since has been
heavily tested.
</p>
<ul className="list-inside list-disc pl-4 text-sm text-muted-foreground mt-4">
<li>
<a
href="https://www.hostinger.com/vps-hosting?REFERRALCODE=1SIUMAURICI97"
className="text-link underline"
>
Hostinger - Get 20% Discount
</a>
</li>
<li>
<a
href=" https://app.americancloud.com/register?ref=dokploy"
className="text-link underline"
>
American Cloud - Get $20 Credits
</a>
</li>
<li>
<a
href="https://m.do.co/c/db24efd43f35"
className="text-link underline"
>
DigitalOcean - Get $200 Credits
</a>
</li>
<li>
<a
href="https://hetzner.cloud/?ref=vou4fhxJ1W2D"
className="text-link underline"
>
Hetzner - Get 20 Credits
</a>
</li>
<li>
<a
href="https://www.vultr.com/?ref=9679828"
className="text-link underline"
>
Vultr
</a>
</li>
<li>
<a
href="https://www.linode.com/es/pricing/#compute-shared"
className="text-link underline"
>
Linode
</a>
</li>
</ul>
<AlertBlock className="mt-4 px-4">
You are free to use whatever provider, but we recommend to use one
of the above, to avoid issues.
</AlertBlock>
</div>
{!canCreateMoreServers && ( {!canCreateMoreServers && (
<AlertBlock type="warning"> <AlertBlock type="warning">
You cannot create more servers,{" "} You cannot create more servers,{" "}

View File

@@ -1,7 +1,7 @@
import { ShowSchedules } from "@/components/dashboard/application/schedules/show-schedules"; import { useState } from "react";
import { Dialog, DialogContent, DialogTrigger } from "@/components/ui/dialog"; import { Dialog, DialogContent, DialogTrigger } from "@/components/ui/dialog";
import { DropdownMenuItem } from "@/components/ui/dropdown-menu"; import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
import { useState } from "react"; import { ShowSchedules } from "@/components/dashboard/application/schedules/show-schedules";
interface Props { interface Props {
serverId: string; serverId: string;

View File

@@ -40,10 +40,10 @@ import { HandleServers } from "./handle-servers";
import { SetupServer } from "./setup-server"; import { SetupServer } from "./setup-server";
import { ShowDockerContainersModal } from "./show-docker-containers-modal"; import { ShowDockerContainersModal } from "./show-docker-containers-modal";
import { ShowMonitoringModal } from "./show-monitoring-modal"; import { ShowMonitoringModal } from "./show-monitoring-modal";
import { ShowSchedulesModal } from "./show-schedules-modal";
import { ShowSwarmOverviewModal } from "./show-swarm-overview-modal"; import { ShowSwarmOverviewModal } from "./show-swarm-overview-modal";
import { ShowTraefikFileSystemModal } from "./show-traefik-file-system-modal"; import { ShowTraefikFileSystemModal } from "./show-traefik-file-system-modal";
import { WelcomeSuscription } from "./welcome-stripe/welcome-suscription"; import { WelcomeSuscription } from "./welcome-stripe/welcome-suscription";
import { ShowSchedulesModal } from "./show-schedules-modal";
export const ShowServers = () => { export const ShowServers = () => {
const { t } = useTranslation("settings"); const { t } = useTranslation("settings");
@@ -141,7 +141,7 @@ export const ShowServers = () => {
</TableCaption> </TableCaption>
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead className="text-left">Name</TableHead> <TableHead className="w-[100px]">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="text-left"> <TableCell className="w-[100px]">
{server.name} {server.name}
</TableCell> </TableCell>
{isCloud && ( {isCloud && (

View File

@@ -177,14 +177,6 @@ export const WelcomeSuscription = () => {
Hostinger - Get 20% Discount Hostinger - Get 20% Discount
</a> </a>
</li> </li>
<li>
<a
href=" https://app.americancloud.com/register?ref=dokploy"
className="text-link underline"
>
American Cloud - Get $20 Credits
</a>
</li>
<li> <li>
<a <a
href="https://m.do.co/c/db24efd43f35" href="https://m.do.co/c/db24efd43f35"

View File

@@ -41,7 +41,6 @@ const addInvitation = z.object({
.min(1, "Email is required") .min(1, "Email is required")
.email({ message: "Invalid email" }), .email({ message: "Invalid email" }),
role: z.enum(["member", "admin"]), role: z.enum(["member", "admin"]),
notificationId: z.string().optional(),
}); });
type AddInvitation = z.infer<typeof addInvitation>; type AddInvitation = z.infer<typeof addInvitation>;
@@ -50,10 +49,6 @@ export const AddInvitation = () => {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const utils = api.useUtils(); const utils = api.useUtils();
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const { data: isCloud } = api.settings.isCloud.useQuery();
const { data: emailProviders } =
api.notification.getEmailProviders.useQuery();
const { mutateAsync: sendInvitation } = api.user.sendInvitation.useMutation();
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const { data: activeOrganization } = authClient.useActiveOrganization(); const { data: activeOrganization } = authClient.useActiveOrganization();
@@ -61,7 +56,6 @@ export const AddInvitation = () => {
defaultValues: { defaultValues: {
email: "", email: "",
role: "member", role: "member",
notificationId: "",
}, },
resolver: zodResolver(addInvitation), resolver: zodResolver(addInvitation),
}); });
@@ -80,20 +74,7 @@ export const AddInvitation = () => {
if (result.error) { if (result.error) {
setError(result.error.message || ""); setError(result.error.message || "");
} else { } else {
if (!isCloud && data.notificationId) { toast.success("Invitation created");
await sendInvitation({
invitationId: result.data.id,
notificationId: data.notificationId || "",
})
.then(() => {
toast.success("Invitation created and email sent");
})
.catch((error: any) => {
toast.error(error.message);
});
} else {
toast.success("Invitation created");
}
setError(null); setError(null);
setOpen(false); setOpen(false);
} }
@@ -168,47 +149,6 @@ export const AddInvitation = () => {
); );
}} }}
/> />
{!isCloud && (
<FormField
control={form.control}
name="notificationId"
render={({ field }) => {
return (
<FormItem>
<FormLabel>Email Provider</FormLabel>
<Select
onValueChange={field.onChange}
defaultValue={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select an email provider" />
</SelectTrigger>
</FormControl>
<SelectContent>
{emailProviders?.map((provider) => (
<SelectItem
key={provider.notificationId}
value={provider.notificationId}
>
{provider.name}
</SelectItem>
))}
<SelectItem value="none" disabled>
None
</SelectItem>
</SelectContent>
</Select>
<FormDescription>
Select the email provider to send the invitation
</FormDescription>
<FormMessage />
</FormItem>
);
}}
/>
)}
<DialogFooter className="flex w-full flex-row"> <DialogFooter className="flex w-full flex-row">
<Button <Button
isLoading={isLoading} isLoading={isLoading}

View File

@@ -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={() => { onSelect={(_e) => {
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 () => { onSelect={async (_e) => {
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 () => { onSelect={async (_e) => {
await removeInvitation({ await removeInvitation({
invitationId: invitation.id, invitationId: invitation.id,
}).then(() => { }).then(() => {

View File

@@ -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 {} } catch (_error) {}
}; };
return ( return (

View File

@@ -87,7 +87,7 @@ export const ShowNodeApplications = ({ serverId }: Props) => {
Services Services
</Button> </Button>
</DialogTrigger> </DialogTrigger>
<DialogContent className={"sm:max-w-10xl overflow-y-auto max-h-screen"}> <DialogContent className={"sm:max-w-6xl overflow-y-auto max-h-screen"}>
<DialogHeader> <DialogHeader>
<DialogTitle>Node Applications</DialogTitle> <DialogTitle>Node Applications</DialogTitle>
<DialogDescription> <DialogDescription>

View File

@@ -1,7 +1,6 @@
import { api } from "@/utils/api";
import { ImpersonationBar } from "../dashboard/impersonation/impersonation-bar";
import { ChatwootWidget } from "../shared/ChatwootWidget";
import Page from "./side"; import Page from "./side";
import { ImpersonationBar } from "../dashboard/impersonation/impersonation-bar";
import { api } from "@/utils/api";
interface Props { interface Props {
children: React.ReactNode; children: React.ReactNode;
@@ -10,15 +9,10 @@ interface Props {
export const DashboardLayout = ({ children }: Props) => { export const DashboardLayout = ({ children }: Props) => {
const { data: haveRootAccess } = api.user.haveRootAccess.useQuery(); const { data: haveRootAccess } = api.user.haveRootAccess.useQuery();
const { data: isCloud } = api.settings.isCloud.useQuery();
return ( return (
<> <>
<Page>{children}</Page> <Page>{children}</Page>
{isCloud === true && (
<ChatwootWidget websiteToken="USCpQRKzHvFMssf3p6Eacae5" />
)}
{haveRootAccess === true && <ImpersonationBar />} {haveRootAccess === true && <ImpersonationBar />}
</> </>
); );

View File

@@ -0,0 +1,9 @@
import Page from "./side";
interface Props {
children: React.ReactNode;
}
export const ProjectLayout = ({ children }: Props) => {
return <Page>{children}</Page>;
};

View File

@@ -1,69 +0,0 @@
import Script from "next/script";
import { useEffect } from "react";
interface ChatwootWidgetProps {
websiteToken: string;
baseUrl?: string;
settings?: {
position?: "left" | "right";
type?: "standard" | "expanded_bubble";
launcherTitle?: string;
darkMode?: boolean;
hideMessageBubble?: boolean;
placement?: "right" | "left";
showPopoutButton?: boolean;
widgetStyle?: "standard" | "bubble";
};
user?: {
identifier: string;
name?: string;
email?: string;
phoneNumber?: string;
avatarUrl?: string;
customAttributes?: Record<string, any>;
identifierHash?: string;
};
}
export const ChatwootWidget = ({
websiteToken,
baseUrl = "https://app.chatwoot.com",
settings = {
position: "right",
type: "standard",
launcherTitle: "Chat with us",
},
user,
}: ChatwootWidgetProps) => {
useEffect(() => {
// Configurar los settings de Chatwoot
window.chatwootSettings = {
position: "right",
};
(window as any).chatwootSDKReady = () => {
window.chatwootSDK?.run({ websiteToken, baseUrl });
const trySetUser = () => {
if (window.$chatwoot && user) {
window.$chatwoot.setUser(user.identifier, {
email: user.email,
name: user.name,
avatar_url: user.avatarUrl,
phone_number: user.phoneNumber,
});
}
};
trySetUser();
};
}, [websiteToken, baseUrl, user, settings]);
return (
<Script
src={`${baseUrl}/packs/js/sdk.js`}
strategy="lazyOnload"
onLoad={() => (window as any).chatwootSDKReady?.()}
/>
);
};

View File

@@ -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 whitespace-nowrap 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 transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
{ {
variants: { variants: {
variant: { variant: {

View File

@@ -246,9 +246,7 @@ const Leaf = React.forwardRef<
aria-hidden="true" aria-hidden="true"
/> />
)} )}
<p className=" text-sm whitespace-normal font-mono text-left"> <p className=" text-sm whitespace-normal font-mono">{item.name}</p>
{item.name}
</p>
</button> </button>
); );
}); });

View File

@@ -17,7 +17,7 @@ const PopoverContent = React.forwardRef<
align={align} align={align}
sideOffset={sideOffset} sideOffset={sideOffset}
className={cn( className={cn(
"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", "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",
className, className,
)} )}
{...props} {...props}

View File

@@ -1 +0,0 @@
ALTER TABLE "application" ADD COLUMN "isStaticSpa" boolean;

View File

@@ -1,3 +0,0 @@
ALTER TABLE "user_temp" ALTER COLUMN "logCleanupCron" SET DEFAULT '0 0 * * *';
UPDATE "user_temp" SET "logCleanupCron" = '0 0 * * *' WHERE "logCleanupCron" IS NULL;

View File

@@ -1,14 +0,0 @@
ALTER TABLE "git_provider" ADD COLUMN "userId" text;--> statement-breakpoint
-- Update existing git providers to be owned by the organization owner
-- We can get the owner_id directly from the organization table
UPDATE "git_provider"
SET "userId" = (
SELECT o."owner_id"
FROM "organization" o
WHERE o.id = "git_provider"."organizationId"
);--> statement-breakpoint
-- Now make the column NOT NULL since all rows should have values
ALTER TABLE "git_provider" ALTER COLUMN "userId" SET NOT NULL;--> statement-breakpoint
ALTER TABLE "git_provider" ADD CONSTRAINT "git_provider_userId_user_temp_id_fk" FOREIGN KEY ("userId") REFERENCES "public"."user_temp"("id") ON DELETE cascade ON UPDATE no action;

View File

@@ -1,13 +0,0 @@
CREATE TABLE "rollback" (
"rollbackId" text PRIMARY KEY NOT NULL,
"deploymentId" text NOT NULL,
"version" serial NOT NULL,
"image" text,
"createdAt" text NOT NULL,
"fullContext" jsonb
);
--> statement-breakpoint
ALTER TABLE "application" ADD COLUMN "rollbackActive" boolean DEFAULT false;--> statement-breakpoint
ALTER TABLE "deployment" ADD COLUMN "rollbackId" text;--> statement-breakpoint
ALTER TABLE "rollback" ADD CONSTRAINT "rollback_deploymentId_deployment_deploymentId_fk" FOREIGN KEY ("deploymentId") REFERENCES "public"."deployment"("deploymentId") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "deployment" ADD CONSTRAINT "deployment_rollbackId_rollback_rollbackId_fk" FOREIGN KEY ("rollbackId") REFERENCES "public"."rollback"("rollbackId") ON DELETE cascade ON UPDATE no action;

View File

@@ -1,3 +0,0 @@
ALTER TABLE "rollback" DROP CONSTRAINT "rollback_deploymentId_deployment_deploymentId_fk";
--> statement-breakpoint
ALTER TABLE "rollback" ADD CONSTRAINT "rollback_deploymentId_deployment_deploymentId_fk" FOREIGN KEY ("deploymentId") REFERENCES "public"."deployment"("deploymentId") ON DELETE set null ON UPDATE no action;

View File

@@ -1,3 +0,0 @@
ALTER TABLE "rollback" DROP CONSTRAINT "rollback_deploymentId_deployment_deploymentId_fk";
--> statement-breakpoint
ALTER TABLE "rollback" ADD CONSTRAINT "rollback_deploymentId_deployment_deploymentId_fk" FOREIGN KEY ("deploymentId") REFERENCES "public"."deployment"("deploymentId") ON DELETE cascade ON UPDATE no action;

View File

@@ -1 +0,0 @@
ALTER TABLE "deployment" ADD COLUMN "pid" text;

View File

@@ -1,2 +0,0 @@
CREATE TYPE "public"."publishModeType" AS ENUM('ingress', 'host');--> statement-breakpoint
ALTER TABLE "port" ADD COLUMN "publishMode" "publishModeType" DEFAULT 'host' NOT NULL;

View File

@@ -1,2 +0,0 @@
ALTER TABLE "domain" ADD COLUMN "internalPath" text DEFAULT '/';--> statement-breakpoint
ALTER TABLE "domain" ADD COLUMN "stripPath" boolean DEFAULT false NOT NULL;

View File

@@ -1,33 +0,0 @@
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;

View File

@@ -1,3 +0,0 @@
ALTER TABLE "compose" ADD COLUMN "isolatedDeploymentsVolume" boolean DEFAULT false NOT NULL;
UPDATE "compose" SET "isolatedDeploymentsVolume" = true;

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More