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 a69fa6861..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 and recommend this specific version. If you have nvm installed, you can run `nvm install 20.9.0 && nvm use` in the root directory. +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 diff --git a/Dockerfile b/Dockerfile index 98ed9851a..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 rsync && 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,7 +50,7 @@ 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.35.0 +ARG NIXPACKS_VERSION=1.39.0 RUN curl -sSL https://nixpacks.com/install.sh -o install.sh \ && chmod +x install.sh \ && ./install.sh \ @@ -63,4 +64,4 @@ RUN curl -sSL https://railpack.com/install.sh | bash COPY --from=buildpacksio/pack:0.35.0 /usr/local/bin/pack /usr/local/bin/pack 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 7e49a35ba..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. diff --git a/README.md b/README.md index cdadce19f..f156d3188 100644 --- a/README.md +++ b/README.md @@ -62,37 +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 - - - - agentdock.ai - + Supafort.com + agentdock.ai
+### Elite Contributors 🥈 + +
+ AmericanCloud + Tolgee + +
+ + ### Supporting Members 🥉 @@ -104,6 +99,7 @@ For detailed documentation, visit [docs.dokploy.com](https://docs.dokploy.com). Itsdb-center Openalternative Synexa + @@ -136,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 74a4eb66f..9fa68b6bb 100644 --- a/apps/dokploy/__test__/drop/drop.test.test.ts +++ b/apps/dokploy/__test__/drop/drop.test.test.ts @@ -105,6 +105,7 @@ const baseApp: ApplicationNested = { ports: [], projectId: "", publishDirectory: null, + isStaticSpa: null, redirects: [], refreshToken: "", registry: null, @@ -120,6 +121,7 @@ const baseApp: ApplicationNested = { updateConfigSwarm: null, username: null, dockerContextPath: null, + rollbackActive: false, }; describe("unzipDrop using real zip files", () => { @@ -149,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__/traefik/traefik.test.ts b/apps/dokploy/__test__/traefik/traefik.test.ts index 5437e64d8..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: "", @@ -85,6 +86,7 @@ const baseApp: ApplicationNested = { ports: [], projectId: "", publishDirectory: null, + isStaticSpa: null, redirects: [], refreshToken: "", registry: null, @@ -117,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/add-volumes.tsx b/apps/dokploy/components/dashboard/application/advanced/volumes/add-volumes.tsx index 718f98b72..639410bb4 100644 --- a/apps/dokploy/components/dashboard/application/advanced/volumes/add-volumes.tsx +++ b/apps/dokploy/components/dashboard/application/advanced/volumes/add-volumes.tsx @@ -1,3 +1,4 @@ +import { AlertBlock } from "@/components/shared/alert-block"; import { CodeEditor } from "@/components/shared/code-editor"; import { Button } from "@/components/ui/button"; import { @@ -169,6 +170,23 @@ export const AddVolumes = ({ onSubmit={form.handleSubmit(onSubmit)} className="grid w-full gap-8 " > + {type === "bind" && ( + +
+

+ Make sure the host path is a valid path and exists in the + host machine. +

+

+ Cluster Warning: If you're using cluster + features, bind mounts may cause deployment failures since + the path must exist on all worker/manager nodes. Consider + using external tools to distribute the folder across nodes + or use named volumes instead. +

