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 🎖
### Premium Supporters 🥇
+### Elite Contributors 🥈
+
+
+
+
+
+
+
+
### Supporting Members 🥉
@@ -104,6 +99,7 @@ For detailed documentation, visit [docs.dokploy.com](https://docs.dokploy.com).
+
@@ -136,19 +132,6 @@ For detailed documentation, visit [docs.dokploy.com](https://docs.dokploy.com).
-
-
## 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
+
+
+
+
+
+
+
+ Ingress
+ Host
+
+
+
+
+ );
+ }}
+ />
{
{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)
+
+
+
+
+
+ )}
+ />
+ )}
Save
diff --git a/apps/dokploy/components/dashboard/application/deployments/show-deployments-modal.tsx b/apps/dokploy/components/dashboard/application/deployments/show-deployments-modal.tsx
index c018a97cc..24446902d 100644
--- a/apps/dokploy/components/dashboard/application/deployments/show-deployments-modal.tsx
+++ b/apps/dokploy/components/dashboard/application/deployments/show-deployments-modal.tsx
@@ -14,7 +14,8 @@ interface Props {
| "schedule"
| "server"
| "backup"
- | "previewDeployment";
+ | "previewDeployment"
+ | "volumeBackup";
serverId?: string;
refreshToken?: string;
children?: React.ReactNode;
diff --git a/apps/dokploy/components/dashboard/application/deployments/show-deployments.tsx b/apps/dokploy/components/dashboard/application/deployments/show-deployments.tsx
index 199c11545..04631b9b3 100644
--- a/apps/dokploy/components/dashboard/application/deployments/show-deployments.tsx
+++ b/apps/dokploy/components/dashboard/application/deployments/show-deployments.tsx
@@ -1,5 +1,6 @@
import { DateTooltip } from "@/components/shared/date-tooltip";
import { StatusTooltip } from "@/components/shared/status-tooltip";
+import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
@@ -9,12 +10,14 @@ import {
CardTitle,
} from "@/components/ui/card";
import { type RouterOutputs, api } from "@/utils/api";
-import { RocketIcon, Clock, Loader2 } from "lucide-react";
+import { Clock, Loader2, RocketIcon, Settings, RefreshCcw } from "lucide-react";
import React, { useEffect, useState } from "react";
import { CancelQueues } from "./cancel-queues";
import { RefreshToken } from "./refresh-token";
import { ShowDeployment } from "./show-deployment";
-import { Badge } from "@/components/ui/badge";
+import { ShowRollbackSettings } from "../rollbacks/show-rollback-settings";
+import { DialogAction } from "@/components/shared/dialog-action";
+import { toast } from "sonner";
interface Props {
id: string;
@@ -24,7 +27,8 @@ interface Props {
| "schedule"
| "server"
| "backup"
- | "previewDeployment";
+ | "previewDeployment"
+ | "volumeBackup";
refreshToken?: string;
serverId?: string;
}
@@ -57,6 +61,11 @@ export const ShowDeployments = ({
},
);
+ const { mutateAsync: rollback, isLoading: isRollingBack } =
+ api.rollback.rollback.useMutation();
+ const { mutateAsync: killProcess, isLoading: isKillingProcess } =
+ api.deployment.killProcess.useMutation();
+
const [url, setUrl] = React.useState("");
useEffect(() => {
setUrl(document.location.origin);
@@ -71,9 +80,18 @@ export const ShowDeployments = ({
See all the 10 last deployments for this {type}
- {(type === "application" || type === "compose") && (
-
- )}
+
+ {(type === "application" || type === "compose") && (
+
+ )}
+ {type === "application" && (
+
+
+ Configure Rollbacks
+
+
+ )}
+
{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 = ({
)}
- {
- setActiveLog(deployment);
- }}
- >
- View
-
+
+ {deployment.pid && deployment.status === "running" && (
+ {
+ await killProcess({
+ deploymentId: deployment.deploymentId,
+ })
+ .then(() => {
+ toast.success("Process killed successfully");
+ })
+ .catch(() => {
+ toast.error("Error killing process");
+ });
+ }}
+ >
+
+ Kill Process
+
+
+ )}
+ {
+ setActiveLog(deployment);
+ }}
+ >
+ View
+
+
+ {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");
+ });
+ }}
+ >
+
+
+ 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 && (
+
+ )}
+ {repo && (
+
+
+ Repository:
+
+
{repo}
+
+ )}
+ {branch && (
+
+
+ Branch:
+
+
{branch}
+
+ )}
+
+
+
{
+ onDisconnect();
+ }}
+ >
+
+
+ Disconnect Repository
+
+
+
+ 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.
+
+
+
+
+
+
+
+ );
+};
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 ? (
+
+
+
+ ) : (
+
+
+ Add Volume Backup
+
+ )}
+
+
+
+
+ {volumeBackupId ? "Edit" : "Create"} Volume Backup
+
+
+ Create a volume backup to backup your volume to a destination
+
+
+
+
+
+
+ );
+};
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
+
+
+
+
+
+
+ Restore Volume Backup
+
+
+ Select a destination and search for volume backup files
+
+
+ Make sure the volume name is not being used by another container.
+
+
+
+
+
+
+ {
+ 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}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {
+ toast.success("Volume backup run successfully");
+
+ await runManually({
+ volumeBackupId: volumeBackup.volumeBackupId,
+ })
+ .then(async () => {
+ await new Promise((resolve) =>
+ setTimeout(resolve, 1500),
+ );
+ refetchVolumeBackups();
+ })
+ .catch(() => {
+ toast.error("Error running volume backup");
+ });
+ }}
+ >
+
+
+
+
+ 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.
+
+
+ ) : (
+ <>
+
+ {
+ mutateAsync({ composeId })
+ .then(() => {
+ refetch();
+ toast.success("Fetched source type");
+ })
+ .catch((err) => {
+ toast.error("Error fetching source type", {
+ description: err.message,
+ });
+ });
+ }}
+ >
+ Refresh
+
+
-
- {
- mutateAsync({ composeId })
- .then(() => {
- refetch();
- toast.success("Fetched source type");
- })
- .catch((err) => {
- toast.error("Error fetching source type", {
- description: err.message,
- });
- });
- }}
- >
- Refresh
-
-
-
-
-
-
+
+
+
+ >
+ )}
);
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
-
Name
-
setName(e.target.value)}
- placeholder="New project name"
- />
+
Duplicate to
+
+
+
+ New project
+
+
+
+ Same project
+
+
-
- Description
- setDescription(e.target.value)}
- placeholder="Project description (optional)"
- />
-
+ {duplicateType === "new-project" && (
+ <>
+
+ Name
+ setName(e.target.value)}
+ placeholder="New project name"
+ />
+
+
+
+ Description
+ setDescription(e.target.value)}
+ placeholder="Project description (optional)"
+ />
+
+ >
+ )}
Selected services to duplicate
@@ -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
+
+
+
+
+
+
+
+ {emailProviders?.map((provider) => (
+
+ {provider.name}
+
+ ))}
+
+ None
+
+
+
+
+ Select the email provider to send the invitation
+
+
+
+ );
+ }}
+ />
+ )}
{
{invitation.status === "pending" && (
{
+ onSelect={() => {
copy(
`${origin}/invitation?token=${invitation.id}`,
);
@@ -162,7 +162,7 @@ export const ShowInvitations = () => {
{invitation.status === "pending" && (
{
+ onSelect={async () => {
const result =
await authClient.organization.cancelInvitation(
{
@@ -185,24 +185,21 @@ export const ShowInvitations = () => {
Cancel Invitation
)}
-
- {
- await removeInvitation({
- invitationId: invitation.id,
- }).then(() => {
- refetch();
- toast.success(
- "Invitation removed",
- );
- });
- }}
- >
- Remove Invitation
-
>
)}
+ {
+ await removeInvitation({
+ invitationId: invitation.id,
+ }).then(() => {
+ refetch();
+ toast.success("Invitation removed");
+ });
+ }}
+ >
+ Remove Invitation
+
diff --git a/apps/dokploy/components/dashboard/settings/web-server/manage-traefik-ports.tsx b/apps/dokploy/components/dashboard/settings/web-server/manage-traefik-ports.tsx
index dd9839e3d..a5cfb6308 100644
--- a/apps/dokploy/components/dashboard/settings/web-server/manage-traefik-ports.tsx
+++ b/apps/dokploy/components/dashboard/settings/web-server/manage-traefik-ports.tsx
@@ -91,7 +91,7 @@ export const ManageTraefikPorts = ({ children, serverId }: Props) => {
});
toast.success(t("settings.server.webServer.traefik.portsUpdated"));
setOpen(false);
- } catch (_error) {}
+ } catch {}
};
return (
diff --git a/apps/dokploy/components/dashboard/swarm/applications/show-applications.tsx b/apps/dokploy/components/dashboard/swarm/applications/show-applications.tsx
index 681afd755..7e0f25fed 100644
--- a/apps/dokploy/components/dashboard/swarm/applications/show-applications.tsx
+++ b/apps/dokploy/components/dashboard/swarm/applications/show-applications.tsx
@@ -87,7 +87,7 @@ export const ShowNodeApplications = ({ serverId }: Props) => {
Services
-
+
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 (
+