diff --git a/.github/sponsors/agentdock.png b/.github/sponsors/agentdock.png new file mode 100644 index 000000000..bd76dc171 Binary files /dev/null and b/.github/sponsors/agentdock.png differ diff --git a/.github/sponsors/american-cloud.png b/.github/sponsors/american-cloud.png new file mode 100644 index 000000000..daa902078 Binary files /dev/null and b/.github/sponsors/american-cloud.png differ diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml index 2ac542296..e9591f3cc 100644 --- a/.github/workflows/pull-request.yml +++ b/.github/workflows/pull-request.yml @@ -12,7 +12,7 @@ jobs: - uses: pnpm/action-setup@v4 - uses: actions/setup-node@v4 with: - node-version: 20.9.0 + node-version: 20.16.0 cache: "pnpm" - run: pnpm install --frozen-lockfile - run: pnpm run server:build @@ -26,7 +26,7 @@ jobs: - uses: pnpm/action-setup@v4 - uses: actions/setup-node@v4 with: - node-version: 20.9.0 + node-version: 20.16.0 cache: "pnpm" - run: pnpm install --frozen-lockfile - run: pnpm run server:build @@ -39,7 +39,7 @@ jobs: - uses: pnpm/action-setup@v4 - uses: actions/setup-node@v4 with: - node-version: 20.9.0 + node-version: 20.16.0 cache: "pnpm" - run: pnpm install --frozen-lockfile - run: pnpm run server:build diff --git a/.nvmrc b/.nvmrc index 43bff1f8c..593cb75bc 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -20.9.0 \ No newline at end of file +20.16.0 \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 015095aa6..0ac5a3581 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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. -We use Node v20.9.0 +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. ```bash git clone https://github.com/dokploy/dokploy.git @@ -87,6 +87,8 @@ pnpm run dokploy:dev Go to http://localhost:3000 to see the development server +Note: this project uses Biome. If your editor is configured to use another formatter such as Prettier, it's recommended to either change it to use Biome or turn it off. + ## Build ```bash @@ -145,11 +147,9 @@ curl -sSL https://railpack.com/install.sh | sh ```bash # Install Buildpacks -curl -sSL "https://github.com/buildpacks/pack/releases/download/v0.32.1/pack-v0.32.1-linux.tgz" | tar -C /usr/local/bin/ --no-same-owner -xzv pack +curl -sSL "https://github.com/buildpacks/pack/releases/download/v0.35.0/pack-v0.35.0-linux.tgz" | tar -C /usr/local/bin/ --no-same-owner -xzv pack ``` - - ## Pull Request - The `main` branch is the source of truth and should always reflect the latest stable release. @@ -167,7 +167,6 @@ Thank you for your contribution! To add a new template, go to `https://github.com/Dokploy/templates` repository and read the README.md file. - ### Recommendations - Use the same name of the folder as the id of the template. diff --git a/Dockerfile b/Dockerfile index ad2239b05..c41df8c73 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,3 +1,4 @@ +# syntax=docker/dockerfile:1 FROM node:20.9-slim AS base ENV PNPM_HOME="/pnpm" ENV PATH="$PNPM_HOME:$PATH" @@ -29,7 +30,7 @@ WORKDIR /app # Set production ENV NODE_ENV=production -RUN apt-get update && apt-get install -y curl unzip zip apache2-utils iproute2 && rm -rf /var/lib/apt/lists/* +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/* # Copy only the necessary files COPY --from=build /prod/dokploy/.next ./.next @@ -49,18 +50,18 @@ RUN curl -fsSL https://get.docker.com -o get-docker.sh && sh get-docker.sh && rm # Install Nixpacks and tsx # | VERBOSE=1 VERSION=1.21.0 bash -ARG NIXPACKS_VERSION=1.29.1 +ARG NIXPACKS_VERSION=1.39.0 RUN curl -sSL https://nixpacks.com/install.sh -o install.sh \ && chmod +x install.sh \ && ./install.sh \ && pnpm install -g tsx # Install Railpack -ARG RAILPACK_VERSION=0.0.37 +ARG RAILPACK_VERSION=0.0.64 RUN curl -sSL https://railpack.com/install.sh | bash # Install buildpacks COPY --from=buildpacksio/pack:0.35.0 /usr/local/bin/pack /usr/local/bin/pack EXPOSE 3000 -CMD [ "pnpm", "start" ] \ No newline at end of file +CMD [ "pnpm", "start" ] diff --git a/Dockerfile.cloud b/Dockerfile.cloud index c1b667963..c234259dc 100644 --- a/Dockerfile.cloud +++ b/Dockerfile.cloud @@ -1,3 +1,4 @@ +# syntax=docker/dockerfile:1 FROM node:20.9-slim AS base ENV PNPM_HOME="/pnpm" ENV PATH="$PNPM_HOME:$PATH" diff --git a/Dockerfile.monitoring b/Dockerfile.monitoring index 814625dbf..c54580ee1 100644 --- a/Dockerfile.monitoring +++ b/Dockerfile.monitoring @@ -1,3 +1,4 @@ +# syntax=docker/dockerfile:1 # Build stage FROM golang:1.21-alpine3.19 AS builder diff --git a/Dockerfile.schedule b/Dockerfile.schedule index eba08f7ba..70976523c 100644 --- a/Dockerfile.schedule +++ b/Dockerfile.schedule @@ -1,3 +1,4 @@ +# syntax=docker/dockerfile:1 FROM node:20.9-slim AS base ENV PNPM_HOME="/pnpm" ENV PATH="$PNPM_HOME:$PATH" diff --git a/Dockerfile.server b/Dockerfile.server index 8fef51422..e911c8780 100644 --- a/Dockerfile.server +++ b/Dockerfile.server @@ -1,3 +1,4 @@ +# syntax=docker/dockerfile:1 FROM node:20.9-slim AS base ENV PNPM_HOME="/pnpm" ENV PATH="$PNPM_HOME:$PATH" diff --git a/GUIDES.md b/GUIDES.md index cfb7cd812..90fba522d 100644 --- a/GUIDES.md +++ b/GUIDES.md @@ -16,28 +16,29 @@ Here's how to install docker on different operating systems: ### Ubuntu ```bash +# Uninstall old versions +for pkg in docker.io docker-doc docker-compose docker-compose-v2 podman-docker containerd runc; do sudo apt-get remove $pkg; done + # Update package index sudo apt-get update # Install prerequisites -sudo apt-get install \ - apt-transport-https \ - ca-certificates \ - curl \ - gnupg \ - lsb-release +sudo apt-get install ca-certificates curl +sudo install -m 0755 -d /etc/apt/keyrings # Add Docker's official GPG key -curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /usr/share/keyrings/docker-archive-keyring.gpg +sudo curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc +sudo chmod a+r /etc/apt/keyrings/docker.asc -# Set up stable repository +# Add the repository to Apt sources echo \ - "deb [arch=amd64 signed-by=/usr/share/keyrings/docker-archive-keyring.gpg] https://download.docker.com/linux/ubuntu \ - $(lsb_release -cs) stable" | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null + "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu \ + $(. /etc/os-release && echo "${UBUNTU_CODENAME:-$VERSION_CODENAME}") stable" | \ + sudo tee /etc/apt/sources.list.d/docker.list > /dev/null # Install Docker Engine sudo apt-get update -sudo apt-get install docker-ce docker-ce-cli containerd.io +sudo apt-get install docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin ``` ## Windows diff --git a/LICENSE.MD b/LICENSE.MD index 8a508efb4..6cbef2c6d 100644 --- a/LICENSE.MD +++ b/LICENSE.MD @@ -2,7 +2,7 @@ ## Core License (Apache License 2.0) -Copyright 2024 Mauricio Siu. +Copyright 2025 Mauricio Siu. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -19,8 +19,8 @@ See the License for the specific language governing permissions and limitations 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. +- **Self-Hosted Version Free**: All features of Dokploy, including multi-node support, Docker Compose file support, Schedules, 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, Schedules, 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, Schedules, 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. diff --git a/README.md b/README.md index 9246cf556..f156d3188 100644 --- a/README.md +++ b/README.md @@ -62,30 +62,32 @@ For detailed documentation, visit [docs.dokploy.com](https://docs.dokploy.com). ### Hero Sponsors 🎖
- - Hostinger - - - LX Aer - - - Mandarin - - - Lightnode - + Hostinger + LX Aer + Mandarin + Lightnode -
### Premium Supporters 🥇 -
-Supafort.com +
+ Supafort.com + agentdock.ai +
+### Elite Contributors 🥈 + +
+ AmericanCloud + Tolgee + +
+ + ### Supporting Members 🥉 @@ -97,6 +99,7 @@ For detailed documentation, visit [docs.dokploy.com](https://docs.dokploy.com). Itsdb-center Openalternative Synexa +
@@ -129,19 +132,6 @@ For detailed documentation, visit [docs.dokploy.com](https://docs.dokploy.com). Watch the video - - ## Contributing Check out the [Contributing Guide](CONTRIBUTING.md) for more information. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 000000000..47633ab95 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,28 @@ +# 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. diff --git a/apps/api/package.json b/apps/api/package.json index 56ea56952..65f9d4ad9 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -9,25 +9,25 @@ "typecheck": "tsc --noEmit" }, "dependencies": { - "pino": "9.4.0", - "pino-pretty": "11.2.2", - "@hono/zod-validator": "0.3.0", - "zod": "^3.23.4", - "react": "18.2.0", - "react-dom": "18.2.0", "@dokploy/server": "workspace:*", "@hono/node-server": "^1.12.1", - "hono": "^4.5.8", + "@hono/zod-validator": "0.3.0", + "@nerimity/mimiqueue": "1.2.3", "dotenv": "^16.3.1", + "hono": "^4.5.8", + "pino": "9.4.0", + "pino-pretty": "11.2.2", + "react": "18.2.0", + "react-dom": "18.2.0", "redis": "4.7.0", - "@nerimity/mimiqueue": "1.2.3" + "zod": "^3.23.4" }, "devDependencies": { - "typescript": "^5.4.2", + "@types/node": "^20.11.17", "@types/react": "^18.2.37", "@types/react-dom": "^18.2.15", - "@types/node": "^20.11.17", - "tsx": "^4.7.1" + "tsx": "^4.7.1", + "typescript": "^5.4.2" }, "packageManager": "pnpm@9.5.0" } diff --git a/apps/dokploy/.nvmrc b/apps/dokploy/.nvmrc index 43bff1f8c..593cb75bc 100644 --- a/apps/dokploy/.nvmrc +++ b/apps/dokploy/.nvmrc @@ -1 +1 @@ -20.9.0 \ No newline at end of file +20.16.0 \ No newline at end of file diff --git a/apps/dokploy/Dockerfile b/apps/dokploy/Dockerfile deleted file mode 100644 index f4188c54e..000000000 --- a/apps/dokploy/Dockerfile +++ /dev/null @@ -1,26 +0,0 @@ -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" ] \ No newline at end of file diff --git a/apps/dokploy/LICENSE.MD b/apps/dokploy/LICENSE.MD deleted file mode 100644 index 8a508efb4..000000000 --- a/apps/dokploy/LICENSE.MD +++ /dev/null @@ -1,26 +0,0 @@ -# License - -## Core License (Apache License 2.0) - -Copyright 2024 Mauricio Siu. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and limitations under the License. - -## Additional Terms for Specific Features - -The following additional terms apply to the multi-node support, Docker Compose file, Preview Deployments and Multi Server features of Dokploy. In the event of a conflict, these provisions shall take precedence over those in the Apache License: - -- **Self-Hosted Version Free**: All features of Dokploy, including multi-node support, Docker Compose file support, Preview Deployments and Multi Server, will always be free to use in the self-hosted version. -- **Restriction on Resale**: The multi-node support, Docker Compose file support, Preview Deployments and Multi Server features cannot be sold or offered as a service by any party other than the copyright holder without prior written consent. -- **Modification Distribution**: Any modifications to the multi-node support, Docker Compose file support, Preview Deployments and Multi Server features must be distributed freely and cannot be sold or offered as a service. - -For further inquiries or permissions, please contact us directly. diff --git a/apps/dokploy/__test__/compose/domain/labels.test.ts b/apps/dokploy/__test__/compose/domain/labels.test.ts index c5f45810f..172bff2af 100644 --- a/apps/dokploy/__test__/compose/domain/labels.test.ts +++ b/apps/dokploy/__test__/compose/domain/labels.test.ts @@ -19,6 +19,8 @@ describe("createDomainLabels", () => { path: "/", createdAt: "", previewDeploymentId: "", + internalPath: "/", + stripPath: false, }; it("should create basic labels for web entrypoint", async () => { diff --git a/apps/dokploy/__test__/drop/drop.test.test.ts b/apps/dokploy/__test__/drop/drop.test.test.ts index 7dc9e560c..9fa68b6bb 100644 --- a/apps/dokploy/__test__/drop/drop.test.test.ts +++ b/apps/dokploy/__test__/drop/drop.test.test.ts @@ -34,7 +34,9 @@ const baseApp: ApplicationNested = { giteaRepository: "", cleanCache: false, watchPaths: [], + enableSubmodules: false, applicationStatus: "done", + triggerType: "push", appName: "", autoDeploy: true, serverId: "", @@ -103,6 +105,7 @@ const baseApp: ApplicationNested = { ports: [], projectId: "", publishDirectory: null, + isStaticSpa: null, redirects: [], refreshToken: "", registry: null, @@ -118,6 +121,7 @@ const baseApp: ApplicationNested = { updateConfigSwarm: null, username: null, dockerContextPath: null, + rollbackActive: false, }; describe("unzipDrop using real zip files", () => { @@ -147,67 +151,68 @@ describe("unzipDrop using real zip files", () => { } 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); +// }); +// }); diff --git a/apps/dokploy/__test__/templates/config.template.test.ts b/apps/dokploy/__test__/templates/config.template.test.ts index d6e87cb7a..202abdf2d 100644 --- a/apps/dokploy/__test__/templates/config.template.test.ts +++ b/apps/dokploy/__test__/templates/config.template.test.ts @@ -51,6 +51,35 @@ describe("processTemplate", () => { expect(result.domains).toHaveLength(0); expect(result.mounts).toHaveLength(0); }); + + it("should allow creation of real jwt secret", () => { + const template: CompleteTemplate = { + metadata: {} as any, + variables: { + jwt_secret: "cQsdycq1hDLopQonF6jUTqgQc5WEZTwWLL02J6XJ", + anon_payload: JSON.stringify({ + role: "tester", + iss: "dockploy", + iat: "${timestamps:2025-01-01T00:00:00Z}", + exp: "${timestamps:2030-01-01T00:00:00Z}", + }), + anon_key: "${jwt:jwt_secret:anon_payload}", + }, + config: { + domains: [], + env: { + ANON_KEY: "${anon_key}", + }, + }, + }; + const result = processTemplate(template, mockSchema); + expect(result.envs).toHaveLength(1); + expect(result.envs).toContain( + "ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpYXQiOiIxNzM1Njg5NjAwIiwiZXhwIjoiMTg5MzQ1NjAwMCIsInJvbGUiOiJ0ZXN0ZXIiLCJpc3MiOiJkb2NrcGxveSJ9.BG5JoxL2_NaTFbPgyZdm3kRWenf_O3su_HIRKGCJ_kY", + ); + expect(result.mounts).toHaveLength(0); + expect(result.domains).toHaveLength(0); + }); }); describe("domains processing", () => { diff --git a/apps/dokploy/__test__/templates/helpers.template.test.ts b/apps/dokploy/__test__/templates/helpers.template.test.ts new file mode 100644 index 000000000..1144b65fe --- /dev/null +++ b/apps/dokploy/__test__/templates/helpers.template.test.ts @@ -0,0 +1,232 @@ +import type { Schema } from "@dokploy/server/templates"; +import { processValue } from "@dokploy/server/templates/processors"; +import { describe, expect, it } from "vitest"; + +describe("helpers functions", () => { + // Mock schema for testing + const mockSchema: Schema = { + projectName: "test", + serverIp: "127.0.0.1", + }; + // some helpers to test jwt + type JWTParts = [string, string, string]; + const jwtMatchExp = /^[A-Za-z0-9-_]+\.[A-Za-z0-9-_]+\.[A-Za-z0-9-_]+$/; + const jwtBase64Decode = (str: string) => { + const base64 = str.replace(/-/g, "+").replace(/_/g, "/"); + const padding = "=".repeat((4 - (base64.length % 4)) % 4); + const decoded = Buffer.from(base64 + padding, "base64").toString("utf-8"); + return JSON.parse(decoded); + }; + const jwtCheckHeader = (jwtHeader: string) => { + const decodedHeader = jwtBase64Decode(jwtHeader); + expect(decodedHeader).toHaveProperty("alg"); + expect(decodedHeader).toHaveProperty("typ"); + expect(decodedHeader.alg).toEqual("HS256"); + expect(decodedHeader.typ).toEqual("JWT"); + }; + + describe("${domain}", () => { + it("should generate a random domain", () => { + const domain = processValue("${domain}", {}, mockSchema); + expect(domain.startsWith(`${mockSchema.projectName}-`)).toBeTruthy(); + expect( + domain.endsWith( + `${mockSchema.serverIp.replaceAll(".", "-")}.traefik.me`, + ), + ).toBeTruthy(); + }); + }); + + describe("${base64}", () => { + it("should generate a base64 string", () => { + const base64 = processValue("${base64}", {}, mockSchema); + expect(base64).toMatch(/^[A-Za-z0-9+=/]+={0,2}$/); + }); + it.each([ + [4, 8], + [8, 12], + [16, 24], + [32, 44], + [64, 88], + [128, 172], + ])( + "should generate a base64 string from parameter %d bytes length", + (length, finalLength) => { + const base64 = processValue(`\${base64:${length}}`, {}, mockSchema); + expect(base64).toMatch(/^[A-Za-z0-9+=/]+={0,2}$/); + expect(base64.length).toBe(finalLength); + }, + ); + }); + + describe("${password}", () => { + it("should generate a password string", () => { + const password = processValue("${password}", {}, mockSchema); + expect(password).toMatch(/^[A-Za-z0-9]+$/); + }); + it.each([6, 8, 12, 16, 32])( + "should generate a password string respecting parameter %d length", + (length) => { + const password = processValue(`\${password:${length}}`, {}, mockSchema); + expect(password).toMatch(/^[A-Za-z0-9]+$/); + expect(password.length).toBe(length); + }, + ); + }); + + describe("${hash}", () => { + it("should generate a hash string", () => { + const hash = processValue("${hash}", {}, mockSchema); + expect(hash).toMatch(/^[A-Za-z0-9]+$/); + }); + it.each([6, 8, 12, 16, 32])( + "should generate a hash string respecting parameter %d length", + (length) => { + const hash = processValue(`\${hash:${length}}`, {}, mockSchema); + expect(hash).toMatch(/^[A-Za-z0-9]+$/); + expect(hash.length).toBe(length); + }, + ); + }); + + describe("${uuid}", () => { + it("should generate a UUID string", () => { + const uuid = processValue("${uuid}", {}, mockSchema); + expect(uuid).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/, + ); + }); + }); + + describe("${timestamp}", () => { + it("should generate a timestamp string in milliseconds", () => { + const timestamp = processValue("${timestamp}", {}, mockSchema); + const nowLength = Math.floor(Date.now()).toString().length; + expect(timestamp).toMatch(/^\d+$/); + expect(timestamp.length).toBe(nowLength); + }); + }); + describe("${timestampms}", () => { + it("should generate a timestamp string in milliseconds", () => { + const timestamp = processValue("${timestampms}", {}, mockSchema); + const nowLength = Date.now().toString().length; + expect(timestamp).toMatch(/^\d+$/); + expect(timestamp.length).toBe(nowLength); + }); + it("should generate a timestamp string in milliseconds from parameter", () => { + const timestamp = processValue( + "${timestampms:2025-01-01}", + {}, + mockSchema, + ); + expect(timestamp).toEqual("1735689600000"); + }); + }); + describe("${timestamps}", () => { + it("should generate a timestamp string in seconds", () => { + const timestamps = processValue("${timestamps}", {}, mockSchema); + const nowLength = Math.floor(Date.now() / 1000).toString().length; + expect(timestamps).toMatch(/^\d+$/); + expect(timestamps.length).toBe(nowLength); + }); + it("should generate a timestamp string in seconds from parameter", () => { + const timestamps = processValue( + "${timestamps:2025-01-01}", + {}, + mockSchema, + ); + expect(timestamps).toEqual("1735689600"); + }); + }); + + describe("${randomPort}", () => { + it("should generate a random port string", () => { + const randomPort = processValue("${randomPort}", {}, mockSchema); + expect(randomPort).toMatch(/^\d+$/); + expect(Number(randomPort)).toBeLessThan(65536); + }); + }); + + describe("${username}", () => { + it("should generate a username string", () => { + const username = processValue("${username}", {}, mockSchema); + expect(username).toMatch(/^[a-zA-Z0-9._-]{3,}$/); + }); + }); + + describe("${email}", () => { + it("should generate an email string", () => { + const email = processValue("${email}", {}, mockSchema); + expect(email).toMatch(/^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/); + }); + }); + + describe("${jwt}", () => { + it("should generate a JWT string", () => { + const jwt = processValue("${jwt}", {}, mockSchema); + expect(jwt).toMatch(jwtMatchExp); + const parts = jwt.split(".") as JWTParts; + const decodedPayload = jwtBase64Decode(parts[1]); + jwtCheckHeader(parts[0]); + expect(decodedPayload).toHaveProperty("iat"); + expect(decodedPayload).toHaveProperty("iss"); + expect(decodedPayload).toHaveProperty("exp"); + expect(decodedPayload.iss).toEqual("dokploy"); + }); + it.each([6, 8, 12, 16, 32])( + "should generate a random hex string from parameter %d byte length", + (length) => { + const jwt = processValue(`\${jwt:${length}}`, {}, mockSchema); + expect(jwt).toMatch(/^[A-Za-z0-9-_.]+$/); + expect(jwt.length).toBeGreaterThanOrEqual(length); // bytes translated to hex can take up to 2x the length + expect(jwt.length).toBeLessThanOrEqual(length * 2); + }, + ); + }); + describe("${jwt:secret}", () => { + it("should generate a JWT string respecting parameter secret from variable", () => { + const jwt = processValue( + "${jwt:secret}", + { secret: "mysecret" }, + mockSchema, + ); + expect(jwt).toMatch(jwtMatchExp); + const parts = jwt.split(".") as JWTParts; + const decodedPayload = jwtBase64Decode(parts[1]); + jwtCheckHeader(parts[0]); + expect(decodedPayload).toHaveProperty("iat"); + expect(decodedPayload).toHaveProperty("iss"); + expect(decodedPayload).toHaveProperty("exp"); + expect(decodedPayload.iss).toEqual("dokploy"); + }); + }); + describe("${jwt:secret:payload}", () => { + it("should generate a JWT string respecting parameters secret and payload from variables", () => { + const iat = Math.floor(new Date("2025-01-01T00:00:00Z").getTime() / 1000); + const expiry = iat + 3600; + const jwt = processValue( + "${jwt:secret:payload}", + { + secret: "mysecret", + payload: `{"iss": "test-issuer", "iat": ${iat}, "exp": ${expiry}, "customprop": "customvalue"}`, + }, + mockSchema, + ); + expect(jwt).toMatch(jwtMatchExp); + const parts = jwt.split(".") as JWTParts; + jwtCheckHeader(parts[0]); + const decodedPayload = jwtBase64Decode(parts[1]); + expect(decodedPayload).toHaveProperty("iat"); + expect(decodedPayload.iat).toEqual(iat); + expect(decodedPayload).toHaveProperty("iss"); + expect(decodedPayload.iss).toEqual("test-issuer"); + expect(decodedPayload).toHaveProperty("exp"); + expect(decodedPayload.exp).toEqual(expiry); + expect(decodedPayload).toHaveProperty("customprop"); + expect(decodedPayload.customprop).toEqual("customvalue"); + expect(jwt).toEqual( + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpYXQiOjE3MzU2ODk2MDAsImV4cCI6MTczNTY5MzIwMCwiaXNzIjoidGVzdC1pc3N1ZXIiLCJjdXN0b21wcm9wIjoiY3VzdG9tdmFsdWUifQ.m42U7PZSUSCf7gBOJrxJir0rQmyPq4rA59Dydr_QahI", + ); + }); + }); +}); diff --git a/apps/dokploy/__test__/traefik/server/update-server-config.test.ts b/apps/dokploy/__test__/traefik/server/update-server-config.test.ts index 201aee1ed..6858f0f00 100644 --- a/apps/dokploy/__test__/traefik/server/update-server-config.test.ts +++ b/apps/dokploy/__test__/traefik/server/update-server-config.test.ts @@ -16,6 +16,8 @@ import { beforeEach, expect, test, vi } from "vitest"; const baseAdmin: User = { https: false, enablePaidFeatures: false, + allowImpersonation: false, + role: "user", metricsConfig: { containers: { refreshRate: 20, diff --git a/apps/dokploy/__test__/traefik/traefik.test.ts b/apps/dokploy/__test__/traefik/traefik.test.ts index d8a14ab42..f2d0f0a50 100644 --- a/apps/dokploy/__test__/traefik/traefik.test.ts +++ b/apps/dokploy/__test__/traefik/traefik.test.ts @@ -5,6 +5,7 @@ import { createRouterConfig } from "@dokploy/server"; import { expect, test } from "vitest"; const baseApp: ApplicationNested = { + rollbackActive: false, applicationId: "", herokuVersion: "", giteaRepository: "", @@ -16,6 +17,7 @@ const baseApp: ApplicationNested = { applicationStatus: "done", appName: "", autoDeploy: true, + enableSubmodules: false, serverId: "", branch: null, dockerBuildStage: "", @@ -24,6 +26,7 @@ const baseApp: ApplicationNested = { buildArgs: null, isPreviewDeploymentsActive: false, previewBuildArgs: null, + triggerType: "push", previewCertificateType: "none", previewEnv: null, previewHttps: false, @@ -83,6 +86,7 @@ const baseApp: ApplicationNested = { ports: [], projectId: "", publishDirectory: null, + isStaticSpa: null, redirects: [], refreshToken: "", registry: null, @@ -115,6 +119,8 @@ const baseDomain: Domain = { domainType: "application", uniqueConfigKey: 1, previewDeploymentId: "", + internalPath: "/", + stripPath: false, }; const baseRedirect: Redirect = { diff --git a/apps/dokploy/__test__/utils/backups.test.ts b/apps/dokploy/__test__/utils/backups.test.ts index c7bc310cf..2c1e5decc 100644 --- a/apps/dokploy/__test__/utils/backups.test.ts +++ b/apps/dokploy/__test__/utils/backups.test.ts @@ -1,5 +1,5 @@ -import { describe, expect, test } from "vitest"; import { normalizeS3Path } from "@dokploy/server/utils/backups/utils"; +import { describe, expect, test } from "vitest"; describe("normalizeS3Path", () => { test("should handle empty and whitespace-only prefix", () => { diff --git a/apps/dokploy/components/dashboard/application/advanced/cluster/modify-swarm-settings.tsx b/apps/dokploy/components/dashboard/application/advanced/cluster/modify-swarm-settings.tsx index 95a559f66..ae30a799d 100644 --- a/apps/dokploy/components/dashboard/application/advanced/cluster/modify-swarm-settings.tsx +++ b/apps/dokploy/components/dashboard/application/advanced/cluster/modify-swarm-settings.tsx @@ -130,7 +130,7 @@ const createStringToJSONSchema = (schema: z.ZodTypeAny) => { } try { return JSON.parse(str); - } catch (_e) { + } catch { ctx.addIssue({ code: "custom", message: "Invalid JSON format" }); return z.NEVER; } diff --git a/apps/dokploy/components/dashboard/application/advanced/import/show-import.tsx b/apps/dokploy/components/dashboard/application/advanced/import/show-import.tsx index 0e848fece..d44455b27 100644 --- a/apps/dokploy/components/dashboard/application/advanced/import/show-import.tsx +++ b/apps/dokploy/components/dashboard/application/advanced/import/show-import.tsx @@ -107,7 +107,7 @@ export const ShowImport = ({ composeId }: Props) => { composeId, }); setShowModal(false); - } catch (_error) { + } catch { toast.error("Error importing template"); } }; @@ -126,7 +126,7 @@ export const ShowImport = ({ composeId }: Props) => { }); setTemplateInfo(result); setShowModal(true); - } catch (_error) { + } catch { toast.error("Error processing template"); } }; @@ -263,7 +263,7 @@ export const ShowImport = ({ composeId }: Props) => { {templateInfo.template.envs.map((env, index) => (
{env}
@@ -328,7 +328,7 @@ export const ShowImport = ({ composeId }: Props) => { Mount File Content - + { form.reset({ publishedPort: data?.publishedPort ?? 0, + publishMode: data?.publishMode ?? "ingress", targetPort: data?.targetPort ?? 0, protocol: data?.protocol ?? "tcp", }); @@ -165,6 +169,32 @@ export const HandlePorts = ({ )} /> + { + return ( + + Published Port Mode + + + + ); + }} + /> { {data?.ports.map((port) => (
-
+
Published Port @@ -68,7 +68,13 @@ export const ShowPorts = ({ applicationId }: Props) => {
- Target Port + Published Port Mode + + {port?.publishMode?.toUpperCase()} + +
+
+ Target Port {port.targetPort} diff --git a/apps/dokploy/components/dashboard/application/advanced/volumes/update-volume.tsx b/apps/dokploy/components/dashboard/application/advanced/volumes/update-volume.tsx index 8da09b58b..d185b2160 100644 --- a/apps/dokploy/components/dashboard/application/advanced/volumes/update-volume.tsx +++ b/apps/dokploy/components/dashboard/application/advanced/volumes/update-volume.tsx @@ -247,7 +247,7 @@ export const UpdateVolume = ({ control={form.control} name="content" render={({ field }) => ( - + Content @@ -256,7 +256,7 @@ export const UpdateVolume = ({ placeholder={`NODE_ENV=production PORT=3000 `} - className="h-96 font-mono" + className="h-96 font-mono w-full" {...field} /> diff --git a/apps/dokploy/components/dashboard/application/build/show.tsx b/apps/dokploy/components/dashboard/application/build/show.tsx index 16a452257..291026d4f 100644 --- a/apps/dokploy/components/dashboard/application/build/show.tsx +++ b/apps/dokploy/components/dashboard/application/build/show.tsx @@ -1,6 +1,8 @@ +import { AlertBlock } from "@/components/shared/alert-block"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Checkbox } from "@/components/ui/checkbox"; import { Form, FormControl, @@ -62,10 +64,11 @@ const mySchema = z.discriminatedUnion("buildType", [ publishDirectory: z.string().optional(), }), z.object({ - buildType: z.literal(BuildType.static), + buildType: z.literal(BuildType.railpack), }), z.object({ - buildType: z.literal(BuildType.railpack), + buildType: z.literal(BuildType.static), + isStaticSpa: z.boolean().default(false), }), ]); @@ -82,6 +85,7 @@ interface ApplicationData { dockerBuildStage?: string | null; herokuVersion?: string | null; publishDirectory?: string | null; + isStaticSpa?: boolean | null; } function isValidBuildType(value: string): value is BuildType { @@ -114,16 +118,18 @@ const resetData = (data: ApplicationData): AddTemplate => { case BuildType.static: return { buildType: BuildType.static, + isStaticSpa: data.isStaticSpa ?? false, }; case BuildType.railpack: return { buildType: BuildType.railpack, }; - default: + default: { const buildType = data.buildType as BuildType; return { buildType, } as AddTemplate; + } } }; @@ -173,6 +179,8 @@ export const ShowBuildChooseForm = ({ applicationId }: Props) => { data.buildType === BuildType.heroku_buildpacks ? data.herokuVersion : null, + isStaticSpa: + data.buildType === BuildType.static ? data.isStaticSpa : null, }) .then(async () => { toast.success("Build type saved"); @@ -200,6 +208,22 @@ export const ShowBuildChooseForm = ({ applicationId }: Props) => {
+ + Builders can consume significant memory and CPU resources + (recommended: 4+ GB RAM and 2+ CPU cores). For production + environments, please review our{" "} + + Production Guide + {" "} + for best practices and optimization recommendations. Builders are + suitable for development and prototyping purposes when you have + sufficient resources available. + { )} /> )} + {buildType === BuildType.static && ( + ( + + +
+ + + Single Page Application (SPA) + +
+
+ +
+ )} + /> + )}
+ )} + + + + + setActiveLog(null)} + logPath={activeLog?.logPath || ""} + errorMessage={activeLog?.errorMessage || ""} + /> + + ); +}; diff --git a/apps/dokploy/components/dashboard/application/deployments/show-deployments.tsx b/apps/dokploy/components/dashboard/application/deployments/show-deployments.tsx index 76e5bb266..04631b9b3 100644 --- a/apps/dokploy/components/dashboard/application/deployments/show-deployments.tsx +++ b/apps/dokploy/components/dashboard/application/deployments/show-deployments.tsx @@ -1,5 +1,6 @@ import { DateTooltip } from "@/components/shared/date-tooltip"; import { StatusTooltip } from "@/components/shared/status-tooltip"; +import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Card, @@ -9,28 +10,61 @@ import { CardTitle, } from "@/components/ui/card"; import { type RouterOutputs, api } from "@/utils/api"; -import { RocketIcon } from "lucide-react"; +import { Clock, Loader2, RocketIcon, Settings, RefreshCcw } from "lucide-react"; import React, { useEffect, useState } from "react"; import { CancelQueues } from "./cancel-queues"; import { RefreshToken } from "./refresh-token"; import { ShowDeployment } from "./show-deployment"; +import { ShowRollbackSettings } from "../rollbacks/show-rollback-settings"; +import { DialogAction } from "@/components/shared/dialog-action"; +import { toast } from "sonner"; interface Props { - applicationId: string; + id: string; + type: + | "application" + | "compose" + | "schedule" + | "server" + | "backup" + | "previewDeployment" + | "volumeBackup"; + refreshToken?: string; + serverId?: string; } -export const ShowDeployments = ({ applicationId }: Props) => { +export const formatDuration = (seconds: number) => { + if (seconds < 60) return `${seconds}s`; + const minutes = Math.floor(seconds / 60); + const remainingSeconds = seconds % 60; + return `${minutes}m ${remainingSeconds}s`; +}; + +export const ShowDeployments = ({ + id, + type, + refreshToken, + serverId, +}: Props) => { const [activeLog, setActiveLog] = useState< RouterOutputs["deployment"]["all"][number] | null >(null); - const { data } = api.application.one.useQuery({ applicationId }); - const { data: deployments } = api.deployment.all.useQuery( - { applicationId }, - { - enabled: !!applicationId, - refetchInterval: 1000, - }, - ); + const { data: deployments, isLoading: isLoadingDeployments } = + api.deployment.allByType.useQuery( + { + id, + type, + }, + { + enabled: !!id, + refetchInterval: 1000, + }, + ); + + const { mutateAsync: rollback, isLoading: isRollingBack } = + api.rollback.rollback.useMutation(); + const { mutateAsync: killProcess, isLoading: isKillingProcess } = + api.deployment.killProcess.useMutation(); const [url, setUrl] = React.useState(""); useEffect(() => { @@ -38,34 +72,57 @@ export const ShowDeployments = ({ applicationId }: Props) => { }, []); return ( - +
Deployments - See all the 10 last deployments for this application + See all the 10 last deployments for this {type}
- +
+ {(type === "application" || type === "compose") && ( + + )} + {type === "application" && ( + + + + )} +
-
- - If you want to re-deploy this application use this URL in the config - of your git provider or docker - -
- Webhook URL: -
- - {`${url}/api/deploy/${data?.refreshToken}`} - - + {refreshToken && ( +
+ + If you want to re-deploy this application use this URL in the + config of your git provider or docker + +
+ Webhook URL: +
+ + {`${url}/api/deploy${type === "compose" ? "/compose" : ""}/${refreshToken}`} + + {(type === "application" || type === "compose") && ( + + )} +
-
- {data?.deployments?.length === 0 ? ( -
+ )} + + {isLoadingDeployments ? ( +
+ + + Loading deployments... + +
+ ) : deployments?.length === 0 ? ( +
No deployments found @@ -96,24 +153,99 @@ export const ShowDeployments = ({ applicationId }: Props) => { )}
-
+
+ {deployment.startedAt && deployment.finishedAt && ( + + + {formatDuration( + Math.floor( + (new Date(deployment.finishedAt).getTime() - + new Date(deployment.startedAt).getTime()) / + 1000, + ), + )} + + )}
- +
+ {deployment.pid && deployment.status === "running" && ( + { + await killProcess({ + deploymentId: deployment.deploymentId, + }) + .then(() => { + toast.success("Process killed successfully"); + }) + .catch(() => { + toast.error("Error killing process"); + }); + }} + > + + + )} + + + {deployment?.rollback && + deployment.status === "done" && + type === "application" && ( + { + await rollback({ + rollbackId: deployment.rollback.rollbackId, + }) + .then(() => { + toast.success( + "Rollback initiated successfully", + ); + }) + .catch(() => { + toast.error("Error initiating rollback"); + }); + }} + > + + + )} +
))}
)} setActiveLog(null)} logPath={activeLog?.logPath || ""} diff --git a/apps/dokploy/components/dashboard/application/domains/dns-helper-modal.tsx b/apps/dokploy/components/dashboard/application/domains/dns-helper-modal.tsx new file mode 100644 index 000000000..e7b2f1877 --- /dev/null +++ b/apps/dokploy/components/dashboard/application/domains/dns-helper-modal.tsx @@ -0,0 +1,109 @@ +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 { Copy, HelpCircle, Server } from "lucide-react"; +import { toast } from "sonner"; + +interface Props { + domain: { + host: string; + https: boolean; + path?: string; + }; + serverIp?: string; +} + +export const DnsHelperModal = ({ domain, serverIp }: Props) => { + const copyToClipboard = (text: string) => { + navigator.clipboard.writeText(text); + toast.success("Copied to clipboard!"); + }; + + return ( + + + + + + + + + DNS Configuration Guide + + + Follow these steps to configure your DNS records for {domain.host} + + + +
+ + To make your domain accessible, you need to configure your DNS + records with your domain provider (e.g., Cloudflare, GoDaddy, + NameCheap). + + +
+
+

1. Add A Record

+
+

+ Create an A record that points your domain to the server's IP + address: +

+
+
+
+

Type: A

+

+ Name: @ or {domain.host.split(".")[0]} +

+

+ Value: {serverIp || "Your server IP"} +

+
+ +
+
+
+
+ +
+

2. Verify Configuration

+
+

+ After configuring your DNS records: +

+
    +
  • Wait for DNS propagation (usually 15-30 minutes)
  • +
  • + Test your domain by visiting:{" "} + {domain.https ? "https://" : "http://"} + {domain.host} + {domain.path || "/"} +
  • +
  • Use a DNS lookup tool to verify your records
  • +
+
+
+
+
+
+
+ ); +}; diff --git a/apps/dokploy/components/dashboard/application/domains/add-domain.tsx b/apps/dokploy/components/dashboard/application/domains/handle-domain.tsx similarity index 52% rename from apps/dokploy/components/dashboard/application/domains/add-domain.tsx rename to apps/dokploy/components/dashboard/application/domains/handle-domain.tsx index 0c16fdd2f..c8522f5f5 100644 --- a/apps/dokploy/components/dashboard/application/domains/add-domain.tsx +++ b/apps/dokploy/components/dashboard/application/domains/handle-domain.tsx @@ -38,26 +38,92 @@ import { useEffect, useState } from "react"; import { useForm } from "react-hook-form"; import { toast } from "sonner"; -import { domain } from "@/server/db/validations/domain"; import { zodResolver } from "@hookform/resolvers/zod"; -import { Dices } from "lucide-react"; +import { DatabaseZap, Dices, RefreshCw } from "lucide-react"; import Link from "next/link"; -import type z from "zod"; +import z from "zod"; + +export type CacheType = "fetch" | "cache"; + +export const domain = z + .object({ + host: z.string().min(1, { message: "Add a hostname" }), + path: z.string().min(1).optional(), + internalPath: z.string().optional(), + stripPath: z.boolean().optional(), + port: z + .number() + .min(1, { message: "Port must be at least 1" }) + .max(65535, { message: "Port must be 65535 or below" }) + .optional(), + https: z.boolean().optional(), + certificateType: z.enum(["letsencrypt", "none", "custom"]).optional(), + customCertResolver: z.string().optional(), + serviceName: z.string().optional(), + domainType: z.enum(["application", "compose", "preview"]).optional(), + }) + .superRefine((input, ctx) => { + if (input.https && !input.certificateType) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["certificateType"], + message: "Required", + }); + } + + if (input.certificateType === "custom" && !input.customCertResolver) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["customCertResolver"], + message: "Required", + }); + } + + if (input.domainType === "compose" && !input.serviceName) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["serviceName"], + 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; interface Props { - applicationId: string; + id: string; + type: "application" | "compose"; domainId?: string; children: React.ReactNode; } -export const AddDomain = ({ - applicationId, - domainId = "", - children, -}: Props) => { +export const AddDomain = ({ id, type, domainId = "", children }: Props) => { const [isOpen, setIsOpen] = useState(false); + const [cacheType, setCacheType] = useState("cache"); + const utils = api.useUtils(); const { data, refetch } = api.domain.one.useQuery( { @@ -68,14 +134,24 @@ export const AddDomain = ({ }, ); - const { data: application } = api.application.one.useQuery( - { - applicationId, - }, - { - enabled: !!applicationId, - }, - ); + const { data: application } = + type === "application" + ? api.application.one.useQuery( + { + applicationId: id, + }, + { + enabled: !!id, + }, + ) + : api.compose.one.useQuery( + { + composeId: id, + }, + { + enabled: !!id, + }, + ); const { mutateAsync, isError, error, isLoading } = domainId ? api.domain.update.useMutation() @@ -89,21 +165,43 @@ export const AddDomain = ({ serverId: application?.serverId || "", }); + const { + data: services, + isFetching: isLoadingServices, + error: errorServices, + refetch: refetchServices, + } = api.compose.loadServices.useQuery( + { + composeId: id, + type: cacheType, + }, + { + retry: false, + refetchOnWindowFocus: false, + enabled: type === "compose" && !!id, + }, + ); + const form = useForm({ resolver: zodResolver(domain), defaultValues: { host: "", path: undefined, + internalPath: undefined, + stripPath: false, port: undefined, https: false, certificateType: undefined, customCertResolver: undefined, + serviceName: undefined, + domainType: type, }, mode: "onChange", }); const certificateType = form.watch("certificateType"); const https = form.watch("https"); + const domainType = form.watch("domainType"); useEffect(() => { if (data) { @@ -111,9 +209,13 @@ export const AddDomain = ({ ...data, /* Convert null to undefined */ path: data?.path || undefined, + internalPath: data?.internalPath || undefined, + stripPath: data?.stripPath || false, port: data?.port || undefined, certificateType: data?.certificateType || undefined, customCertResolver: data?.customCertResolver || undefined, + serviceName: data?.serviceName || undefined, + domainType: data?.domainType || type, }); } @@ -121,10 +223,13 @@ export const AddDomain = ({ form.reset({ host: "", path: undefined, + internalPath: undefined, + stripPath: false, port: undefined, https: false, certificateType: undefined, customCertResolver: undefined, + domainType: type, }); } }, [form, data, isLoading, domainId]); @@ -148,22 +253,37 @@ export const AddDomain = ({ const onSubmit = async (data: Domain) => { await mutateAsync({ domainId, - applicationId, + ...(data.domainType === "application" && { + applicationId: id, + }), + ...(data.domainType === "compose" && { + composeId: id, + }), ...data, }) .then(async () => { toast.success(dictionary.success); - await utils.domain.byApplicationId.invalidate({ - applicationId, - }); - await utils.application.readTraefikConfig.invalidate({ applicationId }); + + if (data.domainType === "application") { + await utils.domain.byApplicationId.invalidate({ + applicationId: id, + }); + await utils.application.readTraefikConfig.invalidate({ + applicationId: id, + }); + } else if (data.domainType === "compose") { + await utils.domain.byComposeId.invalidate({ + composeId: id, + }); + } if (domainId) { refetch(); } setIsOpen(false); }) - .catch(() => { + .catch((e) => { + console.log(e); toast.error(dictionary.error); }); }; @@ -187,6 +307,119 @@ export const AddDomain = ({ >
+
+ {domainType === "compose" && ( +
+ {errorServices && ( + + {errorServices?.message} + + )} + ( + + Service Name +
+ + + + + + + +

+ Fetch: Will clone the repository and load + the services +

+
+
+
+ + + + + + +

+ Cache: If you previously deployed this + compose, it will read the services from + the last deployment/fetch from the + repository +

+
+
+
+
+ + +
+ )} + /> +
+ )} +
+ { + return ( + + Internal Path + + The path where your application expects to receive + requests internally (defaults to "/") + + + + + + + ); + }} + /> + + ( + +
+ Strip Path + + Remove the external path from the request before + forwarding to the application + + +
+ + + +
+ )} + /> + Container Port + + The port where your application is running inside the + container (e.g., 3000 for Node.js, 80 for Nginx, 8080 + for Java) + diff --git a/apps/dokploy/components/dashboard/application/domains/show-domains.tsx b/apps/dokploy/components/dashboard/application/domains/show-domains.tsx index 17dbc91f0..7bb58dfbe 100644 --- a/apps/dokploy/components/dashboard/application/domains/show-domains.tsx +++ b/apps/dokploy/components/dashboard/application/domains/show-domains.tsx @@ -1,4 +1,5 @@ import { DialogAction } from "@/components/shared/dialog-action"; +import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Card, @@ -7,29 +8,135 @@ import { CardHeader, CardTitle, } from "@/components/ui/card"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; import { api } from "@/utils/api"; -import { ExternalLink, GlobeIcon, PenBoxIcon, Trash2 } from "lucide-react"; +import { + CheckCircle2, + ExternalLink, + GlobeIcon, + InfoIcon, + Loader2, + PenBoxIcon, + RefreshCw, + Server, + Trash2, + XCircle, +} from "lucide-react"; import Link from "next/link"; +import { useState } from "react"; import { toast } from "sonner"; -import { AddDomain } from "./add-domain"; +import { DnsHelperModal } from "./dns-helper-modal"; +import { AddDomain } from "./handle-domain"; + +export type ValidationState = { + isLoading: boolean; + isValid?: boolean; + error?: string; + resolvedIp?: string; + message?: string; + cdnProvider?: string; +}; + +export type ValidationStates = Record; interface Props { - applicationId: string; + id: string; + type: "application" | "compose"; } -export const ShowDomains = ({ applicationId }: Props) => { - const { data, refetch } = api.domain.byApplicationId.useQuery( - { - applicationId, - }, - { - enabled: !!applicationId, - }, +export const ShowDomains = ({ id, type }: Props) => { + const { data: application } = + type === "application" + ? api.application.one.useQuery( + { + applicationId: id, + }, + { + enabled: !!id, + }, + ) + : api.compose.one.useQuery( + { + composeId: id, + }, + { + enabled: !!id, + }, + ); + const [validationStates, setValidationStates] = useState( + {}, ); + const { data: ip } = api.settings.getIp.useQuery(); + const { + data, + refetch, + isLoading: isLoadingDomains, + } = type === "application" + ? api.domain.byApplicationId.useQuery( + { + applicationId: id, + }, + { + enabled: !!id, + }, + ) + : api.domain.byComposeId.useQuery( + { + composeId: id, + }, + { + enabled: !!id, + }, + ); + + const { mutateAsync: validateDomain } = + api.domain.validateDomain.useMutation(); const { mutateAsync: deleteDomain, isLoading: isRemoving } = api.domain.delete.useMutation(); + const handleValidateDomain = async (host: string) => { + setValidationStates((prev) => ({ + ...prev, + [host]: { isLoading: true }, + })); + + try { + const result = await validateDomain({ + domain: host, + serverIp: + application?.server?.ipAddress?.toString() || ip?.toString() || "", + }); + + setValidationStates((prev) => ({ + ...prev, + [host]: { + isLoading: false, + isValid: result.isValid, + error: result.error, + resolvedIp: result.resolvedIp, + cdnProvider: result.cdnProvider, + message: result.error && result.isValid ? result.error : undefined, + }, + })); + } catch (err) { + const error = err as Error; + setValidationStates((prev) => ({ + ...prev, + [host]: { + isLoading: false, + isValid: false, + error: error.message || "Failed to validate domain", + }, + })); + } + }; + return (
@@ -43,7 +150,7 @@ export const ShowDomains = ({ applicationId }: Props) => {
{data && data?.length > 0 && ( - + @@ -52,15 +159,22 @@ export const ShowDomains = ({ applicationId }: Props) => {
- {data?.length === 0 ? ( -
+ {isLoadingDomains ? ( +
+ + + Loading domains... + +
+ ) : data?.length === 0 ? ( +
To access the application it is required to set at least 1 domain
- + @@ -68,73 +182,216 @@ export const ShowDomains = ({ applicationId }: Props) => {
) : ( -
+
{data?.map((item) => { + const validationState = validationStates[item.host]; return ( -
- - - {item.host} - - - - -
-
- {item.path} - {item.port} - {item.https ? "HTTPS" : "HTTP"} -
- -
- - + + { + await deleteDomain({ + domainId: item.domainId, + }) + .then((_data) => { + refetch(); + toast.success( + "Domain deleted successfully", + ); + }) + .catch(() => { + toast.error("Error deleting domain"); + }); + }} + > + + +
+
+
+ - - - - { - await deleteDomain({ - domainId: item.domainId, - }) - .then(() => { - refetch(); - toast.success("Domain deleted successfully"); - }) - .catch(() => { - toast.error("Error deleting domain"); - }); - }} - > - - + {item.host} + + +
+ + {/* Domain Details */} +
+ + + + + + Path: {item.path || "/"} + + + +

URL path for this service

+
+
+
+ + + + + + + Port: {item.port} + + + +

Container port exposed

+
+
+
+ + + + + + {item.https ? "HTTPS" : "HTTP"} + + + +

+ {item.https + ? "Secure HTTPS connection" + : "Standard HTTP connection"} +

+
+
+
+ + {item.certificateType && ( + + + + + Cert: {item.certificateType} + + + +

SSL Certificate Provider

+
+
+
+ )} + + + + + + handleValidateDomain(item.host) + } + > + {validationState?.isLoading ? ( + <> + + Checking DNS... + + ) : validationState?.isValid ? ( + <> + + {validationState.message && + validationState.cdnProvider + ? `Behind ${validationState.cdnProvider}` + : "DNS Valid"} + + ) : validationState?.error ? ( + <> + + {validationState.error} + + ) : ( + <> + + Validate DNS + + )} + + + + {validationState?.error ? ( +
+

+ Error: +

+

{validationState.error}

+
+ ) : ( + "Click to validate DNS configuration" + )} +
+
+
+
-
-
+ + ); })}
diff --git a/apps/dokploy/components/dashboard/application/general/generic/save-bitbucket-provider.tsx b/apps/dokploy/components/dashboard/application/general/generic/save-bitbucket-provider.tsx index b506fbac5..befc85957 100644 --- a/apps/dokploy/components/dashboard/application/general/generic/save-bitbucket-provider.tsx +++ b/apps/dokploy/components/dashboard/application/general/generic/save-bitbucket-provider.tsx @@ -31,6 +31,7 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; +import { Switch } from "@/components/ui/switch"; import { Tooltip, TooltipContent, @@ -58,6 +59,7 @@ const BitbucketProviderSchema = z.object({ branch: z.string().min(1, "Branch is required"), bitbucketId: z.string().min(1, "Bitbucket Provider is required"), watchPaths: z.array(z.string()).optional(), + enableSubmodules: z.boolean().optional(), }); type BitbucketProvider = z.infer; @@ -84,6 +86,7 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => { bitbucketId: "", branch: "", watchPaths: [], + enableSubmodules: false, }, resolver: zodResolver(BitbucketProviderSchema), }); @@ -130,9 +133,10 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => { buildPath: data.bitbucketBuildPath || "/", bitbucketId: data.bitbucketId || "", watchPaths: data.watchPaths || [], + enableSubmodules: data.enableSubmodules || false, }); } - }, [form.reset, data, form]); + }, [form.reset, data?.applicationId, form]); const onSubmit = async (data: BitbucketProvider) => { await mutateAsync({ @@ -143,6 +147,7 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => { bitbucketId: data.bitbucketId, applicationId, watchPaths: data.watchPaths || [], + enableSubmodules: data.enableSubmodules || false, }) .then(async () => { toast.success("Service Provided Saved"); @@ -430,7 +435,7 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
{ if (e.key === "Enter") { e.preventDefault(); @@ -449,7 +454,7 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => { variant="secondary" onClick={() => { const input = document.querySelector( - 'input[placeholder="Enter a path to watch (e.g., src/*, dist/*)"]', + 'input[placeholder="Enter a path to watch (e.g., src/**, dist/*.js)"]', ) as HTMLInputElement; const value = input.value.trim(); if (value) { @@ -467,6 +472,21 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => { )} /> + ( + + + + + Enable Submodules + + )} + />
-
- + > + + +
+ + + )} + /> + )} + + ( + + + + + Enable Submodules )} /> diff --git a/apps/dokploy/components/dashboard/application/general/generic/save-gitlab-provider.tsx b/apps/dokploy/components/dashboard/application/general/generic/save-gitlab-provider.tsx index 0f8bb849e..b4f27da74 100644 --- a/apps/dokploy/components/dashboard/application/general/generic/save-gitlab-provider.tsx +++ b/apps/dokploy/components/dashboard/application/general/generic/save-gitlab-provider.tsx @@ -31,6 +31,7 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; +import { Switch } from "@/components/ui/switch"; import { Tooltip, TooltipContent, @@ -60,6 +61,7 @@ const GitlabProviderSchema = z.object({ branch: z.string().min(1, "Branch is required"), gitlabId: z.string().min(1, "Gitlab Provider is required"), watchPaths: z.array(z.string()).optional(), + enableSubmodules: z.boolean().default(false), }); type GitlabProvider = z.infer; @@ -86,6 +88,7 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => { }, gitlabId: "", branch: "", + enableSubmodules: false, }, resolver: zodResolver(GitlabProviderSchema), }); @@ -135,9 +138,10 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => { buildPath: data.gitlabBuildPath || "/", gitlabId: data.gitlabId || "", watchPaths: data.watchPaths || [], + enableSubmodules: data.enableSubmodules ?? false, }); } - }, [form.reset, data, form]); + }, [form.reset, data?.applicationId, form]); const onSubmit = async (data: GitlabProvider) => { await mutateAsync({ @@ -150,6 +154,7 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => { gitlabProjectId: data.repository.id, gitlabPathNamespace: data.repository.gitlabPathNamespace, watchPaths: data.watchPaths || [], + enableSubmodules: data.enableSubmodules, }) .then(async () => { toast.success("Service Provided Saved"); @@ -447,7 +452,7 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
{ if (e.key === "Enter") { e.preventDefault(); @@ -483,6 +488,21 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => { )} /> + ( + + + + + Enable Submodules + + )} + />
+ +

+ Disconnecting will allow you to configure a new repository with + your own git providers. +

+
+ + +
+ ); +}; diff --git a/apps/dokploy/components/dashboard/application/preview-deployments/show-preview-builds.tsx b/apps/dokploy/components/dashboard/application/preview-deployments/show-preview-builds.tsx deleted file mode 100644 index 90800f757..000000000 --- a/apps/dokploy/components/dashboard/application/preview-deployments/show-preview-builds.tsx +++ /dev/null @@ -1,100 +0,0 @@ -import { DateTooltip } from "@/components/shared/date-tooltip"; -import { StatusTooltip } from "@/components/shared/status-tooltip"; -import { Button } from "@/components/ui/button"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogHeader, - DialogTitle, - DialogTrigger, -} from "@/components/ui/dialog"; - -import type { RouterOutputs } from "@/utils/api"; -import { useState } from "react"; -import { ShowDeployment } from "../deployments/show-deployment"; - -interface Props { - deployments: RouterOutputs["deployment"]["all"]; - serverId?: string; - trigger?: React.ReactNode; -} - -export const ShowPreviewBuilds = ({ - deployments, - serverId, - trigger, -}: Props) => { - const [activeLog, setActiveLog] = useState< - RouterOutputs["deployment"]["all"][number] | null - >(null); - const [isOpen, setIsOpen] = useState(false); - return ( - - - {trigger ? ( - trigger - ) : ( - - )} - - - - Preview Builds - - See all the preview builds for this application on this Pull Request - - -
- {deployments?.map((deployment) => ( -
-
- - {deployment.status} - - - - - {deployment.title} - - {deployment.description && ( - - {deployment.description} - - )} -
-
-
- -
- - -
-
- ))} -
-
- setActiveLog(null)} - logPath={activeLog?.logPath || ""} - errorMessage={activeLog?.errorMessage || ""} - /> -
- ); -}; diff --git a/apps/dokploy/components/dashboard/application/preview-deployments/show-preview-deployments.tsx b/apps/dokploy/components/dashboard/application/preview-deployments/show-preview-deployments.tsx index ec3680f10..bf93af718 100644 --- a/apps/dokploy/components/dashboard/application/preview-deployments/show-preview-deployments.tsx +++ b/apps/dokploy/components/dashboard/application/preview-deployments/show-preview-deployments.tsx @@ -17,15 +17,15 @@ import { ExternalLink, FileText, GitPullRequest, - Layers, + Loader2, PenSquare, RocketIcon, Trash2, } from "lucide-react"; import { toast } from "sonner"; import { ShowModalLogs } from "../../settings/web-server/show-modal-logs"; +import { ShowDeploymentsModal } from "../deployments/show-deployments-modal"; import { AddPreviewDomain } from "./add-preview-domain"; -import { ShowPreviewBuilds } from "./show-preview-builds"; import { ShowPreviewSettings } from "./show-preview-settings"; interface Props { @@ -38,13 +38,16 @@ export const ShowPreviewDeployments = ({ applicationId }: Props) => { const { mutateAsync: deletePreviewDeployment, isLoading } = api.previewDeployment.delete.useMutation(); - const { data: previewDeployments, refetch: refetchPreviewDeployments } = - api.previewDeployment.all.useQuery( - { applicationId }, - { - enabled: !!applicationId, - }, - ); + const { + data: previewDeployments, + refetch: refetchPreviewDeployments, + isLoading: isLoadingPreviewDeployments, + } = api.previewDeployment.all.useQuery( + { applicationId }, + { + enabled: !!applicationId, + }, + ); const handleDeletePreviewDeployment = async (previewDeploymentId: string) => { deletePreviewDeployment({ @@ -80,8 +83,15 @@ export const ShowPreviewDeployments = ({ applicationId }: Props) => { each pull request you create.
- {!previewDeployments?.length ? ( -
+ {isLoadingPreviewDeployments ? ( +
+ + + Loading preview deployments... + +
+ ) : !previewDeployments?.length ? ( +
No preview deployments found @@ -168,19 +178,10 @@ export const ShowPreviewDeployments = ({ applicationId }: Props) => { - - - Builds - - } /> { }) .then(() => { refetch(); - toast.success("Preview deployments enabled"); + toast.success( + checked + ? "Preview deployments enabled" + : "Preview deployments disabled", + ); }) .catch((error) => { toast.error(error.message); diff --git a/apps/dokploy/components/dashboard/application/rollbacks/Backup b/apps/dokploy/components/dashboard/application/rollbacks/Backup new file mode 100644 index 000000000..2a58e92df --- /dev/null +++ b/apps/dokploy/components/dashboard/application/rollbacks/Backup @@ -0,0 +1,108 @@ +Backup +# license-namedbackups-abxelc +1. docker ps --filter "label=com.docker.swarm.service.name=license-namedbackups-abxelc" --format "{{.Names}}" +2. docker run --rm \ +--volumes-from "license-namedbackups-abxelc.1.m3cxy78ocj3w0zu42kmgamc5y" \ +-v $(pwd):/backup \ +ubuntu \ +tar cvf /backup/backup.tar /var/lib/postgresql/data + + +# Official Command Backup + +1. Backup + +docker run --rm \ + -v license-namedbackups-abxelc-data:/volume_data \ + -v $(pwd):/backup \ + ubuntu \ + bash -c "cd /volume_data && tar cvf /backup/generic_backup.tar ." + + +2. Restore + +docker service scale license-namedbackups-abxelc=0 + +docker volume rm license-namedbackups-abxelc-data + +2. docker run --rm \ +-v license-namedbackups-abxelc-data:/volume_data \ +-v $(pwd):/backup \ +ubuntu \ +bash -c "cd /volume_data && tar xvf /backup/generic_backup.tar ." + +docker service scale license-namedbackups-abxelc=1 + + +root@srv594061:~# docker volume inspect n8n_data-data +[ + { + "CreatedAt": "2025-06-28T18:07:44Z", + "Driver": "local", + "Labels": null, + "Mountpoint": "/var/lib/docker/volumes/n8n_data-data/_data", + "Name": "n8n_data-data", + "Options": null, + "Scope": "local" + } +] + +Archivos funcuionando creados por N8N + +# root@srv594061:~# cd /var/lib/docker/volumes/n8n_data-data/_data +# root@srv594061:/var/lib/docker/volumes/n8n_data-data/_data# ls +# binaryData config crash.journal database.sqlite git n8nEventLog.log ssh + +Luego que intente hacer el backup con el comando de backup + + +root@srv594061:~# docker run --rm -v n8n_data-data:/volume_data -v $(pwd):/backup ubuntu bash -c "cd /volume_data && tar cvf /backup/generic_backup6.tar ." +./ +./config +./crash.journal +./binaryData/ +./git/ +./database.sqlite +./ssh/ +./n8nEventLog.log +root@srv594061:~# + +# Paramos la aplicacion +docker service scale n8n=0 + +# Haciendo el restore +root@srv594061:~# docker volume rm n8n_data-data +n8n_data-data +root@srv594061:~# docker run --rm -v n8n_data-data:/volume_data -v $(pwd):/backup ubuntu bash -c "cd /volume_data && tar xvf /backup/generic_backup6.tar && chown -R 999:999 ." +./ +./config +./crash.journal +./binaryData/ +./git/ +./database.sqlite +./ssh/ +./n8nEventLog.log + +# Tenemos los archivos en el volumen +root@srv594061:~# ls /var/lib/docker/volumes/n8n_data-data/_data +binaryData config crash.journal database.sqlite git n8nEventLog.log ssh +root@srv594061:~# + +docker service scale n8n=1 + +# Luego en N8N Cuando se que el volumen tiene la data +Permissions 0644 for n8n settings file /home/node/.n8n/config are too wide. This is ignored for now, but in the future n8n will attempt to change the permissions automatically. To automatically enforce correct permissions now set N8N_ENFORCE_SETTINGS_FILE_PERMISSIONS=true (recommended), or turn this check off set N8N_ENFORCE_SETTINGS_FILE_PERMISSIONS=false. +User settings loaded from: /home/node/.n8n/config +Last session crashed +Error: EACCES: permission denied, open '/home/node/.n8n/crash.journal' +at open (node:internal/fs/promises:639:25) +at touchFile (/usr/local/lib/node_modules/n8n/dist/crash-journal.js:18:20) +at Object.init (/usr/local/lib/node_modules/n8n/dist/crash-journal.js:32:5) +at Start.initCrashJournal (/usr/local/lib/node_modules/n8n/dist/commands/base-command.js:113:9) +at Start.init (/usr/local/lib/node_modules/n8n/dist/commands/start.js:141:9) +at Start._run (/usr/local/lib/node_modules/n8n/node_modules/@oclif/core/lib/command.js:301:13) +at Config.runCommand (/usr/local/lib/node_modules/n8n/node_modules/@oclif/core/lib/config/config.js:424:25) +at run (/usr/local/lib/node_modules/n8n/node_modules/@oclif/core/lib/main.js:94:16) +at /usr/local/lib/node_modules/n8n/bin/n8n:71:2 +TypeError: Cannot read properties of undefined (reading 'error') + diff --git a/apps/dokploy/components/dashboard/application/rollbacks/show-rollback-settings.tsx b/apps/dokploy/components/dashboard/application/rollbacks/show-rollback-settings.tsx new file mode 100644 index 000000000..77575ea03 --- /dev/null +++ b/apps/dokploy/components/dashboard/application/rollbacks/show-rollback-settings.tsx @@ -0,0 +1,123 @@ +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; + +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({ + 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 ( + + {children} + + + Rollback Settings + + Configure how rollbacks work for this application + + + 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. + + + + + + ( + +
+ + Enable Rollbacks + + + Allow rolling back to previous deployments + +
+ + + +
+ )} + /> + + + + +
+
+ ); +}; diff --git a/apps/dokploy/components/dashboard/application/schedules/handle-schedules.tsx b/apps/dokploy/components/dashboard/application/schedules/handle-schedules.tsx new file mode 100644 index 000000000..2d26d7a94 --- /dev/null +++ b/apps/dokploy/components/dashboard/application/schedules/handle-schedules.tsx @@ -0,0 +1,538 @@ +import { AlertBlock } from "@/components/shared/alert-block"; +import { CodeEditor } from "@/components/shared/code-editor"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + 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"; + +export const commonCronExpressions = [ + { label: "Every minute", value: "* * * * *" }, + { label: "Every hour", value: "0 * * * *" }, + { label: "Every day at midnight", value: "0 0 * * *" }, + { label: "Every Sunday at midnight", value: "0 0 * * 0" }, + { label: "Every month on the 1st at midnight", value: "0 0 1 * *" }, + { label: "Every 15 minutes", value: "*/15 * * * *" }, + { label: "Every weekday at midnight", value: "0 0 * * 1-5" }, +]; + +const formSchema = z + .object({ + name: z.string().min(1, "Name is required"), + cronExpression: z.string().min(1, "Cron expression is required"), + shellType: z.enum(["bash", "sh"]).default("bash"), + command: z.string(), + enabled: z.boolean().default(true), + serviceName: z.string(), + scheduleType: z.enum([ + "application", + "compose", + "server", + "dokploy-server", + ]), + script: z.string(), + }) + .superRefine((data, ctx) => { + if (data.scheduleType === "compose" && !data.serviceName) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Service name is required", + path: ["serviceName"], + }); + } + + if ( + (data.scheduleType === "dokploy-server" || + data.scheduleType === "server") && + !data.script + ) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Script is required", + path: ["script"], + }); + } + + if ( + (data.scheduleType === "application" || + data.scheduleType === "compose") && + !data.command + ) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Command is required", + path: ["command"], + }); + } + }); + +interface Props { + id?: string; + scheduleId?: string; + scheduleType?: "application" | "compose" | "server" | "dokploy-server"; +} + +export const HandleSchedules = ({ id, scheduleId, scheduleType }: Props) => { + const [isOpen, setIsOpen] = useState(false); + const [cacheType, setCacheType] = useState("cache"); + + const utils = api.useUtils(); + const form = useForm>({ + resolver: zodResolver(formSchema), + defaultValues: { + name: "", + cronExpression: "", + shellType: "bash", + command: "", + enabled: true, + serviceName: "", + scheduleType: scheduleType || "application", + script: "", + }, + }); + + const scheduleTypeForm = form.watch("scheduleType"); + + const { data: schedule } = api.schedule.one.useQuery( + { scheduleId: scheduleId || "" }, + { enabled: !!scheduleId }, + ); + + const { + data: services, + isFetching: isLoadingServices, + error: errorServices, + refetch: refetchServices, + } = api.compose.loadServices.useQuery( + { + composeId: id || "", + type: cacheType, + }, + { + retry: false, + refetchOnWindowFocus: false, + enabled: !!id && scheduleType === "compose", + }, + ); + + useEffect(() => { + if (scheduleId && schedule) { + form.reset({ + name: schedule.name, + cronExpression: schedule.cronExpression, + shellType: schedule.shellType, + command: schedule.command, + enabled: schedule.enabled, + serviceName: schedule.serviceName || "", + scheduleType: schedule.scheduleType, + script: schedule.script || "", + }); + } + }, [form, schedule, scheduleId]); + + const { mutateAsync, isLoading } = scheduleId + ? api.schedule.update.useMutation() + : api.schedule.create.useMutation(); + + const onSubmit = async (values: z.infer) => { + if (!id && !scheduleId) return; + + await mutateAsync({ + ...values, + scheduleId: scheduleId || "", + ...(scheduleType === "application" && { + applicationId: id || "", + }), + ...(scheduleType === "compose" && { + composeId: id || "", + }), + ...(scheduleType === "server" && { + serverId: id || "", + }), + ...(scheduleType === "dokploy-server" && { + userId: id || "", + }), + }) + .then(() => { + toast.success( + `Schedule ${scheduleId ? "updated" : "created"} successfully`, + ); + utils.schedule.list.invalidate({ + id, + scheduleType, + }); + setIsOpen(false); + }) + .catch((error) => { + toast.error( + error instanceof Error ? error.message : "An unknown error occurred", + ); + }); + }; + + return ( + + + {scheduleId ? ( + + ) : ( + + )} + + + + {scheduleId ? "Edit" : "Create"} Schedule + +
+ + {scheduleTypeForm === "compose" && ( +
+ {errorServices && ( + + {errorServices?.message} + + )} + ( + + Service Name +
+ + + + + + + +

+ Fetch: Will clone the repository and load the + services +

+
+
+
+ + + + + + +

+ Cache: If you previously deployed this compose, + it will read the services from the last + deployment/fetch from the repository +

+
+
+
+
+ + +
+ )} + /> +
+ )} + + ( + + + Task Name + + + + + + A descriptive name for your scheduled task + + + + )} + /> + + ( + + + Schedule + + + + + + +

+ Cron expression format: minute hour day month + weekday +

+

Example: 0 0 * * * (daily at midnight)

+
+
+
+
+
+ +
+ + + +
+
+ + Choose a predefined schedule or enter a custom cron + expression + + +
+ )} + /> + + {(scheduleTypeForm === "application" || + scheduleTypeForm === "compose") && ( + <> + ( + + + Shell Type + + + + Choose the shell to execute your command + + + + )} + /> + ( + + + Command + + + + + + The command to execute in your container + + + + )} + /> + + )} + + {(scheduleTypeForm === "dokploy-server" || + scheduleTypeForm === "server") && ( + ( + + Script + + + + + + + + )} + /> + )} + + ( + + + + Enabled + + + )} + /> + + + + +
+
+ ); +}; diff --git a/apps/dokploy/components/dashboard/application/schedules/show-schedules.tsx b/apps/dokploy/components/dashboard/application/schedules/show-schedules.tsx new file mode 100644 index 000000000..ecef0deeb --- /dev/null +++ b/apps/dokploy/components/dashboard/application/schedules/show-schedules.tsx @@ -0,0 +1,243 @@ +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, + Clock, + Loader2, + Play, + Terminal, + Trash2, +} from "lucide-react"; +import { toast } from "sonner"; +import { ShowDeploymentsModal } from "../deployments/show-deployments-modal"; +import { HandleSchedules } from "./handle-schedules"; + +interface Props { + id: string; + scheduleType?: "application" | "compose" | "server" | "dokploy-server"; +} + +export const ShowSchedules = ({ id, scheduleType = "application" }: Props) => { + const { + data: schedules, + isLoading: isLoadingSchedules, + refetch: refetchSchedules, + } = api.schedule.list.useQuery( + { + id: id || "", + scheduleType, + }, + { + enabled: !!id, + }, + ); + + const utils = api.useUtils(); + + const { mutateAsync: deleteSchedule, isLoading: isDeleting } = + api.schedule.delete.useMutation(); + + const { mutateAsync: runManually, isLoading } = + api.schedule.runManually.useMutation(); + + return ( + + +
+
+ + Scheduled Tasks + + + Schedule tasks to run automatically at specified intervals. + +
+ + {schedules && schedules.length > 0 && ( + + )} +
+
+ + {isLoadingSchedules ? ( +
+ + + Loading scheduled tasks... + +
+ ) : schedules && schedules.length > 0 ? ( +
+ {schedules.map((schedule) => { + const serverId = + schedule.serverId || + schedule.application?.serverId || + schedule.compose?.serverId; + return ( +
+
+
+ +
+
+
+

+ {schedule.name} +

+ + {schedule.enabled ? "Enabled" : "Disabled"} + +
+
+ + Cron: {schedule.cronExpression} + + {schedule.scheduleType !== "server" && + schedule.scheduleType !== "dokploy-server" && ( + <> + + • + + + {schedule.shellType} + + + )} +
+ {schedule.command && ( +
+ + + {schedule.command} + +
+ )} +
+
+ +
+ + + + + + + + + + Run Manual Schedule + + + + + + { + await deleteSchedule({ + scheduleId: schedule.scheduleId, + }) + .then(() => { + utils.schedule.list.invalidate({ + id, + scheduleType, + }); + toast.success("Schedule deleted successfully"); + }) + .catch(() => { + toast.error("Error deleting schedule"); + }); + }} + > + + +
+
+ ); + })} +
+ ) : ( +
+ +

+ No scheduled tasks +

+

+ Create your first scheduled task to automate your workflows +

+ +
+ )} +
+
+ ); +}; diff --git a/apps/dokploy/components/dashboard/application/volume-backups/handle-volume-backups.tsx b/apps/dokploy/components/dashboard/application/volume-backups/handle-volume-backups.tsx new file mode 100644 index 000000000..aee797e45 --- /dev/null +++ b/apps/dokploy/components/dashboard/application/volume-backups/handle-volume-backups.tsx @@ -0,0 +1,672 @@ +import { AlertBlock } from "@/components/shared/alert-block"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Switch } from "@/components/ui/switch"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { cn } from "@/lib/utils"; +import { api } from "@/utils/api"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { + DatabaseZap, + Info, + PenBoxIcon, + PlusCircle, + RefreshCw, +} from "lucide-react"; +import { useEffect, useState } from "react"; +import { useForm } from "react-hook-form"; +import { toast } from "sonner"; +import { z } from "zod"; +import type { CacheType } from "../domains/handle-domain"; +import { commonCronExpressions } from "../schedules/handle-schedules"; + +const formSchema = z + .object({ + name: z.string().min(1, "Name is required"), + cronExpression: z.string().min(1, "Cron expression is required"), + volumeName: z.string().min(1, "Volume name is required"), + prefix: z.string(), + keepLatestCount: z.coerce.number().optional(), + turnOff: z.boolean().default(false), + enabled: z.boolean().default(true), + serviceType: z.enum([ + "application", + "compose", + "postgres", + "mariadb", + "mongo", + "mysql", + "redis", + ]), + serviceName: z.string(), + destinationId: z.string().min(1, "Destination required"), + }) + .superRefine((data, ctx) => { + if (data.serviceType === "compose" && !data.serviceName) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Service name is required", + path: ["serviceName"], + }); + } + + if (data.serviceType === "compose" && !data.serviceName) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Service name is required", + path: ["serviceName"], + }); + } + }); + +interface Props { + id?: string; + volumeBackupId?: string; + volumeBackupType?: + | "application" + | "compose" + | "postgres" + | "mariadb" + | "mongo" + | "mysql" + | "redis"; +} + +export const HandleVolumeBackups = ({ + id, + volumeBackupId, + volumeBackupType, +}: Props) => { + const [isOpen, setIsOpen] = useState(false); + const [cacheType, setCacheType] = useState("cache"); + + const utils = api.useUtils(); + const form = useForm>({ + 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) => { + 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 ( + + + {volumeBackupId ? ( + + ) : ( + + )} + + + + + {volumeBackupId ? "Edit" : "Create"} Volume Backup + + + Create a volume backup to backup your volume to a destination + + +
+ + ( + + + Task Name + + + + + + A descriptive name for your scheduled task + + + + )} + /> + + ( + + + Schedule + + + + + + +

+ Cron expression format: minute hour day month + weekday +

+

Example: 0 0 * * * (daily at midnight)

+
+
+
+
+
+ +
+ + + +
+
+ + Choose a predefined schedule or enter a custom cron + expression + + +
+ )} + /> + + ( + + Destination + + + Choose the backup destination where files will be stored + + + + )} + /> + {serviceTypeForm === "compose" && ( + <> +
+ {errorServices && ( + + {errorServices?.message} + + )} + ( + + Service Name +
+ + + + + + + +

+ Fetch: Will clone the repository and load the + services +

+
+
+
+ + + + + + +

+ Cache: If you previously deployed this + compose, it will read the services from the + last deployment/fetch from the repository +

+
+
+
+
+ + +
+ )} + /> +
+ {mountsByService && mountsByService.length > 0 && ( + ( + + Volumes + + + Choose the volume to backup, if you dont see the + volume here, you can type the volume name manually + + + + )} + /> + )} + + )} + {serviceTypeForm === "application" && ( + ( + + Volumes + + + Choose the volume to backup, if you dont see the volume + here, you can type the volume name manually + + + + )} + /> + )} + + ( + + Volume Name + + + + + The name of the Docker volume to backup + + + + )} + /> + + ( + + Backup Prefix + + + + + Prefix for backup files (optional) + + + + )} + /> + + ( + + Keep Latest Count + + + field.onChange(Number(e.target.value) || undefined) + } + /> + + + Number of backup files to keep (optional) + + + + )} + /> + + ( + + + + Turn Off Container During Backup + + + ⚠️ The container will be temporarily stopped during backup to + prevent file corruption. This ensures data integrity but may + cause temporary service interruption. + + + )} + /> + + ( + + + + Enabled + + + )} + /> + + + + +
+
+ ); +}; diff --git a/apps/dokploy/components/dashboard/application/volume-backups/restore-volume-backups.tsx b/apps/dokploy/components/dashboard/application/volume-backups/restore-volume-backups.tsx new file mode 100644 index 000000000..7c45d0ceb --- /dev/null +++ b/apps/dokploy/components/dashboard/application/volume-backups/restore-volume-backups.tsx @@ -0,0 +1,411 @@ +import { DrawerLogs } from "@/components/shared/drawer-logs"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, +} from "@/components/ui/command"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { cn } from "@/lib/utils"; +import { api } from "@/utils/api"; +import { zodResolver } from "@hookform/resolvers/zod"; +import copy from "copy-to-clipboard"; +import { debounce } from "lodash"; +import { CheckIcon, ChevronsUpDown, Copy, RotateCcw } from "lucide-react"; +import { useState } from "react"; +import { useForm } from "react-hook-form"; +import { toast } from "sonner"; +import { z } from "zod"; +import { type LogLine, parseLogs } from "../../docker/logs/utils"; +import { formatBytes } from "../../database/backups/restore-backup"; +import { AlertBlock } from "@/components/shared/alert-block"; + +interface Props { + id: string; + type: "application" | "compose"; + serverId?: string; +} + +const RestoreBackupSchema = z.object({ + destinationId: z + .string({ + required_error: "Please select a destination", + }) + .min(1, { + message: "Destination is required", + }), + backupFile: z + .string({ + required_error: "Please select a backup file", + }) + .min(1, { + message: "Backup file is required", + }), + volumeName: z + .string({ + required_error: "Please enter a volume name", + }) + .min(1, { + message: "Volume name is required", + }), +}); + +export const RestoreVolumeBackups = ({ id, type, serverId }: Props) => { + const [isOpen, setIsOpen] = useState(false); + const [search, setSearch] = useState(""); + const [debouncedSearchTerm, setDebouncedSearchTerm] = useState(""); + + const { data: destinations = [] } = api.destination.all.useQuery(); + + const form = useForm>({ + 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([]); + 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 ( + + + + + + + + + Restore Volume Backup + + + Select a destination and search for volume backup files + + + Make sure the volume name is not being used by another container. + + + +
+ + ( + + Destination + + + + + + + + + + No destinations found. + + + {destinations.map((destination) => ( + { + form.setValue( + "destinationId", + destination.destinationId, + ); + }} + > + {destination.name} + + + ))} + + + + + + + + )} + /> + + ( + + + Search Backup Files + {field.value && ( + + {field.value} + { + e.stopPropagation(); + e.preventDefault(); + copy(field.value); + toast.success("Backup file copied to clipboard"); + }} + /> + + )} + + + + + + + + + + + {isLoading ? ( +
+ Loading backup files... +
+ ) : files.length === 0 && search ? ( +
+ No backup files found for "{search}" +
+ ) : files.length === 0 ? ( +
+ No backup files available +
+ ) : ( + + + {files?.map((file) => ( + { + form.setValue("backupFile", file.Path); + if (file.IsDir) { + setSearch(`${file.Path}/`); + setDebouncedSearchTerm(`${file.Path}/`); + } else { + setSearch(file.Path); + setDebouncedSearchTerm(file.Path); + } + }} + > +
+
+ + {file.Path} + + + +
+
+ + Size: {formatBytes(file.Size)} + + {file.IsDir && ( + + Directory + + )} + {file.Hashes?.MD5 && ( + MD5: {file.Hashes.MD5} + )} +
+
+
+ ))} +
+
+ )} +
+
+
+ +
+ )} + /> + ( + + Volume Name + + + + + + )} + /> + + + + + + + + { + setIsDrawerOpen(false); + setFilteredLogs([]); + setIsDeploying(false); + // refetch(); + }} + filteredLogs={filteredLogs} + /> +
+
+ ); +}; diff --git a/apps/dokploy/components/dashboard/application/volume-backups/show-volume-backups.tsx b/apps/dokploy/components/dashboard/application/volume-backups/show-volume-backups.tsx new file mode 100644 index 000000000..bb071947e --- /dev/null +++ b/apps/dokploy/components/dashboard/application/volume-backups/show-volume-backups.tsx @@ -0,0 +1,250 @@ +import { DialogAction } from "@/components/shared/dialog-action"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { api } from "@/utils/api"; +import { + ClipboardList, + DatabaseBackup, + Loader2, + Play, + Trash2, +} from "lucide-react"; +import { toast } from "sonner"; +import { HandleVolumeBackups } from "./handle-volume-backups"; +import { ShowDeploymentsModal } from "../deployments/show-deployments-modal"; +import { RestoreVolumeBackups } from "./restore-volume-backups"; + +interface Props { + id: string; + type?: "application" | "compose"; + serverId?: string; +} + +export const ShowVolumeBackups = ({ + id, + type = "application", + serverId, +}: Props) => { + const { + data: volumeBackups, + isLoading: isLoadingVolumeBackups, + refetch: refetchVolumeBackups, + } = api.volumeBackups.list.useQuery( + { + id: id || "", + volumeBackupType: type, + }, + { + enabled: !!id, + }, + ); + + const utils = api.useUtils(); + + const { mutateAsync: deleteVolumeBackup, isLoading: isDeleting } = + api.volumeBackups.delete.useMutation(); + + const { mutateAsync: runManually, isLoading } = + api.volumeBackups.runManually.useMutation(); + + return ( + + +
+
+ + Volume Backups + + + Schedule volume backups to run automatically at specified + intervals. + +
+ +
+ {volumeBackups && volumeBackups.length > 0 && ( + <> + + +
+ +
+ + )} +
+
+
+ + {isLoadingVolumeBackups ? ( +
+ + + Loading volume backups... + +
+ ) : volumeBackups && volumeBackups.length > 0 ? ( +
+ {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 ( +
+
+
+ +
+
+
+

+ {volumeBackup.name} +

+ + {volumeBackup.enabled ? "Enabled" : "Disabled"} + +
+
+ + Cron: {volumeBackup.cronExpression} + +
+
+
+ +
+ + + + + + + + + + + Run Manual Volume Backup + + + + + + + { + 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"); + }); + }} + > + + +
+
+ ); + })} +
+ ) : ( +
+ +

+ No volume backups +

+

+ Create your first volume backup to automate your workflows +

+
+ + +
+
+ )} +
+
+ ); +}; diff --git a/apps/dokploy/components/dashboard/compose/deployments/cancel-queues-compose.tsx b/apps/dokploy/components/dashboard/compose/deployments/cancel-queues-compose.tsx deleted file mode 100644 index a430ae18f..000000000 --- a/apps/dokploy/components/dashboard/compose/deployments/cancel-queues-compose.tsx +++ /dev/null @@ -1,66 +0,0 @@ -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, - AlertDialogTrigger, -} from "@/components/ui/alert-dialog"; -import { Button } from "@/components/ui/button"; -import { api } from "@/utils/api"; -import { Paintbrush } from "lucide-react"; -import { toast } from "sonner"; - -interface Props { - composeId: string; -} - -export const CancelQueuesCompose = ({ composeId }: Props) => { - const { mutateAsync, isLoading } = api.compose.cleanQueues.useMutation(); - const { data: isCloud } = api.settings.isCloud.useQuery(); - - if (isCloud) { - return null; - } - return ( - - - - - - - - Are you sure to cancel the incoming deployments? - - - This will cancel all the incoming deployments - - - - Cancel - { - await mutateAsync({ - composeId, - }) - .then(() => { - toast.success("Queues are being cleaned"); - }) - .catch((err) => { - toast.error(err.message); - }); - }} - > - Confirm - - - - - ); -}; diff --git a/apps/dokploy/components/dashboard/compose/deployments/refresh-token-compose.tsx b/apps/dokploy/components/dashboard/compose/deployments/refresh-token-compose.tsx deleted file mode 100644 index b062b0994..000000000 --- a/apps/dokploy/components/dashboard/compose/deployments/refresh-token-compose.tsx +++ /dev/null @@ -1,59 +0,0 @@ -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, - AlertDialogTrigger, -} from "@/components/ui/alert-dialog"; -import { api } from "@/utils/api"; -import { RefreshCcw } from "lucide-react"; -import { toast } from "sonner"; - -interface Props { - composeId: string; -} -export const RefreshTokenCompose = ({ composeId }: Props) => { - const { mutateAsync } = api.compose.refreshToken.useMutation(); - const utils = api.useUtils(); - return ( - - - - - - - Are you absolutely sure? - - This action cannot be undone. This will permanently change the token - and all the previous tokens will be invalidated - - - - Cancel - { - await mutateAsync({ - composeId, - }) - .then(() => { - utils.compose.one.invalidate({ - composeId, - }); - toast.success("Refresh Token updated"); - }) - .catch(() => { - toast.error("Error updating the refresh token"); - }); - }} - > - Confirm - - - - - ); -}; diff --git a/apps/dokploy/components/dashboard/compose/deployments/show-deployment-compose.tsx b/apps/dokploy/components/dashboard/compose/deployments/show-deployment-compose.tsx deleted file mode 100644 index 7c191a14c..000000000 --- a/apps/dokploy/components/dashboard/compose/deployments/show-deployment-compose.tsx +++ /dev/null @@ -1,184 +0,0 @@ -import { Badge } from "@/components/ui/badge"; -import { Checkbox } from "@/components/ui/checkbox"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogHeader, - DialogTitle, -} from "@/components/ui/dialog"; -import { Loader2 } from "lucide-react"; -import { useEffect, useRef, useState } from "react"; -import { TerminalLine } from "../../docker/logs/terminal-line"; -import { type LogLine, parseLogs } from "../../docker/logs/utils"; - -interface Props { - logPath: string | null; - serverId?: string; - open: boolean; - onClose: () => void; - errorMessage?: string; -} -export const ShowDeploymentCompose = ({ - logPath, - open, - onClose, - serverId, - errorMessage, -}: Props) => { - const [data, setData] = useState(""); - const [filteredLogs, setFilteredLogs] = useState([]); - const [showExtraLogs, setShowExtraLogs] = useState(false); - const wsRef = useRef(null); // Ref to hold WebSocket instance - const [autoScroll, setAutoScroll] = useState(true); - const scrollRef = useRef(null); - - const scrollToBottom = () => { - if (autoScroll && scrollRef.current) { - scrollRef.current.scrollTop = scrollRef.current.scrollHeight; - } - }; - - const handleScroll = () => { - if (!scrollRef.current) return; - - const { scrollTop, scrollHeight, clientHeight } = scrollRef.current; - const isAtBottom = Math.abs(scrollHeight - scrollTop - clientHeight) < 10; - setAutoScroll(isAtBottom); - }; - - useEffect(() => { - if (!open || !logPath) return; - - setData(""); - const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"; - - const wsUrl = `${protocol}//${window.location.host}/listen-deployment?logPath=${logPath}&serverId=${serverId}`; - const ws = new WebSocket(wsUrl); - - wsRef.current = ws; // Store WebSocket instance in ref - - ws.onmessage = (e) => { - setData((currentData) => currentData + e.data); - }; - - ws.onerror = (error) => { - console.error("WebSocket error: ", error); - }; - - ws.onclose = () => { - wsRef.current = null; - }; - - return () => { - if (wsRef.current?.readyState === WebSocket.OPEN) { - ws.close(); - wsRef.current = null; - } - }; - }, [logPath, open]); - - useEffect(() => { - const logs = parseLogs(data); - let filteredLogsResult = logs; - if (serverId) { - let hideSubsequentLogs = false; - filteredLogsResult = logs.filter((log) => { - if ( - log.message.includes( - "===================================EXTRA LOGS============================================", - ) - ) { - hideSubsequentLogs = true; - return showExtraLogs; - } - return showExtraLogs ? true : !hideSubsequentLogs; - }); - } - - setFilteredLogs(filteredLogsResult); - }, [data, showExtraLogs]); - - useEffect(() => { - scrollToBottom(); - - if (autoScroll && scrollRef.current) { - scrollRef.current.scrollTop = scrollRef.current.scrollHeight; - } - }, [filteredLogs, autoScroll]); - - const optionalErrors = parseLogs(errorMessage || ""); - - return ( - { - onClose(); - if (!e) { - setData(""); - } - - if (wsRef.current) { - if (wsRef.current.readyState === WebSocket.OPEN) { - wsRef.current.close(); - } - } - }} - > - - - Deployment - - - See all the details of this deployment |{" "} - - {filteredLogs.length} lines - - - {serverId && ( -
- - setShowExtraLogs(checked as boolean) - } - /> - -
- )} -
-
- -
- {filteredLogs.length > 0 ? ( - filteredLogs.map((log: LogLine, index: number) => ( - - )) - ) : ( - <> - {optionalErrors.length > 0 ? ( - optionalErrors.map((log: LogLine, index: number) => ( - - )) - ) : ( -
- -
- )} - - )} -
-
-
- ); -}; diff --git a/apps/dokploy/components/dashboard/compose/deployments/show-deployments-compose.tsx b/apps/dokploy/components/dashboard/compose/deployments/show-deployments-compose.tsx deleted file mode 100644 index fce4f33f9..000000000 --- a/apps/dokploy/components/dashboard/compose/deployments/show-deployments-compose.tsx +++ /dev/null @@ -1,125 +0,0 @@ -import { DateTooltip } from "@/components/shared/date-tooltip"; -import { StatusTooltip } from "@/components/shared/status-tooltip"; -import { Button } from "@/components/ui/button"; -import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from "@/components/ui/card"; -import { type RouterOutputs, api } from "@/utils/api"; -import { RocketIcon } from "lucide-react"; -import React, { useEffect, useState } from "react"; -import { CancelQueuesCompose } from "./cancel-queues-compose"; -import { RefreshTokenCompose } from "./refresh-token-compose"; -import { ShowDeploymentCompose } from "./show-deployment-compose"; - -interface Props { - composeId: string; -} -export const ShowDeploymentsCompose = ({ composeId }: Props) => { - const [activeLog, setActiveLog] = useState< - RouterOutputs["deployment"]["all"][number] | null - >(null); - const { data } = api.compose.one.useQuery({ composeId }); - const { data: deployments } = api.deployment.allByCompose.useQuery( - { composeId }, - { - enabled: !!composeId, - refetchInterval: 5000, - }, - ); - const [url, setUrl] = React.useState(""); - useEffect(() => { - setUrl(document.location.origin); - }, []); - - return ( - - -
- Deployments - - See all the 10 last deployments for this compose - -
- - {/* */} -
- -
- - If you want to re-deploy this application use this URL in the config - of your git provider or docker - -
- Webhook URL: -
- - {`${url}/api/deploy/compose/${data?.refreshToken}`} - - -
-
-
- {data?.deployments?.length === 0 ? ( -
- - - No deployments found - -
- ) : ( -
- {deployments?.map((deployment) => ( -
-
- - {deployment.status} - - - - - {deployment.title} - - {deployment.description && ( - - {deployment.description} - - )} -
-
-
- -
- - -
-
- ))} -
- )} - setActiveLog(null)} - logPath={activeLog?.logPath || ""} - errorMessage={activeLog?.errorMessage || ""} - /> -
-
- ); -}; diff --git a/apps/dokploy/components/dashboard/compose/domains/add-domain.tsx b/apps/dokploy/components/dashboard/compose/domains/add-domain.tsx deleted file mode 100644 index 6089c99ff..000000000 --- a/apps/dokploy/components/dashboard/compose/domains/add-domain.tsx +++ /dev/null @@ -1,503 +0,0 @@ -import { AlertBlock } from "@/components/shared/alert-block"; -import { Button } from "@/components/ui/button"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, - DialogTrigger, -} from "@/components/ui/dialog"; -import { - Form, - FormControl, - FormDescription, - FormField, - FormItem, - FormLabel, - FormMessage, -} from "@/components/ui/form"; -import { Input, NumberInput } from "@/components/ui/input"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; -import { Switch } from "@/components/ui/switch"; -import { api } from "@/utils/api"; -import { useEffect, useState } from "react"; -import { useForm } from "react-hook-form"; -import { toast } from "sonner"; - -import { - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, -} from "@/components/ui/tooltip"; -import { domainCompose } from "@/server/db/validations/domain"; -import { zodResolver } from "@hookform/resolvers/zod"; -import { DatabaseZap, Dices, RefreshCw } from "lucide-react"; -import Link from "next/link"; -import type z from "zod"; - -type Domain = z.infer; - -export type CacheType = "fetch" | "cache"; - -interface Props { - composeId: string; - domainId?: string; - children: React.ReactNode; -} - -export const AddDomainCompose = ({ - composeId, - domainId = "", - children, -}: Props) => { - const [isOpen, setIsOpen] = useState(false); - const [cacheType, setCacheType] = useState("cache"); - const utils = api.useUtils(); - const { data, refetch } = api.domain.one.useQuery( - { - domainId, - }, - { - enabled: !!domainId, - }, - ); - - const { data: compose } = api.compose.one.useQuery( - { - composeId, - }, - { - enabled: !!composeId, - }, - ); - - const { - data: services, - isFetching: isLoadingServices, - error: errorServices, - refetch: refetchServices, - } = api.compose.loadServices.useQuery( - { - composeId, - type: cacheType, - }, - { - retry: false, - refetchOnWindowFocus: false, - }, - ); - - const { mutateAsync: generateDomain, isLoading: isLoadingGenerate } = - api.domain.generateDomain.useMutation(); - - const { mutateAsync, isError, error, isLoading } = domainId - ? api.domain.update.useMutation() - : api.domain.create.useMutation(); - - const { data: canGenerateTraefikMeDomains } = - api.domain.canGenerateTraefikMeDomains.useQuery({ - serverId: compose?.serverId || "", - }); - - const form = useForm({ - resolver: zodResolver(domainCompose), - defaultValues: { - host: "", - path: undefined, - port: undefined, - https: false, - certificateType: undefined, - customCertResolver: undefined, - serviceName: "", - }, - }); - - const https = form.watch("https"); - - useEffect(() => { - if (data) { - form.reset({ - ...data, - /* Convert null to undefined */ - path: data?.path || undefined, - port: data?.port || undefined, - serviceName: data?.serviceName || undefined, - certificateType: data?.certificateType || undefined, - customCertResolver: data?.customCertResolver || undefined, - }); - } - - if (!domainId) { - form.reset({ - host: "", - path: undefined, - port: undefined, - https: false, - certificateType: undefined, - customCertResolver: undefined, - serviceName: "", - }); - } - }, [form, form.reset, data, isLoading]); - - const dictionary = { - success: domainId ? "Domain Updated" : "Domain Created", - error: domainId ? "Error updating the domain" : "Error creating the domain", - submit: domainId ? "Update" : "Create", - dialogDescription: domainId - ? "In this section you can edit a domain" - : "In this section you can add domains", - }; - - const onSubmit = async (data: Domain) => { - await mutateAsync({ - domainId, - composeId, - domainType: "compose", - ...data, - }) - .then(async () => { - await utils.domain.byComposeId.invalidate({ - composeId, - }); - toast.success(dictionary.success); - if (domainId) { - refetch(); - } - setIsOpen(false); - }) - .catch(() => { - toast.error(dictionary.error); - }); - }; - return ( - - - {children} - - - - Domain - {dictionary.dialogDescription} - -
- - Deploy is required to apply changes after creating or updating a - domain. - - {isError && {error?.message}} -
- -
- -
-
- {errorServices && ( - - {errorServices?.message} - - )} -
- ( - - Service Name -
- - - - - - - -

- Fetch: Will clone the repository and load the - services -

-
-
-
- - - - - - -

- Cache: If you previously deployed this - compose, it will read the services from the - last deployment/fetch from the repository -

-
-
-
-
- - -
- )} - /> -
- - ( - - {!canGenerateTraefikMeDomains && - field.value.includes("traefik.me") && ( - - You need to set an IP address in your{" "} - - {compose?.serverId - ? "Remote Servers -> Server -> Edit Server -> Update IP Address" - : "Web Server -> Server -> Update Server IP"} - {" "} - to make your traefik.me domain work. - - )} - Host -
- - - - - - - - - -

Generate traefik.me domain

-
-
-
-
- - -
- )} - /> - - { - return ( - - Path - - - - - - ); - }} - /> - - { - return ( - - Container Port - - - - - - ); - }} - /> - - ( - -
- HTTPS - - Automatically provision SSL Certificate. - - -
- - - -
- )} - /> - - {https && ( - <> - ( - - Certificate Provider - - - - )} - /> - - {form.getValues().certificateType === "custom" && ( - ( - - Custom Certificate Resolver - - - - - - )} - /> - )} - - )} -
-
-
- - - - - -
-
- ); -}; diff --git a/apps/dokploy/components/dashboard/compose/domains/show-domains.tsx b/apps/dokploy/components/dashboard/compose/domains/show-domains.tsx deleted file mode 100644 index e6468d6fa..000000000 --- a/apps/dokploy/components/dashboard/compose/domains/show-domains.tsx +++ /dev/null @@ -1,150 +0,0 @@ -import { DialogAction } from "@/components/shared/dialog-action"; -import { Button } from "@/components/ui/button"; -import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from "@/components/ui/card"; -import { api } from "@/utils/api"; -import { ExternalLink, GlobeIcon, PenBoxIcon, Trash2 } from "lucide-react"; -import Link from "next/link"; -import { toast } from "sonner"; -import { AddDomainCompose } from "./add-domain"; - -interface Props { - composeId: string; -} - -export const ShowDomainsCompose = ({ composeId }: Props) => { - const { data, refetch } = api.domain.byComposeId.useQuery( - { - composeId, - }, - { - enabled: !!composeId, - }, - ); - - const { mutateAsync: deleteDomain, isLoading: isRemoving } = - api.domain.delete.useMutation(); - - return ( -
- - -
- Domains - - Domains are used to access to the application - -
- -
- {data && data?.length > 0 && ( - - - - )} -
-
- - {data?.length === 0 ? ( -
- - - To access to the application it is required to set at least 1 - domain - -
- - - -
-
- ) : ( -
- {data?.map((item) => { - return ( -
-
- - {item.serviceName} - - - - {item.host} - - -
- -
-
- {item.path} - {item.port} - {item.https ? "HTTPS" : "HTTP"} -
- -
- - - - { - await deleteDomain({ - domainId: item.domainId, - }) - .then((_data) => { - refetch(); - toast.success("Domain deleted successfully"); - }) - .catch(() => { - toast.error("Error deleting domain"); - }); - }} - > - - -
-
-
- ); - })} -
- )} -
-
-
- ); -}; diff --git a/apps/dokploy/components/dashboard/compose/general/compose-file-editor.tsx b/apps/dokploy/components/dashboard/compose/general/compose-file-editor.tsx index bbcbfd833..41e40efbe 100644 --- a/apps/dokploy/components/dashboard/compose/general/compose-file-editor.tsx +++ b/apps/dokploy/components/dashboard/compose/general/compose-file-editor.tsx @@ -44,8 +44,10 @@ export const ComposeFileEditor = ({ composeId }: Props) => { resolver: zodResolver(AddComposeFile), }); + const composeFile = form.watch("composeFile"); + useEffect(() => { - if (data) { + if (data && !composeFile) { form.reset({ composeFile: data.composeFile || "", }); @@ -75,10 +77,26 @@ export const ComposeFileEditor = ({ composeId }: Props) => { composeId, }); }) - .catch((_e) => { + .catch(() => { toast.error("Error updating the Compose config"); }); }; + + // Add keyboard shortcut for Ctrl+S/Cmd+S + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if ((e.ctrlKey || e.metaKey) && e.key === "s" && !isLoading) { + e.preventDefault(); + form.handleSubmit(onSubmit)(); + } + }; + + document.addEventListener("keydown", handleKeyDown); + return () => { + document.removeEventListener("keydown", handleKeyDown); + }; + }, [form, onSubmit, isLoading]); + return ( <>
diff --git a/apps/dokploy/components/dashboard/compose/general/generic/save-bitbucket-provider-compose.tsx b/apps/dokploy/components/dashboard/compose/general/generic/save-bitbucket-provider-compose.tsx index ff329a0af..73d8cf1c6 100644 --- a/apps/dokploy/components/dashboard/compose/general/generic/save-bitbucket-provider-compose.tsx +++ b/apps/dokploy/components/dashboard/compose/general/generic/save-bitbucket-provider-compose.tsx @@ -31,6 +31,7 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; +import { Switch } from "@/components/ui/switch"; import { Tooltip, TooltipContent, @@ -58,6 +59,7 @@ const BitbucketProviderSchema = z.object({ branch: z.string().min(1, "Branch is required"), bitbucketId: z.string().min(1, "Bitbucket Provider is required"), watchPaths: z.array(z.string()).optional(), + enableSubmodules: z.boolean().default(false), }); type BitbucketProvider = z.infer; @@ -84,6 +86,7 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => { bitbucketId: "", branch: "", watchPaths: [], + enableSubmodules: false, }, resolver: zodResolver(BitbucketProviderSchema), }); @@ -130,9 +133,10 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => { composePath: data.composePath, bitbucketId: data.bitbucketId || "", watchPaths: data.watchPaths || [], + enableSubmodules: data.enableSubmodules ?? false, }); } - }, [form.reset, data, form]); + }, [form.reset, data?.composeId, form]); const onSubmit = async (data: BitbucketProvider) => { await mutateAsync({ @@ -145,6 +149,7 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => { sourceType: "bitbucket", composeStatus: "idle", watchPaths: data.watchPaths, + enableSubmodules: data.enableSubmodules, }) .then(async () => { toast.success("Service Provided Saved"); @@ -432,7 +437,7 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
{ if (e.key === "Enter") { e.preventDefault(); @@ -451,7 +456,7 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => { variant="secondary" onClick={() => { const input = document.querySelector( - 'input[placeholder="Enter a path to watch (e.g., src/*, dist/*)"]', + 'input[placeholder="Enter a path to watch (e.g., src/**, dist/*.js)"]', ) as HTMLInputElement; const value = input.value.trim(); if (value) { @@ -469,6 +474,21 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => { )} /> + ( + + + + + Enable Submodules + + )} + />
-
+ }} + > + Add + +
+ + + + )} + /> + )} + ( + + + - + Enable Submodules )} /> diff --git a/apps/dokploy/components/dashboard/compose/general/generic/save-gitlab-provider-compose.tsx b/apps/dokploy/components/dashboard/compose/general/generic/save-gitlab-provider-compose.tsx index c191248ea..c630cd71e 100644 --- a/apps/dokploy/components/dashboard/compose/general/generic/save-gitlab-provider-compose.tsx +++ b/apps/dokploy/components/dashboard/compose/general/generic/save-gitlab-provider-compose.tsx @@ -31,6 +31,7 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; +import { Switch } from "@/components/ui/switch"; import { Tooltip, TooltipContent, @@ -60,6 +61,7 @@ const GitlabProviderSchema = z.object({ branch: z.string().min(1, "Branch is required"), gitlabId: z.string().min(1, "Gitlab Provider is required"), watchPaths: z.array(z.string()).optional(), + enableSubmodules: z.boolean().default(false), }); type GitlabProvider = z.infer; @@ -87,6 +89,7 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => { gitlabId: "", branch: "", watchPaths: [], + enableSubmodules: false, }, resolver: zodResolver(GitlabProviderSchema), }); @@ -136,9 +139,10 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => { composePath: data.composePath, gitlabId: data.gitlabId || "", watchPaths: data.watchPaths || [], + enableSubmodules: data.enableSubmodules ?? false, }); } - }, [form.reset, data, form]); + }, [form.reset, data?.composeId, form]); const onSubmit = async (data: GitlabProvider) => { await mutateAsync({ @@ -153,6 +157,7 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => { sourceType: "gitlab", composeStatus: "idle", watchPaths: data.watchPaths, + enableSubmodules: data.enableSubmodules, }) .then(async () => { toast.success("Service Provided Saved"); @@ -448,7 +453,7 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
{ if (e.key === "Enter") { e.preventDefault(); @@ -467,7 +472,7 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => { variant="secondary" onClick={() => { const input = document.querySelector( - 'input[placeholder="Enter a path to watch (e.g., src/*, dist/*)"]', + 'input[placeholder="Enter a path to watch (e.g., src/**, dist/*.js)"]', ) as HTMLInputElement; const value = input.value.trim(); if (value) { @@ -485,6 +490,21 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => { )} /> + ( + + + + + Enable Submodules + + )} + />
+
-
- -
- -
-					
-				
+
+							
+						
+ + )} ); diff --git a/apps/dokploy/components/dashboard/database/backups/add-backup.tsx b/apps/dokploy/components/dashboard/database/backups/add-backup.tsx deleted file mode 100644 index 0fa568a90..000000000 --- a/apps/dokploy/components/dashboard/database/backups/add-backup.tsx +++ /dev/null @@ -1,347 +0,0 @@ -import { Button } from "@/components/ui/button"; -import { - Command, - CommandEmpty, - CommandGroup, - CommandInput, - CommandItem, -} from "@/components/ui/command"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, - DialogTrigger, -} from "@/components/ui/dialog"; -import { - Form, - FormControl, - FormDescription, - FormField, - FormItem, - FormLabel, - FormMessage, -} from "@/components/ui/form"; -import { Input } from "@/components/ui/input"; -import { - Popover, - PopoverContent, - PopoverTrigger, -} from "@/components/ui/popover"; -import { ScrollArea } from "@/components/ui/scroll-area"; -import { Switch } from "@/components/ui/switch"; -import { cn } from "@/lib/utils"; -import { api } from "@/utils/api"; -import { zodResolver } from "@hookform/resolvers/zod"; -import { PlusIcon } from "lucide-react"; -import { CheckIcon, ChevronsUpDown } from "lucide-react"; -import { useEffect } from "react"; -import { useForm } from "react-hook-form"; -import { toast } from "sonner"; -import { z } from "zod"; - -const AddPostgresBackup1Schema = z.object({ - destinationId: z.string().min(1, "Destination required"), - schedule: z.string().min(1, "Schedule (Cron) required"), - // .regex( - // new RegExp( - // /^(\*|([0-9]|1[0-9]|2[0-9]|3[0-9]|4[0-9]|5[0-9])|\*\/([0-9]|1[0-9]|2[0-9]|3[0-9]|4[0-9]|5[0-9])) (\*|([0-9]|1[0-9]|2[0-3])|\*\/([0-9]|1[0-9]|2[0-3])) (\*|([1-9]|1[0-9]|2[0-9]|3[0-1])|\*\/([1-9]|1[0-9]|2[0-9]|3[0-1])) (\*|([1-9]|1[0-2])|\*\/([1-9]|1[0-2])) (\*|([0-6])|\*\/([0-6]))$/, - // ), - // "Invalid Cron", - // ), - prefix: z.string().min(1, "Prefix required"), - enabled: z.boolean(), - database: z.string().min(1, "Database required"), - keepLatestCount: z.coerce.number().optional(), -}); - -type AddPostgresBackup = z.infer; - -interface Props { - databaseId: string; - databaseType: "postgres" | "mariadb" | "mysql" | "mongo" | "web-server"; - refetch: () => void; -} - -export const AddBackup = ({ databaseId, databaseType, refetch }: Props) => { - const { data, isLoading } = api.destination.all.useQuery(); - - const { mutateAsync: createBackup, isLoading: isCreatingPostgresBackup } = - api.backup.create.useMutation(); - - const form = useForm({ - defaultValues: { - database: "", - destinationId: "", - enabled: true, - prefix: "/", - schedule: "", - keepLatestCount: undefined, - }, - resolver: zodResolver(AddPostgresBackup1Schema), - }); - - useEffect(() => { - form.reset({ - database: databaseType === "web-server" ? "dokploy" : "", - destinationId: "", - enabled: true, - prefix: "/", - schedule: "", - keepLatestCount: undefined, - }); - }, [form, form.reset, form.formState.isSubmitSuccessful]); - - const onSubmit = async (data: AddPostgresBackup) => { - const getDatabaseId = - databaseType === "postgres" - ? { - postgresId: databaseId, - } - : databaseType === "mariadb" - ? { - mariadbId: databaseId, - } - : databaseType === "mysql" - ? { - mysqlId: databaseId, - } - : databaseType === "mongo" - ? { - mongoId: databaseId, - } - : databaseType === "web-server" - ? { - userId: databaseId, - } - : undefined; - - await createBackup({ - destinationId: data.destinationId, - prefix: data.prefix, - schedule: data.schedule, - enabled: data.enabled, - database: data.database, - keepLatestCount: data.keepLatestCount, - databaseType, - ...getDatabaseId, - }) - .then(async () => { - toast.success("Backup Created"); - refetch(); - }) - .catch(() => { - toast.error("Error creating a backup"); - }); - }; - return ( - - - - - - - Create a backup - Add a new backup - - -
- -
- ( - - Destination - - - - - - - - - - {isLoading && ( - - Loading Destinations.... - - )} - No destinations found. - - - {data?.map((destination) => ( - { - form.setValue( - "destinationId", - destination.destinationId, - ); - }} - > - {destination.name} - - - ))} - - - - - - - - - )} - /> - { - return ( - - Database - - - - - - ); - }} - /> - { - return ( - - Schedule (Cron) - - - - - - ); - }} - /> - { - return ( - - Prefix Destination - - - - - Use if you want to back up in a specific path of your - destination/bucket - - - - - ); - }} - /> - { - return ( - - Keep the latest - - - - - Optional. If provided, only keeps the latest N backups - in the cloud. - - - - ); - }} - /> - ( - -
- Enabled - - Enable or disable the backup - -
- - - -
- )} - /> -
- - - -
- -
-
- ); -}; diff --git a/apps/dokploy/components/dashboard/database/backups/handle-backup.tsx b/apps/dokploy/components/dashboard/database/backups/handle-backup.tsx new file mode 100644 index 000000000..ca2cd27fb --- /dev/null +++ b/apps/dokploy/components/dashboard/database/backups/handle-backup.tsx @@ -0,0 +1,828 @@ +import { AlertBlock } from "@/components/shared/alert-block"; +import { Button } from "@/components/ui/button"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, +} from "@/components/ui/command"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Switch } from "@/components/ui/switch"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { cn } from "@/lib/utils"; +import { api } from "@/utils/api"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { + DatabaseZap, + Info, + PenBoxIcon, + PlusIcon, + RefreshCw, +} from "lucide-react"; +import { CheckIcon, ChevronsUpDown } from "lucide-react"; +import { useEffect, useState } from "react"; +import { useForm } from "react-hook-form"; +import { toast } from "sonner"; +import { z } from "zod"; +import { commonCronExpressions } from "../../application/schedules/handle-schedules"; + +type CacheType = "cache" | "fetch"; + +type DatabaseType = "postgres" | "mariadb" | "mysql" | "mongo" | "web-server"; + +const Schema = z + .object({ + destinationId: z.string().min(1, "Destination required"), + schedule: z.string().min(1, "Schedule (Cron) required"), + prefix: z.string().min(1, "Prefix required"), + enabled: z.boolean(), + database: z.string().min(1, "Database required"), + keepLatestCount: z.coerce.number().optional(), + serviceName: z.string().nullable(), + databaseType: z + .enum(["postgres", "mariadb", "mysql", "mongo", "web-server"]) + .optional(), + backupType: z.enum(["database", "compose"]), + metadata: z + .object({ + postgres: z + .object({ + databaseUser: z.string(), + }) + .optional(), + mariadb: z + .object({ + databaseUser: z.string(), + databasePassword: z.string(), + }) + .optional(), + mongo: z + .object({ + databaseUser: z.string(), + databasePassword: z.string(), + }) + .optional(), + mysql: z + .object({ + databaseRootPassword: z.string(), + }) + .optional(), + }) + .optional(), + }) + .superRefine((data, ctx) => { + if (data.backupType === "compose" && !data.databaseType) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Database type is required for compose backups", + path: ["databaseType"], + }); + } + + if (data.backupType === "compose" && !data.serviceName) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Service name is required for compose backups", + path: ["serviceName"], + }); + } + + if (data.backupType === "compose" && data.databaseType) { + if (data.databaseType === "postgres") { + if (!data.metadata?.postgres?.databaseUser) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Database user is required for PostgreSQL", + path: ["metadata", "postgres", "databaseUser"], + }); + } + } else if (data.databaseType === "mariadb") { + if (!data.metadata?.mariadb?.databaseUser) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Database user is required for MariaDB", + path: ["metadata", "mariadb", "databaseUser"], + }); + } + if (!data.metadata?.mariadb?.databasePassword) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Database password is required for MariaDB", + path: ["metadata", "mariadb", "databasePassword"], + }); + } + } else if (data.databaseType === "mongo") { + if (!data.metadata?.mongo?.databaseUser) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Database user is required for MongoDB", + path: ["metadata", "mongo", "databaseUser"], + }); + } + if (!data.metadata?.mongo?.databasePassword) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Database password is required for MongoDB", + path: ["metadata", "mongo", "databasePassword"], + }); + } + } else if (data.databaseType === "mysql") { + if (!data.metadata?.mysql?.databaseRootPassword) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Root password is required for MySQL", + path: ["metadata", "mysql", "databaseRootPassword"], + }); + } + } + } + }); + +interface Props { + id?: string; + backupId?: string; + databaseType?: DatabaseType; + refetch: () => void; + backupType: "database" | "compose"; +} + +export const HandleBackup = ({ + id, + backupId, + databaseType = "postgres", + refetch, + backupType = "database", +}: Props) => { + const [isOpen, setIsOpen] = useState(false); + + const { data, isLoading } = api.destination.all.useQuery(); + const { data: backup } = api.backup.one.useQuery( + { + backupId: backupId ?? "", + }, + { + enabled: !!backupId, + }, + ); + const [cacheType, setCacheType] = useState("cache"); + const { mutateAsync: createBackup, isLoading: isCreatingPostgresBackup } = + backupId + ? api.backup.update.useMutation() + : api.backup.create.useMutation(); + + const form = useForm>({ + defaultValues: { + database: databaseType === "web-server" ? "dokploy" : "", + destinationId: "", + enabled: true, + prefix: "/", + schedule: "", + keepLatestCount: undefined, + serviceName: null, + databaseType: backupType === "compose" ? undefined : databaseType, + backupType: backupType, + metadata: {}, + }, + resolver: zodResolver(Schema), + }); + + const { + data: services, + isFetching: isLoadingServices, + error: errorServices, + refetch: refetchServices, + } = api.compose.loadServices.useQuery( + { + composeId: backup?.composeId ?? id ?? "", + type: cacheType, + }, + { + retry: false, + refetchOnWindowFocus: false, + enabled: backupType === "compose" && !!backup?.composeId && !!id, + }, + ); + + useEffect(() => { + form.reset({ + database: backup?.database + ? backup?.database + : databaseType === "web-server" + ? "dokploy" + : "", + destinationId: backup?.destinationId ?? "", + enabled: backup?.enabled ?? true, + prefix: backup?.prefix ?? "/", + schedule: backup?.schedule ?? "", + keepLatestCount: backup?.keepLatestCount ?? undefined, + serviceName: backup?.serviceName ?? null, + databaseType: backup?.databaseType ?? databaseType, + backupType: backup?.backupType ?? backupType, + metadata: backup?.metadata ?? {}, + }); + }, [form, form.reset, backupId, backup]); + + const onSubmit = async (data: z.infer) => { + const getDatabaseId = + backupType === "compose" + ? { + composeId: id, + } + : databaseType === "postgres" + ? { + postgresId: id, + } + : databaseType === "mariadb" + ? { + mariadbId: id, + } + : databaseType === "mysql" + ? { + mysqlId: id, + } + : databaseType === "mongo" + ? { + mongoId: id, + } + : databaseType === "web-server" + ? { + userId: id, + } + : undefined; + + await createBackup({ + destinationId: data.destinationId, + prefix: data.prefix, + schedule: data.schedule, + enabled: data.enabled, + database: data.database, + keepLatestCount: data.keepLatestCount ?? null, + databaseType: data.databaseType || databaseType, + serviceName: data.serviceName, + ...getDatabaseId, + backupId: backupId ?? "", + backupType, + metadata: data.metadata, + }) + .then(async () => { + toast.success(`Backup ${backupId ? "Updated" : "Created"}`); + refetch(); + setIsOpen(false); + }) + .catch(() => { + toast.error(`Error ${backupId ? "updating" : "creating"} a backup`); + }); + }; + + return ( + + + {backupId ? ( + + ) : ( + + )} + + + + + {backupId ? "Update Backup" : "Create Backup"} + + + {backupId ? "Update a backup" : "Add a new backup"} + + + +
+ +
+ {errorServices && ( + + {errorServices?.message} + + )} + {backupType === "compose" && ( + ( + + Database Type + + + + )} + /> + )} + ( + + Destination + + + + + + + + + + {isLoading && ( + + Loading Destinations.... + + )} + No destinations found. + + + {data?.map((destination) => ( + { + form.setValue( + "destinationId", + destination.destinationId, + ); + }} + > + {destination.name} + + + ))} + + + + + + + + + )} + /> + {backupType === "compose" && ( +
+ ( + + Service Name +
+ + + + + + + +

+ Fetch: Will clone the repository and load the + services +

+
+
+
+ + + + + + +

+ Cache: If you previously deployed this + compose, it will read the services from the + last deployment/fetch from the repository +

+
+
+
+
+ + +
+ )} + /> +
+ )} + { + return ( + + Database + + + + + + ); + }} + /> + { + return ( + + + Schedule + + + + + + +

+ Cron expression format: minute hour day month + weekday +

+

Example: 0 0 * * * (daily at midnight)

+
+
+
+
+
+ +
+ + + +
+
+ + Choose a predefined schedule or enter a custom cron + expression + + +
+ ); + }} + /> + { + return ( + + Prefix Destination + + + + + Use if you want to back up in a specific path of your + destination/bucket + + + + + ); + }} + /> + { + return ( + + Keep the latest + + + + + Optional. If provided, only keeps the latest N backups + in the cloud. + + + + ); + }} + /> + ( + +
+ Enabled + + Enable or disable the backup + +
+ + + +
+ )} + /> + {backupType === "compose" && ( + <> + {form.watch("databaseType") === "postgres" && ( + ( + + Database User + + + + + + )} + /> + )} + + {form.watch("databaseType") === "mariadb" && ( + <> + ( + + Database User + + + + + + )} + /> + ( + + Database Password + + + + + + )} + /> + + )} + + {form.watch("databaseType") === "mongo" && ( + <> + ( + + Database User + + + + + + )} + /> + ( + + Database Password + + + + + + )} + /> + + )} + + {form.watch("databaseType") === "mysql" && ( + ( + + Root Password + + + + + + )} + /> + )} + + )} +
+ + + +
+ +
+
+ ); +}; diff --git a/apps/dokploy/components/dashboard/database/backups/restore-backup.tsx b/apps/dokploy/components/dashboard/database/backups/restore-backup.tsx index 10ebbe083..76ab7b6cf 100644 --- a/apps/dokploy/components/dashboard/database/backups/restore-backup.tsx +++ b/apps/dokploy/components/dashboard/database/backups/restore-backup.tsx @@ -32,12 +32,32 @@ import { PopoverTrigger, } from "@/components/ui/popover"; import { ScrollArea } from "@/components/ui/scroll-area"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +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 copy from "copy-to-clipboard"; import { debounce } from "lodash"; -import { CheckIcon, ChevronsUpDown, Copy, RotateCcw } from "lucide-react"; +import { + CheckIcon, + ChevronsUpDown, + Copy, + DatabaseZap, + RefreshCw, + RotateCcw, +} from "lucide-react"; import { useState } from "react"; import { useForm } from "react-hook-form"; import { toast } from "sonner"; @@ -45,67 +65,190 @@ import { z } from "zod"; import type { ServiceType } from "../../application/advanced/show-resources"; import { type LogLine, parseLogs } from "../../docker/logs/utils"; +type DatabaseType = + | Exclude + | "web-server"; + interface Props { - databaseId: string; - databaseType: Exclude | "web-server"; + id: string; + databaseType?: DatabaseType; serverId?: string | null; + backupType?: "database" | "compose"; } -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", - }), - databaseName: z - .string({ - required_error: "Please enter a database name", - }) - .min(1, { - message: "Database name is required", - }), -}); +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", + }), + databaseName: z + .string({ + required_error: "Please enter a database name", + }) + .min(1, { + message: "Database name is required", + }), + databaseType: z + .enum(["postgres", "mariadb", "mysql", "mongo", "web-server"]) + .optional(), + backupType: z.enum(["database", "compose"]).default("database"), + metadata: z + .object({ + postgres: z + .object({ + databaseUser: z.string(), + }) + .optional(), + mariadb: z + .object({ + databaseUser: z.string(), + databasePassword: z.string(), + }) + .optional(), + mongo: z + .object({ + databaseUser: z.string(), + databasePassword: z.string(), + }) + .optional(), + mysql: z + .object({ + databaseRootPassword: z.string(), + }) + .optional(), + serviceName: z.string().optional(), + }) + .optional(), + }) + .superRefine((data, ctx) => { + if (data.backupType === "compose" && !data.databaseType) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Database type is required for compose backups", + path: ["databaseType"], + }); + } -type RestoreBackup = z.infer; + if (data.backupType === "compose" && !data.metadata?.serviceName) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Service name is required for compose backups", + path: ["metadata", "serviceName"], + }); + } + + if (data.backupType === "compose" && data.databaseType) { + if (data.databaseType === "postgres") { + if (!data.metadata?.postgres?.databaseUser) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Database user is required for PostgreSQL", + path: ["metadata", "postgres", "databaseUser"], + }); + } + } else if (data.databaseType === "mariadb") { + if (!data.metadata?.mariadb?.databaseUser) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Database user is required for MariaDB", + path: ["metadata", "mariadb", "databaseUser"], + }); + } + if (!data.metadata?.mariadb?.databasePassword) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Database password is required for MariaDB", + path: ["metadata", "mariadb", "databasePassword"], + }); + } + } else if (data.databaseType === "mongo") { + if (!data.metadata?.mongo?.databaseUser) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Database user is required for MongoDB", + path: ["metadata", "mongo", "databaseUser"], + }); + } + if (!data.metadata?.mongo?.databasePassword) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Database password is required for MongoDB", + path: ["metadata", "mongo", "databasePassword"], + }); + } + } else if (data.databaseType === "mysql") { + if (!data.metadata?.mysql?.databaseRootPassword) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Root password is required for MySQL", + path: ["metadata", "mysql", "databaseRootPassword"], + }); + } + } + } + }); + +export const formatBytes = (bytes: number): string => { + if (bytes === 0) return "0 Bytes"; + const k = 1024; + const sizes = ["Bytes", "KB", "MB", "GB", "TB"]; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return `${Number.parseFloat((bytes / k ** i).toFixed(2))} ${sizes[i]}`; +}; export const RestoreBackup = ({ - databaseId, + id, databaseType, serverId, + backupType = "database", }: Props) => { const [isOpen, setIsOpen] = useState(false); const [search, setSearch] = useState(""); + const [debouncedSearchTerm, setDebouncedSearchTerm] = useState(""); const { data: destinations = [] } = api.destination.all.useQuery(); - const form = useForm({ + const form = useForm>({ defaultValues: { destinationId: "", backupFile: "", databaseName: databaseType === "web-server" ? "dokploy" : "", + databaseType: + backupType === "compose" ? ("postgres" as DatabaseType) : databaseType, + backupType: backupType, + metadata: {}, }, resolver: zodResolver(RestoreBackupSchema), }); const destionationId = form.watch("destinationId"); + const currentDatabaseType = form.watch("databaseType"); + const metadata = form.watch("metadata"); const debouncedSetSearch = debounce((value: string) => { + setDebouncedSearchTerm(value); + }, 350); + + const handleSearchChange = (value: string) => { setSearch(value); - }, 300); + debouncedSetSearch(value); + }; const { data: files = [], isLoading } = api.backup.listBackupFiles.useQuery( { destinationId: destionationId, - search, + search: debouncedSearchTerm, serverId: serverId ?? "", }, { @@ -117,16 +260,15 @@ export const RestoreBackup = ({ const [filteredLogs, setFilteredLogs] = useState([]); const [isDeploying, setIsDeploying] = useState(false); - // const { mutateAsync: restore, isLoading: isRestoring } = - // api.backup.restoreBackup.useMutation(); - api.backup.restoreBackupWithLogs.useSubscription( { - databaseId, - databaseType, + databaseId: id, + databaseType: currentDatabaseType as DatabaseType, databaseName: form.watch("databaseName"), backupFile: form.watch("backupFile"), destinationId: form.watch("destinationId"), + backupType: backupType, + metadata: metadata, }, { enabled: isDeploying, @@ -148,10 +290,32 @@ export const RestoreBackup = ({ }, ); - const onSubmit = async (_data: RestoreBackup) => { + const onSubmit = async (data: z.infer) => { + if (backupType === "compose" && !data.databaseType) { + toast.error("Please select a database type"); + return; + } + console.log({ data }); setIsDeploying(true); }; + const [cacheType, setCacheType] = useState<"fetch" | "cache">("cache"); + const { + data: services = [], + isLoading: isLoadingServices, + refetch: refetchServices, + } = api.compose.loadServices.useQuery( + { + composeId: id, + type: cacheType, + }, + { + retry: false, + refetchOnWindowFocus: false, + enabled: backupType === "compose", + }, + ); + return ( @@ -160,7 +324,7 @@ export const RestoreBackup = ({ Restore Backup - + @@ -251,7 +415,7 @@ export const RestoreBackup = ({ Search Backup Files {field.value && ( - + {field.value} )} - + @@ -284,7 +450,8 @@ export const RestoreBackup = ({ {isLoading ? ( @@ -301,26 +468,51 @@ export const RestoreBackup = ({
) : ( - - {files.map((file) => ( + + {files?.map((file) => ( { - form.setValue("backupFile", file); + form.setValue("backupFile", file.Path); + if (file.IsDir) { + setSearch(`${file.Path}/`); + setDebouncedSearchTerm(`${file.Path}/`); + } else { + setSearch(file.Path); + setDebouncedSearchTerm(file.Path); + } }} > -
- {file} +
+
+ + {file.Path} + + + +
+
+ + Size: {formatBytes(file.Size)} + + {file.IsDir && ( + + Directory + + )} + {file.Hashes?.MD5 && ( + MD5: {file.Hashes.MD5} + )} +
- ))} @@ -337,25 +529,274 @@ export const RestoreBackup = ({ control={form.control} name="databaseName" render={({ field }) => ( - + Database Name )} /> + + {backupType === "compose" && ( + <> + ( + + Database Type + + + + )} + /> + + ( + + Service Name +
+ + + + + + + +

+ Fetch: Will clone the repository and load the + services +

+
+
+
+ + + + + + +

+ Cache: If you previously deployed this compose, + it will read the services from the last + deployment/fetch from the repository +

+
+
+
+
+ + +
+ )} + /> + + {currentDatabaseType === "postgres" && ( + ( + + Database User + + + + + + )} + /> + )} + + {currentDatabaseType === "mariadb" && ( + <> + ( + + Database User + + + + + + )} + /> + ( + + Database Password + + + + + + )} + /> + + )} + + {currentDatabaseType === "mongo" && ( + <> + ( + + Database User + + + + + + )} + /> + ( + + Database Password + + + + + + )} + /> + + )} + + {currentDatabaseType === "mysql" && ( + ( + + Root Password + + + + + + )} + /> + )} + + )} + diff --git a/apps/dokploy/components/dashboard/database/backups/show-backups.tsx b/apps/dokploy/components/dashboard/database/backups/show-backups.tsx index 1c2b527bf..28ee68a9c 100644 --- a/apps/dokploy/components/dashboard/database/backups/show-backups.tsx +++ b/apps/dokploy/components/dashboard/database/backups/show-backups.tsx @@ -1,3 +1,10 @@ +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 { Button } from "@/components/ui/button"; import { @@ -13,50 +20,77 @@ import { TooltipProvider, TooltipTrigger, } from "@/components/ui/tooltip"; +import { cn } from "@/lib/utils"; import { api } from "@/utils/api"; -import { Database, DatabaseBackup, Play, Trash2 } from "lucide-react"; +import { + ClipboardList, + Database, + DatabaseBackup, + Play, + Trash2, +} from "lucide-react"; import Link from "next/link"; import { useState } from "react"; import { toast } from "sonner"; import type { ServiceType } from "../../application/advanced/show-resources"; -import { AddBackup } from "./add-backup"; +import { ShowDeploymentsModal } from "../../application/deployments/show-deployments-modal"; +import { HandleBackup } from "./handle-backup"; import { RestoreBackup } from "./restore-backup"; -import { UpdateBackup } from "./update-backup"; interface Props { id: string; - type: Exclude | "web-server"; + databaseType?: Exclude | "web-server"; + backupType?: "database" | "compose"; } -export const ShowBackups = ({ id, type }: Props) => { +export const ShowBackups = ({ + id, + databaseType, + backupType = "database", +}: Props) => { const [activeManualBackup, setActiveManualBackup] = useState< string | undefined >(); - const queryMap = { - postgres: () => - api.postgres.one.useQuery({ postgresId: id }, { enabled: !!id }), - mysql: () => api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }), - mariadb: () => - api.mariadb.one.useQuery({ mariadbId: id }, { enabled: !!id }), - mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }), - "web-server": () => api.user.getBackups.useQuery(), - }; + const queryMap = + backupType === "database" + ? { + postgres: () => + api.postgres.one.useQuery({ postgresId: id }, { enabled: !!id }), + mysql: () => + api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }), + mariadb: () => + api.mariadb.one.useQuery({ mariadbId: id }, { enabled: !!id }), + mongo: () => + api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }), + "web-server": () => api.user.getBackups.useQuery(), + } + : { + compose: () => + api.compose.one.useQuery({ composeId: id }, { enabled: !!id }), + }; const { data } = api.destination.all.useQuery(); - const { data: postgres, refetch } = queryMap[type] - ? queryMap[type]() + const key = backupType === "database" ? databaseType : "compose"; + const query = queryMap[key as keyof typeof queryMap]; + const { data: postgres, refetch } = query + ? query() : api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }); - const mutationMap = { - postgres: () => api.backup.manualBackupPostgres.useMutation(), - mysql: () => api.backup.manualBackupMySql.useMutation(), - mariadb: () => api.backup.manualBackupMariadb.useMutation(), - mongo: () => api.backup.manualBackupMongo.useMutation(), - "web-server": () => api.backup.manualBackupWebServer.useMutation(), - }; + const mutationMap = + backupType === "database" + ? { + postgres: api.backup.manualBackupPostgres.useMutation(), + mysql: api.backup.manualBackupMySql.useMutation(), + mariadb: api.backup.manualBackupMariadb.useMutation(), + mongo: api.backup.manualBackupMongo.useMutation(), + "web-server": api.backup.manualBackupWebServer.useMutation(), + } + : { + compose: api.backup.manualBackupCompose.useMutation(), + }; - const { mutateAsync: manualBackup, isLoading: isManualBackup } = mutationMap[ - type - ] - ? mutationMap[type]() + const mutation = mutationMap[key as keyof typeof mutationMap]; + + const { mutateAsync: manualBackup, isLoading: isManualBackup } = mutation + ? mutation : api.backup.manualBackupMongo.useMutation(); const { mutateAsync: deleteBackup, isLoading: isRemoving } = @@ -78,16 +112,18 @@ export const ShowBackups = ({ id, type }: Props) => { {postgres && postgres?.backups?.length > 0 && (
- {type !== "web-server" && ( - )}
@@ -95,7 +131,7 @@ export const ShowBackups = ({ id, type }: Props) => { {data?.length === 0 ? ( -
+
To create a backup it is required to set at least 1 provider. @@ -110,7 +146,7 @@ export const ShowBackups = ({ id, type }: Props) => {
) : ( -
+
{postgres?.backups.length === 0 ? (
@@ -118,14 +154,16 @@ export const ShowBackups = ({ id, type }: Props) => { No backups configured
- {
) : ( -
+
+ {backupType === "compose" && ( + + Make sure the compose is running before creating a backup. + + )}
- {postgres?.backups.map((backup) => ( -
-
-
-
- Destination - - {backup.destination.name} - -
-
- Database - - {backup.database} - -
-
- Scheduled - - {backup.schedule} - -
-
- Prefix Storage - - {backup.prefix} - -
-
- Enabled - - {backup.enabled ? "Yes" : "No"} - -
-
- Keep Latest - - {backup.keepLatestCount || "All"} - -
-
-
- - - - - - Run Manual Backup - - + {postgres?.backups.map((backup) => { + const serverId = + "serverId" in postgres ? postgres.serverId : undefined; - - { - await deleteBackup({ - backupId: backup.backupId, - }) - .then(() => { - refetch(); - toast.success("Backup deleted successfully"); - }) - .catch(() => { - toast.error("Error deleting backup"); - }); - }} - > - - + + + + + + + + + Run Manual Backup + + + + + + { + await deleteBackup({ + backupId: backup.backupId, + }) + .then(() => { + refetch(); + toast.success( + "Backup deleted successfully", + ); + }) + .catch(() => { + toast.error("Error deleting backup"); + }); + }} + > + + +
-
- ))} + ); + })}
)} diff --git a/apps/dokploy/components/dashboard/database/backups/update-backup.tsx b/apps/dokploy/components/dashboard/database/backups/update-backup.tsx deleted file mode 100644 index 2cf7b7a5f..000000000 --- a/apps/dokploy/components/dashboard/database/backups/update-backup.tsx +++ /dev/null @@ -1,329 +0,0 @@ -import { Button } from "@/components/ui/button"; -import { - Command, - CommandEmpty, - CommandGroup, - CommandInput, - CommandItem, -} from "@/components/ui/command"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, - DialogTrigger, -} from "@/components/ui/dialog"; -import { - Form, - FormControl, - FormDescription, - FormField, - FormItem, - FormLabel, - FormMessage, -} from "@/components/ui/form"; -import { Input } from "@/components/ui/input"; -import { - Popover, - PopoverContent, - PopoverTrigger, -} from "@/components/ui/popover"; -import { ScrollArea } from "@/components/ui/scroll-area"; -import { Switch } from "@/components/ui/switch"; -import { cn } from "@/lib/utils"; -import { api } from "@/utils/api"; -import { zodResolver } from "@hookform/resolvers/zod"; -import { CheckIcon, ChevronsUpDown, PenBoxIcon } from "lucide-react"; -import { useEffect, useState } from "react"; -import { useForm } from "react-hook-form"; -import { toast } from "sonner"; -import { z } from "zod"; - -const UpdateBackupSchema = z.object({ - destinationId: z.string().min(1, "Destination required"), - schedule: z.string().min(1, "Schedule (Cron) required"), - prefix: z.string().min(1, "Prefix required"), - enabled: z.boolean(), - database: z.string().min(1, "Database required"), - keepLatestCount: z.coerce.number().optional(), -}); - -type UpdateBackup = z.infer; - -interface Props { - backupId: string; - refetch: () => void; -} - -export const UpdateBackup = ({ backupId, refetch }: Props) => { - const [isOpen, setIsOpen] = useState(false); - const { data, isLoading } = api.destination.all.useQuery(); - const { data: backup } = api.backup.one.useQuery( - { - backupId, - }, - { - enabled: !!backupId, - }, - ); - - const { mutateAsync, isLoading: isLoadingUpdate } = - api.backup.update.useMutation(); - - const form = useForm({ - defaultValues: { - database: "", - destinationId: "", - enabled: true, - prefix: "/", - schedule: "", - keepLatestCount: undefined, - }, - resolver: zodResolver(UpdateBackupSchema), - }); - - useEffect(() => { - if (backup) { - form.reset({ - database: backup.database, - destinationId: backup.destinationId, - enabled: backup.enabled || false, - prefix: backup.prefix, - schedule: backup.schedule, - keepLatestCount: backup.keepLatestCount - ? Number(backup.keepLatestCount) - : undefined, - }); - } - }, [form, form.reset, backup]); - - const onSubmit = async (data: UpdateBackup) => { - await mutateAsync({ - backupId, - destinationId: data.destinationId, - prefix: data.prefix, - schedule: data.schedule, - enabled: data.enabled, - database: data.database, - keepLatestCount: data.keepLatestCount as number | null, - }) - .then(async () => { - toast.success("Backup Updated"); - refetch(); - setIsOpen(false); - }) - .catch(() => { - toast.error("Error updating the Backup"); - }); - }; - - return ( - - - - - - - Update Backup - Update the backup - - -
- -
- ( - - Destination - - - - - - - - - - {isLoading && ( - - Loading Destinations.... - - )} - No destinations found. - - - {data?.map((destination) => ( - { - form.setValue( - "destinationId", - destination.destinationId, - ); - }} - > - {destination.name} - - - ))} - - - - - - - - - )} - /> - { - return ( - - Database - - - - - - ); - }} - /> - { - return ( - - Schedule (Cron) - - - - - - ); - }} - /> - { - return ( - - Prefix Destination - - - - - Use if you want to back up in a specific path of your - destination/bucket - - - - - ); - }} - /> - { - return ( - - Keep the latest - - - - - Optional. If provided, only keeps the latest N backups - in the cloud. - - - - ); - }} - /> - ( - -
- Enabled - - Enable or disable the backup - -
- - - -
- )} - /> -
- - - -
- -
-
- ); -}; diff --git a/apps/dokploy/components/dashboard/docker/logs/docker-logs-id.tsx b/apps/dokploy/components/dashboard/docker/logs/docker-logs-id.tsx index 861b4bbe9..611af355e 100644 --- a/apps/dokploy/components/dashboard/docker/logs/docker-logs-id.tsx +++ b/apps/dokploy/components/dashboard/docker/logs/docker-logs-id.tsx @@ -140,7 +140,14 @@ export const DockerLogsId: React.FC = ({ ws.onmessage = (e) => { if (!isCurrentConnection) return; - setRawLogs((prev) => prev + e.data); + setRawLogs((prev) => { + const updated = prev + e.data; + const splitLines = updated.split("\n"); + if (splitLines.length > lines) { + return splitLines.slice(-lines).join("\n"); + } + return updated; + }); setIsLoading(false); if (noDataTimeout) clearTimeout(noDataTimeout); }; diff --git a/apps/dokploy/components/dashboard/impersonation/impersonation-bar.tsx b/apps/dokploy/components/dashboard/impersonation/impersonation-bar.tsx new file mode 100644 index 000000000..8a9f55c90 --- /dev/null +++ b/apps/dokploy/components/dashboard/impersonation/impersonation-bar.tsx @@ -0,0 +1,454 @@ +"use client"; + +import { Logo } from "@/components/shared/logo"; +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@/components/ui/command"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { authClient } from "@/lib/auth-client"; +import { cn } from "@/lib/utils"; +import { api } from "@/utils/api"; +import copy from "copy-to-clipboard"; +import { format } from "date-fns"; +import { + Building2, + 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; + +export const ImpersonationBar = () => { + const [users, setUsers] = useState([]); + const [selectedUser, setSelectedUser] = useState(null); + const [isImpersonating, setIsImpersonating] = useState(false); + const [open, setOpen] = useState(false); + const [isLoading, setIsLoading] = useState(false); + const [showBar, setShowBar] = useState(false); + const { data } = api.user.get.useQuery(); + + const fetchUsers = async (search?: string) => { + try { + const session = await authClient.getSession(); + if (session?.data?.session?.impersonatedBy) { + return; + } + setIsLoading(true); + const response = await authClient.admin.listUsers({ + query: { + limit: 30, + ...(search && { + searchField: "email", + searchOperator: "contains", + searchValue: search, + }), + }, + }); + + const filteredUsers = response.data?.users.filter( + // @ts-ignore + (user) => user.allowImpersonation && data?.user?.email !== user.email, + ); + + if (!response.error) { + // @ts-ignore + setUsers(filteredUsers || []); + } + } catch (error) { + console.error("Error fetching users:", error); + toast.error("Error loading users"); + } finally { + setIsLoading(false); + } + }; + + const handleImpersonate = async () => { + if (!selectedUser) return; + + try { + await authClient.admin.impersonateUser({ + userId: selectedUser.id, + }); + setIsImpersonating(true); + setOpen(false); + + toast.success("Successfully impersonating user", { + description: `You are now viewing as ${selectedUser.name || selectedUser.email}`, + }); + window.location.reload(); + } catch (error) { + console.error("Error impersonating user:", error); + toast.error("Error impersonating user"); + } + }; + + const handleStopImpersonating = async () => { + try { + await authClient.admin.stopImpersonating(); + setIsImpersonating(false); + setSelectedUser(null); + setShowBar(false); + toast.success("Stopped impersonating user"); + window.location.reload(); + } catch (error) { + console.error("Error stopping impersonation:", error); + toast.error("Error stopping impersonation"); + } + }; + + useEffect(() => { + const checkImpersonation = async () => { + try { + const session = await authClient.getSession(); + if (session?.data?.session?.impersonatedBy) { + setIsImpersonating(true); + setShowBar(true); + // setSelectedUser(data); + } + } catch (error) { + console.error("Error checking impersonation status:", error); + } + }; + + checkImpersonation(); + fetchUsers(); + }, []); + + return ( + + <> + + + + + + {isImpersonating ? "Impersonation Controls" : "User Impersonation"} + + + +
+
+ + {!isImpersonating ? ( +
+ + + + + + + { + fetchUsers(search); + }} + className="h-9" + /> + {isLoading ? ( +
+ Loading users... +
+ ) : ( + <> + No users found. + + + {users.map((user) => ( + { + setSelectedUser(user); + setOpen(false); + }} + > + + + + + {user.name || ""} + + + {user.email} • {user.role} + + + + + + ))} + + + + )} +
+
+
+ +
+ ) : ( +
+
+ + + + {data?.user?.name?.slice(0, 2).toUpperCase() || "U"} + + +
+
+ + + Impersonating + + + {data?.user?.name || ""} + +
+
+ + + {data?.user?.email} • {data?.role} + + + + + ID: {data?.user?.id?.slice(0, 8)} + + + + + + + Org: {data?.organizationId?.slice(0, 8)} + + + + {data?.user?.stripeCustomerId && ( + + + + Customer: + {data?.user?.stripeCustomerId?.slice(0, 8)} + + + + )} + {data?.user?.stripeSubscriptionId && ( + + + + Sub: {data?.user?.stripeSubscriptionId?.slice(0, 8)} + + + + )} + {data?.user?.serversQuantity !== undefined && ( + + + Servers: {data.user.serversQuantity} + + )} + {data?.createdAt && ( + + + Created:{" "} + {format(new Date(data.createdAt), "MMM d, yyyy")} + + )} + + + + + + 2FA{" "} + {data?.user?.twoFactorEnabled + ? "Enabled" + : "Disabled"} + + + + + Two-Factor Authentication Status + + +
+
+
+ +
+ )} +
+
+ +
+ ); +}; diff --git a/apps/dokploy/components/dashboard/project/add-application.tsx b/apps/dokploy/components/dashboard/project/add-application.tsx index 16c56917d..6b0a690db 100644 --- a/apps/dokploy/components/dashboard/project/add-application.tsx +++ b/apps/dokploy/components/dashboard/project/add-application.tsx @@ -103,7 +103,7 @@ export const AddApplication = ({ projectId, projectName }: Props) => { projectId, }); }) - .catch((_e) => { + .catch(() => { toast.error("Error creating the service"); }); }; @@ -145,10 +145,8 @@ export const AddApplication = ({ projectId, projectName }: Props) => { {...field} onChange={(e) => { const val = e.target.value?.trim() || ""; - form.setValue( - "appName", - `${slug}-${val.toLowerCase().replaceAll(" ", "-")}`, - ); + const serviceName = slugify(val); + form.setValue("appName", `${slug}-${serviceName}`); field.onChange(val); }} /> diff --git a/apps/dokploy/components/dashboard/project/add-compose.tsx b/apps/dokploy/components/dashboard/project/add-compose.tsx index ea8690a83..5f2bb137f 100644 --- a/apps/dokploy/components/dashboard/project/add-compose.tsx +++ b/apps/dokploy/components/dashboard/project/add-compose.tsx @@ -152,10 +152,8 @@ export const AddCompose = ({ projectId, projectName }: Props) => { {...field} onChange={(e) => { const val = e.target.value?.trim() || ""; - form.setValue( - "appName", - `${slug}-${val.toLowerCase()}`, - ); + const serviceName = slugify(val); + form.setValue("appName", `${slug}-${serviceName}`); field.onChange(val); }} /> diff --git a/apps/dokploy/components/dashboard/project/add-database.tsx b/apps/dokploy/components/dashboard/project/add-database.tsx index a58aef2c8..2420e603f 100644 --- a/apps/dokploy/components/dashboard/project/add-database.tsx +++ b/apps/dokploy/components/dashboard/project/add-database.tsx @@ -363,10 +363,8 @@ export const AddDatabase = ({ projectId, projectName }: Props) => { {...field} onChange={(e) => { const val = e.target.value?.trim() || ""; - form.setValue( - "appName", - `${slug}-${val.toLowerCase()}`, - ); + const serviceName = slugify(val); + form.setValue("appName", `${slug}-${serviceName}`); field.onChange(val); }} /> diff --git a/apps/dokploy/components/dashboard/project/duplicate-project.tsx b/apps/dokploy/components/dashboard/project/duplicate-project.tsx index 038ddcb6a..ffcfeba87 100644 --- a/apps/dokploy/components/dashboard/project/duplicate-project.tsx +++ b/apps/dokploy/components/dashboard/project/duplicate-project.tsx @@ -10,6 +10,7 @@ import { } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; +import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; import { api } from "@/utils/api"; import { Copy, Loader2 } from "lucide-react"; import { useRouter } from "next/router"; @@ -48,6 +49,7 @@ export const DuplicateProject = ({ const [open, setOpen] = useState(false); const [name, setName] = useState(""); const [description, setDescription] = useState(""); + const [duplicateType, setDuplicateType] = useState("new-project"); // "new-project" or "same-project" const utils = api.useUtils(); const router = useRouter(); @@ -59,9 +61,15 @@ export const DuplicateProject = ({ api.project.duplicate.useMutation({ onSuccess: async (newProject) => { await utils.project.all.invalidate(); - toast.success("Project duplicated successfully"); + toast.success( + duplicateType === "new-project" + ? "Project duplicated successfully" + : "Services duplicated successfully", + ); setOpen(false); - router.push(`/dashboard/project/${newProject.projectId}`); + if (duplicateType === "new-project") { + router.push(`/dashboard/project/${newProject.projectId}`); + } }, onError: (error) => { toast.error(error.message); @@ -69,7 +77,7 @@ export const DuplicateProject = ({ }); const handleDuplicate = async () => { - if (!name) { + if (duplicateType === "new-project" && !name) { toast.error("Project name is required"); return; } @@ -83,6 +91,7 @@ export const DuplicateProject = ({ id: service.id, type: service.type, })), + duplicateInSameProject: duplicateType === "same-project", }); }; @@ -95,6 +104,7 @@ export const DuplicateProject = ({ // Reset form when closing setName(""); setDescription(""); + setDuplicateType("new-project"); } }} > @@ -106,32 +116,54 @@ export const DuplicateProject = ({ - Duplicate Project + Duplicate Services - Create a new project with the selected services + Choose where to duplicate the selected services
- - setName(e.target.value)} - placeholder="New project name" - /> + + +
+ + +
+
+ + +
+
-
- - setDescription(e.target.value)} - placeholder="Project description (optional)" - /> -
+ {duplicateType === "new-project" && ( + <> +
+ + setName(e.target.value)} + placeholder="New project name" + /> +
+ +
+ + setDescription(e.target.value)} + placeholder="Project description (optional)" + /> +
+ + )}
@@ -159,10 +191,14 @@ export const DuplicateProject = ({ {isLoading ? ( <> - Duplicating... + {duplicateType === "new-project" + ? "Duplicating project..." + : "Duplicating services..."} + ) : duplicateType === "new-project" ? ( + "Duplicate project" ) : ( - "Duplicate" + "Duplicate services" )} diff --git a/apps/dokploy/components/dashboard/projects/handle-project.tsx b/apps/dokploy/components/dashboard/projects/handle-project.tsx index dcb812419..01d66fbaa 100644 --- a/apps/dokploy/components/dashboard/projects/handle-project.tsx +++ b/apps/dokploy/components/dashboard/projects/handle-project.tsx @@ -33,12 +33,23 @@ import { z } from "zod"; const AddProjectSchema = z.object({ name: z .string() - .min(1, { - message: "Name is required", - }) - .regex(/^[a-zA-Z]/, { + .min(1, "Project name is required") + .refine( + (name) => { + const trimmedName = name.trim(); + const validNameRegex = + /^[\p{L}\p{N}_-][\p{L}\p{N}\s_.-]*[\p{L}\p{N}_-]$/u; + return validNameRegex.test(trimmedName); + }, + { + message: + "Project name must start and end with a letter, number, hyphen or underscore. Spaces are allowed in between.", + }, + ) + .refine((name) => !/^\d/.test(name.trim()), { message: "Project name cannot start with a number", - }), + }) + .transform((name) => name.trim()), description: z.string().optional(), }); diff --git a/apps/dokploy/components/dashboard/settings/ai-form.tsx b/apps/dokploy/components/dashboard/settings/ai-form.tsx index 05ab93a4f..b1923918e 100644 --- a/apps/dokploy/components/dashboard/settings/ai-form.tsx +++ b/apps/dokploy/components/dashboard/settings/ai-form.tsx @@ -55,7 +55,7 @@ export const AiForm = () => { key={config.aiId} className="flex items-center justify-between bg-sidebar p-1 w-full rounded-lg" > -
+
{config.name} diff --git a/apps/dokploy/components/dashboard/settings/certificates/show-certificates.tsx b/apps/dokploy/components/dashboard/settings/certificates/show-certificates.tsx index 6aaa25630..b80c7b549 100644 --- a/apps/dokploy/components/dashboard/settings/certificates/show-certificates.tsx +++ b/apps/dokploy/components/dashboard/settings/certificates/show-certificates.tsx @@ -70,7 +70,7 @@ export const ShowCertificates = () => { key={certificate.certificateId} className="flex items-center justify-between bg-sidebar p-1 w-full rounded-lg" > -
+
diff --git a/apps/dokploy/components/dashboard/settings/certificates/utils.ts b/apps/dokploy/components/dashboard/settings/certificates/utils.ts index 34d3ad77c..e2aa59ef3 100644 --- a/apps/dokploy/components/dashboard/settings/certificates/utils.ts +++ b/apps/dokploy/components/dashboard/settings/certificates/utils.ts @@ -1,68 +1,93 @@ +// @ts-nocheck + export const extractExpirationDate = (certData: string): Date | null => { try { - const match = certData.match( - /-----BEGIN CERTIFICATE-----\s*([^-]+)\s*-----END CERTIFICATE-----/, - ); - if (!match?.[1]) return null; - - 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); + // Decode PEM base64 to DER binary + const b64 = certData.replace(/-----[^-]+-----/g, "").replace(/\s+/g, ""); + const binStr = atob(b64); + const der = new Uint8Array(binStr.length); + for (let i = 0; i < binStr.length; i++) { + der[i] = binStr.charCodeAt(i); } - let dateFound = 0; - for (let i = 0; i < bytes.length - 2; i++) { - if (bytes[i] === 0x17 || bytes[i] === 0x18) { - const dateType = bytes[i]; - const dateLength = bytes[i + 1]; - if (typeof dateLength === "undefined") continue; + let offset = 0; - if (dateFound === 0) { - dateFound++; - i += dateLength + 1; - continue; + // Helper: read ASN.1 length field + function readLength(pos: number): { length: number; offset: number } { + // biome-ignore lint/style/noParameterAssign: + let len = der[pos++]; + if (len & 0x80) { + const bytes = len & 0x7f; + len = 0; + for (let i = 0; i < bytes; i++) { + // biome-ignore lint/style/noParameterAssign: + len = (len << 8) + der[pos++]; } + } + return { length: len, offset: pos }; + } - let dateStr = ""; - for (let j = 0; j < dateLength; j++) { - const charCode = bytes[i + 2 + j]; - if (typeof charCode === "undefined") continue; - dateStr += String.fromCharCode(charCode); - } + // Skip the outer certificate sequence + if (der[offset++] !== 0x30) throw new Error("Expected sequence"); + ({ offset } = readLength(offset)); - 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)), - ), - ); - } + // Skip tbsCertificate sequence + if (der[offset++] !== 0x30) throw new Error("Expected tbsCertificate"); + ({ offset } = readLength(offset)); - // GeneralizedTime (YYYYMMDDhhmmssZ) + // 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( - 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)), - ), + `${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 null; + + return parseTime(notAfterStr); } catch (error) { console.error("Error parsing certificate:", error); return null; diff --git a/apps/dokploy/components/dashboard/settings/cluster/nodes/show-nodes-modal.tsx b/apps/dokploy/components/dashboard/settings/cluster/nodes/show-nodes-modal.tsx index 82e6e1f9a..5f0b32fc3 100644 --- a/apps/dokploy/components/dashboard/settings/cluster/nodes/show-nodes-modal.tsx +++ b/apps/dokploy/components/dashboard/settings/cluster/nodes/show-nodes-modal.tsx @@ -20,7 +20,7 @@ export const ShowNodesModal = ({ serverId }: Props) => { Show Swarm Nodes - +
diff --git a/apps/dokploy/components/dashboard/settings/cluster/nodes/show-nodes.tsx b/apps/dokploy/components/dashboard/settings/cluster/nodes/show-nodes.tsx index 4354a8bca..51f874d8d 100644 --- a/apps/dokploy/components/dashboard/settings/cluster/nodes/show-nodes.tsx +++ b/apps/dokploy/components/dashboard/settings/cluster/nodes/show-nodes.tsx @@ -87,7 +87,7 @@ export const ShowNodes = ({ serverId }: Props) => { - Hostname + Hostname Status Role Availability @@ -104,7 +104,7 @@ export const ShowNodes = ({ serverId }: Props) => { const isManager = node.Spec.Role === "manager"; return ( - + {node.Description.Hostname} diff --git a/apps/dokploy/components/dashboard/settings/cluster/registry/show-registry.tsx b/apps/dokploy/components/dashboard/settings/cluster/registry/show-registry.tsx index 08cb03813..9ae595d6f 100644 --- a/apps/dokploy/components/dashboard/settings/cluster/registry/show-registry.tsx +++ b/apps/dokploy/components/dashboard/settings/cluster/registry/show-registry.tsx @@ -54,7 +54,7 @@ export const ShowRegistry = () => { key={registry.registryId} className="flex items-center justify-between bg-sidebar p-1 w-full rounded-lg" > -
+
diff --git a/apps/dokploy/components/dashboard/settings/destination/show-destinations.tsx b/apps/dokploy/components/dashboard/settings/destination/show-destinations.tsx index 0639b0f75..014596ce3 100644 --- a/apps/dokploy/components/dashboard/settings/destination/show-destinations.tsx +++ b/apps/dokploy/components/dashboard/settings/destination/show-destinations.tsx @@ -55,7 +55,7 @@ export const ShowDestinations = () => { key={destination.destinationId} className="flex items-center justify-between bg-sidebar p-1 w-full rounded-lg" > -
+
{index + 1}. {destination.name} diff --git a/apps/dokploy/components/dashboard/settings/git/github/add-github-provider.tsx b/apps/dokploy/components/dashboard/settings/git/github/add-github-provider.tsx index 90cefe592..af7d58544 100644 --- a/apps/dokploy/components/dashboard/settings/git/github/add-github-provider.tsx +++ b/apps/dokploy/components/dashboard/settings/git/github/add-github-provider.tsx @@ -18,6 +18,7 @@ import { useEffect, useState } from "react"; export const AddGithubProvider = () => { const [isOpen, setIsOpen] = useState(false); const { data: activeOrganization } = authClient.useActiveOrganization(); + const { data: session } = authClient.useSession(); const { data } = api.user.get.useQuery(); const [manifest, setManifest] = useState(""); const [isOrganization, setIsOrganization] = useState(false); @@ -27,7 +28,7 @@ export const AddGithubProvider = () => { const url = document.location.origin; const manifest = JSON.stringify( { - redirect_url: `${origin}/api/providers/github/setup?organizationId=${activeOrganization?.id}`, + redirect_url: `${origin}/api/providers/github/setup?organizationId=${activeOrganization?.id}&userId=${session?.user?.id}`, name: `Dokploy-${format(new Date(), "yyyy-MM-dd")}`, url: origin, hook_attributes: { diff --git a/apps/dokploy/components/dashboard/settings/git/gitlab/add-gitlab-provider.tsx b/apps/dokploy/components/dashboard/settings/git/gitlab/add-gitlab-provider.tsx index 4dd7da93c..023e46ed2 100644 --- a/apps/dokploy/components/dashboard/settings/git/gitlab/add-gitlab-provider.tsx +++ b/apps/dokploy/components/dashboard/settings/git/gitlab/add-gitlab-provider.tsx @@ -248,7 +248,9 @@ export const AddGitlabProvider = () => { name="groupName" render={({ field }) => ( - Group Name (Optional) + + Group Name (Optional, Comma-Separated List) + { name="groupName" render={({ field }) => ( - Group Name (Optional) + + Group Name (Optional, Comma-Separated List) + { }); } toast.success("Connection Success"); - } catch (_err) { + } catch { toast.error("Error testing the provider"); } }} diff --git a/apps/dokploy/components/dashboard/settings/notifications/show-notifications.tsx b/apps/dokploy/components/dashboard/settings/notifications/show-notifications.tsx index 782b92413..26ac17932 100644 --- a/apps/dokploy/components/dashboard/settings/notifications/show-notifications.tsx +++ b/apps/dokploy/components/dashboard/settings/notifications/show-notifications.tsx @@ -61,7 +61,7 @@ export const ShowNotifications = () => { key={notification.notificationId} className="flex items-center justify-between bg-sidebar p-1 w-full rounded-lg" > -
+
{notification.notificationType === "slack" && (
diff --git a/apps/dokploy/components/dashboard/settings/profile/disable-2fa.tsx b/apps/dokploy/components/dashboard/settings/profile/disable-2fa.tsx index 458bf5632..11f164355 100644 --- a/apps/dokploy/components/dashboard/settings/profile/disable-2fa.tsx +++ b/apps/dokploy/components/dashboard/settings/profile/disable-2fa.tsx @@ -63,7 +63,7 @@ export const Disable2FA = () => { toast.success("2FA disabled successfully"); utils.user.get.invalidate(); setIsOpen(false); - } catch (_error) { + } catch { form.setError("password", { message: "Connection error. Please try again.", }); diff --git a/apps/dokploy/components/dashboard/settings/profile/enable-2fa.tsx b/apps/dokploy/components/dashboard/settings/profile/enable-2fa.tsx index 6cf2c6a53..afc859f41 100644 --- a/apps/dokploy/components/dashboard/settings/profile/enable-2fa.tsx +++ b/apps/dokploy/components/dashboard/settings/profile/enable-2fa.tsx @@ -36,6 +36,7 @@ const PasswordSchema = z.object({ password: z.string().min(8, { message: "Password is required", }), + issuer: z.string().optional(), }); const PinSchema = z.object({ @@ -60,12 +61,86 @@ export const Enable2FA = () => { const [isDialogOpen, setIsDialogOpen] = useState(false); const [step, setStep] = useState<"password" | "verify">("password"); const [isPasswordLoading, setIsPasswordLoading] = useState(false); + const [otpValue, setOtpValue] = useState(""); + + const handleVerifySubmit = async (e: React.FormEvent) => { + e.preventDefault(); + try { + const result = await authClient.twoFactor.verifyTotp({ + code: otpValue, + }); + + if (result.error) { + if (result.error.code === "INVALID_TWO_FACTOR_AUTHENTICATION") { + toast.error("Invalid verification code"); + return; + } + + throw result.error; + } + + if (!result.data) { + throw new Error("No response received from server"); + } + + toast.success("2FA configured successfully"); + utils.user.get.invalidate(); + setIsDialogOpen(false); + } catch (error) { + if (error instanceof Error) { + const errorMessage = + error.message === "Failed to fetch" + ? "Connection error. Please check your internet connection." + : error.message; + + toast.error(errorMessage); + } else { + toast.error("Error verifying 2FA code", { + description: error instanceof Error ? error.message : "Unknown error", + }); + } + } + }; + + const passwordForm = useForm({ + resolver: zodResolver(PasswordSchema), + defaultValues: { + password: "", + }, + }); + + const pinForm = useForm({ + resolver: zodResolver(PinSchema), + defaultValues: { + pin: "", + }, + }); + + useEffect(() => { + if (!isDialogOpen) { + setStep("password"); + setData(null); + setBackupCodes([]); + setOtpValue(""); + passwordForm.reset({ + password: "", + issuer: "", + }); + } + }, [isDialogOpen, passwordForm]); + + useEffect(() => { + if (step === "verify") { + setOtpValue(""); + } + }, [step]); const handlePasswordSubmit = async (formData: PasswordForm) => { setIsPasswordLoading(true); try { const { data: enableData, error } = await authClient.twoFactor.enable({ password: formData.password, + issuer: formData.issuer, }); if (!enableData) { @@ -103,75 +178,6 @@ export const Enable2FA = () => { } }; - const handleVerifySubmit = async (formData: PinForm) => { - try { - const result = await authClient.twoFactor.verifyTotp({ - code: formData.pin, - }); - - if (result.error) { - if (result.error.code === "INVALID_TWO_FACTOR_AUTHENTICATION") { - pinForm.setError("pin", { - message: "Invalid code. Please try again.", - }); - toast.error("Invalid verification code"); - return; - } - - throw result.error; - } - - if (!result.data) { - throw new Error("No response received from server"); - } - - toast.success("2FA configured successfully"); - utils.user.get.invalidate(); - setIsDialogOpen(false); - } catch (error) { - if (error instanceof Error) { - const errorMessage = - error.message === "Failed to fetch" - ? "Connection error. Please check your internet connection." - : error.message; - - pinForm.setError("pin", { - message: errorMessage, - }); - toast.error(errorMessage); - } else { - pinForm.setError("pin", { - message: "Error verifying code", - }); - toast.error("Error verifying 2FA code"); - } - } - }; - - const passwordForm = useForm({ - resolver: zodResolver(PasswordSchema), - defaultValues: { - password: "", - }, - }); - - const pinForm = useForm({ - resolver: zodResolver(PinSchema), - defaultValues: { - pin: "", - }, - }); - - useEffect(() => { - if (!isDialogOpen) { - setStep("password"); - setData(null); - setBackupCodes([]); - passwordForm.reset(); - pinForm.reset(); - } - }, [isDialogOpen, passwordForm, pinForm]); - return ( @@ -217,6 +223,27 @@ export const Enable2FA = () => { )} /> + ( + + Issuer + + + + + Use a custom issuer to identify the service you're + authenticating with. + + + + )} + /> diff --git a/apps/dokploy/components/dashboard/settings/profile/profile-form.tsx b/apps/dokploy/components/dashboard/settings/profile/profile-form.tsx index 32179378a..59e4736de 100644 --- a/apps/dokploy/components/dashboard/settings/profile/profile-form.tsx +++ b/apps/dokploy/components/dashboard/settings/profile/profile-form.tsx @@ -10,6 +10,7 @@ import { import { Form, FormControl, + FormDescription, FormField, FormItem, FormLabel, @@ -17,6 +18,7 @@ import { } from "@/components/ui/form"; import { Input } from "@/components/ui/input"; import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; +import { Switch } from "@/components/ui/switch"; import { generateSHA256Hash } from "@/lib/utils"; import { api } from "@/utils/api"; import { zodResolver } from "@hookform/resolvers/zod"; @@ -34,6 +36,7 @@ const profileSchema = z.object({ password: z.string().nullable(), currentPassword: z.string().nullable(), image: z.string().optional(), + allowImpersonation: z.boolean().optional().default(false), }); type Profile = z.infer; @@ -56,6 +59,8 @@ const randomImages = [ export const ProfileForm = () => { const _utils = api.useUtils(); const { data, refetch, isLoading } = api.user.get.useQuery(); + const { data: isCloud } = api.settings.isCloud.useQuery(); + const { mutateAsync, isLoading: isUpdating, @@ -78,18 +83,26 @@ export const ProfileForm = () => { password: "", image: data?.user?.image || "", currentPassword: "", + allowImpersonation: data?.user?.allowImpersonation || false, }, resolver: zodResolver(profileSchema), }); useEffect(() => { if (data) { - form.reset({ - email: data?.user?.email || "", - password: "", - image: data?.user?.image || "", - currentPassword: "", - }); + form.reset( + { + email: data?.user?.email || "", + password: form.getValues("password") || "", + image: data?.user?.image || "", + currentPassword: form.getValues("currentPassword") || "", + allowImpersonation: data?.user?.allowImpersonation, + }, + { + keepValues: true, + }, + ); + form.setValue("allowImpersonation", data?.user?.allowImpersonation); if (data.user.email) { generateSHA256Hash(data.user.email).then((hash) => { @@ -97,8 +110,7 @@ export const ProfileForm = () => { }); } } - form.reset(); - }, [form, form.reset, data]); + }, [form, data]); const onSubmit = async (values: Profile) => { await mutateAsync({ @@ -106,11 +118,17 @@ export const ProfileForm = () => { password: values.password || undefined, image: values.image, currentPassword: values.currentPassword || undefined, + allowImpersonation: values.allowImpersonation, }) .then(async () => { await refetch(); toast.success("Profile Updated"); - form.reset(); + form.reset({ + email: values.email, + password: "", + image: values.image, + currentPassword: "", + }); }) .catch(() => { toast.error("Error updating the profile"); @@ -246,7 +264,34 @@ export const ProfileForm = () => { )} /> + {isCloud && ( + ( + +
+ Allow Impersonation + + Enable this option to allow Dokploy Cloud + administrators to temporarily access your + account for troubleshooting and support + purposes. This helps them quickly identify and + resolve any issues you may encounter. + +
+ + + +
+ )} + /> + )}
+
- + Node Applications diff --git a/apps/dokploy/components/layouts/dashboard-layout.tsx b/apps/dokploy/components/layouts/dashboard-layout.tsx index 00697e7c4..b4832b4b3 100644 --- a/apps/dokploy/components/layouts/dashboard-layout.tsx +++ b/apps/dokploy/components/layouts/dashboard-layout.tsx @@ -1,9 +1,25 @@ +import { api } from "@/utils/api"; +import { ImpersonationBar } from "../dashboard/impersonation/impersonation-bar"; +import { ChatwootWidget } from "../shared/ChatwootWidget"; import Page from "./side"; + interface Props { children: React.ReactNode; metaName?: string; } export const DashboardLayout = ({ children }: Props) => { - return {children}; + const { data: haveRootAccess } = api.user.haveRootAccess.useQuery(); + const { data: isCloud } = api.settings.isCloud.useQuery(); + + return ( + <> + {children} + {isCloud === true && ( + + )} + + {haveRootAccess === true && } + + ); }; diff --git a/apps/dokploy/components/layouts/project-layout.tsx b/apps/dokploy/components/layouts/project-layout.tsx deleted file mode 100644 index f5fdf3504..000000000 --- a/apps/dokploy/components/layouts/project-layout.tsx +++ /dev/null @@ -1,9 +0,0 @@ -import Page from "./side"; - -interface Props { - children: React.ReactNode; -} - -export const ProjectLayout = ({ children }: Props) => { - return {children}; -}; diff --git a/apps/dokploy/components/layouts/side.tsx b/apps/dokploy/components/layouts/side.tsx index 15d8728bc..8d180967e 100644 --- a/apps/dokploy/components/layouts/side.tsx +++ b/apps/dokploy/components/layouts/side.tsx @@ -10,6 +10,7 @@ import { ChevronRight, ChevronsUpDown, CircleHelp, + Clock, CreditCard, Database, Folder, @@ -158,6 +159,14 @@ const MENU: Menu = { // Only enabled in non-cloud environments isEnabled: ({ isCloud }) => !isCloud, }, + { + isSingle: true, + title: "Schedules", + url: "/dashboard/schedules", + icon: Clock, + // Only enabled in non-cloud environments + isEnabled: ({ isCloud, auth }) => !isCloud && auth?.role === "owner", + }, { isSingle: true, title: "Traefik File System", diff --git a/apps/dokploy/components/layouts/user-nav.tsx b/apps/dokploy/components/layouts/user-nav.tsx index 0aca5b00d..05c601f6e 100644 --- a/apps/dokploy/components/layouts/user-nav.tsx +++ b/apps/dokploy/components/layouts/user-nav.tsx @@ -133,17 +133,6 @@ export const UserNav = () => { Servers )} - - {data?.role === "owner" && ( - { - router.push("/dashboard/settings"); - }} - > - Settings - - )} )} diff --git a/apps/dokploy/components/shared/ChatwootWidget.tsx b/apps/dokploy/components/shared/ChatwootWidget.tsx new file mode 100644 index 000000000..6694b13cc --- /dev/null +++ b/apps/dokploy/components/shared/ChatwootWidget.tsx @@ -0,0 +1,69 @@ +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; + 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 ( +