+
+
+ )} ( - + 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 9535a318e..291026d4f 100644 --- a/apps/dokploy/components/dashboard/application/build/show.tsx +++ b/apps/dokploy/components/dashboard/application/build/show.tsx @@ -2,6 +2,7 @@ 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, @@ -63,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), }), ]); @@ -83,6 +85,7 @@ interface ApplicationData { dockerBuildStage?: string | null; herokuVersion?: string | null; publishDirectory?: string | null; + isStaticSpa?: boolean | null; } function isValidBuildType(value: string): value is BuildType { @@ -115,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; + } } }; @@ -174,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"); @@ -364,6 +371,30 @@ export const ShowBuildChooseForm = ({ applicationId }: Props) => { )} /> )} + {buildType === BuildType.static && ( + ( + + +
+ + + Single Page Application (SPA) + +
+
+ +
+ )} + /> + )}
- {(type === "application" || type === "compose") && ( - - )} +
+ {(type === "application" || type === "compose") && ( + + )} + {type === "application" && ( + + + + )} +
{refreshToken && ( @@ -86,7 +104,7 @@ export const ShowDeployments = ({ Webhook URL:
- {`${url}/api/deploy/${refreshToken}`} + {`${url}/api/deploy${type === "compose" ? "/compose" : ""}/${refreshToken}`} {(type === "application" || type === "compose") && ( @@ -154,13 +172,73 @@ export const ShowDeployments = ({ )}
- +
+ {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"); + }); + }} + > + + + )} +
))} diff --git a/apps/dokploy/components/dashboard/application/domains/dns-helper-modal.tsx b/apps/dokploy/components/dashboard/application/domains/dns-helper-modal.tsx index 82c25d0f9..e7b2f1877 100644 --- a/apps/dokploy/components/dashboard/application/domains/dns-helper-modal.tsx +++ b/apps/dokploy/components/dashboard/application/domains/dns-helper-modal.tsx @@ -1,3 +1,5 @@ +import { AlertBlock } from "@/components/shared/alert-block"; +import { Button } from "@/components/ui/button"; import { Dialog, DialogContent, @@ -6,8 +8,6 @@ import { DialogTitle, DialogTrigger, } from "@/components/ui/dialog"; -import { Button } from "@/components/ui/button"; -import { AlertBlock } from "@/components/shared/alert-block"; import { Copy, HelpCircle, Server } from "lucide-react"; import { toast } from "sonner"; diff --git a/apps/dokploy/components/dashboard/application/domains/handle-domain.tsx b/apps/dokploy/components/dashboard/application/domains/handle-domain.tsx index c145afcfc..c8522f5f5 100644 --- a/apps/dokploy/components/dashboard/application/domains/handle-domain.tsx +++ b/apps/dokploy/components/dashboard/application/domains/handle-domain.tsx @@ -49,6 +49,8 @@ export const domain = z .object({ host: z.string().min(1, { message: "Add a hostname" }), path: z.string().min(1).optional(), + internalPath: z.string().optional(), + stripPath: z.boolean().optional(), port: z .number() .min(1, { message: "Port must be at least 1" }) @@ -84,6 +86,29 @@ export const domain = z message: "Required", }); } + + // Validate stripPath requires a valid path + if (input.stripPath && (!input.path || input.path === "/")) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["stripPath"], + message: + "Strip path can only be enabled when a path other than '/' is specified", + }); + } + + // Validate internalPath starts with / + if ( + input.internalPath && + input.internalPath !== "/" && + !input.internalPath.startsWith("/") + ) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["internalPath"], + message: "Internal path must start with '/'", + }); + } }); type Domain = z.infer; @@ -162,6 +187,8 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => { defaultValues: { host: "", path: undefined, + internalPath: undefined, + stripPath: false, port: undefined, https: false, certificateType: undefined, @@ -182,6 +209,8 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => { ...data, /* Convert null to undefined */ path: data?.path || undefined, + internalPath: data?.internalPath || undefined, + stripPath: data?.stripPath || false, port: data?.port || undefined, certificateType: data?.certificateType || undefined, customCertResolver: data?.customCertResolver || undefined, @@ -194,6 +223,8 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => { form.reset({ host: "", path: undefined, + internalPath: undefined, + stripPath: false, port: undefined, https: false, certificateType: undefined, @@ -469,6 +500,49 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => { }} /> + { + 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 + + +
+ + + +
+ )} + /> + ; @@ -119,6 +120,7 @@ export const ShowDomains = ({ id, type }: Props) => { isValid: result.isValid, error: result.error, resolvedIp: result.resolvedIp, + cdnProvider: result.cdnProvider, message: result.error && result.isValid ? result.error : undefined, }, })); @@ -186,30 +188,19 @@ export const ShowDomains = ({ id, type }: Props) => { return (
{/* Service & Domain Info */} -
-
- {item.serviceName && ( - - - {item.serviceName} - - )} - - - {item.host} - - -
-
+
+ {item.serviceName && ( + + + {item.serviceName} + + )} +
{!item.host.includes("traefik.me") && ( {
+
+ + {item.host} + + +
{/* Domain Details */}
@@ -355,8 +356,9 @@ export const ShowDomains = ({ id, type }: Props) => { ) : validationState?.isValid ? ( <> - {validationState.message - ? "Behind Cloudflare" + {validationState.message && + validationState.cdnProvider + ? `Behind ${validationState.cdnProvider}` : "DNS Valid"} ) : validationState?.error ? ( 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 f0179d9cf..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 @@ -136,7 +136,7 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => { enableSubmodules: data.enableSubmodules || false, }); } - }, [form.reset, data, form]); + }, [form.reset, data?.applicationId, form]); const onSubmit = async (data: BitbucketProvider) => { await mutateAsync({ @@ -435,7 +435,7 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
{ if (e.key === "Enter") { e.preventDefault(); @@ -454,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) { diff --git a/apps/dokploy/components/dashboard/application/general/generic/save-docker-provider.tsx b/apps/dokploy/components/dashboard/application/general/generic/save-docker-provider.tsx index a1f3367dd..72b2578c5 100644 --- a/apps/dokploy/components/dashboard/application/general/generic/save-docker-provider.tsx +++ b/apps/dokploy/components/dashboard/application/general/generic/save-docker-provider.tsx @@ -53,7 +53,7 @@ export const SaveDockerProvider = ({ applicationId }: Props) => { registryURL: data.registryUrl || "", }); } - }, [form.reset, data, form]); + }, [form.reset, data?.applicationId, form]); const onSubmit = async (values: DockerProvider) => { await mutateAsync({ diff --git a/apps/dokploy/components/dashboard/application/general/generic/save-git-provider.tsx b/apps/dokploy/components/dashboard/application/general/generic/save-git-provider.tsx index 258582077..f3e8116e6 100644 --- a/apps/dokploy/components/dashboard/application/general/generic/save-git-provider.tsx +++ b/apps/dokploy/components/dashboard/application/general/generic/save-git-provider.tsx @@ -17,13 +17,13 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; +import { Switch } from "@/components/ui/switch"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, } from "@/components/ui/tooltip"; -import { Switch } from "@/components/ui/switch"; import { api } from "@/utils/api"; import { zodResolver } from "@hookform/resolvers/zod"; import { KeyRoundIcon, LockIcon, X } from "lucide-react"; @@ -262,7 +262,7 @@ export const SaveGitProvider = ({ applicationId }: Props) => {
{ if (e.key === "Enter") { e.preventDefault(); @@ -281,7 +281,7 @@ export const SaveGitProvider = ({ 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) { diff --git a/apps/dokploy/components/dashboard/application/general/generic/save-gitea-provider.tsx b/apps/dokploy/components/dashboard/application/general/generic/save-gitea-provider.tsx index 531ace127..55fbfebda 100644 --- a/apps/dokploy/components/dashboard/application/general/generic/save-gitea-provider.tsx +++ b/apps/dokploy/components/dashboard/application/general/generic/save-gitea-provider.tsx @@ -158,7 +158,7 @@ export const SaveGiteaProvider = ({ applicationId }: Props) => { enableSubmodules: data.enableSubmodules || false, }); } - }, [form.reset, data, form]); + }, [form.reset, data?.applicationId, form]); const onSubmit = async (data: GiteaProvider) => { await mutateAsync({ @@ -470,7 +470,7 @@ export const SaveGiteaProvider = ({ applicationId }: Props) => {
{ if (e.key === "Enter") { e.preventDefault(); diff --git a/apps/dokploy/components/dashboard/application/general/generic/save-github-provider.tsx b/apps/dokploy/components/dashboard/application/general/generic/save-github-provider.tsx index 0bf1ac8ac..c76b9ae58 100644 --- a/apps/dokploy/components/dashboard/application/general/generic/save-github-provider.tsx +++ b/apps/dokploy/components/dashboard/application/general/generic/save-github-provider.tsx @@ -134,7 +134,7 @@ export const SaveGithubProvider = ({ applicationId }: Props) => { enableSubmodules: data.enableSubmodules ?? false, }); } - }, [form.reset, data, form]); + }, [form.reset, data?.applicationId, form]); const onSubmit = async (data: GithubProvider) => { await mutateAsync({ @@ -474,7 +474,7 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
{ if (e.key === "Enter") { e.preventDefault(); 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 b4b55d3fa..3b054fc99 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 @@ -141,7 +141,7 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => { enableSubmodules: data.enableSubmodules ?? false, }); } - }, [form.reset, data, form]); + }, [form.reset, data?.applicationId, form]); const onSubmit = async (data: GitlabProvider) => { await mutateAsync({ @@ -278,7 +278,7 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => { {repositories?.map((repo) => { return ( { form.setValue("repository", { @@ -299,7 +299,8 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => { {
{ if (e.key === "Enter") { e.preventDefault(); diff --git a/apps/dokploy/components/dashboard/application/general/generic/show.tsx b/apps/dokploy/components/dashboard/application/general/generic/show.tsx index 905fe7113..13d3a6d8f 100644 --- a/apps/dokploy/components/dashboard/application/general/generic/show.tsx +++ b/apps/dokploy/components/dashboard/application/general/generic/show.tsx @@ -16,9 +16,11 @@ import { api } from "@/utils/api"; import { GitBranch, Loader2, UploadCloud } from "lucide-react"; import Link from "next/link"; import { useState } from "react"; +import { toast } from "sonner"; import { SaveBitbucketProvider } from "./save-bitbucket-provider"; import { SaveDragNDrop } from "./save-drag-n-drop"; import { SaveGitlabProvider } from "./save-gitlab-provider"; +import { UnauthorizedGitProvider } from "./unauthorized-git-provider"; type TabState = | "github" @@ -43,12 +45,31 @@ export const ShowProviderForm = ({ applicationId }: Props) => { const { data: giteaProviders, isLoading: isLoadingGitea } = api.gitea.giteaProviders.useQuery(); - const { data: application } = api.application.one.useQuery({ applicationId }); + const { data: application, refetch } = api.application.one.useQuery({ + applicationId, + }); + const { mutateAsync: disconnectGitProvider } = + api.application.disconnectGitProvider.useMutation(); + const [tab, setSab] = useState(application?.sourceType || "github"); const isLoading = isLoadingGithub || isLoadingGitlab || isLoadingBitbucket || isLoadingGitea; + const handleDisconnect = async () => { + try { + await disconnectGitProvider({ applicationId }); + toast.success("Repository disconnected successfully"); + await refetch(); + } catch (error) { + toast.error( + `Failed to disconnect repository: ${ + error instanceof Error ? error.message : "Unknown error" + }`, + ); + } + }; + if (isLoading) { return ( @@ -77,6 +98,38 @@ export const ShowProviderForm = ({ applicationId }: Props) => { ); } + // Check if user doesn't have access to the current git provider + if ( + application && + !application.hasGitProviderAccess && + application.sourceType !== "docker" && + application.sourceType !== "drop" + ) { + return ( + + + +
+ Provider +

+ Repository connection through unauthorized provider +

+
+
+ +
+
+
+ + + +
+ ); + } + return ( diff --git a/apps/dokploy/components/dashboard/application/general/generic/unauthorized-git-provider.tsx b/apps/dokploy/components/dashboard/application/general/generic/unauthorized-git-provider.tsx new file mode 100644 index 000000000..4dbdf7a69 --- /dev/null +++ b/apps/dokploy/components/dashboard/application/general/generic/unauthorized-git-provider.tsx @@ -0,0 +1,149 @@ +import { + BitbucketIcon, + GitIcon, + GiteaIcon, + GithubIcon, + GitlabIcon, +} from "@/components/icons/data-tools-icons"; +import { DialogAction } from "@/components/shared/dialog-action"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import type { RouterOutputs } from "@/utils/api"; +import { AlertCircle, GitBranch, Unlink } from "lucide-react"; + +interface Props { + service: + | RouterOutputs["application"]["one"] + | RouterOutputs["compose"]["one"]; + onDisconnect: () => void; +} + +export const UnauthorizedGitProvider = ({ service, onDisconnect }: Props) => { + const getProviderIcon = (sourceType: string) => { + switch (sourceType) { + case "github": + return ; + case "gitlab": + return ; + case "bitbucket": + return ; + case "gitea": + return ; + case "git": + return ; + default: + return ; + } + }; + + const getRepositoryInfo = () => { + switch (service.sourceType) { + case "github": + return { + repo: service.repository, + branch: service.branch, + owner: service.owner, + }; + case "gitlab": + return { + repo: service.gitlabRepository, + branch: service.gitlabBranch, + owner: service.gitlabOwner, + }; + case "bitbucket": + return { + repo: service.bitbucketRepository, + branch: service.bitbucketBranch, + owner: service.bitbucketOwner, + }; + case "gitea": + return { + repo: service.giteaRepository, + branch: service.giteaBranch, + owner: service.giteaOwner, + }; + case "git": + return { + repo: service.customGitUrl, + branch: service.customGitBranch, + owner: null, + }; + default: + return { repo: null, branch: null, owner: null }; + } + }; + + const { repo, branch, owner } = getRepositoryInfo(); + + return ( +
+ + + + This application is connected to a {service.sourceType} repository + through a git provider that you don't have access to. You can see + basic repository information below, but cannot modify the + configuration. + + + + + + + {getProviderIcon(service.sourceType)} + + {service.sourceType} Repository + + + + + {owner && ( +
+ + Owner: + +

{owner}

+
+ )} + {repo && ( +
+ + Repository: + +

{repo}

+
+ )} + {branch && ( +
+ + Branch: + +

{branch}

+
+ )} + +
+ { + onDisconnect(); + }} + > + + +

+ 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-deployments.tsx b/apps/dokploy/components/dashboard/application/preview-deployments/show-preview-deployments.tsx index d436055e6..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 @@ -24,9 +24,9 @@ import { } 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 { ShowPreviewSettings } from "./show-preview-settings"; -import { ShowDeploymentsModal } from "../deployments/show-deployments-modal"; interface Props { applicationId: string; 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 index 5c34206a8..2d26d7a94 100644 --- a/apps/dokploy/components/dashboard/application/schedules/handle-schedules.tsx +++ b/apps/dokploy/components/dashboard/application/schedules/handle-schedules.tsx @@ -1,40 +1,6 @@ +import { AlertBlock } from "@/components/shared/alert-block"; +import { CodeEditor } from "@/components/shared/code-editor"; import { Button } from "@/components/ui/button"; -import { - Form, - FormControl, - FormField, - FormItem, - FormLabel, - FormMessage, - FormDescription, -} from "@/components/ui/form"; -import { Input } from "@/components/ui/input"; -import { api } from "@/utils/api"; -import { zodResolver } from "@hookform/resolvers/zod"; -import { useForm } from "react-hook-form"; -import { z } from "zod"; -import { - Info, - PlusCircle, - PenBoxIcon, - RefreshCw, - DatabaseZap, -} from "lucide-react"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; -import { - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, -} from "@/components/ui/tooltip"; -import { Switch } from "@/components/ui/switch"; -import { useEffect, useState } from "react"; import { Dialog, DialogContent, @@ -42,10 +8,44 @@ import { DialogTitle, DialogTrigger, } from "@/components/ui/dialog"; -import { toast } from "sonner"; -import { AlertBlock } from "@/components/shared/alert-block"; -import { CodeEditor } from "@/components/shared/code-editor"; +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 = [ diff --git a/apps/dokploy/components/dashboard/application/schedules/show-schedules.tsx b/apps/dokploy/components/dashboard/application/schedules/show-schedules.tsx index bb62eb3e7..ecef0deeb 100644 --- a/apps/dokploy/components/dashboard/application/schedules/show-schedules.tsx +++ b/apps/dokploy/components/dashboard/application/schedules/show-schedules.tsx @@ -1,14 +1,6 @@ +import { DialogAction } from "@/components/shared/dialog-action"; +import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; -import { api } from "@/utils/api"; -import { HandleSchedules } from "./handle-schedules"; -import { - Clock, - Play, - Terminal, - Trash2, - ClipboardList, - Loader2, -} from "lucide-react"; import { Card, CardContent, @@ -16,16 +8,24 @@ import { CardHeader, CardTitle, } from "@/components/ui/card"; -import { Badge } from "@/components/ui/badge"; -import { toast } from "sonner"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, } from "@/components/ui/tooltip"; -import { DialogAction } from "@/components/shared/dialog-action"; +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; @@ -166,12 +166,16 @@ export const ShowSchedules = ({ id, scheduleType = "application" }: Props) => { await runManually({ scheduleId: schedule.scheduleId, - }).then(async () => { - await new Promise((resolve) => - setTimeout(resolve, 1500), - ); - refetchSchedules(); - }); + }) + .then(async () => { + await new Promise((resolve) => + setTimeout(resolve, 1500), + ); + refetchSchedules(); + }) + .catch(() => { + toast.error("Error running schedule"); + }); }} > 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/general/compose-file-editor.tsx b/apps/dokploy/components/dashboard/compose/general/compose-file-editor.tsx index 50f0f4ab5..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,7 +77,7 @@ export const ComposeFileEditor = ({ composeId }: Props) => { composeId, }); }) - .catch((_e) => { + .catch(() => { toast.error("Error updating the Compose config"); }); }; 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 353ccc6ca..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 @@ -136,7 +136,7 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => { enableSubmodules: data.enableSubmodules ?? false, }); } - }, [form.reset, data, form]); + }, [form.reset, data?.composeId, form]); const onSubmit = async (data: BitbucketProvider) => { await mutateAsync({ @@ -437,7 +437,7 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
{ if (e.key === "Enter") { e.preventDefault(); @@ -456,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) { diff --git a/apps/dokploy/components/dashboard/compose/general/generic/save-git-provider-compose.tsx b/apps/dokploy/components/dashboard/compose/general/generic/save-git-provider-compose.tsx index a5968e02e..fc90f4f1a 100644 --- a/apps/dokploy/components/dashboard/compose/general/generic/save-git-provider-compose.tsx +++ b/apps/dokploy/components/dashboard/compose/general/generic/save-git-provider-compose.tsx @@ -263,7 +263,7 @@ export const SaveGitProviderCompose = ({ composeId }: Props) => {
{ if (e.key === "Enter") { e.preventDefault(); @@ -282,7 +282,7 @@ export const SaveGitProviderCompose = ({ 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) { diff --git a/apps/dokploy/components/dashboard/compose/general/generic/save-gitea-provider-compose.tsx b/apps/dokploy/components/dashboard/compose/general/generic/save-gitea-provider-compose.tsx index 6f9b50dad..0b57b03d2 100644 --- a/apps/dokploy/components/dashboard/compose/general/generic/save-gitea-provider-compose.tsx +++ b/apps/dokploy/components/dashboard/compose/general/generic/save-gitea-provider-compose.tsx @@ -142,7 +142,7 @@ export const SaveGiteaProviderCompose = ({ composeId }: Props) => { enableSubmodules: data.enableSubmodules ?? false, }); } - }, [form.reset, data, form]); + }, [form.reset, data?.composeId, form]); const onSubmit = async (data: GiteaProvider) => { await mutateAsync({ @@ -437,7 +437,7 @@ export const SaveGiteaProviderCompose = ({ composeId }: Props) => {
{ if (e.key === "Enter") { e.preventDefault(); diff --git a/apps/dokploy/components/dashboard/compose/general/generic/save-github-provider-compose.tsx b/apps/dokploy/components/dashboard/compose/general/generic/save-github-provider-compose.tsx index 97b57f0b7..5b2019fe3 100644 --- a/apps/dokploy/components/dashboard/compose/general/generic/save-github-provider-compose.tsx +++ b/apps/dokploy/components/dashboard/compose/general/generic/save-github-provider-compose.tsx @@ -134,7 +134,7 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => { enableSubmodules: data.enableSubmodules ?? false, }); } - }, [form.reset, data, form]); + }, [form.reset, data?.composeId, form]); const onSubmit = async (data: GithubProvider) => { await mutateAsync({ @@ -474,7 +474,7 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
{ if (e.key === "Enter") { e.preventDefault(); @@ -496,7 +496,7 @@ export const SaveGithubProviderCompose = ({ 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) { 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 30b542cef..4a63b3ce9 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 @@ -142,7 +142,7 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => { enableSubmodules: data.enableSubmodules ?? false, }); } - }, [form.reset, data, form]); + }, [form.reset, data?.composeId, form]); const onSubmit = async (data: GitlabProvider) => { await mutateAsync({ @@ -280,7 +280,7 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => { {repositories?.map((repo) => { return ( { form.setValue("repository", { @@ -301,7 +301,8 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => { {
{ if (e.key === "Enter") { e.preventDefault(); @@ -472,7 +473,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) { diff --git a/apps/dokploy/components/dashboard/compose/general/generic/show.tsx b/apps/dokploy/components/dashboard/compose/general/generic/show.tsx index afdfbfba4..cd510ad69 100644 --- a/apps/dokploy/components/dashboard/compose/general/generic/show.tsx +++ b/apps/dokploy/components/dashboard/compose/general/generic/show.tsx @@ -18,6 +18,8 @@ import { SaveGitProviderCompose } from "./save-git-provider-compose"; import { SaveGiteaProviderCompose } from "./save-gitea-provider-compose"; import { SaveGithubProviderCompose } from "./save-github-provider-compose"; import { SaveGitlabProviderCompose } from "./save-gitlab-provider-compose"; +import { UnauthorizedGitProvider } from "@/components/dashboard/application/general/generic/unauthorized-git-provider"; +import { toast } from "sonner"; type TabState = "github" | "git" | "raw" | "gitlab" | "bitbucket" | "gitea"; interface Props { @@ -34,12 +36,29 @@ export const ShowProviderFormCompose = ({ composeId }: Props) => { const { data: giteaProviders, isLoading: isLoadingGitea } = api.gitea.giteaProviders.useQuery(); - const { data: compose } = api.compose.one.useQuery({ composeId }); + const { mutateAsync: disconnectGitProvider } = + api.compose.disconnectGitProvider.useMutation(); + + const { data: compose, refetch } = api.compose.one.useQuery({ composeId }); const [tab, setSab] = useState(compose?.sourceType || "github"); const isLoading = isLoadingGithub || isLoadingGitlab || isLoadingBitbucket || isLoadingGitea; + const handleDisconnect = async () => { + try { + await disconnectGitProvider({ composeId }); + toast.success("Repository disconnected successfully"); + await refetch(); + } catch (error) { + toast.error( + `Failed to disconnect repository: ${ + error instanceof Error ? error.message : "Unknown error" + }`, + ); + } + }; + if (isLoading) { return ( @@ -68,6 +87,37 @@ export const ShowProviderFormCompose = ({ composeId }: Props) => { ); } + // Check if user doesn't have access to the current git provider + if ( + compose && + !compose.hasGitProviderAccess && + compose.sourceType !== "raw" + ) { + return ( + + + +
+ Provider +

+ Repository connection through unauthorized provider +

+
+
+ +
+
+
+ + + +
+ ); + } + return ( diff --git a/apps/dokploy/components/dashboard/compose/general/isolated-deployment.tsx b/apps/dokploy/components/dashboard/compose/general/isolated-deployment.tsx index 8ee9c786b..d76f79021 100644 --- a/apps/dokploy/components/dashboard/compose/general/isolated-deployment.tsx +++ b/apps/dokploy/components/dashboard/compose/general/isolated-deployment.tsx @@ -71,8 +71,8 @@ export const IsolatedDeployment = ({ composeId }: Props) => { isolatedDeployment: formData?.isolatedDeployment || false, }) .then(async (_data) => { - randomizeCompose(); - refetch(); + await randomizeCompose(); + await refetch(); toast.success("Compose updated"); }) .catch(() => { @@ -84,15 +84,10 @@ export const IsolatedDeployment = ({ composeId }: Props) => { await mutateAsync({ composeId, suffix: data?.appName || "", - }) - .then(async (data) => { - await utils.project.all.invalidate(); - setCompose(data); - toast.success("Compose Isolated"); - }) - .catch(() => { - toast.error("Error isolating the compose"); - }); + }).then(async (data) => { + await utils.project.all.invalidate(); + setCompose(data); + }); }; return ( diff --git a/apps/dokploy/components/dashboard/compose/general/randomize-compose.tsx b/apps/dokploy/components/dashboard/compose/general/randomize-compose.tsx index 4cc877fde..5ac67e0c8 100644 --- a/apps/dokploy/components/dashboard/compose/general/randomize-compose.tsx +++ b/apps/dokploy/components/dashboard/compose/general/randomize-compose.tsx @@ -77,8 +77,8 @@ export const RandomizeCompose = ({ composeId }: Props) => { randomize: formData?.randomize || false, }) .then(async (_data) => { - randomizeCompose(); - refetch(); + await randomizeCompose(); + await refetch(); toast.success("Compose updated"); }) .catch(() => { @@ -90,15 +90,10 @@ export const RandomizeCompose = ({ composeId }: Props) => { await mutateAsync({ composeId, suffix, - }) - .then(async (data) => { - await utils.project.all.invalidate(); - setCompose(data); - toast.success("Compose randomized"); - }) - .catch(() => { - toast.error("Error randomizing the compose"); - }); + }).then(async (data) => { + await utils.project.all.invalidate(); + setCompose(data); + }); }; return ( diff --git a/apps/dokploy/components/dashboard/compose/general/show-converted-compose.tsx b/apps/dokploy/components/dashboard/compose/general/show-converted-compose.tsx index 89a9e0753..4370dcf87 100644 --- a/apps/dokploy/components/dashboard/compose/general/show-converted-compose.tsx +++ b/apps/dokploy/components/dashboard/compose/general/show-converted-compose.tsx @@ -10,7 +10,7 @@ import { DialogTrigger, } from "@/components/ui/dialog"; import { api } from "@/utils/api"; -import { Puzzle, RefreshCw } from "lucide-react"; +import { Loader2, Puzzle, RefreshCw } from "lucide-react"; import { useEffect, useState } from "react"; import { toast } from "sonner"; @@ -40,7 +40,7 @@ export const ShowConvertedCompose = ({ composeId }: Props) => { .then(() => { refetch(); }) - .catch((_err) => {}); + .catch(() => {}); } }, [isOpen]); @@ -66,36 +66,50 @@ export const ShowConvertedCompose = ({ composeId }: Props) => { Preview your docker-compose file with added domains. Note: At least one domain must be specified for this conversion to take effect. + {isLoading ? ( +
+ +
+ ) : compose?.length === 5 ? ( +
+ + + No converted compose data available. + +
+ ) : ( + <> +
+ +
-
- -
- -
-					
-				
+
+							
+						
+ + )} ); diff --git a/apps/dokploy/components/dashboard/database/backups/restore-backup.tsx b/apps/dokploy/components/dashboard/database/backups/restore-backup.tsx index a8a2f1ae2..76ab7b6cf 100644 --- a/apps/dokploy/components/dashboard/database/backups/restore-backup.tsx +++ b/apps/dokploy/components/dashboard/database/backups/restore-backup.tsx @@ -39,6 +39,12 @@ import { 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"; @@ -48,9 +54,9 @@ import { CheckIcon, ChevronsUpDown, Copy, - RotateCcw, - RefreshCw, DatabaseZap, + RefreshCw, + RotateCcw, } from "lucide-react"; import { useState } from "react"; import { useForm } from "react-hook-form"; @@ -58,12 +64,6 @@ import { toast } from "sonner"; import { z } from "zod"; import type { ServiceType } from "../../application/advanced/show-resources"; import { type LogLine, parseLogs } from "../../docker/logs/utils"; -import { - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, -} from "@/components/ui/tooltip"; type DatabaseType = | Exclude @@ -199,7 +199,7 @@ const RestoreBackupSchema = z } }); -const formatBytes = (bytes: number): string => { +export const formatBytes = (bytes: number): string => { if (bytes === 0) return "0 Bytes"; const k = 1024; const sizes = ["Bytes", "KB", "MB", "GB", "TB"]; @@ -415,7 +415,7 @@ export const RestoreBackup = ({ Search Backup Files {field.value && ( - + {field.value} - {field.value || "Search and select a backup file"} + + {field.value || "Search and select a backup file"} + diff --git a/apps/dokploy/components/dashboard/database/backups/show-backups.tsx b/apps/dokploy/components/dashboard/database/backups/show-backups.tsx index bb3128cf3..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,6 +20,7 @@ import { TooltipProvider, TooltipTrigger, } from "@/components/ui/tooltip"; +import { cn } from "@/lib/utils"; import { api } from "@/utils/api"; import { ClipboardList, @@ -25,17 +33,9 @@ import Link from "next/link"; import { useState } from "react"; import { toast } from "sonner"; import type { ServiceType } from "../../application/advanced/show-resources"; -import { RestoreBackup } from "./restore-backup"; -import { HandleBackup } from "./handle-backup"; -import { cn } from "@/lib/utils"; -import { - MariadbIcon, - MongodbIcon, - MysqlIcon, - PostgresqlIcon, -} from "@/components/icons/data-tools-icons"; -import { AlertBlock } from "@/components/shared/alert-block"; import { ShowDeploymentsModal } from "../../application/deployments/show-deployments-modal"; +import { HandleBackup } from "./handle-backup"; +import { RestoreBackup } from "./restore-backup"; interface Props { id: string; diff --git a/apps/dokploy/components/dashboard/impersonation/impersonation-bar.tsx b/apps/dokploy/components/dashboard/impersonation/impersonation-bar.tsx index 4b89e9842..8a9f55c90 100644 --- a/apps/dokploy/components/dashboard/impersonation/impersonation-bar.tsx +++ b/apps/dokploy/components/dashboard/impersonation/impersonation-bar.tsx @@ -1,24 +1,9 @@ "use client"; -import { authClient } from "@/lib/auth-client"; -import { useEffect, useState } from "react"; +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 { - CheckIcon, - ChevronsUpDown, - Settings2, - UserIcon, - XIcon, - Shield, - Calendar, - Key, - Copy, - Fingerprint, - Building2, - CreditCard, - Server, -} from "lucide-react"; -import { toast } from "sonner"; import { Command, CommandEmpty, @@ -32,19 +17,34 @@ import { PopoverContent, PopoverTrigger, } from "@/components/ui/popover"; -import { cn } from "@/lib/utils"; -import { Logo } from "@/components/shared/logo"; -import { Badge } from "@/components/ui/badge"; import { Tooltip, TooltipContent, - TooltipTrigger, TooltipProvider, + TooltipTrigger, } from "@/components/ui/tooltip"; -import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; -import { format } from "date-fns"; -import copy from "copy-to-clipboard"; +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; diff --git a/apps/dokploy/components/dashboard/project/add-application.tsx b/apps/dokploy/components/dashboard/project/add-application.tsx index c93de2519..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"); }); }; 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 ddc1303e4..01d66fbaa 100644 --- a/apps/dokploy/components/dashboard/projects/handle-project.tsx +++ b/apps/dokploy/components/dashboard/projects/handle-project.tsx @@ -38,7 +38,7 @@ const AddProjectSchema = z.object({ (name) => { const trimmedName = name.trim(); const validNameRegex = - /^[\p{L}\p{N}_-][\p{L}\p{N}\s_-]*[\p{L}\p{N}_-]$/u; + /^[\p{L}\p{N}_-][\p{L}\p{N}\s_.-]*[\p{L}\p{N}_-]$/u; return validNameRegex.test(trimmedName); }, { diff --git a/apps/dokploy/components/dashboard/settings/certificates/utils.ts b/apps/dokploy/components/dashboard/settings/certificates/utils.ts index 80f332d8d..e2aa59ef3 100644 --- a/apps/dokploy/components/dashboard/settings/certificates/utils.ts +++ b/apps/dokploy/components/dashboard/settings/certificates/utils.ts @@ -1,80 +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); } - // ASN.1 tag for UTCTime is 0x17, GeneralizedTime is 0x18 - // We need to find the second occurrence of either tag as it's the "not after" (expiration) date - let dateFound = false; - for (let i = 0; i < bytes.length - 2; i++) { - // Look for sequence containing validity period (0x30) - if (bytes[i] === 0x30) { - // Check next bytes for UTCTime or GeneralizedTime - let j = i + 1; - while (j < bytes.length - 2) { - if (bytes[j] === 0x17 || bytes[j] === 0x18) { - const dateType = bytes[j]; - const dateLength = bytes[j + 1]; - if (typeof dateLength === "undefined") break; + let offset = 0; - if (!dateFound) { - // Skip "not before" date - dateFound = true; - j += dateLength + 2; - continue; - } - - // Found "not after" date - let dateStr = ""; - for (let k = 0; k < dateLength; k++) { - const charCode = bytes[j + 2 + k]; - if (typeof charCode === "undefined") continue; - dateStr += String.fromCharCode(charCode); - } - - if (dateType === 0x17) { - // UTCTime (YYMMDDhhmmssZ) - const year = Number.parseInt(dateStr.slice(0, 2)); - const fullYear = year >= 50 ? 1900 + year : 2000 + year; - return new Date( - Date.UTC( - fullYear, - Number.parseInt(dateStr.slice(2, 4)) - 1, - Number.parseInt(dateStr.slice(4, 6)), - Number.parseInt(dateStr.slice(6, 8)), - Number.parseInt(dateStr.slice(8, 10)), - Number.parseInt(dateStr.slice(10, 12)), - ), - ); - } - - // GeneralizedTime (YYYYMMDDhhmmssZ) - return new Date( - Date.UTC( - Number.parseInt(dateStr.slice(0, 4)), - Number.parseInt(dateStr.slice(4, 6)) - 1, - Number.parseInt(dateStr.slice(6, 8)), - Number.parseInt(dateStr.slice(8, 10)), - Number.parseInt(dateStr.slice(10, 12)), - Number.parseInt(dateStr.slice(12, 14)), - ), - ); - } - j++; + // 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 }; } - return null; + + // Skip the outer certificate sequence + if (der[offset++] !== 0x30) throw new Error("Expected sequence"); + ({ offset } = readLength(offset)); + + // Skip tbsCertificate sequence + if (der[offset++] !== 0x30) throw new Error("Expected tbsCertificate"); + ({ offset } = readLength(offset)); + + // Check for optional version field (context-specific tag [0]) + if (der[offset] === 0xa0) { + offset++; + const versionLen = readLength(offset); + offset = versionLen.offset + versionLen.length; + } + + // Skip serialNumber, signature, issuer + for (let i = 0; i < 3; i++) { + if (der[offset] !== 0x30 && der[offset] !== 0x02) + throw new Error("Unexpected structure"); + offset++; + const fieldLen = readLength(offset); + offset = fieldLen.offset + fieldLen.length; + } + + // Validity sequence (notBefore and notAfter) + if (der[offset++] !== 0x30) throw new Error("Expected validity sequence"); + const validityLen = readLength(offset); + offset = validityLen.offset; + + // notBefore + offset++; + const notBeforeLen = readLength(offset); + offset = notBeforeLen.offset + notBeforeLen.length; + + // notAfter + offset++; + const notAfterLen = readLength(offset); + const notAfterStr = new TextDecoder().decode( + der.slice(notAfterLen.offset, notAfterLen.offset + notAfterLen.length), + ); + + // Parse GeneralizedTime (15 chars) or UTCTime (13 chars) + function parseTime(str: string): Date { + if (str.length === 13) { + // UTCTime YYMMDDhhmmssZ + const year = Number.parseInt(str.slice(0, 2), 10); + const fullYear = year < 50 ? 2000 + year : 1900 + year; + return new Date( + `${fullYear}-${str.slice(2, 4)}-${str.slice(4, 6)}T${str.slice(6, 8)}:${str.slice(8, 10)}:${str.slice(10, 12)}Z`, + ); + } + if (str.length === 15) { + // GeneralizedTime YYYYMMDDhhmmssZ + return new Date( + `${str.slice(0, 4)}-${str.slice(4, 6)}-${str.slice(6, 8)}T${str.slice(8, 10)}:${str.slice(10, 12)}:${str.slice(12, 14)}Z`, + ); + } + throw new Error("Invalid ASN.1 time format"); + } + + return parseTime(notAfterStr); } catch (error) { 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/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/notifications/handle-notifications.tsx b/apps/dokploy/components/dashboard/settings/notifications/handle-notifications.tsx index e04765298..cfa0ca83c 100644 --- a/apps/dokploy/components/dashboard/settings/notifications/handle-notifications.tsx +++ b/apps/dokploy/components/dashboard/settings/notifications/handle-notifications.tsx @@ -1063,7 +1063,7 @@ export const HandleNotifications = ({ notificationId }: Props) => { }); } toast.success("Connection Success"); - } catch (_err) { + } catch { toast.error("Error testing the provider"); } }} 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/profile-form.tsx b/apps/dokploy/components/dashboard/settings/profile/profile-form.tsx index 1a8bc684f..59e4736de 100644 --- a/apps/dokploy/components/dashboard/settings/profile/profile-form.tsx +++ b/apps/dokploy/components/dashboard/settings/profile/profile-form.tsx @@ -18,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"; @@ -29,7 +30,6 @@ import { toast } from "sonner"; import { z } from "zod"; import { Disable2FA } from "./disable-2fa"; import { Enable2FA } from "./enable-2fa"; -import { Switch } from "@/components/ui/switch"; const profileSchema = z.object({ email: z.string(), diff --git a/apps/dokploy/components/dashboard/settings/servers/actions/toggle-docker-cleanup.tsx b/apps/dokploy/components/dashboard/settings/servers/actions/toggle-docker-cleanup.tsx index 12e279423..604ab6ce0 100644 --- a/apps/dokploy/components/dashboard/settings/servers/actions/toggle-docker-cleanup.tsx +++ b/apps/dokploy/components/dashboard/settings/servers/actions/toggle-docker-cleanup.tsx @@ -36,7 +36,7 @@ export const ToggleDockerCleanup = ({ serverId }: Props) => { await refetch(); } toast.success("Docker Cleanup updated"); - } catch (_error) { + } catch { toast.error("Docker Cleanup Error"); } }; diff --git a/apps/dokploy/components/dashboard/settings/servers/gpu-support.tsx b/apps/dokploy/components/dashboard/settings/servers/gpu-support.tsx index c24440a61..fdd57f5b0 100644 --- a/apps/dokploy/components/dashboard/settings/servers/gpu-support.tsx +++ b/apps/dokploy/components/dashboard/settings/servers/gpu-support.tsx @@ -56,7 +56,7 @@ export function GPUSupport({ serverId }: GPUSupportProps) { try { await utils.settings.checkGPUStatus.invalidate({ serverId }); await refetch(); - } catch (_error) { + } catch { toast.error("Failed to refresh GPU status"); } finally { setIsRefreshing(false); @@ -74,7 +74,7 @@ export function GPUSupport({ serverId }: GPUSupportProps) { try { await setupGPU.mutateAsync({ serverId }); - } catch (_error) { + } catch { // Error handling is done in mutation's onError } }; diff --git a/apps/dokploy/components/dashboard/settings/servers/handle-servers.tsx b/apps/dokploy/components/dashboard/settings/servers/handle-servers.tsx index 979941458..a2c9b50a6 100644 --- a/apps/dokploy/components/dashboard/settings/servers/handle-servers.tsx +++ b/apps/dokploy/components/dashboard/settings/servers/handle-servers.tsx @@ -156,6 +156,67 @@ export const HandleServers = ({ serverId }: Props) => { remotely. +
+

+ You will need to purchase or rent a Virtual Private Server (VPS) to + proceed, we recommend to use one of these providers since has been + heavily tested. +

+ + + You are free to use whatever provider, but we recommend to use one + of the above, to avoid issues. + +
{!canCreateMoreServers && ( You cannot create more servers,{" "} diff --git a/apps/dokploy/components/dashboard/settings/servers/show-schedules-modal.tsx b/apps/dokploy/components/dashboard/settings/servers/show-schedules-modal.tsx index 5f6a26e7e..6f6a1a6d0 100644 --- a/apps/dokploy/components/dashboard/settings/servers/show-schedules-modal.tsx +++ b/apps/dokploy/components/dashboard/settings/servers/show-schedules-modal.tsx @@ -1,7 +1,7 @@ -import { useState } from "react"; +import { ShowSchedules } from "@/components/dashboard/application/schedules/show-schedules"; import { Dialog, DialogContent, DialogTrigger } from "@/components/ui/dialog"; import { DropdownMenuItem } from "@/components/ui/dropdown-menu"; -import { ShowSchedules } from "@/components/dashboard/application/schedules/show-schedules"; +import { useState } from "react"; interface Props { serverId: string; diff --git a/apps/dokploy/components/dashboard/settings/servers/show-servers.tsx b/apps/dokploy/components/dashboard/settings/servers/show-servers.tsx index 7ad9df94b..d6465cf09 100644 --- a/apps/dokploy/components/dashboard/settings/servers/show-servers.tsx +++ b/apps/dokploy/components/dashboard/settings/servers/show-servers.tsx @@ -40,10 +40,10 @@ import { HandleServers } from "./handle-servers"; import { SetupServer } from "./setup-server"; import { ShowDockerContainersModal } from "./show-docker-containers-modal"; import { ShowMonitoringModal } from "./show-monitoring-modal"; +import { ShowSchedulesModal } from "./show-schedules-modal"; import { ShowSwarmOverviewModal } from "./show-swarm-overview-modal"; import { ShowTraefikFileSystemModal } from "./show-traefik-file-system-modal"; import { WelcomeSuscription } from "./welcome-stripe/welcome-suscription"; -import { ShowSchedulesModal } from "./show-schedules-modal"; export const ShowServers = () => { const { t } = useTranslation("settings"); @@ -141,7 +141,7 @@ export const ShowServers = () => { - Name + Name {isCloud && ( Status @@ -173,7 +173,7 @@ export const ShowServers = () => { const isActive = server.serverStatus === "active"; return ( - + {server.name} {isCloud && ( diff --git a/apps/dokploy/components/dashboard/settings/servers/welcome-stripe/welcome-suscription.tsx b/apps/dokploy/components/dashboard/settings/servers/welcome-stripe/welcome-suscription.tsx index bab930478..1ec4f2ab9 100644 --- a/apps/dokploy/components/dashboard/settings/servers/welcome-stripe/welcome-suscription.tsx +++ b/apps/dokploy/components/dashboard/settings/servers/welcome-stripe/welcome-suscription.tsx @@ -177,6 +177,14 @@ export const WelcomeSuscription = () => { Hostinger - Get 20% Discount +
  • + + American Cloud - Get $20 Credits + +
  • ; @@ -49,6 +50,10 @@ export const AddInvitation = () => { const [open, setOpen] = useState(false); const utils = api.useUtils(); const [isLoading, setIsLoading] = useState(false); + const { data: isCloud } = api.settings.isCloud.useQuery(); + const { data: emailProviders } = + api.notification.getEmailProviders.useQuery(); + const { mutateAsync: sendInvitation } = api.user.sendInvitation.useMutation(); const [error, setError] = useState(null); const { data: activeOrganization } = authClient.useActiveOrganization(); @@ -56,6 +61,7 @@ export const AddInvitation = () => { defaultValues: { email: "", role: "member", + notificationId: "", }, resolver: zodResolver(addInvitation), }); @@ -74,7 +80,20 @@ export const AddInvitation = () => { if (result.error) { setError(result.error.message || ""); } else { - toast.success("Invitation created"); + if (!isCloud && data.notificationId) { + await sendInvitation({ + invitationId: result.data.id, + notificationId: data.notificationId || "", + }) + .then(() => { + toast.success("Invitation created and email sent"); + }) + .catch((error: any) => { + toast.error(error.message); + }); + } else { + toast.success("Invitation created"); + } setError(null); setOpen(false); } @@ -149,6 +168,47 @@ export const AddInvitation = () => { ); }} /> + + {!isCloud && ( + { + return ( + + Email Provider + + + Select the email provider to send the invitation + + + + ); + }} + /> + )} - + Node Applications diff --git a/apps/dokploy/components/layouts/dashboard-layout.tsx b/apps/dokploy/components/layouts/dashboard-layout.tsx index 25dd77a52..b4832b4b3 100644 --- a/apps/dokploy/components/layouts/dashboard-layout.tsx +++ b/apps/dokploy/components/layouts/dashboard-layout.tsx @@ -1,6 +1,7 @@ -import Page from "./side"; -import { ImpersonationBar } from "../dashboard/impersonation/impersonation-bar"; 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; @@ -9,10 +10,15 @@ interface Props { export const DashboardLayout = ({ children }: Props) => { 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/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 ( +