diff --git a/.github/sponsors/agentdock.png b/.github/sponsors/agentdock.png
new file mode 100644
index 000000000..bd76dc171
Binary files /dev/null and b/.github/sponsors/agentdock.png differ
diff --git a/.github/sponsors/american-cloud.png b/.github/sponsors/american-cloud.png
new file mode 100644
index 000000000..daa902078
Binary files /dev/null and b/.github/sponsors/american-cloud.png differ
diff --git a/.github/workflows/dokploy.yml b/.github/workflows/dokploy.yml
index adcb1bb54..0f65a50c9 100644
--- a/.github/workflows/dokploy.yml
+++ b/.github/workflows/dokploy.yml
@@ -2,7 +2,7 @@ name: Dokploy Docker Build
on:
push:
- branches: [main, canary, "feat/better-auth-2"]
+ branches: [main, canary, "1061-custom-docker-service-hostname"]
env:
IMAGE_NAME: dokploy/dokploy
diff --git a/.github/workflows/format.yml b/.github/workflows/format.yml
new file mode 100644
index 000000000..827ccc709
--- /dev/null
+++ b/.github/workflows/format.yml
@@ -0,0 +1,22 @@
+name: autofix.ci
+
+on:
+ push:
+ branches: [canary]
+ pull_request:
+ branches: [canary]
+
+jobs:
+ format:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v3
+
+ - name: Setup biomeJs
+ uses: biomejs/setup-biome@v2
+
+ - name: Run Biome formatter
+ run: biome format . --write
+
+ - uses: autofix-ci/action@551dded8c6cc8a1054039c8bc0b8b48c51dfc6ef
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 8584fdf65..0ac5a3581 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -52,7 +52,7 @@ feat: add new feature
Before you start, please make the clone based on the `canary` branch, since the `main` branch is the source of truth and should always reflect the latest stable release, also the PRs will be merged to the `canary` branch.
-We use Node v20.9.0
+We use Node v20.16.0 and recommend this specific version. If you have nvm installed, you can run `nvm install 20.16.0 && nvm use` in the root directory.
```bash
git clone https://github.com/dokploy/dokploy.git
@@ -61,9 +61,9 @@ pnpm install
cp apps/dokploy/.env.example apps/dokploy/.env
```
-## Development
+## Requirements
-Is required to have **Docker** installed on your machine.
+- [Docker](/GUIDES.md#docker)
### Setup
@@ -87,6 +87,8 @@ pnpm run dokploy:dev
Go to http://localhost:3000 to see the development server
+Note: this project uses Biome. If your editor is configured to use another formatter such as Prettier, it's recommended to either change it to use Biome or turn it off.
+
## Build
```bash
@@ -145,11 +147,9 @@ curl -sSL https://railpack.com/install.sh | sh
```bash
# Install Buildpacks
-curl -sSL "https://github.com/buildpacks/pack/releases/download/v0.32.1/pack-v0.32.1-linux.tgz" | tar -C /usr/local/bin/ --no-same-owner -xzv pack
+curl -sSL "https://github.com/buildpacks/pack/releases/download/v0.35.0/pack-v0.35.0-linux.tgz" | tar -C /usr/local/bin/ --no-same-owner -xzv pack
```
-
-
## Pull Request
- The `main` branch is the source of truth and should always reflect the latest stable release.
@@ -165,86 +165,7 @@ Thank you for your contribution!
## Templates
-To add a new template, go to `templates` folder and create a new folder with the name of the template.
-
-Let's take the example of `plausible` template.
-
-1. create a folder in `templates/plausible`
-2. create a `docker-compose.yml` file inside the folder with the content of compose.
-3. create a `index.ts` file inside the folder with the following code as base:
-4. When creating a pull request, please provide a video of the template working in action.
-
-```typescript
-// EXAMPLE
-import {
- generateBase64,
- generateHash,
- generateRandomDomain,
- type Template,
- type Schema,
- type DomainSchema,
-} from "../utils";
-
-export function generate(schema: Schema): Template {
- // do your stuff here, like create a new domain, generate random passwords, mounts.
- const mainServiceHash = generateHash(schema.projectName);
- const mainDomain = generateRandomDomain(schema);
- const secretBase = generateBase64(64);
- const toptKeyBase = generateBase64(32);
-
- const domains: DomainSchema[] = [
- {
- host: mainDomain,
- port: 8000,
- serviceName: "plausible",
- },
- ];
-
- const envs = [
- `BASE_URL=http://${mainDomain}`,
- `SECRET_KEY_BASE=${secretBase}`,
- `TOTP_VAULT_KEY=${toptKeyBase}`,
- `HASH=${mainServiceHash}`,
- ];
-
- const mounts: Template["mounts"] = [
- {
- filePath: "./clickhouse/clickhouse-config.xml",
- content: "some content......",
- },
- ];
-
- return {
- envs,
- mounts,
- domains,
- };
-}
-```
-
-4. Now you need to add the information about the template to the `templates/templates.ts` is a object with the following properties:
-
-**Make sure the id of the template is the same as the folder name and don't have any spaces, only slugified names and lowercase.**
-
-```typescript
-{
- id: "plausible",
- name: "Plausible",
- version: "v2.1.0",
- description:
- "Plausible is a open source, self-hosted web analytics platform that lets you track website traffic and user behavior.",
- logo: "plausible.svg", // we defined the name and the extension of the logo
- links: {
- github: "https://github.com/plausible/plausible",
- website: "https://plausible.io/",
- docs: "https://plausible.io/docs",
- },
- tags: ["analytics"],
- load: () => import("./plausible/index").then((m) => m.generate),
-},
-```
-
-5. Add the logo or image of the template to `public/templates/plausible.svg`
+To add a new template, go to `https://github.com/Dokploy/templates` repository and read the README.md file.
### Recommendations
diff --git a/Dockerfile b/Dockerfile
index a5bd7e5e4..4d18a99ab 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,7 +1,9 @@
-FROM node:20.9-slim AS base
+# syntax=docker/dockerfile:1
+FROM node:20.16.0-slim AS base
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN corepack enable
+RUN corepack prepare pnpm@9.12.0 --activate
FROM base AS build
COPY . /usr/src/app
@@ -29,7 +31,7 @@ WORKDIR /app
# Set production
ENV NODE_ENV=production
-RUN apt-get update && apt-get install -y curl unzip apache2-utils iproute2 && rm -rf /var/lib/apt/lists/*
+RUN apt-get update && apt-get install -y curl unzip zip apache2-utils iproute2 rsync git-lfs && git lfs install && rm -rf /var/lib/apt/lists/*
# Copy only the necessary files
COPY --from=build /prod/dokploy/.next ./.next
@@ -49,18 +51,18 @@ RUN curl -fsSL https://get.docker.com -o get-docker.sh && sh get-docker.sh && rm
# Install Nixpacks and tsx
# | VERBOSE=1 VERSION=1.21.0 bash
-ARG NIXPACKS_VERSION=1.29.1
+ARG NIXPACKS_VERSION=1.39.0
RUN curl -sSL https://nixpacks.com/install.sh -o install.sh \
&& chmod +x install.sh \
&& ./install.sh \
&& pnpm install -g tsx
# Install Railpack
-ARG RAILPACK_VERSION=0.0.37
+ARG RAILPACK_VERSION=0.0.64
RUN curl -sSL https://railpack.com/install.sh | bash
# Install buildpacks
COPY --from=buildpacksio/pack:0.35.0 /usr/local/bin/pack /usr/local/bin/pack
EXPOSE 3000
-CMD [ "pnpm", "start" ]
\ No newline at end of file
+CMD [ "pnpm", "start" ]
diff --git a/Dockerfile.cloud b/Dockerfile.cloud
index c1b667963..8e4bac215 100644
--- a/Dockerfile.cloud
+++ b/Dockerfile.cloud
@@ -1,7 +1,9 @@
-FROM node:20.9-slim AS base
+# syntax=docker/dockerfile:1
+FROM node:20.16.0-slim AS base
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN corepack enable
+RUN corepack prepare pnpm@9.12.0 --activate
FROM base AS build
COPY . /usr/src/app
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..ecb125e09 100644
--- a/Dockerfile.schedule
+++ b/Dockerfile.schedule
@@ -1,7 +1,9 @@
-FROM node:20.9-slim AS base
+# syntax=docker/dockerfile:1
+FROM node:20.16.0-slim AS base
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN corepack enable
+RUN corepack prepare pnpm@9.12.0 --activate
FROM base AS build
COPY . /usr/src/app
diff --git a/Dockerfile.server b/Dockerfile.server
index 8fef51422..ea6b372e8 100644
--- a/Dockerfile.server
+++ b/Dockerfile.server
@@ -1,7 +1,9 @@
-FROM node:20.9-slim AS base
+# syntax=docker/dockerfile:1
+FROM node:20.16.0-slim AS base
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN corepack enable
+RUN corepack prepare pnpm@9.12.0 --activate
FROM base AS build
COPY . /usr/src/app
diff --git a/GUIDES.md b/GUIDES.md
new file mode 100644
index 000000000..90fba522d
--- /dev/null
+++ b/GUIDES.md
@@ -0,0 +1,50 @@
+# Docker
+
+Here's how to install docker on different operating systems:
+
+## macOS
+
+1. Visit [Docker Desktop for Mac](https://www.docker.com/products/docker-desktop)
+2. Download the Docker Desktop installer
+3. Double-click the downloaded `.dmg` file
+4. Drag Docker to your Applications folder
+5. Open Docker Desktop from Applications
+6. Follow the onboarding tutorial if desired
+
+## Linux
+
+### 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 ca-certificates curl
+sudo install -m 0755 -d /etc/apt/keyrings
+
+# Add Docker's official GPG key
+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
+
+# Add the repository to Apt sources
+echo \
+ "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 docker-buildx-plugin docker-compose-plugin
+```
+
+## Windows
+
+1. Enable WSL2 if not already enabled
+2. Visit [Docker Desktop for Windows](https://www.docker.com/products/docker-desktop)
+3. Download the installer
+4. Run the installer and follow the prompts
+5. Start Docker Desktop from the Start menu
\ No newline at end of file
diff --git a/LICENSE.MD b/LICENSE.MD
index 8a508efb4..6cbef2c6d 100644
--- a/LICENSE.MD
+++ b/LICENSE.MD
@@ -2,7 +2,7 @@
## Core License (Apache License 2.0)
-Copyright 2024 Mauricio Siu.
+Copyright 2025 Mauricio Siu.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@@ -19,8 +19,8 @@ See the License for the specific language governing permissions and limitations
The following additional terms apply to the multi-node support, Docker Compose file, Preview Deployments and Multi Server features of Dokploy. In the event of a conflict, these provisions shall take precedence over those in the Apache License:
-- **Self-Hosted Version Free**: All features of Dokploy, including multi-node support, Docker Compose file support, Preview Deployments and Multi Server, will always be free to use in the self-hosted version.
-- **Restriction on Resale**: The multi-node support, Docker Compose file support, Preview Deployments and Multi Server features cannot be sold or offered as a service by any party other than the copyright holder without prior written consent.
-- **Modification Distribution**: Any modifications to the multi-node support, Docker Compose file support, Preview Deployments and Multi Server features must be distributed freely and cannot be sold or offered as a service.
+- **Self-Hosted Version Free**: All features of Dokploy, including multi-node support, Docker Compose file support, Schedules, Preview Deployments and Multi Server, will always be free to use in the self-hosted version.
+- **Restriction on Resale**: The multi-node support, Docker Compose file support, Schedules, Preview Deployments and Multi Server features cannot be sold or offered as a service by any party other than the copyright holder without prior written consent.
+- **Modification Distribution**: Any modifications to the multi-node support, Docker Compose file support, Schedules, Preview Deployments and Multi Server features must be distributed freely and cannot be sold or offered as a service.
For further inquiries or permissions, please contact us directly.
diff --git a/README.md b/README.md
index 9246cf556..bd27474e0 100644
--- a/README.md
+++ b/README.md
@@ -1,20 +1,16 @@
-
-
-
-
-
Join us on Discord for help, feedback, and discussions!
+
+
+
+
+
Join us on Discord for help, feedback, and discussions!
-
+
Dokploy is a free, self-hostable Platform as a Service (PaaS) that simplifies the deployment and management of applications and databases.
### Features
@@ -61,57 +57,48 @@ For detailed documentation, visit [docs.dokploy.com](https://docs.dokploy.com).
### Hero Sponsors 🎖
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
### Premium Supporters 🥇
-
-
+
+
+
-### Supporting Members 🥉
+### Elite Contributors 🥈
-
-
-
-
-
-
-
+
+
+
+### Supporting Members 🥉
+
+
+
+
+
+
+
### Community Backers 🤝
-
#### Organizations:
-[](https://opencollective.com/dokploy)
+[Sponsors on Open Collective](https://opencollective.com/dokploy)
#### Individuals:
@@ -121,27 +108,14 @@ For detailed documentation, visit [docs.dokploy.com](https://docs.dokploy.com).
-
+
## Video Tutorial
-
+
-
-
## 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..98fcea0d3 100644
--- a/apps/api/package.json
+++ b/apps/api/package.json
@@ -9,25 +9,25 @@
"typecheck": "tsc --noEmit"
},
"dependencies": {
+ "@dokploy/server": "workspace:*",
+ "@hono/node-server": "^1.14.3",
+ "@hono/zod-validator": "0.3.0",
+ "@nerimity/mimiqueue": "1.2.3",
+ "dotenv": "^16.4.5",
+ "hono": "^4.7.10",
"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",
- "dotenv": "^16.3.1",
"redis": "4.7.0",
- "@nerimity/mimiqueue": "1.2.3"
+ "zod": "^3.25.32"
},
"devDependencies": {
- "typescript": "^5.4.2",
+ "@types/node": "^20.17.51",
"@types/react": "^18.2.37",
"@types/react-dom": "^18.2.15",
- "@types/node": "^20.11.17",
- "tsx": "^4.7.1"
+ "tsx": "^4.16.2",
+ "typescript": "^5.8.3"
},
"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/CONTRIBUTING.md b/apps/dokploy/CONTRIBUTING.md
deleted file mode 100644
index 8686b98a8..000000000
--- a/apps/dokploy/CONTRIBUTING.md
+++ /dev/null
@@ -1,242 +0,0 @@
-
-
-# Contributing
-
-Hey, thanks for your interest in contributing to Dokploy! We appreciate your help and taking your time to contribute.
-
-
-Before you start, please first discuss the feature/bug you want to add with the owners and comunity via github issues.
-
-We have a few guidelines to follow when contributing to this project:
-
-- [Commit Convention](#commit-convention)
-- [Setup](#setup)
-- [Development](#development)
-- [Build](#build)
-- [Pull Request](#pull-request)
-
-## Commit Convention
-
-Before you craete a Pull Request, please make sure your commit message follows the [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/) specification.
-
-### Commit Message Format
-```
-
[optional scope]:
-
-[optional body]
-
-[optional footer(s)]
-```
-
-#### Type
-Must be one of the following:
-
-* **feat**: A new feature
-* **fix**: A bug fix
-* **docs**: Documentation only changes
-* **style**: Changes that do not affect the meaning of the code (white-space, formatting, missing semi-colons, etc)
-* **refactor**: A code change that neither fixes a bug nor adds a feature
-* **perf**: A code change that improves performance
-* **test**: Adding missing tests or correcting existing tests
-* **build**: Changes that affect the build system or external dependencies (example scopes: gulp, broccoli, npm)
-* **ci**: Changes to our CI configuration files and scripts (example scopes: Travis, Circle, BrowserStack, SauceLabs)
-* **chore**: Other changes that don't modify `src` or `test` files
-* **revert**: Reverts a previous commit
-
-Example:
-```
-feat: add new feature
-```
-
-
-
-
-## Setup
-
-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.
-
-```bash
-git clone https://github.com/dokploy/dokploy.git
-cd dokploy
-pnpm install
-cp .env.example .env
-```
-
-## Development
-
-Is required to have **Docker** installed on your machine.
-
-
-### Setup
-
-Run the command that will spin up all the required services and files.
-
-```bash
-pnpm run setup
-```
-
-Now run the development server.
-
-```bash
-pnpm run dev
-```
-
-
-Go to http://localhost:3000 to see the development server
-
-## Build
-
-```bash
-pnpm run build
-```
-
-## Docker
-
-To build the docker image
-```bash
-pnpm run docker:build
-```
-
-To push the docker image
-```bash
-pnpm run docker:push
-```
-
-## Password Reset
-
-In the case you lost your password, you can reset it using the following command
-
-```bash
-pnpm run reset-password
-```
-
-If you want to test the webhooks on development mode using localtunnel, make sure to install `localtunnel`
-
-```bash
-bunx lt --port 3000
-```
-
-If you run into permission issues of docker run the following command
-
-```bash
-sudo chown -R USERNAME dokploy or sudo chown -R $(whoami) ~/.docker
-```
-
-## Application deploy
-
-In case you want to deploy the application on your machine and you selected nixpacks or buildpacks, you need to install first.
-
-```bash
-# Install Nixpacks
-curl -sSL https://nixpacks.com/install.sh -o install.sh \
- && chmod +x install.sh \
- && ./install.sh
-```
-
-```bash
-# Install Buildpacks
-curl -sSL "https://github.com/buildpacks/pack/releases/download/v0.32.1/pack-v0.32.1-linux.tgz" | tar -C /usr/local/bin/ --no-same-owner -xzv pack
-```
-
-
-## Pull Request
-
-- The `main` branch is the source of truth and should always reflect the latest stable release.
-- Create a new branch for each feature or bug fix.
-- Make sure to add tests for your changes.
-- Make sure to update the documentation for any changes Go to the [docs.dokploy.com](https://docs.dokploy.com) website to see the changes.
-- When creating a pull request, please provide a clear and concise description of the changes made.
-- If you include a video or screenshot, would be awesome so we can see the changes in action.
-- If your pull request fixes an open issue, please reference the issue in the pull request description.
-- Once your pull request is merged, you will be automatically added as a contributor to the project.
-
-Thank you for your contribution!
-
-
-
-
-
-## Templates
-
-To add a new template, go to `templates` folder and create a new folder with the name of the template.
-
-Let's take the example of `plausible` template.
-
-1. create a folder in `templates/plausible`
-2. create a `docker-compose.yml` file inside the folder with the content of compose.
-3. create a `index.ts` file inside the folder with the following code as base:
-4. When creating a pull request, please provide a video of the template working in action.
-
-```typescript
-// EXAMPLE
-import {
- generateHash,
- generateRandomDomain,
- type Template,
- type Schema,
-} from "../utils";
-
-
-export function generate(schema: Schema): Template {
-
- // do your stuff here, like create a new domain, generate random passwords, mounts.
- const mainServiceHash = generateHash(schema.projectName);
- const randomDomain = generateRandomDomain(schema);
- const secretBase = generateBase64(64);
- const toptKeyBase = generateBase64(32);
-
- const envs = [
-// If you want to show a domain in the UI, please add the prefix _HOST at the end of the variable name.
- `PLAUSIBLE_HOST=${randomDomain}`,
- "PLAUSIBLE_PORT=8000",
- `BASE_URL=http://${randomDomain}`,
- `SECRET_KEY_BASE=${secretBase}`,
- `TOTP_VAULT_KEY=${toptKeyBase}`,
- `HASH=${mainServiceHash}`,
- ];
-
- const mounts: Template["mounts"] = [
- {
- mountPath: "./clickhouse/clickhouse-config.xml",
- content: `some content......`,
- },
- ];
-
- return {
- envs,
- mounts,
- };
-}
-```
-
-4. Now you need to add the information about the template to the `templates/templates.ts` is a object with the following properties:
-
-**Make sure the id of the template is the same as the folder name and don't have any spaces, only slugified names and lowercase.**
-
-```typescript
-{
- id: "plausible",
- name: "Plausible",
- version: "v2.1.0",
- description:
- "Plausible is a open source, self-hosted web analytics platform that lets you track website traffic and user behavior.",
- logo: "plausible.svg", // we defined the name and the extension of the logo
- links: {
- github: "https://github.com/plausible/plausible",
- website: "https://plausible.io/",
- docs: "https://plausible.io/docs",
- },
- tags: ["analytics"],
- load: () => import("./plausible/index").then((m) => m.generate),
-},
-```
-
-5. Add the logo or image of the template to `public/templates/plausible.svg`
-
-
-### Recomendations
-- Use the same name of the folder as the id of the template.
-- The logo should be in the public folder.
-- If you want to show a domain in the UI, please add the prefix _HOST at the end of the variable name.
-- Test first on a vps or a server to make sure the template works.
-
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 8bc9fbccb..172bff2af 100644
--- a/apps/dokploy/__test__/compose/domain/labels.test.ts
+++ b/apps/dokploy/__test__/compose/domain/labels.test.ts
@@ -9,6 +9,7 @@ describe("createDomainLabels", () => {
port: 8080,
https: false,
uniqueConfigKey: 1,
+ customCertResolver: null,
certificateType: "none",
applicationId: "",
composeId: "",
@@ -18,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__/compose/volume/volume-2.test.ts b/apps/dokploy/__test__/compose/volume/volume-2.test.ts
index bf34ed494..61cba82d3 100644
--- a/apps/dokploy/__test__/compose/volume/volume-2.test.ts
+++ b/apps/dokploy/__test__/compose/volume/volume-2.test.ts
@@ -1006,7 +1006,7 @@ services:
volumes:
db-config-testhash:
-`) as ComposeSpecification;
+`);
test("Expect to change the suffix in all the possible places (4 Try)", () => {
const composeData = load(composeFileComplex) as ComposeSpecification;
@@ -1115,3 +1115,60 @@ test("Expect to change the suffix in all the possible places (5 Try)", () => {
expect(updatedComposeData).toEqual(expectedDockerComposeExample1);
});
+
+const composeFileBackrest = `
+services:
+ backrest:
+ image: garethgeorge/backrest:v1.7.3
+ restart: unless-stopped
+ ports:
+ - 9898
+ environment:
+ - BACKREST_PORT=9898
+ - BACKREST_DATA=/data
+ - BACKREST_CONFIG=/config/config.json
+ - XDG_CACHE_HOME=/cache
+ - TZ=\${TZ}
+ volumes:
+ - backrest/data:/data
+ - backrest/config:/config
+ - backrest/cache:/cache
+ - /:/userdata:ro
+
+volumes:
+ backrest:
+ backrest-cache:
+`;
+
+const expectedDockerComposeBackrest = load(`
+services:
+ backrest:
+ image: garethgeorge/backrest:v1.7.3
+ restart: unless-stopped
+ ports:
+ - 9898
+ environment:
+ - BACKREST_PORT=9898
+ - BACKREST_DATA=/data
+ - BACKREST_CONFIG=/config/config.json
+ - XDG_CACHE_HOME=/cache
+ - TZ=\${TZ}
+ volumes:
+ - backrest-testhash/data:/data
+ - backrest-testhash/config:/config
+ - backrest-testhash/cache:/cache
+ - /:/userdata:ro
+
+volumes:
+ backrest-testhash:
+ backrest-cache-testhash:
+`) as ComposeSpecification;
+
+test("Should handle volume paths with subdirectories correctly", () => {
+ const composeData = load(composeFileBackrest) as ComposeSpecification;
+ const suffix = "testhash";
+
+ const updatedComposeData = addSuffixToAllVolumes(composeData, suffix);
+
+ expect(updatedComposeData).toEqual(expectedDockerComposeBackrest);
+});
diff --git a/apps/dokploy/__test__/drop/drop.test.test.ts b/apps/dokploy/__test__/drop/drop.test.test.ts
index 4e6f20d3f..9fa68b6bb 100644
--- a/apps/dokploy/__test__/drop/drop.test.test.ts
+++ b/apps/dokploy/__test__/drop/drop.test.test.ts
@@ -27,7 +27,16 @@ if (typeof window === "undefined") {
const baseApp: ApplicationNested = {
applicationId: "",
herokuVersion: "",
+ giteaBranch: "",
+ giteaBuildPath: "",
+ giteaId: "",
+ giteaOwner: "",
+ giteaRepository: "",
+ cleanCache: false,
+ watchPaths: [],
+ enableSubmodules: false,
applicationStatus: "done",
+ triggerType: "push",
appName: "",
autoDeploy: true,
serverId: "",
@@ -37,6 +46,7 @@ const baseApp: ApplicationNested = {
isPreviewDeploymentsActive: false,
previewBuildArgs: null,
previewCertificateType: "none",
+ previewCustomCertResolver: null,
previewEnv: null,
previewHttps: false,
previewPath: "/",
@@ -95,6 +105,7 @@ const baseApp: ApplicationNested = {
ports: [],
projectId: "",
publishDirectory: null,
+ isStaticSpa: null,
redirects: [],
refreshToken: "",
registry: null,
@@ -110,6 +121,7 @@ const baseApp: ApplicationNested = {
updateConfigSwarm: null,
username: null,
dockerContextPath: null,
+ rollbackActive: false,
};
describe("unzipDrop using real zip files", () => {
@@ -139,67 +151,68 @@ describe("unzipDrop using real zip files", () => {
} finally {
}
});
-
- it("should correctly extract a zip with a single root folder and a subfolder", async () => {
- baseApp.appName = "folderwithfile";
- // const appName = "folderwithfile";
- const outputPath = path.join(APPLICATIONS_PATH, baseApp.appName, "code");
- const zip = new AdmZip("./__test__/drop/zips/folder-with-file.zip");
-
- const zipBuffer = zip.toBuffer();
- const file = new File([zipBuffer], "single.zip");
- await unzipDrop(file, baseApp);
-
- const files = await fs.readdir(outputPath, { withFileTypes: true });
- expect(files.some((f) => f.name === "folder1.txt")).toBe(true);
- });
-
- it("should correctly extract a zip with multiple root folders", async () => {
- baseApp.appName = "two-folders";
- // const appName = "two-folders";
- const outputPath = path.join(APPLICATIONS_PATH, baseApp.appName, "code");
- const zip = new AdmZip("./__test__/drop/zips/two-folders.zip");
-
- const zipBuffer = zip.toBuffer();
- const file = new File([zipBuffer], "single.zip");
- await unzipDrop(file, baseApp);
-
- const files = await fs.readdir(outputPath, { withFileTypes: true });
-
- expect(files.some((f) => f.name === "folder1")).toBe(true);
- expect(files.some((f) => f.name === "folder2")).toBe(true);
- });
-
- it("should correctly extract a zip with a single root with a file", async () => {
- baseApp.appName = "nested";
- // const appName = "nested";
- const outputPath = path.join(APPLICATIONS_PATH, baseApp.appName, "code");
- const zip = new AdmZip("./__test__/drop/zips/nested.zip");
-
- const zipBuffer = zip.toBuffer();
- const file = new File([zipBuffer], "single.zip");
- await unzipDrop(file, baseApp);
-
- const files = await fs.readdir(outputPath, { withFileTypes: true });
-
- expect(files.some((f) => f.name === "folder1")).toBe(true);
- expect(files.some((f) => f.name === "folder2")).toBe(true);
- expect(files.some((f) => f.name === "folder3")).toBe(true);
- });
-
- it("should correctly extract a zip with a single root with a folder", async () => {
- baseApp.appName = "folder-with-sibling-file";
- // const appName = "folder-with-sibling-file";
- const outputPath = path.join(APPLICATIONS_PATH, baseApp.appName, "code");
- const zip = new AdmZip("./__test__/drop/zips/folder-with-sibling-file.zip");
-
- const zipBuffer = zip.toBuffer();
- const file = new File([zipBuffer], "single.zip");
- await unzipDrop(file, baseApp);
-
- const files = await fs.readdir(outputPath, { withFileTypes: true });
-
- expect(files.some((f) => f.name === "folder1")).toBe(true);
- expect(files.some((f) => f.name === "test.txt")).toBe(true);
- });
});
+
+// it("should correctly extract a zip with a single root folder and a subfolder", async () => {
+// baseApp.appName = "folderwithfile";
+// // const appName = "folderwithfile";
+// const outputPath = path.join(APPLICATIONS_PATH, baseApp.appName, "code");
+// const zip = new AdmZip("./__test__/drop/zips/folder-with-file.zip");
+
+// const zipBuffer = zip.toBuffer();
+// const file = new File([zipBuffer], "single.zip");
+// await unzipDrop(file, baseApp);
+
+// const files = await fs.readdir(outputPath, { withFileTypes: true });
+// expect(files.some((f) => f.name === "folder1.txt")).toBe(true);
+// });
+
+// it("should correctly extract a zip with multiple root folders", async () => {
+// baseApp.appName = "two-folders";
+// // const appName = "two-folders";
+// const outputPath = path.join(APPLICATIONS_PATH, baseApp.appName, "code");
+// const zip = new AdmZip("./__test__/drop/zips/two-folders.zip");
+
+// const zipBuffer = zip.toBuffer();
+// const file = new File([zipBuffer], "single.zip");
+// await unzipDrop(file, baseApp);
+
+// const files = await fs.readdir(outputPath, { withFileTypes: true });
+
+// expect(files.some((f) => f.name === "folder1")).toBe(true);
+// expect(files.some((f) => f.name === "folder2")).toBe(true);
+// });
+
+// it("should correctly extract a zip with a single root with a file", async () => {
+// baseApp.appName = "nested";
+// // const appName = "nested";
+// const outputPath = path.join(APPLICATIONS_PATH, baseApp.appName, "code");
+// const zip = new AdmZip("./__test__/drop/zips/nested.zip");
+
+// const zipBuffer = zip.toBuffer();
+// const file = new File([zipBuffer], "single.zip");
+// await unzipDrop(file, baseApp);
+
+// const files = await fs.readdir(outputPath, { withFileTypes: true });
+
+// expect(files.some((f) => f.name === "folder1")).toBe(true);
+// expect(files.some((f) => f.name === "folder2")).toBe(true);
+// expect(files.some((f) => f.name === "folder3")).toBe(true);
+// });
+
+// it("should correctly extract a zip with a single root with a folder", async () => {
+// baseApp.appName = "folder-with-sibling-file";
+// // const appName = "folder-with-sibling-file";
+// const outputPath = path.join(APPLICATIONS_PATH, baseApp.appName, "code");
+// const zip = new AdmZip("./__test__/drop/zips/folder-with-sibling-file.zip");
+
+// const zipBuffer = zip.toBuffer();
+// const file = new File([zipBuffer], "single.zip");
+// await unzipDrop(file, baseApp);
+
+// const files = await fs.readdir(outputPath, { withFileTypes: true });
+
+// expect(files.some((f) => f.name === "folder1")).toBe(true);
+// expect(files.some((f) => f.name === "test.txt")).toBe(true);
+// });
+// });
diff --git a/apps/dokploy/__test__/templates/config.template.test.ts b/apps/dokploy/__test__/templates/config.template.test.ts
new file mode 100644
index 000000000..202abdf2d
--- /dev/null
+++ b/apps/dokploy/__test__/templates/config.template.test.ts
@@ -0,0 +1,497 @@
+import type { Schema } from "@dokploy/server/templates";
+import type { CompleteTemplate } from "@dokploy/server/templates/processors";
+import { processTemplate } from "@dokploy/server/templates/processors";
+import { describe, expect, it } from "vitest";
+
+describe("processTemplate", () => {
+ // Mock schema for testing
+ const mockSchema: Schema = {
+ projectName: "test",
+ serverIp: "127.0.0.1",
+ };
+
+ describe("variables processing", () => {
+ it("should process basic variables with utility functions", () => {
+ const template: CompleteTemplate = {
+ metadata: {} as any,
+ variables: {
+ main_domain: "${domain}",
+ secret_base: "${base64:64}",
+ totp_key: "${base64:32}",
+ password: "${password:32}",
+ hash: "${hash:16}",
+ },
+ config: {
+ domains: [],
+ env: {},
+ },
+ };
+
+ const result = processTemplate(template, mockSchema);
+ expect(result.envs).toHaveLength(0);
+ expect(result.domains).toHaveLength(0);
+ expect(result.mounts).toHaveLength(0);
+ });
+
+ it("should allow referencing variables in other variables", () => {
+ const template: CompleteTemplate = {
+ metadata: {} as any,
+ variables: {
+ main_domain: "${domain}",
+ api_domain: "api.${main_domain}",
+ },
+ config: {
+ domains: [],
+ env: {},
+ },
+ };
+
+ const result = processTemplate(template, mockSchema);
+ expect(result.envs).toHaveLength(0);
+ expect(result.domains).toHaveLength(0);
+ expect(result.mounts).toHaveLength(0);
+ });
+
+ it("should allow creation of real jwt secret", () => {
+ const template: CompleteTemplate = {
+ metadata: {} as any,
+ variables: {
+ jwt_secret: "cQsdycq1hDLopQonF6jUTqgQc5WEZTwWLL02J6XJ",
+ anon_payload: JSON.stringify({
+ role: "tester",
+ iss: "dockploy",
+ iat: "${timestamps:2025-01-01T00:00:00Z}",
+ exp: "${timestamps:2030-01-01T00:00:00Z}",
+ }),
+ anon_key: "${jwt:jwt_secret:anon_payload}",
+ },
+ config: {
+ domains: [],
+ env: {
+ ANON_KEY: "${anon_key}",
+ },
+ },
+ };
+ const result = processTemplate(template, mockSchema);
+ expect(result.envs).toHaveLength(1);
+ expect(result.envs).toContain(
+ "ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpYXQiOiIxNzM1Njg5NjAwIiwiZXhwIjoiMTg5MzQ1NjAwMCIsInJvbGUiOiJ0ZXN0ZXIiLCJpc3MiOiJkb2NrcGxveSJ9.BG5JoxL2_NaTFbPgyZdm3kRWenf_O3su_HIRKGCJ_kY",
+ );
+ expect(result.mounts).toHaveLength(0);
+ expect(result.domains).toHaveLength(0);
+ });
+ });
+
+ describe("domains processing", () => {
+ it("should process domains with explicit host", () => {
+ const template: CompleteTemplate = {
+ metadata: {} as any,
+ variables: {
+ main_domain: "${domain}",
+ },
+ config: {
+ domains: [
+ {
+ serviceName: "plausible",
+ port: 8000,
+ host: "${main_domain}",
+ },
+ ],
+ env: {},
+ },
+ };
+
+ const result = processTemplate(template, mockSchema);
+ expect(result.domains).toHaveLength(1);
+ const domain = result.domains[0];
+ expect(domain).toBeDefined();
+ if (!domain) return;
+ expect(domain).toMatchObject({
+ serviceName: "plausible",
+ port: 8000,
+ });
+ expect(domain.host).toBeDefined();
+ expect(domain.host).toContain(mockSchema.projectName);
+ });
+
+ it("should generate random domain if host is not specified", () => {
+ const template: CompleteTemplate = {
+ metadata: {} as any,
+ variables: {},
+ config: {
+ domains: [
+ {
+ serviceName: "plausible",
+ port: 8000,
+ },
+ ],
+ env: {},
+ },
+ };
+
+ const result = processTemplate(template, mockSchema);
+ expect(result.domains).toHaveLength(1);
+ const domain = result.domains[0];
+ expect(domain).toBeDefined();
+ if (!domain || !domain.host) return;
+ expect(domain.host).toBeDefined();
+ expect(domain.host).toContain(mockSchema.projectName);
+ });
+
+ it("should allow using ${domain} directly in host", () => {
+ const template: CompleteTemplate = {
+ metadata: {} as any,
+ variables: {},
+ config: {
+ domains: [
+ {
+ serviceName: "plausible",
+ port: 8000,
+ host: "${domain}",
+ },
+ ],
+ env: {},
+ },
+ };
+
+ const result = processTemplate(template, mockSchema);
+ expect(result.domains).toHaveLength(1);
+ const domain = result.domains[0];
+ expect(domain).toBeDefined();
+ if (!domain || !domain.host) return;
+ expect(domain.host).toBeDefined();
+ expect(domain.host).toContain(mockSchema.projectName);
+ });
+ });
+
+ describe("environment variables processing", () => {
+ it("should process env vars with variable references", () => {
+ const template: CompleteTemplate = {
+ metadata: {} as any,
+ variables: {
+ main_domain: "${domain}",
+ secret_base: "${base64:64}",
+ },
+ config: {
+ domains: [],
+ env: {
+ BASE_URL: "http://${main_domain}",
+ SECRET_KEY_BASE: "${secret_base}",
+ },
+ },
+ };
+
+ const result = processTemplate(template, mockSchema);
+ expect(result.envs).toHaveLength(2);
+ const baseUrl = result.envs.find((env: string) =>
+ env.startsWith("BASE_URL="),
+ );
+ const secretKey = result.envs.find((env: string) =>
+ env.startsWith("SECRET_KEY_BASE="),
+ );
+
+ expect(baseUrl).toBeDefined();
+ expect(secretKey).toBeDefined();
+ if (!baseUrl || !secretKey) return;
+
+ expect(baseUrl).toContain(mockSchema.projectName);
+ const base64Value = secretKey.split("=")[1];
+ expect(base64Value).toBeDefined();
+ if (!base64Value) return;
+ expect(base64Value).toMatch(/^[A-Za-z0-9+/]+={0,2}$/);
+ expect(base64Value.length).toBeGreaterThanOrEqual(86);
+ expect(base64Value.length).toBeLessThanOrEqual(88);
+ });
+
+ it("should process env vars when provided as an array", () => {
+ const template: CompleteTemplate = {
+ metadata: {} as any,
+ variables: {},
+ config: {
+ domains: [],
+ env: [
+ 'CLOUDFLARE_TUNNEL_TOKEN=""',
+ 'ANOTHER_VAR="some value"',
+ "DOMAIN=${domain}",
+ ],
+ mounts: [],
+ },
+ };
+
+ const result = processTemplate(template, mockSchema);
+ expect(result.envs).toHaveLength(3);
+
+ // Should preserve exact format for static values
+ expect(result.envs[0]).toBe('CLOUDFLARE_TUNNEL_TOKEN=""');
+ expect(result.envs[1]).toBe('ANOTHER_VAR="some value"');
+
+ // Should process variables in array items
+ expect(result.envs[2]).toContain(mockSchema.projectName);
+ });
+
+ it("should allow using utility functions directly in env vars", () => {
+ const template: CompleteTemplate = {
+ metadata: {} as any,
+ variables: {},
+ config: {
+ domains: [],
+ env: {
+ RANDOM_DOMAIN: "${domain}",
+ SECRET_KEY: "${base64:32}",
+ },
+ },
+ };
+
+ const result = processTemplate(template, mockSchema);
+ expect(result.envs).toHaveLength(2);
+ const randomDomainEnv = result.envs.find((env: string) =>
+ env.startsWith("RANDOM_DOMAIN="),
+ );
+ const secretKeyEnv = result.envs.find((env: string) =>
+ env.startsWith("SECRET_KEY="),
+ );
+ expect(randomDomainEnv).toBeDefined();
+ expect(secretKeyEnv).toBeDefined();
+ if (!randomDomainEnv || !secretKeyEnv) return;
+
+ expect(randomDomainEnv).toContain(mockSchema.projectName);
+ const base64Value = secretKeyEnv.split("=")[1];
+ expect(base64Value).toBeDefined();
+ if (!base64Value) return;
+ expect(base64Value).toMatch(/^[A-Za-z0-9+/]+={0,2}$/);
+ expect(base64Value.length).toBeGreaterThanOrEqual(42);
+ expect(base64Value.length).toBeLessThanOrEqual(44);
+ });
+
+ it("should handle boolean values in env vars when provided as an array", () => {
+ const template: CompleteTemplate = {
+ metadata: {} as any,
+ variables: {},
+ config: {
+ domains: [],
+ env: [
+ "ENABLE_USER_SIGN_UP=false",
+ "DEBUG_MODE=true",
+ "SOME_NUMBER=42",
+ ],
+ mounts: [],
+ },
+ };
+
+ const result = processTemplate(template, mockSchema);
+ expect(result.envs).toHaveLength(3);
+ expect(result.envs).toContain("ENABLE_USER_SIGN_UP=false");
+ expect(result.envs).toContain("DEBUG_MODE=true");
+ expect(result.envs).toContain("SOME_NUMBER=42");
+ });
+
+ it("should handle boolean values in env vars when provided as an object", () => {
+ const template: CompleteTemplate = {
+ metadata: {} as any,
+ variables: {},
+ config: {
+ domains: [],
+ env: {
+ ENABLE_USER_SIGN_UP: false,
+ DEBUG_MODE: true,
+ SOME_NUMBER: 42,
+ },
+ },
+ };
+
+ const result = processTemplate(template, mockSchema);
+ expect(result.envs).toHaveLength(3);
+ expect(result.envs).toContain("ENABLE_USER_SIGN_UP=false");
+ expect(result.envs).toContain("DEBUG_MODE=true");
+ expect(result.envs).toContain("SOME_NUMBER=42");
+ });
+ });
+
+ describe("mounts processing", () => {
+ it("should process mounts with variable references", () => {
+ const template: CompleteTemplate = {
+ metadata: {} as any,
+ variables: {
+ config_path: "/etc/config",
+ secret_key: "${base64:32}",
+ },
+ config: {
+ domains: [],
+ env: {},
+ mounts: [
+ {
+ filePath: "${config_path}/config.xml",
+ content: "secret_key=${secret_key}",
+ },
+ ],
+ },
+ };
+
+ const result = processTemplate(template, mockSchema);
+ expect(result.mounts).toHaveLength(1);
+ const mount = result.mounts[0];
+ expect(mount).toBeDefined();
+ if (!mount) return;
+ expect(mount.filePath).toContain("/etc/config");
+ expect(mount.content).toMatch(/secret_key=[A-Za-z0-9+/]{32}/);
+ });
+
+ it("should allow using utility functions directly in mount content", () => {
+ const template: CompleteTemplate = {
+ metadata: {} as any,
+ variables: {},
+ config: {
+ domains: [],
+ env: {},
+ mounts: [
+ {
+ filePath: "/config/secrets.txt",
+ content: "random_domain=${domain}\nsecret=${base64:32}",
+ },
+ ],
+ },
+ };
+
+ const result = processTemplate(template, mockSchema);
+ expect(result.mounts).toHaveLength(1);
+ const mount = result.mounts[0];
+ expect(mount).toBeDefined();
+ if (!mount) return;
+ expect(mount.content).toContain(mockSchema.projectName);
+ expect(mount.content).toMatch(/secret=[A-Za-z0-9+/]{32}/);
+ });
+ });
+
+ describe("complex template processing", () => {
+ it("should process a complete template with all features", () => {
+ const template: CompleteTemplate = {
+ metadata: {} as any,
+ variables: {
+ main_domain: "${domain}",
+ secret_base: "${base64:64}",
+ totp_key: "${base64:32}",
+ },
+ config: {
+ domains: [
+ {
+ serviceName: "plausible",
+ port: 8000,
+ host: "${main_domain}",
+ },
+ {
+ serviceName: "api",
+ port: 3000,
+ host: "api.${main_domain}",
+ },
+ ],
+ env: {
+ BASE_URL: "http://${main_domain}",
+ SECRET_KEY_BASE: "${secret_base}",
+ TOTP_VAULT_KEY: "${totp_key}",
+ },
+ mounts: [
+ {
+ filePath: "/config/app.conf",
+ content: `
+ domain=\${main_domain}
+ secret=\${secret_base}
+ totp=\${totp_key}
+ `,
+ },
+ ],
+ },
+ };
+
+ const result = processTemplate(template, mockSchema);
+
+ // Check domains
+ expect(result.domains).toHaveLength(2);
+ const [domain1, domain2] = result.domains;
+ expect(domain1).toBeDefined();
+ expect(domain2).toBeDefined();
+ if (!domain1 || !domain2) return;
+ expect(domain1.host).toBeDefined();
+ expect(domain1.host).toContain(mockSchema.projectName);
+ expect(domain2.host).toContain("api.");
+ expect(domain2.host).toContain(mockSchema.projectName);
+
+ // Check env vars
+ expect(result.envs).toHaveLength(3);
+ const baseUrl = result.envs.find((env: string) =>
+ env.startsWith("BASE_URL="),
+ );
+ const secretKey = result.envs.find((env: string) =>
+ env.startsWith("SECRET_KEY_BASE="),
+ );
+ const totpKey = result.envs.find((env: string) =>
+ env.startsWith("TOTP_VAULT_KEY="),
+ );
+
+ expect(baseUrl).toBeDefined();
+ expect(secretKey).toBeDefined();
+ expect(totpKey).toBeDefined();
+ if (!baseUrl || !secretKey || !totpKey) return;
+
+ expect(baseUrl).toContain(mockSchema.projectName);
+
+ // Check base64 lengths and format
+ const secretKeyValue = secretKey.split("=")[1];
+ const totpKeyValue = totpKey.split("=")[1];
+
+ expect(secretKeyValue).toBeDefined();
+ expect(totpKeyValue).toBeDefined();
+ if (!secretKeyValue || !totpKeyValue) return;
+
+ expect(secretKeyValue).toMatch(/^[A-Za-z0-9+/]+={0,2}$/);
+ expect(secretKeyValue.length).toBeGreaterThanOrEqual(86);
+ expect(secretKeyValue.length).toBeLessThanOrEqual(88);
+
+ expect(totpKeyValue).toMatch(/^[A-Za-z0-9+/]+={0,2}$/);
+ expect(totpKeyValue.length).toBeGreaterThanOrEqual(42);
+ expect(totpKeyValue.length).toBeLessThanOrEqual(44);
+
+ // Check mounts
+ expect(result.mounts).toHaveLength(1);
+ const mount = result.mounts[0];
+ expect(mount).toBeDefined();
+ if (!mount) return;
+ expect(mount.content).toContain(mockSchema.projectName);
+ expect(mount.content).toMatch(/secret=[A-Za-z0-9+/]{86,88}/);
+ expect(mount.content).toMatch(/totp=[A-Za-z0-9+/]{42,44}/);
+ });
+ });
+
+ describe("Should populate envs, domains and mounts in the case we didn't used any variable", () => {
+ it("should populate envs, domains and mounts in the case we didn't used any variable", () => {
+ const template: CompleteTemplate = {
+ metadata: {} as any,
+ variables: {},
+ config: {
+ domains: [
+ {
+ serviceName: "plausible",
+ port: 8000,
+ host: "${hash}",
+ },
+ ],
+ env: {
+ BASE_URL: "http://${domain}",
+ SECRET_KEY_BASE: "${password:32}",
+ TOTP_VAULT_KEY: "${base64:128}",
+ },
+ mounts: [
+ {
+ filePath: "/config/secrets.txt",
+ content: "random_domain=${domain}\nsecret=${password:32}",
+ },
+ ],
+ },
+ };
+
+ const result = processTemplate(template, mockSchema);
+ expect(result.envs).toHaveLength(3);
+ expect(result.domains).toHaveLength(1);
+ expect(result.mounts).toHaveLength(1);
+ });
+ });
+});
diff --git a/apps/dokploy/__test__/templates/helpers.template.test.ts b/apps/dokploy/__test__/templates/helpers.template.test.ts
new file mode 100644
index 000000000..1144b65fe
--- /dev/null
+++ b/apps/dokploy/__test__/templates/helpers.template.test.ts
@@ -0,0 +1,232 @@
+import type { Schema } from "@dokploy/server/templates";
+import { processValue } from "@dokploy/server/templates/processors";
+import { describe, expect, it } from "vitest";
+
+describe("helpers functions", () => {
+ // Mock schema for testing
+ const mockSchema: Schema = {
+ projectName: "test",
+ serverIp: "127.0.0.1",
+ };
+ // some helpers to test jwt
+ type JWTParts = [string, string, string];
+ const jwtMatchExp = /^[A-Za-z0-9-_]+\.[A-Za-z0-9-_]+\.[A-Za-z0-9-_]+$/;
+ const jwtBase64Decode = (str: string) => {
+ const base64 = str.replace(/-/g, "+").replace(/_/g, "/");
+ const padding = "=".repeat((4 - (base64.length % 4)) % 4);
+ const decoded = Buffer.from(base64 + padding, "base64").toString("utf-8");
+ return JSON.parse(decoded);
+ };
+ const jwtCheckHeader = (jwtHeader: string) => {
+ const decodedHeader = jwtBase64Decode(jwtHeader);
+ expect(decodedHeader).toHaveProperty("alg");
+ expect(decodedHeader).toHaveProperty("typ");
+ expect(decodedHeader.alg).toEqual("HS256");
+ expect(decodedHeader.typ).toEqual("JWT");
+ };
+
+ describe("${domain}", () => {
+ it("should generate a random domain", () => {
+ const domain = processValue("${domain}", {}, mockSchema);
+ expect(domain.startsWith(`${mockSchema.projectName}-`)).toBeTruthy();
+ expect(
+ domain.endsWith(
+ `${mockSchema.serverIp.replaceAll(".", "-")}.traefik.me`,
+ ),
+ ).toBeTruthy();
+ });
+ });
+
+ describe("${base64}", () => {
+ it("should generate a base64 string", () => {
+ const base64 = processValue("${base64}", {}, mockSchema);
+ expect(base64).toMatch(/^[A-Za-z0-9+=/]+={0,2}$/);
+ });
+ it.each([
+ [4, 8],
+ [8, 12],
+ [16, 24],
+ [32, 44],
+ [64, 88],
+ [128, 172],
+ ])(
+ "should generate a base64 string from parameter %d bytes length",
+ (length, finalLength) => {
+ const base64 = processValue(`\${base64:${length}}`, {}, mockSchema);
+ expect(base64).toMatch(/^[A-Za-z0-9+=/]+={0,2}$/);
+ expect(base64.length).toBe(finalLength);
+ },
+ );
+ });
+
+ describe("${password}", () => {
+ it("should generate a password string", () => {
+ const password = processValue("${password}", {}, mockSchema);
+ expect(password).toMatch(/^[A-Za-z0-9]+$/);
+ });
+ it.each([6, 8, 12, 16, 32])(
+ "should generate a password string respecting parameter %d length",
+ (length) => {
+ const password = processValue(`\${password:${length}}`, {}, mockSchema);
+ expect(password).toMatch(/^[A-Za-z0-9]+$/);
+ expect(password.length).toBe(length);
+ },
+ );
+ });
+
+ describe("${hash}", () => {
+ it("should generate a hash string", () => {
+ const hash = processValue("${hash}", {}, mockSchema);
+ expect(hash).toMatch(/^[A-Za-z0-9]+$/);
+ });
+ it.each([6, 8, 12, 16, 32])(
+ "should generate a hash string respecting parameter %d length",
+ (length) => {
+ const hash = processValue(`\${hash:${length}}`, {}, mockSchema);
+ expect(hash).toMatch(/^[A-Za-z0-9]+$/);
+ expect(hash.length).toBe(length);
+ },
+ );
+ });
+
+ describe("${uuid}", () => {
+ it("should generate a UUID string", () => {
+ const uuid = processValue("${uuid}", {}, mockSchema);
+ expect(uuid).toMatch(
+ /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/,
+ );
+ });
+ });
+
+ describe("${timestamp}", () => {
+ it("should generate a timestamp string in milliseconds", () => {
+ const timestamp = processValue("${timestamp}", {}, mockSchema);
+ const nowLength = Math.floor(Date.now()).toString().length;
+ expect(timestamp).toMatch(/^\d+$/);
+ expect(timestamp.length).toBe(nowLength);
+ });
+ });
+ describe("${timestampms}", () => {
+ it("should generate a timestamp string in milliseconds", () => {
+ const timestamp = processValue("${timestampms}", {}, mockSchema);
+ const nowLength = Date.now().toString().length;
+ expect(timestamp).toMatch(/^\d+$/);
+ expect(timestamp.length).toBe(nowLength);
+ });
+ it("should generate a timestamp string in milliseconds from parameter", () => {
+ const timestamp = processValue(
+ "${timestampms:2025-01-01}",
+ {},
+ mockSchema,
+ );
+ expect(timestamp).toEqual("1735689600000");
+ });
+ });
+ describe("${timestamps}", () => {
+ it("should generate a timestamp string in seconds", () => {
+ const timestamps = processValue("${timestamps}", {}, mockSchema);
+ const nowLength = Math.floor(Date.now() / 1000).toString().length;
+ expect(timestamps).toMatch(/^\d+$/);
+ expect(timestamps.length).toBe(nowLength);
+ });
+ it("should generate a timestamp string in seconds from parameter", () => {
+ const timestamps = processValue(
+ "${timestamps:2025-01-01}",
+ {},
+ mockSchema,
+ );
+ expect(timestamps).toEqual("1735689600");
+ });
+ });
+
+ describe("${randomPort}", () => {
+ it("should generate a random port string", () => {
+ const randomPort = processValue("${randomPort}", {}, mockSchema);
+ expect(randomPort).toMatch(/^\d+$/);
+ expect(Number(randomPort)).toBeLessThan(65536);
+ });
+ });
+
+ describe("${username}", () => {
+ it("should generate a username string", () => {
+ const username = processValue("${username}", {}, mockSchema);
+ expect(username).toMatch(/^[a-zA-Z0-9._-]{3,}$/);
+ });
+ });
+
+ describe("${email}", () => {
+ it("should generate an email string", () => {
+ const email = processValue("${email}", {}, mockSchema);
+ expect(email).toMatch(/^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/);
+ });
+ });
+
+ describe("${jwt}", () => {
+ it("should generate a JWT string", () => {
+ const jwt = processValue("${jwt}", {}, mockSchema);
+ expect(jwt).toMatch(jwtMatchExp);
+ const parts = jwt.split(".") as JWTParts;
+ const decodedPayload = jwtBase64Decode(parts[1]);
+ jwtCheckHeader(parts[0]);
+ expect(decodedPayload).toHaveProperty("iat");
+ expect(decodedPayload).toHaveProperty("iss");
+ expect(decodedPayload).toHaveProperty("exp");
+ expect(decodedPayload.iss).toEqual("dokploy");
+ });
+ it.each([6, 8, 12, 16, 32])(
+ "should generate a random hex string from parameter %d byte length",
+ (length) => {
+ const jwt = processValue(`\${jwt:${length}}`, {}, mockSchema);
+ expect(jwt).toMatch(/^[A-Za-z0-9-_.]+$/);
+ expect(jwt.length).toBeGreaterThanOrEqual(length); // bytes translated to hex can take up to 2x the length
+ expect(jwt.length).toBeLessThanOrEqual(length * 2);
+ },
+ );
+ });
+ describe("${jwt:secret}", () => {
+ it("should generate a JWT string respecting parameter secret from variable", () => {
+ const jwt = processValue(
+ "${jwt:secret}",
+ { secret: "mysecret" },
+ mockSchema,
+ );
+ expect(jwt).toMatch(jwtMatchExp);
+ const parts = jwt.split(".") as JWTParts;
+ const decodedPayload = jwtBase64Decode(parts[1]);
+ jwtCheckHeader(parts[0]);
+ expect(decodedPayload).toHaveProperty("iat");
+ expect(decodedPayload).toHaveProperty("iss");
+ expect(decodedPayload).toHaveProperty("exp");
+ expect(decodedPayload.iss).toEqual("dokploy");
+ });
+ });
+ describe("${jwt:secret:payload}", () => {
+ it("should generate a JWT string respecting parameters secret and payload from variables", () => {
+ const iat = Math.floor(new Date("2025-01-01T00:00:00Z").getTime() / 1000);
+ const expiry = iat + 3600;
+ const jwt = processValue(
+ "${jwt:secret:payload}",
+ {
+ secret: "mysecret",
+ payload: `{"iss": "test-issuer", "iat": ${iat}, "exp": ${expiry}, "customprop": "customvalue"}`,
+ },
+ mockSchema,
+ );
+ expect(jwt).toMatch(jwtMatchExp);
+ const parts = jwt.split(".") as JWTParts;
+ jwtCheckHeader(parts[0]);
+ const decodedPayload = jwtBase64Decode(parts[1]);
+ expect(decodedPayload).toHaveProperty("iat");
+ expect(decodedPayload.iat).toEqual(iat);
+ expect(decodedPayload).toHaveProperty("iss");
+ expect(decodedPayload.iss).toEqual("test-issuer");
+ expect(decodedPayload).toHaveProperty("exp");
+ expect(decodedPayload.exp).toEqual(expiry);
+ expect(decodedPayload).toHaveProperty("customprop");
+ expect(decodedPayload.customprop).toEqual("customvalue");
+ expect(jwt).toEqual(
+ "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpYXQiOjE3MzU2ODk2MDAsImV4cCI6MTczNTY5MzIwMCwiaXNzIjoidGVzdC1pc3N1ZXIiLCJjdXN0b21wcm9wIjoiY3VzdG9tdmFsdWUifQ.m42U7PZSUSCf7gBOJrxJir0rQmyPq4rA59Dydr_QahI",
+ );
+ });
+ });
+});
diff --git a/apps/dokploy/__test__/traefik/server/update-server-config.test.ts b/apps/dokploy/__test__/traefik/server/update-server-config.test.ts
index f33b37fd1..6858f0f00 100644
--- a/apps/dokploy/__test__/traefik/server/update-server-config.test.ts
+++ b/apps/dokploy/__test__/traefik/server/update-server-config.test.ts
@@ -14,7 +14,10 @@ import {
import { beforeEach, expect, test, vi } from "vitest";
const baseAdmin: User = {
+ https: false,
enablePaidFeatures: false,
+ allowImpersonation: false,
+ role: "user",
metricsConfig: {
containers: {
refreshRate: 20,
@@ -73,7 +76,6 @@ beforeEach(() => {
test("Should read the configuration file", () => {
const config: FileConfig = loadOrCreateConfig("dokploy");
-
expect(config.http?.routers?.["dokploy-router-app"]?.service).toBe(
"dokploy-service-app",
);
@@ -83,6 +85,7 @@ test("Should apply redirect-to-https", () => {
updateServerTraefik(
{
...baseAdmin,
+ https: true,
certificateType: "letsencrypt",
},
"example.com",
diff --git a/apps/dokploy/__test__/traefik/traefik.test.ts b/apps/dokploy/__test__/traefik/traefik.test.ts
index 955103dec..f2d0f0a50 100644
--- a/apps/dokploy/__test__/traefik/traefik.test.ts
+++ b/apps/dokploy/__test__/traefik/traefik.test.ts
@@ -5,24 +5,35 @@ import { createRouterConfig } from "@dokploy/server";
import { expect, test } from "vitest";
const baseApp: ApplicationNested = {
+ rollbackActive: false,
applicationId: "",
herokuVersion: "",
+ giteaRepository: "",
+ giteaOwner: "",
+ giteaBranch: "",
+ giteaBuildPath: "",
+ giteaId: "",
+ cleanCache: false,
applicationStatus: "done",
appName: "",
autoDeploy: true,
+ enableSubmodules: false,
serverId: "",
branch: null,
dockerBuildStage: "",
registryUrl: "",
+ watchPaths: [],
buildArgs: null,
isPreviewDeploymentsActive: false,
previewBuildArgs: null,
+ triggerType: "push",
previewCertificateType: "none",
previewEnv: null,
previewHttps: false,
previewPath: "/",
previewPort: 3000,
previewLimit: 0,
+ previewCustomCertResolver: null,
previewWildcard: "",
project: {
env: "",
@@ -75,6 +86,7 @@ const baseApp: ApplicationNested = {
ports: [],
projectId: "",
publishDirectory: null,
+ isStaticSpa: null,
redirects: [],
refreshToken: "",
registry: null,
@@ -103,9 +115,12 @@ const baseDomain: Domain = {
port: null,
serviceName: "",
composeId: "",
+ customCertResolver: null,
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
new file mode 100644
index 000000000..2c1e5decc
--- /dev/null
+++ b/apps/dokploy/__test__/utils/backups.test.ts
@@ -0,0 +1,61 @@
+import { normalizeS3Path } from "@dokploy/server/utils/backups/utils";
+import { describe, expect, test } from "vitest";
+
+describe("normalizeS3Path", () => {
+ test("should handle empty and whitespace-only prefix", () => {
+ expect(normalizeS3Path("")).toBe("");
+ expect(normalizeS3Path("/")).toBe("");
+ expect(normalizeS3Path(" ")).toBe("");
+ expect(normalizeS3Path("\t")).toBe("");
+ expect(normalizeS3Path("\n")).toBe("");
+ expect(normalizeS3Path(" \n \t ")).toBe("");
+ });
+
+ test("should trim whitespace from prefix", () => {
+ expect(normalizeS3Path(" prefix")).toBe("prefix/");
+ expect(normalizeS3Path("prefix ")).toBe("prefix/");
+ expect(normalizeS3Path(" prefix ")).toBe("prefix/");
+ expect(normalizeS3Path("\tprefix\t")).toBe("prefix/");
+ expect(normalizeS3Path(" prefix/nested ")).toBe("prefix/nested/");
+ });
+
+ test("should remove leading slashes", () => {
+ expect(normalizeS3Path("/prefix")).toBe("prefix/");
+ expect(normalizeS3Path("///prefix")).toBe("prefix/");
+ });
+
+ test("should remove trailing slashes", () => {
+ expect(normalizeS3Path("prefix/")).toBe("prefix/");
+ expect(normalizeS3Path("prefix///")).toBe("prefix/");
+ });
+
+ test("should remove both leading and trailing slashes", () => {
+ expect(normalizeS3Path("/prefix/")).toBe("prefix/");
+ expect(normalizeS3Path("///prefix///")).toBe("prefix/");
+ });
+
+ test("should handle nested paths", () => {
+ expect(normalizeS3Path("prefix/nested")).toBe("prefix/nested/");
+ expect(normalizeS3Path("/prefix/nested/")).toBe("prefix/nested/");
+ expect(normalizeS3Path("///prefix/nested///")).toBe("prefix/nested/");
+ });
+
+ test("should preserve middle slashes", () => {
+ expect(normalizeS3Path("prefix/nested/deep")).toBe("prefix/nested/deep/");
+ expect(normalizeS3Path("/prefix/nested/deep/")).toBe("prefix/nested/deep/");
+ });
+
+ test("should handle special characters", () => {
+ expect(normalizeS3Path("prefix-with-dashes")).toBe("prefix-with-dashes/");
+ expect(normalizeS3Path("prefix_with_underscores")).toBe(
+ "prefix_with_underscores/",
+ );
+ expect(normalizeS3Path("prefix.with.dots")).toBe("prefix.with.dots/");
+ });
+
+ test("should handle the cases from the bug report", () => {
+ expect(normalizeS3Path("instance-backups/")).toBe("instance-backups/");
+ expect(normalizeS3Path("/instance-backups/")).toBe("instance-backups/");
+ expect(normalizeS3Path("instance-backups")).toBe("instance-backups/");
+ });
+});
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..b8a272e15 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;
}
@@ -270,8 +270,8 @@ export const AddSwarmSettings = ({ applicationId }: Props) => {
Swarm Settings
-
-
+
+
Swarm Settings
Update certain settings using a json object.
@@ -753,7 +753,7 @@ export const AddSwarmSettings = ({ applicationId }: Props) => {
)}
/>
-
+
{
placeholder="1"
{...field}
onChange={(e) => {
- field.onChange(Number(e.target.value));
+ const value = e.target.value;
+ field.onChange(value === "" ? 0 : Number(value));
}}
type="number"
+ value={field.value || ""}
/>
diff --git a/apps/dokploy/components/dashboard/application/advanced/import/show-import.tsx b/apps/dokploy/components/dashboard/application/advanced/import/show-import.tsx
new file mode 100644
index 000000000..29033f6b6
--- /dev/null
+++ b/apps/dokploy/components/dashboard/application/advanced/import/show-import.tsx
@@ -0,0 +1,347 @@
+import { AlertBlock } from "@/components/shared/alert-block";
+import { CodeEditor } from "@/components/shared/code-editor";
+import { Button } from "@/components/ui/button";
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+} from "@/components/ui/card";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from "@/components/ui/form";
+import { ScrollArea } from "@/components/ui/scroll-area";
+import { Separator } from "@/components/ui/separator";
+import { Textarea } from "@/components/ui/textarea";
+import { api } from "@/utils/api";
+import { zodResolver } from "@hookform/resolvers/zod";
+import { Code2, Globe2, HardDrive } from "lucide-react";
+import { useEffect, useState } from "react";
+import { useForm } from "react-hook-form";
+import { toast } from "sonner";
+import { z } from "zod";
+
+const ImportSchema = z.object({
+ base64: z.string(),
+});
+
+type ImportType = z.infer;
+
+interface Props {
+ composeId: string;
+}
+
+export const ShowImport = ({ composeId }: Props) => {
+ const [showModal, setShowModal] = useState(false);
+ const [showMountContent, setShowMountContent] = useState(false);
+ const [selectedMount, setSelectedMount] = useState<{
+ filePath: string;
+ content: string;
+ } | null>(null);
+ const [templateInfo, setTemplateInfo] = useState<{
+ compose: string;
+ template: {
+ domains: Array<{
+ serviceName: string;
+ port: number;
+ path?: string;
+ host?: string;
+ }>;
+ envs: string[];
+ mounts: Array<{
+ filePath: string;
+ content: string;
+ }>;
+ };
+ } | null>(null);
+
+ const utils = api.useUtils();
+ const { mutateAsync: processTemplate, isLoading: isLoadingTemplate } =
+ api.compose.processTemplate.useMutation();
+ const {
+ mutateAsync: importTemplate,
+ isLoading: isImporting,
+ isSuccess: isImportSuccess,
+ } = api.compose.import.useMutation();
+
+ const form = useForm({
+ defaultValues: {
+ base64: "",
+ },
+ resolver: zodResolver(ImportSchema),
+ });
+
+ useEffect(() => {
+ form.reset({
+ base64: "",
+ });
+ }, [isImportSuccess]);
+
+ const onSubmit = async () => {
+ const base64 = form.getValues("base64");
+ if (!base64) {
+ toast.error("Please enter a base64 template");
+ return;
+ }
+
+ try {
+ await importTemplate({
+ composeId,
+ base64,
+ });
+ toast.success("Template imported successfully");
+ await utils.compose.one.invalidate({
+ composeId,
+ });
+ setShowModal(false);
+ } catch {
+ toast.error("Error importing template");
+ }
+ };
+
+ const handleLoadTemplate = async () => {
+ const base64 = form.getValues("base64");
+ if (!base64) {
+ toast.error("Please enter a base64 template");
+ return;
+ }
+
+ try {
+ const result = await processTemplate({
+ composeId,
+ base64,
+ });
+ setTemplateInfo(result);
+ setShowModal(true);
+ } catch {
+ toast.error("Error processing template");
+ }
+ };
+
+ const handleShowMountContent = (mount: {
+ filePath: string;
+ content: string;
+ }) => {
+ setSelectedMount(mount);
+ setShowMountContent(true);
+ };
+
+ return (
+ <>
+
+
+ Import
+ Import your Template configuration
+
+
+
+ Warning: Importing a template will remove all existing environment
+ variables, mounts, and domains from this service.
+
+
+
+
+
+
+
+
+
+
+ {selectedMount?.filePath}
+
+ Mount File Content
+
+
+
+
+
+
+
+ setShowMountContent(false)}>Close
+
+
+
+ >
+ );
+};
diff --git a/apps/dokploy/components/dashboard/application/advanced/ports/handle-ports.tsx b/apps/dokploy/components/dashboard/application/advanced/ports/handle-ports.tsx
index c9758e37f..81c1f32c5 100644
--- a/apps/dokploy/components/dashboard/application/advanced/ports/handle-ports.tsx
+++ b/apps/dokploy/components/dashboard/application/advanced/ports/handle-ports.tsx
@@ -35,6 +35,9 @@ import { z } from "zod";
const AddPortSchema = z.object({
publishedPort: z.number().int().min(1).max(65535),
+ publishMode: z.enum(["ingress", "host"], {
+ required_error: "Publish mode is required",
+ }),
targetPort: z.number().int().min(1).max(65535),
protocol: z.enum(["tcp", "udp"], {
required_error: "Protocol is required",
@@ -80,6 +83,7 @@ export const HandlePorts = ({
useEffect(() => {
form.reset({
publishedPort: data?.publishedPort ?? 0,
+ publishMode: data?.publishMode ?? "ingress",
targetPort: data?.targetPort ?? 0,
protocol: data?.protocol ?? "tcp",
});
@@ -120,7 +124,7 @@ export const HandlePorts = ({
{children}
)}
-
+
Ports
@@ -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/redirects/handle-redirect.tsx b/apps/dokploy/components/dashboard/application/advanced/redirects/handle-redirect.tsx
index 5d91d580d..253a8c24d 100644
--- a/apps/dokploy/components/dashboard/application/advanced/redirects/handle-redirect.tsx
+++ b/apps/dokploy/components/dashboard/application/advanced/redirects/handle-redirect.tsx
@@ -179,7 +179,7 @@ export const HandleRedirect = ({
{children}
)}
-
+
Redirects
diff --git a/apps/dokploy/components/dashboard/application/advanced/security/handle-security.tsx b/apps/dokploy/components/dashboard/application/advanced/security/handle-security.tsx
index e7bc0cd1f..1808f7873 100644
--- a/apps/dokploy/components/dashboard/application/advanced/security/handle-security.tsx
+++ b/apps/dokploy/components/dashboard/application/advanced/security/handle-security.tsx
@@ -114,7 +114,7 @@ export const HandleSecurity = ({
{children}
)}
-
+
Security
diff --git a/apps/dokploy/components/dashboard/application/advanced/traefik/update-traefik-config.tsx b/apps/dokploy/components/dashboard/application/advanced/traefik/update-traefik-config.tsx
index f563f1ab4..c73ed5b3d 100644
--- a/apps/dokploy/components/dashboard/application/advanced/traefik/update-traefik-config.tsx
+++ b/apps/dokploy/components/dashboard/application/advanced/traefik/update-traefik-config.tsx
@@ -122,7 +122,7 @@ export const UpdateTraefikConfig = ({ applicationId }: Props) => {
Modify
-
+
Update traefik config
Update the traefik config
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..84c864e3a 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 {
@@ -150,7 +151,7 @@ export const AddVolumes = ({
{children}
-
+
Volumes / Mounts
@@ -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.
+
+
+
+ )}
-
+
Update
Update the mount
@@ -247,7 +247,7 @@ export const UpdateVolume = ({
control={form.control}
name="content"
render={({ field }) => (
-
+
Content
@@ -256,7 +256,7 @@ export const UpdateVolume = ({
placeholder={`NODE_ENV=production
PORT=3000
`}
- className="h-96 font-mono"
+ className="h-96 font-mono w-full"
{...field}
/>
diff --git a/apps/dokploy/components/dashboard/application/build/show.tsx b/apps/dokploy/components/dashboard/application/build/show.tsx
index 5c6e044c5..291026d4f 100644
--- a/apps/dokploy/components/dashboard/application/build/show.tsx
+++ b/apps/dokploy/components/dashboard/application/build/show.tsx
@@ -1,6 +1,8 @@
+import { AlertBlock } from "@/components/shared/alert-block";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import { Checkbox } from "@/components/ui/checkbox";
import {
Form,
FormControl,
@@ -20,7 +22,7 @@ import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
-enum BuildType {
+export enum BuildType {
dockerfile = "dockerfile",
heroku_buildpacks = "heroku_buildpacks",
paketo_buildpacks = "paketo_buildpacks",
@@ -29,9 +31,18 @@ enum BuildType {
railpack = "railpack",
}
+const buildTypeDisplayMap: Record = {
+ [BuildType.dockerfile]: "Dockerfile",
+ [BuildType.railpack]: "Railpack",
+ [BuildType.nixpacks]: "Nixpacks",
+ [BuildType.heroku_buildpacks]: "Heroku Buildpacks",
+ [BuildType.paketo_buildpacks]: "Paketo Buildpacks",
+ [BuildType.static]: "Static",
+};
+
const mySchema = z.discriminatedUnion("buildType", [
z.object({
- buildType: z.literal("dockerfile"),
+ buildType: z.literal(BuildType.dockerfile),
dockerfile: z
.string({
required_error: "Dockerfile path is required",
@@ -42,39 +53,92 @@ const mySchema = z.discriminatedUnion("buildType", [
dockerBuildStage: z.string().nullable().default(""),
}),
z.object({
- buildType: z.literal("heroku_buildpacks"),
+ buildType: z.literal(BuildType.heroku_buildpacks),
herokuVersion: z.string().nullable().default(""),
}),
z.object({
- buildType: z.literal("paketo_buildpacks"),
+ buildType: z.literal(BuildType.paketo_buildpacks),
}),
z.object({
- buildType: z.literal("nixpacks"),
+ buildType: z.literal(BuildType.nixpacks),
publishDirectory: z.string().optional(),
}),
z.object({
- buildType: z.literal("static"),
+ buildType: z.literal(BuildType.railpack),
}),
z.object({
- buildType: z.literal("railpack"),
+ buildType: z.literal(BuildType.static),
+ isStaticSpa: z.boolean().default(false),
}),
]);
type AddTemplate = z.infer;
+
interface Props {
applicationId: string;
}
+interface ApplicationData {
+ buildType: BuildType;
+ dockerfile?: string | null;
+ dockerContextPath?: string | null;
+ dockerBuildStage?: string | null;
+ herokuVersion?: string | null;
+ publishDirectory?: string | null;
+ isStaticSpa?: boolean | null;
+}
+
+function isValidBuildType(value: string): value is BuildType {
+ return Object.values(BuildType).includes(value as BuildType);
+}
+
+const resetData = (data: ApplicationData): AddTemplate => {
+ switch (data.buildType) {
+ case BuildType.dockerfile:
+ return {
+ buildType: BuildType.dockerfile,
+ dockerfile: data.dockerfile || "",
+ dockerContextPath: data.dockerContextPath || "",
+ dockerBuildStage: data.dockerBuildStage || "",
+ };
+ case BuildType.heroku_buildpacks:
+ return {
+ buildType: BuildType.heroku_buildpacks,
+ herokuVersion: data.herokuVersion || "",
+ };
+ case BuildType.nixpacks:
+ return {
+ buildType: BuildType.nixpacks,
+ publishDirectory: data.publishDirectory || undefined,
+ };
+ case BuildType.paketo_buildpacks:
+ return {
+ buildType: BuildType.paketo_buildpacks,
+ };
+ case BuildType.static:
+ return {
+ buildType: BuildType.static,
+ isStaticSpa: data.isStaticSpa ?? false,
+ };
+ case BuildType.railpack:
+ return {
+ buildType: BuildType.railpack,
+ };
+ default: {
+ const buildType = data.buildType as BuildType;
+ return {
+ buildType,
+ } as AddTemplate;
+ }
+ }
+};
+
export const ShowBuildChooseForm = ({ applicationId }: Props) => {
const { mutateAsync, isLoading } =
api.application.saveBuildType.useMutation();
const { data, refetch } = api.application.one.useQuery(
- {
- applicationId,
- },
- {
- enabled: !!applicationId,
- },
+ { applicationId },
+ { enabled: !!applicationId },
);
const form = useForm({
@@ -85,46 +149,38 @@ export const ShowBuildChooseForm = ({ applicationId }: Props) => {
});
const buildType = form.watch("buildType");
+
useEffect(() => {
if (data) {
- if (data.buildType === "dockerfile") {
- form.reset({
- buildType: data.buildType,
- ...(data.buildType && {
- dockerfile: data.dockerfile || "",
- dockerContextPath: data.dockerContextPath || "",
- dockerBuildStage: data.dockerBuildStage || "",
- }),
- });
- } else if (data.buildType === "heroku_buildpacks") {
- form.reset({
- buildType: data.buildType,
- ...(data.buildType && {
- herokuVersion: data.herokuVersion || "",
- }),
- });
- } else {
- form.reset({
- buildType: data.buildType,
- publishDirectory: data.publishDirectory || undefined,
- });
- }
+ const typedData: ApplicationData = {
+ ...data,
+ buildType: isValidBuildType(data.buildType)
+ ? (data.buildType as BuildType)
+ : BuildType.nixpacks, // fallback
+ };
+
+ form.reset(resetData(typedData));
}
- }, [form.formState.isSubmitSuccessful, form.reset, data, form]);
+ }, [data, form]);
const onSubmit = async (data: AddTemplate) => {
await mutateAsync({
applicationId,
buildType: data.buildType,
publishDirectory:
- data.buildType === "nixpacks" ? data.publishDirectory : null,
- dockerfile: data.buildType === "dockerfile" ? data.dockerfile : null,
+ data.buildType === BuildType.nixpacks ? data.publishDirectory : null,
+ dockerfile:
+ data.buildType === BuildType.dockerfile ? data.dockerfile : null,
dockerContextPath:
- data.buildType === "dockerfile" ? data.dockerContextPath : null,
+ data.buildType === BuildType.dockerfile ? data.dockerContextPath : null,
dockerBuildStage:
- data.buildType === "dockerfile" ? data.dockerBuildStage : null,
+ data.buildType === BuildType.dockerfile ? data.dockerBuildStage : null,
herokuVersion:
- data.buildType === "heroku_buildpacks" ? data.herokuVersion : null,
+ data.buildType === BuildType.heroku_buildpacks
+ ? data.herokuVersion
+ : null,
+ isStaticSpa:
+ data.buildType === BuildType.static ? data.isStaticSpa : null,
})
.then(async () => {
toast.success("Build type saved");
@@ -152,6 +208,22 @@ export const ShowBuildChooseForm = ({ applicationId }: Props) => {
+
+ Builders can consume significant memory and CPU resources
+ (recommended: 4+ GB RAM and 2+ CPU cores). For production
+ environments, please review our{" "}
+
+ Production Guide
+ {" "}
+ for best practices and optimization recommendations. Builders are
+ suitable for development and prototyping purposes when you have
+ sufficient resources available.
+
{
control={form.control}
name="buildType"
defaultValue={form.control._defaultValues.buildType}
- render={({ field }) => {
- return (
-
- Build Type
-
-
-
-
-
-
-
- Dockerfile
-
-
-
-
-
-
-
- Railpack{" "}
- New
-
-
-
-
-
-
-
- Nixpacks
-
-
-
-
-
-
-
- Heroku Buildpacks
-
-
-
-
-
-
-
- Paketo Buildpacks
-
-
-
-
-
-
- Static
-
-
-
-
-
- );
- }}
+ render={({ field }) => (
+
+ Build Type
+
+
+ {Object.entries(buildTypeDisplayMap).map(
+ ([value, label]) => (
+
+
+
+
+
+ {label}
+ {value === BuildType.railpack && (
+ New
+ )}
+
+
+ ),
+ )}
+
+
+
+
+ )}
/>
- {buildType === "heroku_buildpacks" && (
+ {buildType === BuildType.heroku_buildpacks && (
{
- return (
-
- Heroku Version (Optional)
-
-
-
-
-
-
- );
- }}
+ render={({ field }) => (
+
+ Heroku Version (Optional)
+
+
+
+
+
+ )}
/>
)}
- {buildType === "dockerfile" && (
+ {buildType === BuildType.dockerfile && (
<>
{
- return (
-
- Docker File
-
-
-
-
-
-
- );
- }}
- />
-
- {
- return (
-
- Docker Context Path
-
-
-
-
-
-
- );
- }}
- />
-
- {
- return (
-
-
- Docker Build Stage
-
- Allows you to target a specific stage in a
- Multi-stage Dockerfile. If empty, Docker defaults to
- build the last defined stage.
-
-
-
-
-
-
- );
- }}
- />
- >
- )}
-
- {buildType === "nixpacks" && (
- {
- return (
+ render={({ field }) => (
-
- Publish Directory
-
- Allows you to serve a single directory via NGINX after
- the build phase. Useful if the final build assets
- should be served as a static site.
-
-
+ Docker File
-
- );
- }}
+ )}
+ />
+ (
+
+ Docker Context Path
+
+
+
+
+
+ )}
+ />
+ (
+
+
+ Docker Build Stage
+
+ Allows you to target a specific stage in a Multi-stage
+ Dockerfile. If empty, Docker defaults to build the
+ last defined stage.
+
+
+
+
+
+
+ )}
+ />
+ >
+ )}
+ {buildType === BuildType.nixpacks && (
+ (
+
+
+ Publish Directory
+
+ Allows you to serve a single directory via NGINX after
+ the build phase. Useful if the final build assets should
+ be served as a static site.
+
+
+
+
+
+
+
+ )}
+ />
+ )}
+ {buildType === BuildType.static && (
+ (
+
+
+
+
+
+ Single Page Application (SPA)
+
+
+
+
+
+ )}
/>
)}
diff --git a/apps/dokploy/components/dashboard/application/deployments/cancel-queues.tsx b/apps/dokploy/components/dashboard/application/deployments/cancel-queues.tsx
index 5fe7ffb07..eb85f383b 100644
--- a/apps/dokploy/components/dashboard/application/deployments/cancel-queues.tsx
+++ b/apps/dokploy/components/dashboard/application/deployments/cancel-queues.tsx
@@ -15,11 +15,15 @@ import { Paintbrush } from "lucide-react";
import { toast } from "sonner";
interface Props {
- applicationId: string;
+ id: string;
+ type: "application" | "compose";
}
-export const CancelQueues = ({ applicationId }: Props) => {
- const { mutateAsync, isLoading } = api.application.cleanQueues.useMutation();
+export const CancelQueues = ({ id, type }: Props) => {
+ const { mutateAsync, isLoading } =
+ type === "application"
+ ? api.application.cleanQueues.useMutation()
+ : api.compose.cleanQueues.useMutation();
const { data: isCloud } = api.settings.isCloud.useQuery();
if (isCloud) {
@@ -48,7 +52,8 @@ export const CancelQueues = ({ applicationId }: Props) => {
{
await mutateAsync({
- applicationId,
+ applicationId: id || "",
+ composeId: id || "",
})
.then(() => {
toast.success("Queues are being cleaned");
diff --git a/apps/dokploy/components/dashboard/application/deployments/refresh-token.tsx b/apps/dokploy/components/dashboard/application/deployments/refresh-token.tsx
index b80450f9f..abfe37c3c 100644
--- a/apps/dokploy/components/dashboard/application/deployments/refresh-token.tsx
+++ b/apps/dokploy/components/dashboard/application/deployments/refresh-token.tsx
@@ -14,10 +14,14 @@ import { RefreshCcw } from "lucide-react";
import { toast } from "sonner";
interface Props {
- applicationId: string;
+ id: string;
+ type: "application" | "compose";
}
-export const RefreshToken = ({ applicationId }: Props) => {
- const { mutateAsync } = api.application.refreshToken.useMutation();
+export const RefreshToken = ({ id, type }: Props) => {
+ const { mutateAsync } =
+ type === "application"
+ ? api.application.refreshToken.useMutation()
+ : api.compose.refreshToken.useMutation();
const utils = api.useUtils();
return (
@@ -37,12 +41,19 @@ export const RefreshToken = ({ applicationId }: Props) => {
{
await mutateAsync({
- applicationId,
+ applicationId: id || "",
+ composeId: id || "",
})
.then(() => {
- utils.application.one.invalidate({
- applicationId,
- });
+ if (type === "application") {
+ utils.application.one.invalidate({
+ applicationId: id,
+ });
+ } else {
+ utils.compose.one.invalidate({
+ composeId: id,
+ });
+ }
toast.success("Refresh updated");
})
.catch(() => {
diff --git a/apps/dokploy/components/dashboard/application/deployments/show-deployment.tsx b/apps/dokploy/components/dashboard/application/deployments/show-deployment.tsx
index e6fdb38be..8733c745b 100644
--- a/apps/dokploy/components/dashboard/application/deployments/show-deployment.tsx
+++ b/apps/dokploy/components/dashboard/application/deployments/show-deployment.tsx
@@ -124,7 +124,7 @@ export const ShowDeployment = ({
}
}}
>
-
+
Deployment
diff --git a/apps/dokploy/components/dashboard/application/deployments/show-deployments-modal.tsx b/apps/dokploy/components/dashboard/application/deployments/show-deployments-modal.tsx
new file mode 100644
index 000000000..4631a066e
--- /dev/null
+++ b/apps/dokploy/components/dashboard/application/deployments/show-deployments-modal.tsx
@@ -0,0 +1,70 @@
+import { Button } from "@/components/ui/button";
+import { Dialog, DialogContent, DialogTrigger } from "@/components/ui/dialog";
+
+import type { RouterOutputs } from "@/utils/api";
+import { useState } from "react";
+import { ShowDeployment } from "../deployments/show-deployment";
+import { ShowDeployments } from "./show-deployments";
+
+interface Props {
+ id: string;
+ type:
+ | "application"
+ | "compose"
+ | "schedule"
+ | "server"
+ | "backup"
+ | "previewDeployment"
+ | "volumeBackup";
+ serverId?: string;
+ refreshToken?: string;
+ children?: React.ReactNode;
+}
+
+export const formatDuration = (seconds: number) => {
+ if (seconds < 60) return `${seconds}s`;
+ const minutes = Math.floor(seconds / 60);
+ const remainingSeconds = seconds % 60;
+ return `${minutes}m ${remainingSeconds}s`;
+};
+
+export const ShowDeploymentsModal = ({
+ id,
+ type,
+ serverId,
+ refreshToken,
+ children,
+}: Props) => {
+ const [activeLog, setActiveLog] = useState<
+ RouterOutputs["deployment"]["all"][number] | null
+ >(null);
+ const [isOpen, setIsOpen] = useState(false);
+ return (
+
+
+ {children ? (
+ children
+ ) : (
+
+ View Logs
+
+ )}
+
+
+
+
+ setActiveLog(null)}
+ logPath={activeLog?.logPath || ""}
+ errorMessage={activeLog?.errorMessage || ""}
+ />
+
+ );
+};
diff --git a/apps/dokploy/components/dashboard/application/deployments/show-deployments.tsx b/apps/dokploy/components/dashboard/application/deployments/show-deployments.tsx
index 76e5bb266..04631b9b3 100644
--- a/apps/dokploy/components/dashboard/application/deployments/show-deployments.tsx
+++ b/apps/dokploy/components/dashboard/application/deployments/show-deployments.tsx
@@ -1,5 +1,6 @@
import { DateTooltip } from "@/components/shared/date-tooltip";
import { StatusTooltip } from "@/components/shared/status-tooltip";
+import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
@@ -9,28 +10,61 @@ import {
CardTitle,
} from "@/components/ui/card";
import { type RouterOutputs, api } from "@/utils/api";
-import { RocketIcon } from "lucide-react";
+import { Clock, Loader2, RocketIcon, Settings, RefreshCcw } from "lucide-react";
import React, { useEffect, useState } from "react";
import { CancelQueues } from "./cancel-queues";
import { RefreshToken } from "./refresh-token";
import { ShowDeployment } from "./show-deployment";
+import { ShowRollbackSettings } from "../rollbacks/show-rollback-settings";
+import { DialogAction } from "@/components/shared/dialog-action";
+import { toast } from "sonner";
interface Props {
- applicationId: string;
+ id: string;
+ type:
+ | "application"
+ | "compose"
+ | "schedule"
+ | "server"
+ | "backup"
+ | "previewDeployment"
+ | "volumeBackup";
+ refreshToken?: string;
+ serverId?: string;
}
-export const ShowDeployments = ({ applicationId }: Props) => {
+export const formatDuration = (seconds: number) => {
+ if (seconds < 60) return `${seconds}s`;
+ const minutes = Math.floor(seconds / 60);
+ const remainingSeconds = seconds % 60;
+ return `${minutes}m ${remainingSeconds}s`;
+};
+
+export const ShowDeployments = ({
+ id,
+ type,
+ refreshToken,
+ serverId,
+}: Props) => {
const [activeLog, setActiveLog] = useState<
RouterOutputs["deployment"]["all"][number] | null
>(null);
- const { data } = api.application.one.useQuery({ applicationId });
- const { data: deployments } = api.deployment.all.useQuery(
- { applicationId },
- {
- enabled: !!applicationId,
- refetchInterval: 1000,
- },
- );
+ const { data: deployments, isLoading: isLoadingDeployments } =
+ api.deployment.allByType.useQuery(
+ {
+ id,
+ type,
+ },
+ {
+ enabled: !!id,
+ refetchInterval: 1000,
+ },
+ );
+
+ const { mutateAsync: rollback, isLoading: isRollingBack } =
+ api.rollback.rollback.useMutation();
+ const { mutateAsync: killProcess, isLoading: isKillingProcess } =
+ api.deployment.killProcess.useMutation();
const [url, setUrl] = React.useState("");
useEffect(() => {
@@ -38,34 +72,57 @@ export const ShowDeployments = ({ applicationId }: Props) => {
}, []);
return (
-
+
Deployments
- See all the 10 last deployments for this application
+ See all the 10 last deployments for this {type}
-
+
+ {(type === "application" || type === "compose") && (
+
+ )}
+ {type === "application" && (
+
+
+ Configure Rollbacks
+
+
+ )}
+
-
-
- If you want to re-deploy this application use this URL in the config
- of your git provider or docker
-
-
-
Webhook URL:
-
-
- {`${url}/api/deploy/${data?.refreshToken}`}
-
-
+ {refreshToken && (
+
+
+ If you want to re-deploy this application use this URL in the
+ config of your git provider or docker
+
+
+
Webhook URL:
+
+
+ {`${url}/api/deploy${type === "compose" ? "/compose" : ""}/${refreshToken}`}
+
+ {(type === "application" || type === "compose") && (
+
+ )}
+
-
- {data?.deployments?.length === 0 ? (
-
+ )}
+
+ {isLoadingDeployments ? (
+
+
+
+ Loading deployments...
+
+
+ ) : deployments?.length === 0 ? (
+
No deployments found
@@ -96,24 +153,99 @@ export const ShowDeployments = ({ applicationId }: Props) => {
)}
-
+
+ {deployment.startedAt && deployment.finishedAt && (
+
+
+ {formatDuration(
+ Math.floor(
+ (new Date(deployment.finishedAt).getTime() -
+ new Date(deployment.startedAt).getTime()) /
+ 1000,
+ ),
+ )}
+
+ )}
-
{
- 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
+
+
+ )}
+
))}
)}
setActiveLog(null)}
logPath={activeLog?.logPath || ""}
diff --git a/apps/dokploy/components/dashboard/application/domains/add-domain.tsx b/apps/dokploy/components/dashboard/application/domains/add-domain.tsx
deleted file mode 100644
index 611689439..000000000
--- a/apps/dokploy/components/dashboard/application/domains/add-domain.tsx
+++ /dev/null
@@ -1,301 +0,0 @@
-import { AlertBlock } from "@/components/shared/alert-block";
-import { Button } from "@/components/ui/button";
-import {
- Dialog,
- DialogContent,
- DialogDescription,
- DialogFooter,
- DialogHeader,
- DialogTitle,
- DialogTrigger,
-} from "@/components/ui/dialog";
-import {
- Form,
- FormControl,
- FormDescription,
- FormField,
- FormItem,
- FormLabel,
- FormMessage,
-} from "@/components/ui/form";
-import { Input, NumberInput } from "@/components/ui/input";
-import {
- Select,
- SelectContent,
- SelectItem,
- SelectTrigger,
- SelectValue,
-} from "@/components/ui/select";
-import { Switch } from "@/components/ui/switch";
-import {
- Tooltip,
- TooltipContent,
- TooltipProvider,
- TooltipTrigger,
-} from "@/components/ui/tooltip";
-import { api } from "@/utils/api";
-import { useEffect, useState } from "react";
-import { useForm } from "react-hook-form";
-import { toast } from "sonner";
-
-import { domain } from "@/server/db/validations/domain";
-import { zodResolver } from "@hookform/resolvers/zod";
-import { Dices } from "lucide-react";
-import type z from "zod";
-
-type Domain = z.infer;
-
-interface Props {
- applicationId: string;
- domainId?: string;
- children: React.ReactNode;
-}
-
-export const AddDomain = ({
- applicationId,
- domainId = "",
- children,
-}: Props) => {
- const [isOpen, setIsOpen] = useState(false);
- const utils = api.useUtils();
- const { data, refetch } = api.domain.one.useQuery(
- {
- domainId,
- },
- {
- enabled: !!domainId,
- },
- );
-
- const { data: application } = api.application.one.useQuery(
- {
- applicationId,
- },
- {
- enabled: !!applicationId,
- },
- );
-
- const { mutateAsync, isError, error, isLoading } = domainId
- ? api.domain.update.useMutation()
- : api.domain.create.useMutation();
-
- const { mutateAsync: generateDomain, isLoading: isLoadingGenerate } =
- api.domain.generateDomain.useMutation();
-
- const form = useForm({
- resolver: zodResolver(domain),
- });
-
- useEffect(() => {
- if (data) {
- form.reset({
- ...data,
- /* Convert null to undefined */
- path: data?.path || undefined,
- port: data?.port || undefined,
- });
- }
-
- if (!domainId) {
- form.reset({});
- }
- }, [form, form.reset, data, isLoading]);
-
- const dictionary = {
- success: domainId ? "Domain Updated" : "Domain Created",
- error: domainId ? "Error updating the domain" : "Error creating the domain",
- submit: domainId ? "Update" : "Create",
- dialogDescription: domainId
- ? "In this section you can edit a domain"
- : "In this section you can add domains",
- };
-
- const onSubmit = async (data: Domain) => {
- await mutateAsync({
- domainId,
- applicationId,
- ...data,
- })
- .then(async () => {
- toast.success(dictionary.success);
- await utils.domain.byApplicationId.invalidate({
- applicationId,
- });
- await utils.application.readTraefikConfig.invalidate({ applicationId });
-
- if (domainId) {
- refetch();
- }
- setIsOpen(false);
- })
- .catch(() => {
- toast.error(dictionary.error);
- });
- };
- return (
-
-
- {children}
-
-
-
- Domain
- {dictionary.dialogDescription}
-
- {isError && {error?.message} }
-
-
-
-
-
-
(
-
- Host
-
-
-
-
-
-
-
- {
- generateDomain({
- appName: application?.appName || "",
- serverId: application?.serverId || "",
- })
- .then((domain) => {
- field.onChange(domain);
- })
- .catch((err) => {
- toast.error(err.message);
- });
- }}
- >
-
-
-
-
- Generate traefik.me domain
-
-
-
-
-
-
-
- )}
- />
-
- {
- return (
-
- Path
-
-
-
-
-
- );
- }}
- />
-
- {
- return (
-
- Container Port
-
-
-
-
-
- );
- }}
- />
-
- (
-
-
- HTTPS
-
- Automatically provision SSL Certificate.
-
-
-
-
-
-
-
- )}
- />
-
- {form.getValues().https && (
- (
-
- Certificate Provider
-
-
-
-
-
-
-
-
- None
-
- Let's Encrypt
-
-
-
-
-
- )}
- />
- )}
-
-
-
-
-
-
- {dictionary.submit}
-
-
-
-
-
- );
-};
diff --git a/apps/dokploy/components/dashboard/application/domains/dns-helper-modal.tsx b/apps/dokploy/components/dashboard/application/domains/dns-helper-modal.tsx
new file mode 100644
index 000000000..c67c2fbfc
--- /dev/null
+++ b/apps/dokploy/components/dashboard/application/domains/dns-helper-modal.tsx
@@ -0,0 +1,109 @@
+import { AlertBlock } from "@/components/shared/alert-block";
+import { Button } from "@/components/ui/button";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "@/components/ui/dialog";
+import { Copy, HelpCircle, Server } from "lucide-react";
+import { toast } from "sonner";
+
+interface Props {
+ domain: {
+ host: string;
+ https: boolean;
+ path?: string;
+ };
+ serverIp?: string;
+}
+
+export const DnsHelperModal = ({ domain, serverIp }: Props) => {
+ const copyToClipboard = (text: string) => {
+ navigator.clipboard.writeText(text);
+ toast.success("Copied to clipboard!");
+ };
+
+ return (
+
+
+
+
+
+
+
+
+
+
+ DNS Configuration Guide
+
+
+ Follow these steps to configure your DNS records for {domain.host}
+
+
+
+
+
+ To make your domain accessible, you need to configure your DNS
+ records with your domain provider (e.g., Cloudflare, GoDaddy,
+ NameCheap).
+
+
+
+
+
1. Add A Record
+
+
+ Create an A record that points your domain to the server's IP
+ address:
+
+
+
+
+
Type: A
+
+ Name: @ or {domain.host.split(".")[0]}
+
+
+ Value: {serverIp || "Your server IP"}
+
+
+
copyToClipboard(serverIp || "")}
+ disabled={!serverIp}
+ >
+
+
+
+
+
+
+
+
+
2. Verify Configuration
+
+
+ After configuring your DNS records:
+
+
+ Wait for DNS propagation (usually 15-30 minutes)
+
+ Test your domain by visiting:{" "}
+ {domain.https ? "https://" : "http://"}
+ {domain.host}
+ {domain.path || "/"}
+
+ Use a DNS lookup tool to verify your records
+
+
+
+
+
+
+
+ );
+};
diff --git a/apps/dokploy/components/dashboard/application/domains/handle-domain.tsx b/apps/dokploy/components/dashboard/application/domains/handle-domain.tsx
new file mode 100644
index 000000000..9069542d9
--- /dev/null
+++ b/apps/dokploy/components/dashboard/application/domains/handle-domain.tsx
@@ -0,0 +1,670 @@
+import { AlertBlock } from "@/components/shared/alert-block";
+import { Button } from "@/components/ui/button";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "@/components/ui/dialog";
+import {
+ Form,
+ FormControl,
+ FormDescription,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from "@/components/ui/form";
+import { Input, NumberInput } from "@/components/ui/input";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import { Switch } from "@/components/ui/switch";
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipProvider,
+ TooltipTrigger,
+} from "@/components/ui/tooltip";
+import { api } from "@/utils/api";
+import { useEffect, useState } from "react";
+import { useForm } from "react-hook-form";
+import { toast } from "sonner";
+
+import { zodResolver } from "@hookform/resolvers/zod";
+import { DatabaseZap, Dices, RefreshCw } from "lucide-react";
+import Link from "next/link";
+import z from "zod";
+
+export type CacheType = "fetch" | "cache";
+
+export const domain = z
+ .object({
+ host: z.string().min(1, { message: "Add a hostname" }),
+ path: z.string().min(1).optional(),
+ internalPath: z.string().optional(),
+ stripPath: z.boolean().optional(),
+ port: z
+ .number()
+ .min(1, { message: "Port must be at least 1" })
+ .max(65535, { message: "Port must be 65535 or below" })
+ .optional(),
+ https: z.boolean().optional(),
+ certificateType: z.enum(["letsencrypt", "none", "custom"]).optional(),
+ customCertResolver: z.string().optional(),
+ serviceName: z.string().optional(),
+ domainType: z.enum(["application", "compose", "preview"]).optional(),
+ })
+ .superRefine((input, ctx) => {
+ if (input.https && !input.certificateType) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ path: ["certificateType"],
+ message: "Required",
+ });
+ }
+
+ if (input.certificateType === "custom" && !input.customCertResolver) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ path: ["customCertResolver"],
+ message: "Required",
+ });
+ }
+
+ if (input.domainType === "compose" && !input.serviceName) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ path: ["serviceName"],
+ message: "Required",
+ });
+ }
+
+ // Validate stripPath requires a valid path
+ if (input.stripPath && (!input.path || input.path === "/")) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ path: ["stripPath"],
+ message:
+ "Strip path can only be enabled when a path other than '/' is specified",
+ });
+ }
+
+ // Validate internalPath starts with /
+ if (
+ input.internalPath &&
+ input.internalPath !== "/" &&
+ !input.internalPath.startsWith("/")
+ ) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ path: ["internalPath"],
+ message: "Internal path must start with '/'",
+ });
+ }
+ });
+
+type Domain = z.infer;
+
+interface Props {
+ id: string;
+ type: "application" | "compose";
+ domainId?: string;
+ children: React.ReactNode;
+}
+
+export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
+ const [isOpen, setIsOpen] = useState(false);
+ const [cacheType, setCacheType] = useState("cache");
+
+ const utils = api.useUtils();
+ const { data, refetch } = api.domain.one.useQuery(
+ {
+ domainId,
+ },
+ {
+ enabled: !!domainId,
+ },
+ );
+
+ const { data: application } =
+ type === "application"
+ ? api.application.one.useQuery(
+ {
+ applicationId: id,
+ },
+ {
+ enabled: !!id,
+ },
+ )
+ : api.compose.one.useQuery(
+ {
+ composeId: id,
+ },
+ {
+ enabled: !!id,
+ },
+ );
+
+ const { mutateAsync, isError, error, isLoading } = domainId
+ ? api.domain.update.useMutation()
+ : api.domain.create.useMutation();
+
+ const { mutateAsync: generateDomain, isLoading: isLoadingGenerate } =
+ api.domain.generateDomain.useMutation();
+
+ const { data: canGenerateTraefikMeDomains } =
+ api.domain.canGenerateTraefikMeDomains.useQuery({
+ serverId: application?.serverId || "",
+ });
+
+ const {
+ data: services,
+ isFetching: isLoadingServices,
+ error: errorServices,
+ refetch: refetchServices,
+ } = api.compose.loadServices.useQuery(
+ {
+ composeId: id,
+ type: cacheType,
+ },
+ {
+ retry: false,
+ refetchOnWindowFocus: false,
+ enabled: type === "compose" && !!id,
+ },
+ );
+
+ const form = useForm({
+ resolver: zodResolver(domain),
+ defaultValues: {
+ host: "",
+ path: undefined,
+ internalPath: undefined,
+ stripPath: false,
+ port: undefined,
+ https: false,
+ certificateType: undefined,
+ customCertResolver: undefined,
+ serviceName: undefined,
+ domainType: type,
+ },
+ mode: "onChange",
+ });
+
+ const certificateType = form.watch("certificateType");
+ const https = form.watch("https");
+ const domainType = form.watch("domainType");
+
+ useEffect(() => {
+ if (data) {
+ form.reset({
+ ...data,
+ /* Convert null to undefined */
+ path: data?.path || undefined,
+ internalPath: data?.internalPath || undefined,
+ stripPath: data?.stripPath || false,
+ port: data?.port || undefined,
+ certificateType: data?.certificateType || undefined,
+ customCertResolver: data?.customCertResolver || undefined,
+ serviceName: data?.serviceName || undefined,
+ domainType: data?.domainType || type,
+ });
+ }
+
+ if (!domainId) {
+ form.reset({
+ host: "",
+ path: undefined,
+ internalPath: undefined,
+ stripPath: false,
+ port: undefined,
+ https: false,
+ certificateType: undefined,
+ customCertResolver: undefined,
+ domainType: type,
+ });
+ }
+ }, [form, data, isLoading, domainId]);
+
+ // Separate effect for handling custom cert resolver validation
+ useEffect(() => {
+ if (certificateType === "custom") {
+ form.trigger("customCertResolver");
+ }
+ }, [certificateType, form]);
+
+ const dictionary = {
+ success: domainId ? "Domain Updated" : "Domain Created",
+ error: domainId ? "Error updating the domain" : "Error creating the domain",
+ submit: domainId ? "Update" : "Create",
+ dialogDescription: domainId
+ ? "In this section you can edit a domain"
+ : "In this section you can add domains",
+ };
+
+ const onSubmit = async (data: Domain) => {
+ await mutateAsync({
+ domainId,
+ ...(data.domainType === "application" && {
+ applicationId: id,
+ }),
+ ...(data.domainType === "compose" && {
+ composeId: id,
+ }),
+ ...data,
+ })
+ .then(async () => {
+ toast.success(dictionary.success);
+
+ if (data.domainType === "application") {
+ await utils.domain.byApplicationId.invalidate({
+ applicationId: id,
+ });
+ await utils.application.readTraefikConfig.invalidate({
+ applicationId: id,
+ });
+ } else if (data.domainType === "compose") {
+ await utils.domain.byComposeId.invalidate({
+ composeId: id,
+ });
+ }
+
+ if (domainId) {
+ refetch();
+ }
+ setIsOpen(false);
+ })
+ .catch((e) => {
+ console.log(e);
+ toast.error(dictionary.error);
+ });
+ };
+ return (
+
+
+ {children}
+
+
+
+ Domain
+ {dictionary.dialogDescription}
+
+ {isError && {error?.message} }
+
+
+
+
+
+
+ {domainType === "compose" && (
+
+ {errorServices && (
+
+ {errorServices?.message}
+
+ )}
+
(
+
+ Service Name
+
+
+
+
+
+
+
+
+
+ {services?.map((service, index) => (
+
+ {service}
+
+ ))}
+
+ Empty
+
+
+
+
+
+
+ {
+ if (cacheType === "fetch") {
+ refetchServices();
+ } else {
+ setCacheType("fetch");
+ }
+ }}
+ >
+
+
+
+
+
+ Fetch: Will clone the repository and load
+ the services
+
+
+
+
+
+
+
+ {
+ if (cacheType === "cache") {
+ refetchServices();
+ } else {
+ setCacheType("cache");
+ }
+ }}
+ >
+
+
+
+
+
+ Cache: If you previously deployed this
+ compose, it will read the services from
+ the last deployment/fetch from the
+ repository
+
+
+
+
+
+
+
+
+ )}
+ />
+
+ )}
+
+
(
+
+ {!canGenerateTraefikMeDomains &&
+ field.value.includes("traefik.me") && (
+
+ You need to set an IP address in your{" "}
+
+ {application?.serverId
+ ? "Remote Servers -> Server -> Edit Server -> Update IP Address"
+ : "Web Server -> Server -> Update Server IP"}
+ {" "}
+ to make your traefik.me domain work.
+
+ )}
+ Host
+
+
+
+
+
+
+
+ {
+ generateDomain({
+ appName: application?.appName || "",
+ serverId: application?.serverId || "",
+ })
+ .then((domain) => {
+ field.onChange(domain);
+ })
+ .catch((err) => {
+ toast.error(err.message);
+ });
+ }}
+ >
+
+
+
+
+ Generate traefik.me domain
+
+
+
+
+
+
+
+ )}
+ />
+
+ {
+ return (
+
+ Path
+
+
+
+
+
+ );
+ }}
+ />
+
+ {
+ 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
+
+
+
+
+
+
+
+ )}
+ />
+
+ {
+ return (
+
+ Container Port
+
+ The port where your application is running inside the
+ container (e.g., 3000 for Node.js, 80 for Nginx, 8080
+ for Java)
+
+
+
+
+
+
+ );
+ }}
+ />
+
+ (
+
+
+ HTTPS
+
+ Automatically provision SSL Certificate.
+
+
+
+
+
+
+
+ )}
+ />
+
+ {https && (
+ <>
+ {
+ return (
+
+ Certificate Provider
+ {
+ field.onChange(value);
+ if (value !== "custom") {
+ form.setValue(
+ "customCertResolver",
+ undefined,
+ );
+ }
+ }}
+ value={field.value}
+ >
+
+
+
+
+
+
+ None
+
+ Let's Encrypt
+
+ Custom
+
+
+
+
+ );
+ }}
+ />
+
+ {certificateType === "custom" && (
+ {
+ return (
+
+ Custom Certificate Resolver
+
+ {
+ field.onChange(e);
+ form.trigger("customCertResolver");
+ }}
+ />
+
+
+
+ );
+ }}
+ />
+ )}
+ >
+ )}
+
+
+
+
+
+
+ {dictionary.submit}
+
+
+
+
+
+ );
+};
diff --git a/apps/dokploy/components/dashboard/application/domains/show-domains.tsx b/apps/dokploy/components/dashboard/application/domains/show-domains.tsx
index 17dbc91f0..7bb58dfbe 100644
--- a/apps/dokploy/components/dashboard/application/domains/show-domains.tsx
+++ b/apps/dokploy/components/dashboard/application/domains/show-domains.tsx
@@ -1,4 +1,5 @@
import { DialogAction } from "@/components/shared/dialog-action";
+import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
@@ -7,29 +8,135 @@ import {
CardHeader,
CardTitle,
} from "@/components/ui/card";
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipProvider,
+ TooltipTrigger,
+} from "@/components/ui/tooltip";
import { api } from "@/utils/api";
-import { ExternalLink, GlobeIcon, PenBoxIcon, Trash2 } from "lucide-react";
+import {
+ CheckCircle2,
+ ExternalLink,
+ GlobeIcon,
+ InfoIcon,
+ Loader2,
+ PenBoxIcon,
+ RefreshCw,
+ Server,
+ Trash2,
+ XCircle,
+} from "lucide-react";
import Link from "next/link";
+import { useState } from "react";
import { toast } from "sonner";
-import { AddDomain } from "./add-domain";
+import { DnsHelperModal } from "./dns-helper-modal";
+import { AddDomain } from "./handle-domain";
+
+export type ValidationState = {
+ isLoading: boolean;
+ isValid?: boolean;
+ error?: string;
+ resolvedIp?: string;
+ message?: string;
+ cdnProvider?: string;
+};
+
+export type ValidationStates = Record;
interface Props {
- applicationId: string;
+ id: string;
+ type: "application" | "compose";
}
-export const ShowDomains = ({ applicationId }: Props) => {
- const { data, refetch } = api.domain.byApplicationId.useQuery(
- {
- applicationId,
- },
- {
- enabled: !!applicationId,
- },
+export const ShowDomains = ({ id, type }: Props) => {
+ const { data: application } =
+ type === "application"
+ ? api.application.one.useQuery(
+ {
+ applicationId: id,
+ },
+ {
+ enabled: !!id,
+ },
+ )
+ : api.compose.one.useQuery(
+ {
+ composeId: id,
+ },
+ {
+ enabled: !!id,
+ },
+ );
+ const [validationStates, setValidationStates] = useState(
+ {},
);
+ const { data: ip } = api.settings.getIp.useQuery();
+ const {
+ data,
+ refetch,
+ isLoading: isLoadingDomains,
+ } = type === "application"
+ ? api.domain.byApplicationId.useQuery(
+ {
+ applicationId: id,
+ },
+ {
+ enabled: !!id,
+ },
+ )
+ : api.domain.byComposeId.useQuery(
+ {
+ composeId: id,
+ },
+ {
+ enabled: !!id,
+ },
+ );
+
+ const { mutateAsync: validateDomain } =
+ api.domain.validateDomain.useMutation();
const { mutateAsync: deleteDomain, isLoading: isRemoving } =
api.domain.delete.useMutation();
+ const handleValidateDomain = async (host: string) => {
+ setValidationStates((prev) => ({
+ ...prev,
+ [host]: { isLoading: true },
+ }));
+
+ try {
+ const result = await validateDomain({
+ domain: host,
+ serverIp:
+ application?.server?.ipAddress?.toString() || ip?.toString() || "",
+ });
+
+ setValidationStates((prev) => ({
+ ...prev,
+ [host]: {
+ isLoading: false,
+ isValid: result.isValid,
+ error: result.error,
+ resolvedIp: result.resolvedIp,
+ cdnProvider: result.cdnProvider,
+ message: result.error && result.isValid ? result.error : undefined,
+ },
+ }));
+ } catch (err) {
+ const error = err as Error;
+ setValidationStates((prev) => ({
+ ...prev,
+ [host]: {
+ isLoading: false,
+ isValid: false,
+ error: error.message || "Failed to validate domain",
+ },
+ }));
+ }
+ };
+
return (
@@ -43,7 +150,7 @@ export const ShowDomains = ({ applicationId }: Props) => {
{data && data?.length > 0 && (
-
+
Add Domain
@@ -52,15 +159,22 @@ export const ShowDomains = ({ applicationId }: Props) => {
- {data?.length === 0 ? (
-
+ {isLoadingDomains ? (
+
+
+
+ Loading domains...
+
+
+ ) : data?.length === 0 ? (
+
To access the application it is required to set at least 1
domain
-
+
Add Domain
@@ -68,73 +182,216 @@ export const ShowDomains = ({ applicationId }: Props) => {
) : (
-
+
{data?.map((item) => {
+ const validationState = validationStates[item.host];
return (
-
-
-
- {item.host}
-
-
-
-
-
-
- {item.path}
- {item.port}
- {item.https ? "HTTPS" : "HTTP"}
-
-
-
-
-
+
+ {/* Service & Domain Info */}
+
+ {item.serviceName && (
+
+
+ {item.serviceName}
+
+ )}
+
+ {!item.host.includes("traefik.me") && (
+
+ )}
+
+
+
+
+
+
{
+ await deleteDomain({
+ domainId: item.domainId,
+ })
+ .then((_data) => {
+ refetch();
+ toast.success(
+ "Domain deleted successfully",
+ );
+ })
+ .catch(() => {
+ toast.error("Error deleting domain");
+ });
+ }}
+ >
+
+
+
+
+
+
+
+
-
-
-
-
{
- await deleteDomain({
- domainId: item.domainId,
- })
- .then(() => {
- refetch();
- toast.success("Domain deleted successfully");
- })
- .catch(() => {
- toast.error("Error deleting domain");
- });
- }}
- >
-
-
-
-
+ {item.host}
+
+
+
+
+ {/* Domain Details */}
+
+
+
+
+
+
+ Path: {item.path || "/"}
+
+
+
+ URL path for this service
+
+
+
+
+
+
+
+
+
+ Port: {item.port}
+
+
+
+ Container port exposed
+
+
+
+
+
+
+
+
+ {item.https ? "HTTPS" : "HTTP"}
+
+
+
+
+ {item.https
+ ? "Secure HTTPS connection"
+ : "Standard HTTP connection"}
+
+
+
+
+
+ {item.certificateType && (
+
+
+
+
+ Cert: {item.certificateType}
+
+
+
+ SSL Certificate Provider
+
+
+
+ )}
+
+
+
+
+
+ handleValidateDomain(item.host)
+ }
+ >
+ {validationState?.isLoading ? (
+ <>
+
+ Checking DNS...
+ >
+ ) : validationState?.isValid ? (
+ <>
+
+ {validationState.message &&
+ validationState.cdnProvider
+ ? `Behind ${validationState.cdnProvider}`
+ : "DNS Valid"}
+ >
+ ) : validationState?.error ? (
+ <>
+
+ {validationState.error}
+ >
+ ) : (
+ <>
+
+ Validate DNS
+ >
+ )}
+
+
+
+ {validationState?.error ? (
+
+
+ Error:
+
+
{validationState.error}
+
+ ) : (
+ "Click to validate DNS configuration"
+ )}
+
+
+
+
-
-
+
+
);
})}
diff --git a/apps/dokploy/components/dashboard/application/environment/show-enviroment.tsx b/apps/dokploy/components/dashboard/application/environment/show-enviroment.tsx
index ba20db315..8a78c2745 100644
--- a/apps/dokploy/components/dashboard/application/environment/show-enviroment.tsx
+++ b/apps/dokploy/components/dashboard/application/environment/show-enviroment.tsx
@@ -71,15 +71,19 @@ export const ShowEnvironment = ({ id, type }: Props) => {
resolver: zodResolver(addEnvironmentSchema),
});
+ // Watch form value
+ const currentEnvironment = form.watch("environment");
+ const hasChanges = currentEnvironment !== (data?.env || "");
+
useEffect(() => {
if (data) {
form.reset({
environment: data.env || "",
});
}
- }, [form.reset, data, form]);
+ }, [data, form]);
- const onSubmit = async (data: EnvironmentSchema) => {
+ const onSubmit = async (formData: EnvironmentSchema) => {
mutateAsync({
mongoId: id || "",
postgresId: id || "",
@@ -87,7 +91,7 @@ export const ShowEnvironment = ({ id, type }: Props) => {
mysqlId: id || "",
mariadbId: id || "",
composeId: id || "",
- env: data.environment,
+ env: formData.environment,
})
.then(async () => {
toast.success("Environments Added");
@@ -98,6 +102,12 @@ export const ShowEnvironment = ({ id, type }: Props) => {
});
};
+ const handleCancel = () => {
+ form.reset({
+ environment: data?.env || "",
+ });
+ };
+
return (
@@ -106,6 +116,11 @@ export const ShowEnvironment = ({ id, type }: Props) => {
Environment Settings
You can add environment variables to your resource.
+ {hasChanges && (
+
+ (You have unsaved changes)
+
+ )}
@@ -132,8 +147,8 @@ export const ShowEnvironment = ({ id, type }: Props) => {
control={form.control}
name="environment"
render={({ field }) => (
-
-
+
+
{
}
language="properties"
disabled={isEnvVisible}
+ className="font-mono"
+ wrapperClassName="compose-file-editor"
placeholder={`NODE_ENV=production
PORT=3000
-`}
- className="h-96 font-mono"
+ `}
{...field}
/>
-
)}
/>
-
-
+
+ {hasChanges && (
+
+ Cancel
+
+ )}
+
Save
diff --git a/apps/dokploy/components/dashboard/application/environment/show.tsx b/apps/dokploy/components/dashboard/application/environment/show.tsx
index d97c39e2f..6f504959c 100644
--- a/apps/dokploy/components/dashboard/application/environment/show.tsx
+++ b/apps/dokploy/components/dashboard/application/environment/show.tsx
@@ -4,6 +4,7 @@ import { Form } from "@/components/ui/form";
import { Secrets } from "@/components/ui/secrets";
import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod";
+import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
@@ -34,16 +35,32 @@ export const ShowEnvironment = ({ applicationId }: Props) => {
const form = useForm({
defaultValues: {
- env: data?.env || "",
- buildArgs: data?.buildArgs || "",
+ env: "",
+ buildArgs: "",
},
resolver: zodResolver(addEnvironmentSchema),
});
- const onSubmit = async (data: EnvironmentSchema) => {
+ // Watch form values
+ const currentEnv = form.watch("env");
+ const currentBuildArgs = form.watch("buildArgs");
+ const hasChanges =
+ currentEnv !== (data?.env || "") ||
+ currentBuildArgs !== (data?.buildArgs || "");
+
+ useEffect(() => {
+ if (data) {
+ form.reset({
+ env: data.env || "",
+ buildArgs: data.buildArgs || "",
+ });
+ }
+ }, [data, form]);
+
+ const onSubmit = async (formData: EnvironmentSchema) => {
mutateAsync({
- env: data.env,
- buildArgs: data.buildArgs,
+ env: formData.env,
+ buildArgs: formData.buildArgs,
applicationId,
})
.then(async () => {
@@ -55,6 +72,13 @@ export const ShowEnvironment = ({ applicationId }: Props) => {
});
};
+ const handleCancel = () => {
+ form.reset({
+ env: data?.env || "",
+ buildArgs: data?.buildArgs || "",
+ });
+ };
+
return (
@@ -65,7 +89,16 @@ export const ShowEnvironment = ({ applicationId }: Props) => {
+ You can add environment variables to your resource.
+ {hasChanges && (
+
+ (You have unsaved changes)
+
+ )}
+
+ }
placeholder={["NODE_ENV=production", "PORT=3000"].join("\n")}
/>
{data?.buildType === "dockerfile" && (
@@ -89,8 +122,18 @@ export const ShowEnvironment = ({ applicationId }: Props) => {
placeholder="NPM_TOKEN=xyz"
/>
)}
-
-
+
+ {hasChanges && (
+
+ Cancel
+
+ )}
+
Save
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 9af040b79..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
@@ -1,4 +1,6 @@
+import { BitbucketIcon } from "@/components/icons/data-tools-icons";
import { AlertBlock } from "@/components/shared/alert-block";
+import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Command,
@@ -29,10 +31,18 @@ import {
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 { CheckIcon, ChevronsUpDown } from "lucide-react";
+import { CheckIcon, ChevronsUpDown, X } from "lucide-react";
+import Link from "next/link";
import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
@@ -48,6 +58,8 @@ const BitbucketProviderSchema = z.object({
.required(),
branch: z.string().min(1, "Branch is required"),
bitbucketId: z.string().min(1, "Bitbucket Provider is required"),
+ watchPaths: z.array(z.string()).optional(),
+ enableSubmodules: z.boolean().optional(),
});
type BitbucketProvider = z.infer;
@@ -73,6 +85,8 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
},
bitbucketId: "",
branch: "",
+ watchPaths: [],
+ enableSubmodules: false,
},
resolver: zodResolver(BitbucketProviderSchema),
});
@@ -118,9 +132,11 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
},
buildPath: data.bitbucketBuildPath || "/",
bitbucketId: data.bitbucketId || "",
+ watchPaths: data.watchPaths || [],
+ enableSubmodules: data.enableSubmodules || false,
});
}
- }, [form.reset, data, form]);
+ }, [form.reset, data?.applicationId, form]);
const onSubmit = async (data: BitbucketProvider) => {
await mutateAsync({
@@ -130,6 +146,8 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
bitbucketBuildPath: data.buildPath,
bitbucketId: data.bitbucketId,
applicationId,
+ watchPaths: data.watchPaths || [],
+ enableSubmodules: data.enableSubmodules || false,
})
.then(async () => {
toast.success("Service Provided Saved");
@@ -195,7 +213,20 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
name="repository"
render={({ field }) => (
- Repository
+
+ Repository
+ {field.value.owner && field.value.repo && (
+
+
+ View Repository
+
+ )}
+
@@ -363,6 +394,99 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
)}
/>
+ (
+
+
+
Watch Paths
+
+
+
+
+ ?
+
+
+
+
+ Add paths to watch for changes. When files in these
+ paths change, a new deployment will be triggered.
+
+
+
+
+
+
+ {field.value?.map((path, index) => (
+
+ {path}
+ {
+ const newPaths = [...(field.value || [])];
+ newPaths.splice(index, 1);
+ form.setValue("watchPaths", newPaths);
+ }}
+ />
+
+ ))}
+
+
+
+ {
+ if (e.key === "Enter") {
+ e.preventDefault();
+ const input = e.currentTarget;
+ const value = input.value.trim();
+ if (value) {
+ const newPaths = [...(field.value || []), value];
+ form.setValue("watchPaths", newPaths);
+ input.value = "";
+ }
+ }
+ }}
+ />
+ {
+ const input = document.querySelector(
+ 'input[placeholder="Enter a path to watch (e.g., src/**, dist/*.js)"]',
+ ) as HTMLInputElement;
+ const value = input.value.trim();
+ if (value) {
+ const newPaths = [...(field.value || []), value];
+ form.setValue("watchPaths", newPaths);
+ input.value = "";
+ }
+ }}
+ >
+ Add
+
+
+
+
+
+ )}
+ />
+ (
+
+
+
+
+ Enable Submodules
+
+ )}
+ />
{
registryURL: data.registryUrl || "",
});
}
- }, [form.reset, data, form]);
+ }, [form.reset, data?.applicationId, form]);
const onSubmit = async (values: DockerProvider) => {
await mutateAsync({
@@ -115,7 +115,11 @@ export const SaveDockerProvider = ({ applicationId }: Props) => {
Username
-
+
@@ -130,7 +134,12 @@ export const SaveDockerProvider = ({ applicationId }: Props) => {
Password
-
+
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 218e004d7..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,23 +17,35 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
+import { Switch } from "@/components/ui/switch";
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipProvider,
+ TooltipTrigger,
+} from "@/components/ui/tooltip";
import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod";
-import { KeyRoundIcon, LockIcon } from "lucide-react";
+import { KeyRoundIcon, LockIcon, X } from "lucide-react";
+import Link from "next/link";
import { useRouter } from "next/router";
+import { GitIcon } from "@/components/icons/data-tools-icons";
+import { Badge } from "@/components/ui/badge";
import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
const GitProviderSchema = z.object({
+ buildPath: z.string().min(1, "Path is required").default("/"),
repositoryURL: z.string().min(1, {
message: "Repository URL is required",
}),
branch: z.string().min(1, "Branch required"),
- buildPath: z.string().min(1, "Build Path required"),
sshKey: z.string().optional(),
+ watchPaths: z.array(z.string()).optional(),
+ enableSubmodules: z.boolean().default(false),
});
type GitProvider = z.infer;
@@ -56,6 +68,8 @@ export const SaveGitProvider = ({ applicationId }: Props) => {
buildPath: "/",
repositoryURL: "",
sshKey: undefined,
+ watchPaths: [],
+ enableSubmodules: false,
},
resolver: zodResolver(GitProviderSchema),
});
@@ -67,6 +81,8 @@ export const SaveGitProvider = ({ applicationId }: Props) => {
branch: data.customGitBranch || "",
buildPath: data.customGitBuildPath || "/",
repositoryURL: data.customGitUrl || "",
+ watchPaths: data.watchPaths || [],
+ enableSubmodules: data.enableSubmodules ?? false,
});
}
}, [form.reset, data, form]);
@@ -78,6 +94,8 @@ export const SaveGitProvider = ({ applicationId }: Props) => {
customGitUrl: values.repositoryURL,
customGitSSHKeyId: values.sshKey === "none" ? null : values.sshKey,
applicationId,
+ watchPaths: values.watchPaths || [],
+ enableSubmodules: values.enableSubmodules,
})
.then(async () => {
toast.success("Git Provider Saved");
@@ -102,9 +120,22 @@ export const SaveGitProvider = ({ applicationId }: Props) => {
name="repositoryURL"
render={({ field }) => (
- Repository URL
+
+ Repository URL
+ {field.value?.startsWith("https://") && (
+
+
+ View Repository
+
+ )}
+
-
+
@@ -160,19 +191,22 @@ export const SaveGitProvider = ({ applicationId }: Props) => {
)}
- (
-
- Branch
-
-
-
-
-
- )}
- />
+
+ (
+
+ Branch
+
+
+
+
+
+ )}
+ />
+
+
{
)}
/>
+ (
+
+
+
Watch Paths
+
+
+
+
+ ?
+
+
+
+
+ Add paths to watch for changes. When files in these
+ paths change, a new deployment will be triggered. This
+ will work only when manual webhook is setup.
+
+
+
+
+
+
+ {field.value?.map((path, index) => (
+
+ {path}
+ {
+ const newPaths = [...(field.value || [])];
+ newPaths.splice(index, 1);
+ form.setValue("watchPaths", newPaths);
+ }}
+ />
+
+ ))}
+
+
+
+ {
+ if (e.key === "Enter") {
+ e.preventDefault();
+ const input = e.currentTarget;
+ const value = input.value.trim();
+ if (value) {
+ const newPaths = [...(field.value || []), value];
+ form.setValue("watchPaths", newPaths);
+ input.value = "";
+ }
+ }
+ }}
+ />
+ {
+ const input = document.querySelector(
+ 'input[placeholder="Enter a path to watch (e.g., src/**, dist/*.js)"]',
+ ) as HTMLInputElement;
+ const value = input.value.trim();
+ if (value) {
+ const newPaths = [...(field.value || []), value];
+ form.setValue("watchPaths", newPaths);
+ input.value = "";
+ }
+ }}
+ >
+ Add
+
+
+
+
+
+ )}
+ />
+
+ (
+
+
+
+
+ Enable Submodules
+
+ )}
+ />
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
new file mode 100644
index 000000000..55fbfebda
--- /dev/null
+++ b/apps/dokploy/components/dashboard/application/general/generic/save-gitea-provider.tsx
@@ -0,0 +1,538 @@
+import { GiteaIcon } from "@/components/icons/data-tools-icons";
+import { AlertBlock } from "@/components/shared/alert-block";
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import {
+ Command,
+ CommandEmpty,
+ CommandGroup,
+ CommandInput,
+ CommandItem,
+} from "@/components/ui/command";
+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 {
+ 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 { CheckIcon, ChevronsUpDown, HelpCircle, Plus, X } from "lucide-react";
+import Link from "next/link";
+import { useEffect } from "react";
+import { useForm } from "react-hook-form";
+import { toast } from "sonner";
+import { z } from "zod";
+
+interface GiteaRepository {
+ name: string;
+ url: string;
+ id: number;
+ owner: {
+ username: string;
+ };
+}
+
+interface GiteaBranch {
+ name: string;
+ commit: {
+ id: string;
+ };
+}
+
+const GiteaProviderSchema = z.object({
+ buildPath: z.string().min(1, "Path is required").default("/"),
+ repository: z
+ .object({
+ repo: z.string().min(1, "Repo is required"),
+ owner: z.string().min(1, "Owner is required"),
+ })
+ .required(),
+ branch: z.string().min(1, "Branch is required"),
+ giteaId: z.string().min(1, "Gitea Provider is required"),
+ watchPaths: z.array(z.string()).default([]),
+ enableSubmodules: z.boolean().optional(),
+});
+
+type GiteaProvider = z.infer
;
+
+interface Props {
+ applicationId: string;
+}
+
+export const SaveGiteaProvider = ({ applicationId }: Props) => {
+ const { data: giteaProviders } = api.gitea.giteaProviders.useQuery();
+ const { data, refetch } = api.application.one.useQuery({ applicationId });
+
+ const { mutateAsync, isLoading: isSavingGiteaProvider } =
+ api.application.saveGiteaProvider.useMutation();
+
+ const form = useForm({
+ defaultValues: {
+ buildPath: "/",
+ repository: {
+ owner: "",
+ repo: "",
+ },
+ giteaId: "",
+ branch: "",
+ watchPaths: [],
+ enableSubmodules: false,
+ },
+ resolver: zodResolver(GiteaProviderSchema),
+ });
+
+ const repository = form.watch("repository");
+ const giteaId = form.watch("giteaId");
+
+ const { data: giteaUrl } = api.gitea.getGiteaUrl.useQuery(
+ { giteaId },
+ {
+ enabled: !!giteaId,
+ },
+ );
+
+ const {
+ data: repositories,
+ isLoading: isLoadingRepositories,
+ error,
+ } = api.gitea.getGiteaRepositories.useQuery(
+ {
+ giteaId,
+ },
+ {
+ enabled: !!giteaId,
+ },
+ );
+
+ const {
+ data: branches,
+ fetchStatus,
+ status,
+ } = api.gitea.getGiteaBranches.useQuery(
+ {
+ owner: repository?.owner,
+ repositoryName: repository?.repo,
+ giteaId: giteaId,
+ },
+ {
+ enabled: !!repository?.owner && !!repository?.repo && !!giteaId,
+ },
+ );
+
+ useEffect(() => {
+ if (data) {
+ form.reset({
+ branch: data.giteaBranch || "",
+ repository: {
+ repo: data.giteaRepository || "",
+ owner: data.giteaOwner || "",
+ },
+ buildPath: data.giteaBuildPath || "/",
+ giteaId: data.giteaId || "",
+ watchPaths: data.watchPaths || [],
+ enableSubmodules: data.enableSubmodules || false,
+ });
+ }
+ }, [form.reset, data?.applicationId, form]);
+
+ const onSubmit = async (data: GiteaProvider) => {
+ await mutateAsync({
+ giteaBranch: data.branch,
+ giteaRepository: data.repository.repo,
+ giteaOwner: data.repository.owner,
+ giteaBuildPath: data.buildPath,
+ giteaId: data.giteaId,
+ applicationId,
+ watchPaths: data.watchPaths,
+ enableSubmodules: data.enableSubmodules || false,
+ })
+ .then(async () => {
+ toast.success("Service Provider Saved");
+ await refetch();
+ })
+ .catch(() => {
+ toast.error("Error saving the Gitea provider");
+ });
+ };
+
+ return (
+
+
+
+ {error && {error?.message} }
+
+
(
+
+ Gitea Account
+ {
+ field.onChange(value);
+ form.setValue("repository", {
+ owner: "",
+ repo: "",
+ });
+ form.setValue("branch", "");
+ }}
+ defaultValue={field.value}
+ value={field.value}
+ >
+
+
+
+
+
+
+ {giteaProviders?.map((giteaProvider) => (
+
+ {giteaProvider.gitProvider.name}
+
+ ))}
+
+
+
+
+ )}
+ />
+
+ (
+
+
+ Repository
+ {field.value.owner && field.value.repo && (
+
+
+ View Repository
+
+ )}
+
+
+
+
+
+
+ {isLoadingRepositories
+ ? "Loading...."
+ : field.value.owner
+ ? repositories?.find(
+ (repo: GiteaRepository) =>
+ repo.name === field.value.repo,
+ )?.name
+ : "Select repository"}
+
+
+
+
+
+
+
+
+ {isLoadingRepositories && (
+
+ Loading Repositories....
+
+ )}
+ No repositories found.
+
+
+ {repositories && repositories.length === 0 && (
+
+ No repositories found.
+
+ )}
+ {repositories?.map((repo: GiteaRepository) => {
+ return (
+ {
+ form.setValue("repository", {
+ owner: repo.owner.username as string,
+ repo: repo.name,
+ });
+ form.setValue("branch", "");
+ }}
+ >
+
+ {repo.name}
+
+ {repo.owner.username}
+
+
+
+
+ );
+ })}
+
+
+
+
+
+ {form.formState.errors.repository && (
+
+ Repository is required
+
+ )}
+
+ )}
+ />
+ (
+
+ Branch
+
+
+
+
+ {status === "loading" && fetchStatus === "fetching"
+ ? "Loading...."
+ : field.value
+ ? branches?.find(
+ (branch: GiteaBranch) =>
+ branch.name === field.value,
+ )?.name
+ : "Select branch"}
+
+
+
+
+
+
+
+ {status === "loading" && fetchStatus === "fetching" && (
+
+ Loading Branches....
+
+ )}
+ {!repository?.owner && (
+
+ Select a repository
+
+ )}
+
+ No branch found.
+
+
+ {branches && branches.length === 0 && (
+ No branches found.
+ )}
+ {branches?.map((branch: GiteaBranch) => (
+ {
+ form.setValue("branch", branch.name);
+ }}
+ >
+ {branch.name}
+
+
+ ))}
+
+
+
+
+
+
+
+
+ )}
+ />
+ (
+
+ Build Path
+
+
+
+
+
+
+ )}
+ />
+ (
+
+
+
Watch Paths
+
+
+
+
+
+
+
+ Add paths to watch for changes. When files in these
+ paths change, a new deployment will be triggered.
+
+
+
+
+
+
+ {field.value?.map((path: string, index: number) => (
+
+ {path}
+ {
+ const newPaths = [...field.value];
+ newPaths.splice(index, 1);
+ field.onChange(newPaths);
+ }}
+ />
+
+ ))}
+
+
+
+ {
+ if (e.key === "Enter") {
+ e.preventDefault();
+ const input = e.currentTarget;
+ const path = input.value.trim();
+ if (path) {
+ field.onChange([...field.value, path]);
+ input.value = "";
+ }
+ }
+ }}
+ />
+
+
{
+ const input = document.querySelector(
+ 'input[placeholder*="Enter a path"]',
+ ) as HTMLInputElement;
+ const path = input.value.trim();
+ if (path) {
+ field.onChange([...field.value, path]);
+ input.value = "";
+ }
+ }}
+ >
+
+
+
+
+
+ )}
+ />
+ (
+
+
+
+
+ Enable Submodules
+
+ )}
+ />
+
+
+
+ Save
+
+
+
+
+
+ );
+};
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 adb445752..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
@@ -1,3 +1,5 @@
+import { GithubIcon } from "@/components/icons/data-tools-icons";
+import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Command,
@@ -28,10 +30,18 @@ import {
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 { CheckIcon, ChevronsUpDown } from "lucide-react";
+import { CheckIcon, ChevronsUpDown, HelpCircle, Plus, X } from "lucide-react";
+import Link from "next/link";
import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
@@ -47,6 +57,9 @@ const GithubProviderSchema = z.object({
.required(),
branch: z.string().min(1, "Branch is required"),
githubId: z.string().min(1, "Github Provider is required"),
+ watchPaths: z.array(z.string()).optional(),
+ triggerType: z.enum(["push", "tag"]).default("push"),
+ enableSubmodules: z.boolean().default(false),
});
type GithubProvider = z.infer;
@@ -71,12 +84,15 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
},
githubId: "",
branch: "",
+ triggerType: "push",
+ enableSubmodules: false,
},
resolver: zodResolver(GithubProviderSchema),
});
const repository = form.watch("repository");
const githubId = form.watch("githubId");
+ const triggerType = form.watch("triggerType");
const { data: repositories, isLoading: isLoadingRepositories } =
api.github.getGithubRepositories.useQuery(
@@ -113,9 +129,12 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
},
buildPath: data.buildPath || "/",
githubId: data.githubId || "",
+ watchPaths: data.watchPaths || [],
+ triggerType: data.triggerType || "push",
+ enableSubmodules: data.enableSubmodules ?? false,
});
}
- }, [form.reset, data, form]);
+ }, [form.reset, data?.applicationId, form]);
const onSubmit = async (data: GithubProvider) => {
await mutateAsync({
@@ -125,6 +144,9 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
owner: data.repository.owner,
buildPath: data.buildPath,
githubId: data.githubId,
+ watchPaths: data.watchPaths || [],
+ triggerType: data.triggerType,
+ enableSubmodules: data.enableSubmodules,
})
.then(async () => {
toast.success("Service Provided Saved");
@@ -187,7 +209,20 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
name="repository"
render={({ field }) => (
- Repository
+
+ Repository
+ {field.value.owner && field.value.repo && (
+
+
+ View Repository
+
+ )}
+
@@ -350,11 +385,148 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
-
)}
/>
+ (
+
+
+
Trigger Type
+
+
+
+
+
+
+
+ Choose when to trigger deployments: on push to the
+ selected branch or when a new tag is created.
+
+
+
+
+
+
+
+
+
+
+
+
+ On Push
+ On Tag
+
+
+
+
+ )}
+ />
+ {triggerType === "push" && (
+ (
+
+
+
Watch Paths
+
+
+
+
+
+
+
+ Add paths to watch for changes. When files in
+ these paths change, a new deployment will be
+ triggered.
+
+
+
+
+
+
+ {field.value?.map((path, index) => (
+
+ {path}
+ {
+ const newPaths = [...(field.value || [])];
+ newPaths.splice(index, 1);
+ field.onChange(newPaths);
+ }}
+ />
+
+ ))}
+
+
+
+ {
+ if (e.key === "Enter") {
+ e.preventDefault();
+ const input = e.currentTarget;
+ const path = input.value.trim();
+ if (path) {
+ field.onChange([...(field.value || []), path]);
+ input.value = "";
+ }
+ }
+ }}
+ />
+
+
{
+ const input = document.querySelector(
+ 'input[placeholder*="Enter a path"]',
+ ) as HTMLInputElement;
+ const path = input.value.trim();
+ if (path) {
+ field.onChange([...(field.value || []), path]);
+ input.value = "";
+ }
+ }}
+ >
+
+
+
+
+
+ )}
+ />
+ )}
+
+ (
+
+
+
+
+ Enable Submodules
+
+ )}
+ />
;
@@ -76,6 +88,7 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
},
gitlabId: "",
branch: "",
+ enableSubmodules: false,
},
resolver: zodResolver(GitlabProviderSchema),
});
@@ -83,6 +96,16 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
const repository = form.watch("repository");
const gitlabId = form.watch("gitlabId");
+ const gitlabUrl = useMemo(() => {
+ const url = gitlabProviders?.find(
+ (provider) => provider.gitlabId === gitlabId,
+ )?.gitlabUrl;
+
+ const gitlabUrl = url?.replace(/\/$/, "");
+
+ return gitlabUrl || "https://gitlab.com";
+ }, [gitlabId, gitlabProviders]);
+
const {
data: repositories,
isLoading: isLoadingRepositories,
@@ -124,9 +147,11 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
},
buildPath: data.gitlabBuildPath || "/",
gitlabId: data.gitlabId || "",
+ watchPaths: data.watchPaths || [],
+ enableSubmodules: data.enableSubmodules ?? false,
});
}
- }, [form.reset, data, form]);
+ }, [form.reset, data?.applicationId, form]);
const onSubmit = async (data: GitlabProvider) => {
await mutateAsync({
@@ -138,6 +163,8 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
applicationId,
gitlabProjectId: data.repository.id,
gitlabPathNamespace: data.repository.gitlabPathNamespace,
+ watchPaths: data.watchPaths || [],
+ enableSubmodules: data.enableSubmodules,
})
.then(async () => {
toast.success("Service Provided Saved");
@@ -203,7 +230,20 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
name="repository"
render={({ field }) => (
- Repository
+
+ Repository
+ {field.value.owner && field.value.repo && (
+
+
+ View Repository
+
+ )}
+
@@ -248,7 +288,7 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
{repositories?.map((repo) => {
return (
{
form.setValue("repository", {
@@ -269,7 +309,8 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
{
-
)}
/>
+ (
+
+
+
Watch Paths
+
+
+
+
+
+
+
+ Add paths to watch for changes. When files in these
+ paths change, a new deployment will be triggered.
+
+
+
+
+
+
+ {field.value?.map((path, index) => (
+
+ {path}
+ {
+ const newPaths = [...(field.value || [])];
+ newPaths.splice(index, 1);
+ field.onChange(newPaths);
+ }}
+ />
+
+ ))}
+
+
+
+ {
+ if (e.key === "Enter") {
+ e.preventDefault();
+ const input = e.currentTarget;
+ const path = input.value.trim();
+ if (path) {
+ field.onChange([...(field.value || []), path]);
+ input.value = "";
+ }
+ }
+ }}
+ />
+
+
{
+ const input = document.querySelector(
+ 'input[placeholder*="Enter a path"]',
+ ) as HTMLInputElement;
+ const path = input.value.trim();
+ if (path) {
+ field.onChange([...(field.value || []), path]);
+ input.value = "";
+ }
+ }}
+ >
+
+
+
+
+
+ )}
+ />
+ (
+
+
+
+
+ Enable Submodules
+
+ )}
+ />
{
- const { data: githubProviders } = api.github.githubProviders.useQuery();
- const { data: gitlabProviders } = api.gitlab.gitlabProviders.useQuery();
- const { data: bitbucketProviders } =
+ const { data: githubProviders, isLoading: isLoadingGithub } =
+ api.github.githubProviders.useQuery();
+ const { data: gitlabProviders, isLoading: isLoadingGitlab } =
+ api.gitlab.gitlabProviders.useQuery();
+ const { data: bitbucketProviders, isLoading: isLoadingBitbucket } =
api.bitbucket.bitbucketProviders.useQuery();
+ const { data: giteaProviders, isLoading: isLoadingGitea } =
+ api.gitea.giteaProviders.useQuery();
+
+ const { data: application, refetch } = api.application.one.useQuery({
+ applicationId,
+ });
+ const { mutateAsync: disconnectGitProvider } =
+ api.application.disconnectGitProvider.useMutation();
- const { data: application } = api.application.one.useQuery({ applicationId });
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 (
+
+
+
+
+
Provider
+
+ Select the source of your code
+
+
+
+
+
+
+
+
+
+
+
+ Loading providers...
+
+
+
+
+ );
+ }
+
+ // 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 (
@@ -55,8 +153,8 @@ export const ShowProviderForm = ({ applicationId }: Props) => {
setSab(e as TabState);
}}
>
-
-
+
+
{
Bitbucket
+
+
+ Gitea
+
{
{githubProviders && githubProviders?.length > 0 ? (
) : (
-
+
To deploy using GitHub, you need to configure your account
@@ -126,7 +231,7 @@ export const ShowProviderForm = ({ applicationId }: Props) => {
{gitlabProviders && gitlabProviders?.length > 0 ? (
) : (
-
+
To deploy using GitLab, you need to configure your account
@@ -146,7 +251,7 @@ export const ShowProviderForm = ({ applicationId }: Props) => {
{bitbucketProviders && bitbucketProviders?.length > 0 ? (
) : (
-
+
To deploy using Bitbucket, you need to configure your account
@@ -162,6 +267,26 @@ export const ShowProviderForm = ({ applicationId }: Props) => {
)}
+
+ {giteaProviders && giteaProviders?.length > 0 ? (
+
+ ) : (
+
+
+
+ To deploy using Gitea, you need to configure your account
+ first. Please, go to{" "}
+
+ Settings
+ {" "}
+ to do so.
+
+
+ )}
+
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/general/show.tsx b/apps/dokploy/components/dashboard/application/general/show.tsx
index 8989ca198..c917d7ab7 100644
--- a/apps/dokploy/components/dashboard/application/general/show.tsx
+++ b/apps/dokploy/components/dashboard/application/general/show.tsx
@@ -4,8 +4,22 @@ import { DialogAction } from "@/components/shared/dialog-action";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Switch } from "@/components/ui/switch";
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipProvider,
+ TooltipTrigger,
+} from "@/components/ui/tooltip";
import { api } from "@/utils/api";
-import { Ban, CheckCircle2, Hammer, RefreshCcw, Terminal } from "lucide-react";
+import * as TooltipPrimitive from "@radix-ui/react-tooltip";
+import {
+ Ban,
+ CheckCircle2,
+ Hammer,
+ RefreshCcw,
+ Rocket,
+ Terminal,
+} from "lucide-react";
import { useRouter } from "next/router";
import { toast } from "sonner";
import { DockerTerminalModal } from "../../settings/web-server/docker-terminal-modal";
@@ -41,141 +55,224 @@ export const ShowGeneralApplication = ({ applicationId }: Props) => {
Deploy Settings
- {
- await deploy({
- applicationId: applicationId,
- })
- .then(() => {
- toast.success("Application deployed successfully");
- refetch();
- router.push(
- `/dashboard/project/${data?.projectId}/services/application/${applicationId}?tab=deployments`,
- );
- })
- .catch(() => {
- toast.error("Error deploying application");
- });
- }}
- >
-
- Deploy
-
-
- {
- await reload({
- applicationId: applicationId,
- appName: data?.appName || "",
- })
- .then(() => {
- toast.success("Application reloaded successfully");
- refetch();
- })
- .catch(() => {
- toast.error("Error reloading application");
- });
- }}
- >
-
- Reload
-
-
-
- {
- await redeploy({
- applicationId: applicationId,
- })
- .then(() => {
- toast.success("Application rebuilt successfully");
- refetch();
- })
- .catch(() => {
- toast.error("Error rebuilding application");
- });
- }}
- >
-
- Rebuild
-
-
-
-
- {data?.applicationStatus === "idle" ? (
+
{
- await start({
+ await deploy({
applicationId: applicationId,
})
.then(() => {
- toast.success("Application started successfully");
+ toast.success("Application deployed successfully");
refetch();
+ router.push(
+ `/dashboard/project/${data?.projectId}/services/application/${applicationId}?tab=deployments`,
+ );
})
.catch(() => {
- toast.error("Error starting application");
+ toast.error("Error deploying application");
});
}}
>
-
- Start
-
+
+
+
+
+
+ Deploy
+
+
+
+
+
+ Downloads the source code and performs a complete build
+
+
+
+
- ) : (
{
- await stop({
+ await reload({
applicationId: applicationId,
+ appName: data?.appName || "",
})
.then(() => {
- toast.success("Application stopped successfully");
+ toast.success("Application reloaded successfully");
refetch();
})
.catch(() => {
- toast.error("Error stopping application");
+ toast.error("Error reloading application");
});
}}
>
-
- Stop
-
+
+
+
+
+
+ Reload
+
+
+
+
+ Reload the application without rebuilding it
+
+
+
- )}
+ {
+ await redeploy({
+ applicationId: applicationId,
+ })
+ .then(() => {
+ toast.success("Application rebuilt successfully");
+ refetch();
+ })
+ .catch(() => {
+ toast.error("Error rebuilding application");
+ });
+ }}
+ >
+
+
+
+
+
+ Rebuild
+
+
+
+
+
+ Only rebuilds the application without downloading new
+ code
+
+
+
+
+
+
+
+ {data?.applicationStatus === "idle" ? (
+ {
+ await start({
+ applicationId: applicationId,
+ })
+ .then(() => {
+ toast.success("Application started successfully");
+ refetch();
+ })
+ .catch(() => {
+ toast.error("Error starting application");
+ });
+ }}
+ >
+
+
+
+
+
+ Start
+
+
+
+
+
+ Start the application (requires a previous successful
+ build)
+
+
+
+
+
+
+ ) : (
+ {
+ await stop({
+ applicationId: applicationId,
+ })
+ .then(() => {
+ toast.success("Application stopped successfully");
+ refetch();
+ })
+ .catch(() => {
+ toast.error("Error stopping application");
+ });
+ }}
+ >
+
+
+
+
+
+ Stop
+
+
+
+
+ Stop the currently running application
+
+
+
+
+
+ )}
+
-
-
+
+
Open Terminal
Autodeploy
{
await update({
@@ -190,7 +287,29 @@ export const ShowGeneralApplication = ({ applicationId }: Props) => {
toast.error("Error updating Auto Deploy");
});
}}
- className="flex flex-row gap-2 items-center"
+ className="flex flex-row gap-2 items-center data-[state=checked]:bg-primary"
+ />
+
+
+
+ Clean Cache
+ {
+ await update({
+ applicationId,
+ cleanCache: enabled,
+ })
+ .then(async () => {
+ toast.success("Clean Cache Updated");
+ await refetch();
+ })
+ .catch(() => {
+ toast.error("Error updating Clean Cache");
+ });
+ }}
+ className="flex flex-row gap-2 items-center data-[state=checked]:bg-primary"
/>
diff --git a/apps/dokploy/components/dashboard/application/preview-deployments/add-preview-domain.tsx b/apps/dokploy/components/dashboard/application/preview-deployments/add-preview-domain.tsx
index 64b7c3c61..bb6f0e0a7 100644
--- a/apps/dokploy/components/dashboard/application/preview-deployments/add-preview-domain.tsx
+++ b/apps/dokploy/components/dashboard/application/preview-deployments/add-preview-domain.tsx
@@ -94,6 +94,7 @@ export const AddPreviewDomain = ({
/* Convert null to undefined */
path: data?.path || undefined,
port: data?.port || undefined,
+ customCertResolver: data?.customCertResolver || undefined,
});
}
@@ -137,7 +138,7 @@ export const AddPreviewDomain = ({
{children}
-
+
Domain
{dictionary.dialogDescription}
diff --git a/apps/dokploy/components/dashboard/application/preview-deployments/show-preview-builds.tsx b/apps/dokploy/components/dashboard/application/preview-deployments/show-preview-builds.tsx
deleted file mode 100644
index 90800f757..000000000
--- a/apps/dokploy/components/dashboard/application/preview-deployments/show-preview-builds.tsx
+++ /dev/null
@@ -1,100 +0,0 @@
-import { DateTooltip } from "@/components/shared/date-tooltip";
-import { StatusTooltip } from "@/components/shared/status-tooltip";
-import { Button } from "@/components/ui/button";
-import {
- Dialog,
- DialogContent,
- DialogDescription,
- DialogHeader,
- DialogTitle,
- DialogTrigger,
-} from "@/components/ui/dialog";
-
-import type { RouterOutputs } from "@/utils/api";
-import { useState } from "react";
-import { ShowDeployment } from "../deployments/show-deployment";
-
-interface Props {
- deployments: RouterOutputs["deployment"]["all"];
- serverId?: string;
- trigger?: React.ReactNode;
-}
-
-export const ShowPreviewBuilds = ({
- deployments,
- serverId,
- trigger,
-}: Props) => {
- const [activeLog, setActiveLog] = useState<
- RouterOutputs["deployment"]["all"][number] | null
- >(null);
- const [isOpen, setIsOpen] = useState(false);
- return (
-
-
- {trigger ? (
- trigger
- ) : (
-
- View Builds
-
- )}
-
-
-
- Preview Builds
-
- See all the preview builds for this application on this Pull Request
-
-
-
- {deployments?.map((deployment) => (
-
-
-
- {deployment.status}
-
-
-
-
- {deployment.title}
-
- {deployment.description && (
-
- {deployment.description}
-
- )}
-
-
-
-
-
-
-
{
- setActiveLog(deployment);
- }}
- >
- View
-
-
-
- ))}
-
-
- setActiveLog(null)}
- logPath={activeLog?.logPath || ""}
- errorMessage={activeLog?.errorMessage || ""}
- />
-
- );
-};
diff --git a/apps/dokploy/components/dashboard/application/preview-deployments/show-preview-deployments.tsx b/apps/dokploy/components/dashboard/application/preview-deployments/show-preview-deployments.tsx
index ec3680f10..bf93af718 100644
--- a/apps/dokploy/components/dashboard/application/preview-deployments/show-preview-deployments.tsx
+++ b/apps/dokploy/components/dashboard/application/preview-deployments/show-preview-deployments.tsx
@@ -17,15 +17,15 @@ import {
ExternalLink,
FileText,
GitPullRequest,
- Layers,
+ Loader2,
PenSquare,
RocketIcon,
Trash2,
} from "lucide-react";
import { toast } from "sonner";
import { ShowModalLogs } from "../../settings/web-server/show-modal-logs";
+import { ShowDeploymentsModal } from "../deployments/show-deployments-modal";
import { AddPreviewDomain } from "./add-preview-domain";
-import { ShowPreviewBuilds } from "./show-preview-builds";
import { ShowPreviewSettings } from "./show-preview-settings";
interface Props {
@@ -38,13 +38,16 @@ export const ShowPreviewDeployments = ({ applicationId }: Props) => {
const { mutateAsync: deletePreviewDeployment, isLoading } =
api.previewDeployment.delete.useMutation();
- const { data: previewDeployments, refetch: refetchPreviewDeployments } =
- api.previewDeployment.all.useQuery(
- { applicationId },
- {
- enabled: !!applicationId,
- },
- );
+ const {
+ data: previewDeployments,
+ refetch: refetchPreviewDeployments,
+ isLoading: isLoadingPreviewDeployments,
+ } = api.previewDeployment.all.useQuery(
+ { applicationId },
+ {
+ enabled: !!applicationId,
+ },
+ );
const handleDeletePreviewDeployment = async (previewDeploymentId: string) => {
deletePreviewDeployment({
@@ -80,8 +83,15 @@ export const ShowPreviewDeployments = ({ applicationId }: Props) => {
each pull request you create.
- {!previewDeployments?.length ? (
-
+ {isLoadingPreviewDeployments ? (
+
+
+
+ Loading preview deployments...
+
+
+ ) : !previewDeployments?.length ? (
+
No preview deployments found
@@ -168,19 +178,10 @@ export const ShowPreviewDeployments = ({ applicationId }: Props) => {
-
-
- Builds
-
- }
/>
{
+ if (
+ input.previewCertificateType === "custom" &&
+ !input.previewCustomCertResolver
+ ) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ path: ["previewCustomCertResolver"],
+ message: "Required",
+ });
+ }
+ });
type Schema = z.infer;
@@ -90,6 +104,7 @@ export const ShowPreviewSettings = ({ applicationId }: Props) => {
previewHttps: data.previewHttps || false,
previewPath: data.previewPath || "/",
previewCertificateType: data.previewCertificateType || "none",
+ previewCustomCertResolver: data.previewCustomCertResolver || "",
});
}
}, [data]);
@@ -105,6 +120,7 @@ export const ShowPreviewSettings = ({ applicationId }: Props) => {
previewHttps: formData.previewHttps,
previewPath: formData.previewPath,
previewCertificateType: formData.previewCertificateType,
+ previewCustomCertResolver: formData.previewCustomCertResolver,
})
.then(() => {
toast.success("Preview Deployments settings updated");
@@ -122,7 +138,7 @@ export const ShowPreviewSettings = ({ applicationId }: Props) => {
Configure
-
+
Preview Deployment Settings
@@ -184,10 +200,6 @@ export const ShowPreviewSettings = ({ applicationId }: Props) => {
render={({ field }) => (
Preview Limit
- {/*
- Set the limit of preview deployments that can be
- created for this app.
- */}
@@ -238,6 +250,7 @@ export const ShowPreviewSettings = ({ applicationId }: Props) => {
Let's Encrypt
+ Custom
@@ -245,6 +258,25 @@ export const ShowPreviewSettings = ({ applicationId }: Props) => {
)}
/>
)}
+
+ {form.watch("previewCertificateType") === "custom" && (
+ (
+
+ Certificate Provider
+
+
+
+
+
+ )}
+ />
+ )}
@@ -266,7 +298,11 @@ export const ShowPreviewSettings = ({ applicationId }: Props) => {
})
.then(() => {
refetch();
- toast.success("Preview deployments enabled");
+ toast.success(
+ checked
+ ? "Preview deployments enabled"
+ : "Preview deployments disabled",
+ );
})
.catch((error) => {
toast.error(error.message);
diff --git a/apps/dokploy/components/dashboard/application/rollbacks/Backup b/apps/dokploy/components/dashboard/application/rollbacks/Backup
new file mode 100644
index 000000000..2a58e92df
--- /dev/null
+++ b/apps/dokploy/components/dashboard/application/rollbacks/Backup
@@ -0,0 +1,108 @@
+Backup
+# license-namedbackups-abxelc
+1. docker ps --filter "label=com.docker.swarm.service.name=license-namedbackups-abxelc" --format "{{.Names}}"
+2. docker run --rm \
+--volumes-from "license-namedbackups-abxelc.1.m3cxy78ocj3w0zu42kmgamc5y" \
+-v $(pwd):/backup \
+ubuntu \
+tar cvf /backup/backup.tar /var/lib/postgresql/data
+
+
+# Official Command Backup
+
+1. Backup
+
+docker run --rm \
+ -v license-namedbackups-abxelc-data:/volume_data \
+ -v $(pwd):/backup \
+ ubuntu \
+ bash -c "cd /volume_data && tar cvf /backup/generic_backup.tar ."
+
+
+2. Restore
+
+docker service scale license-namedbackups-abxelc=0
+
+docker volume rm license-namedbackups-abxelc-data
+
+2. docker run --rm \
+-v license-namedbackups-abxelc-data:/volume_data \
+-v $(pwd):/backup \
+ubuntu \
+bash -c "cd /volume_data && tar xvf /backup/generic_backup.tar ."
+
+docker service scale license-namedbackups-abxelc=1
+
+
+root@srv594061:~# docker volume inspect n8n_data-data
+[
+ {
+ "CreatedAt": "2025-06-28T18:07:44Z",
+ "Driver": "local",
+ "Labels": null,
+ "Mountpoint": "/var/lib/docker/volumes/n8n_data-data/_data",
+ "Name": "n8n_data-data",
+ "Options": null,
+ "Scope": "local"
+ }
+]
+
+Archivos funcuionando creados por N8N
+
+# root@srv594061:~# cd /var/lib/docker/volumes/n8n_data-data/_data
+# root@srv594061:/var/lib/docker/volumes/n8n_data-data/_data# ls
+# binaryData config crash.journal database.sqlite git n8nEventLog.log ssh
+
+Luego que intente hacer el backup con el comando de backup
+
+
+root@srv594061:~# docker run --rm -v n8n_data-data:/volume_data -v $(pwd):/backup ubuntu bash -c "cd /volume_data && tar cvf /backup/generic_backup6.tar ."
+./
+./config
+./crash.journal
+./binaryData/
+./git/
+./database.sqlite
+./ssh/
+./n8nEventLog.log
+root@srv594061:~#
+
+# Paramos la aplicacion
+docker service scale n8n=0
+
+# Haciendo el restore
+root@srv594061:~# docker volume rm n8n_data-data
+n8n_data-data
+root@srv594061:~# docker run --rm -v n8n_data-data:/volume_data -v $(pwd):/backup ubuntu bash -c "cd /volume_data && tar xvf /backup/generic_backup6.tar && chown -R 999:999 ."
+./
+./config
+./crash.journal
+./binaryData/
+./git/
+./database.sqlite
+./ssh/
+./n8nEventLog.log
+
+# Tenemos los archivos en el volumen
+root@srv594061:~# ls /var/lib/docker/volumes/n8n_data-data/_data
+binaryData config crash.journal database.sqlite git n8nEventLog.log ssh
+root@srv594061:~#
+
+docker service scale n8n=1
+
+# Luego en N8N Cuando se que el volumen tiene la data
+Permissions 0644 for n8n settings file /home/node/.n8n/config are too wide. This is ignored for now, but in the future n8n will attempt to change the permissions automatically. To automatically enforce correct permissions now set N8N_ENFORCE_SETTINGS_FILE_PERMISSIONS=true (recommended), or turn this check off set N8N_ENFORCE_SETTINGS_FILE_PERMISSIONS=false.
+User settings loaded from: /home/node/.n8n/config
+Last session crashed
+Error: EACCES: permission denied, open '/home/node/.n8n/crash.journal'
+at open (node:internal/fs/promises:639:25)
+at touchFile (/usr/local/lib/node_modules/n8n/dist/crash-journal.js:18:20)
+at Object.init (/usr/local/lib/node_modules/n8n/dist/crash-journal.js:32:5)
+at Start.initCrashJournal (/usr/local/lib/node_modules/n8n/dist/commands/base-command.js:113:9)
+at Start.init (/usr/local/lib/node_modules/n8n/dist/commands/start.js:141:9)
+at Start._run (/usr/local/lib/node_modules/n8n/node_modules/@oclif/core/lib/command.js:301:13)
+at Config.runCommand (/usr/local/lib/node_modules/n8n/node_modules/@oclif/core/lib/config/config.js:424:25)
+at run (/usr/local/lib/node_modules/n8n/node_modules/@oclif/core/lib/main.js:94:16)
+at /usr/local/lib/node_modules/n8n/bin/n8n:71:2
+TypeError: Cannot read properties of undefined (reading 'error')
+
diff --git a/apps/dokploy/components/dashboard/application/rollbacks/show-rollback-settings.tsx b/apps/dokploy/components/dashboard/application/rollbacks/show-rollback-settings.tsx
new file mode 100644
index 000000000..77575ea03
--- /dev/null
+++ b/apps/dokploy/components/dashboard/application/rollbacks/show-rollback-settings.tsx
@@ -0,0 +1,123 @@
+import { AlertBlock } from "@/components/shared/alert-block";
+import { Button } from "@/components/ui/button";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "@/components/ui/dialog";
+import {
+ Form,
+ FormControl,
+ FormDescription,
+ FormField,
+ FormItem,
+ FormLabel,
+} from "@/components/ui/form";
+import { Switch } from "@/components/ui/switch";
+import { api } from "@/utils/api";
+import { zodResolver } from "@hookform/resolvers/zod";
+import { useState } from "react";
+import { useForm } from "react-hook-form";
+import { toast } from "sonner";
+import { z } from "zod";
+
+const formSchema = z.object({
+ rollbackActive: z.boolean(),
+});
+
+type FormValues = z.infer
;
+
+interface Props {
+ applicationId: string;
+ children?: React.ReactNode;
+}
+
+export const ShowRollbackSettings = ({ applicationId, children }: Props) => {
+ const [isOpen, setIsOpen] = useState(false);
+ const { data: application, refetch } = api.application.one.useQuery(
+ {
+ applicationId,
+ },
+ {
+ enabled: !!applicationId,
+ },
+ );
+
+ const { mutateAsync: updateApplication, isLoading } =
+ api.application.update.useMutation();
+
+ const form = useForm({
+ resolver: zodResolver(formSchema),
+ defaultValues: {
+ rollbackActive: application?.rollbackActive ?? false,
+ },
+ });
+
+ const onSubmit = async (data: FormValues) => {
+ await updateApplication({
+ applicationId,
+ rollbackActive: data.rollbackActive,
+ })
+ .then(() => {
+ toast.success("Rollback settings updated");
+ setIsOpen(false);
+ refetch();
+ })
+ .catch(() => {
+ toast.error("Failed to update rollback settings");
+ });
+ };
+
+ return (
+
+ {children}
+
+
+ Rollback Settings
+
+ Configure how rollbacks work for this application
+
+
+ Having rollbacks enabled increases storage usage. Be careful with
+ this option. Note that manually cleaning the cache may delete
+ rollback images, making them unavailable for future rollbacks.
+
+
+
+
+
+ (
+
+
+
+ Enable Rollbacks
+
+
+ Allow rolling back to previous deployments
+
+
+
+
+
+
+ )}
+ />
+
+
+ Save Settings
+
+
+
+
+
+ );
+};
diff --git a/apps/dokploy/components/dashboard/application/schedules/handle-schedules.tsx b/apps/dokploy/components/dashboard/application/schedules/handle-schedules.tsx
new file mode 100644
index 000000000..077c289b8
--- /dev/null
+++ b/apps/dokploy/components/dashboard/application/schedules/handle-schedules.tsx
@@ -0,0 +1,542 @@
+import { AlertBlock } from "@/components/shared/alert-block";
+import { CodeEditor } from "@/components/shared/code-editor";
+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";
+
+export const commonCronExpressions = [
+ { label: "Every minute", value: "* * * * *" },
+ { label: "Every hour", value: "0 * * * *" },
+ { label: "Every day at midnight", value: "0 0 * * *" },
+ { label: "Every Sunday at midnight", value: "0 0 * * 0" },
+ { label: "Every month on the 1st at midnight", value: "0 0 1 * *" },
+ { label: "Every 15 minutes", value: "*/15 * * * *" },
+ { label: "Every weekday at midnight", value: "0 0 * * 1-5" },
+];
+
+const formSchema = z
+ .object({
+ name: z.string().min(1, "Name is required"),
+ cronExpression: z.string().min(1, "Cron expression is required"),
+ shellType: z.enum(["bash", "sh"]).default("bash"),
+ command: z.string(),
+ enabled: z.boolean().default(true),
+ serviceName: z.string(),
+ scheduleType: z.enum([
+ "application",
+ "compose",
+ "server",
+ "dokploy-server",
+ ]),
+ script: z.string(),
+ })
+ .superRefine((data, ctx) => {
+ if (data.scheduleType === "compose" && !data.serviceName) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ message: "Service name is required",
+ path: ["serviceName"],
+ });
+ }
+
+ if (
+ (data.scheduleType === "dokploy-server" ||
+ data.scheduleType === "server") &&
+ !data.script
+ ) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ message: "Script is required",
+ path: ["script"],
+ });
+ }
+
+ if (
+ (data.scheduleType === "application" ||
+ data.scheduleType === "compose") &&
+ !data.command
+ ) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ message: "Command is required",
+ path: ["command"],
+ });
+ }
+ });
+
+interface Props {
+ id?: string;
+ scheduleId?: string;
+ scheduleType?: "application" | "compose" | "server" | "dokploy-server";
+}
+
+export const HandleSchedules = ({ id, scheduleId, scheduleType }: Props) => {
+ const [isOpen, setIsOpen] = useState(false);
+ const [cacheType, setCacheType] = useState("cache");
+
+ const utils = api.useUtils();
+ const form = useForm>({
+ resolver: zodResolver(formSchema),
+ defaultValues: {
+ name: "",
+ cronExpression: "",
+ shellType: "bash",
+ command: "",
+ enabled: true,
+ serviceName: "",
+ scheduleType: scheduleType || "application",
+ script: "",
+ },
+ });
+
+ const scheduleTypeForm = form.watch("scheduleType");
+
+ const { data: schedule } = api.schedule.one.useQuery(
+ { scheduleId: scheduleId || "" },
+ { enabled: !!scheduleId },
+ );
+
+ const {
+ data: services,
+ isFetching: isLoadingServices,
+ error: errorServices,
+ refetch: refetchServices,
+ } = api.compose.loadServices.useQuery(
+ {
+ composeId: id || "",
+ type: cacheType,
+ },
+ {
+ retry: false,
+ refetchOnWindowFocus: false,
+ enabled: !!id && scheduleType === "compose",
+ },
+ );
+
+ useEffect(() => {
+ if (scheduleId && schedule) {
+ form.reset({
+ name: schedule.name,
+ cronExpression: schedule.cronExpression,
+ shellType: schedule.shellType,
+ command: schedule.command,
+ enabled: schedule.enabled,
+ serviceName: schedule.serviceName || "",
+ scheduleType: schedule.scheduleType,
+ script: schedule.script || "",
+ });
+ }
+ }, [form, schedule, scheduleId]);
+
+ const { mutateAsync, isLoading } = scheduleId
+ ? api.schedule.update.useMutation()
+ : api.schedule.create.useMutation();
+
+ const onSubmit = async (values: z.infer) => {
+ if (!id && !scheduleId) return;
+
+ await mutateAsync({
+ ...values,
+ scheduleId: scheduleId || "",
+ ...(scheduleType === "application" && {
+ applicationId: id || "",
+ }),
+ ...(scheduleType === "compose" && {
+ composeId: id || "",
+ }),
+ ...(scheduleType === "server" && {
+ serverId: id || "",
+ }),
+ ...(scheduleType === "dokploy-server" && {
+ userId: id || "",
+ }),
+ })
+ .then(() => {
+ toast.success(
+ `Schedule ${scheduleId ? "updated" : "created"} successfully`,
+ );
+ utils.schedule.list.invalidate({
+ id,
+ scheduleType,
+ });
+ setIsOpen(false);
+ })
+ .catch((error) => {
+ toast.error(
+ error instanceof Error ? error.message : "An unknown error occurred",
+ );
+ });
+ };
+
+ return (
+
+
+ {scheduleId ? (
+
+
+
+ ) : (
+
+
+ Add Schedule
+
+ )}
+
+
+
+ {scheduleId ? "Edit" : "Create"} Schedule
+
+ {scheduleId ? "Manage" : "Create"} a schedule to run a task at a
+ specific time or interval.
+
+
+
+
+ {scheduleTypeForm === "compose" && (
+
+ {errorServices && (
+
+ {errorServices?.message}
+
+ )}
+
(
+
+ Service Name
+
+
+
+
+
+
+
+
+
+ {services?.map((service, index) => (
+
+ {service}
+
+ ))}
+
+ Empty
+
+
+
+
+
+
+ {
+ if (cacheType === "fetch") {
+ refetchServices();
+ } else {
+ setCacheType("fetch");
+ }
+ }}
+ >
+
+
+
+
+
+ Fetch: Will clone the repository and load the
+ services
+
+
+
+
+
+
+
+ {
+ if (cacheType === "cache") {
+ refetchServices();
+ } else {
+ setCacheType("cache");
+ }
+ }}
+ >
+
+
+
+
+
+ Cache: If you previously deployed this compose,
+ it will read the services from the last
+ deployment/fetch from the repository
+
+
+
+
+
+
+
+
+ )}
+ />
+
+ )}
+
+ (
+
+
+ Task Name
+
+
+
+
+
+ A descriptive name for your scheduled task
+
+
+
+ )}
+ />
+
+ (
+
+
+ Schedule
+
+
+
+
+
+
+
+ Cron expression format: minute hour day month
+ weekday
+
+ Example: 0 0 * * * (daily at midnight)
+
+
+
+
+
+
{
+ field.onChange(value);
+ }}
+ >
+
+
+
+
+
+
+ {commonCronExpressions.map((expr) => (
+
+ {expr.label} ({expr.value})
+
+ ))}
+
+
+
+
+
+
+
+
+
+ Choose a predefined schedule or enter a custom cron
+ expression
+
+
+
+ )}
+ />
+
+ {(scheduleTypeForm === "application" ||
+ scheduleTypeForm === "compose") && (
+ <>
+ (
+
+
+ Shell Type
+
+
+
+
+
+
+
+
+ Bash
+ Sh
+
+
+
+ Choose the shell to execute your command
+
+
+
+ )}
+ />
+ (
+
+
+ Command
+
+
+
+
+
+ The command to execute in your container
+
+
+
+ )}
+ />
+ >
+ )}
+
+ {(scheduleTypeForm === "dokploy-server" ||
+ scheduleTypeForm === "server") && (
+ (
+
+ Script
+
+
+
+
+
+
+
+ )}
+ />
+ )}
+
+ (
+
+
+
+ Enabled
+
+
+ )}
+ />
+
+
+ {scheduleId ? "Update" : "Create"} Schedule
+
+
+
+
+
+ );
+};
diff --git a/apps/dokploy/components/dashboard/application/schedules/show-schedules.tsx b/apps/dokploy/components/dashboard/application/schedules/show-schedules.tsx
new file mode 100644
index 000000000..2f2ebc85a
--- /dev/null
+++ b/apps/dokploy/components/dashboard/application/schedules/show-schedules.tsx
@@ -0,0 +1,243 @@
+import { DialogAction } from "@/components/shared/dialog-action";
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+} from "@/components/ui/card";
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipProvider,
+ TooltipTrigger,
+} from "@/components/ui/tooltip";
+import { api } from "@/utils/api";
+import {
+ ClipboardList,
+ Clock,
+ Loader2,
+ Play,
+ Terminal,
+ Trash2,
+} from "lucide-react";
+import { toast } from "sonner";
+import { ShowDeploymentsModal } from "../deployments/show-deployments-modal";
+import { HandleSchedules } from "./handle-schedules";
+
+interface Props {
+ id: string;
+ scheduleType?: "application" | "compose" | "server" | "dokploy-server";
+}
+
+export const ShowSchedules = ({ id, scheduleType = "application" }: Props) => {
+ const {
+ data: schedules,
+ isLoading: isLoadingSchedules,
+ refetch: refetchSchedules,
+ } = api.schedule.list.useQuery(
+ {
+ id: id || "",
+ scheduleType,
+ },
+ {
+ enabled: !!id,
+ },
+ );
+
+ const utils = api.useUtils();
+
+ const { mutateAsync: deleteSchedule, isLoading: isDeleting } =
+ api.schedule.delete.useMutation();
+
+ const { mutateAsync: runManually, isLoading } =
+ api.schedule.runManually.useMutation();
+
+ return (
+
+
+
+
+
+ Scheduled Tasks
+
+
+ Schedule tasks to run automatically at specified intervals.
+
+
+
+ {schedules && schedules.length > 0 && (
+
+ )}
+
+
+
+ {isLoadingSchedules ? (
+
+
+
+ Loading scheduled tasks...
+
+
+ ) : schedules && schedules.length > 0 ? (
+
+ {schedules.map((schedule) => {
+ const serverId =
+ schedule.serverId ||
+ schedule.application?.serverId ||
+ schedule.compose?.serverId;
+ return (
+
+
+
+
+
+
+
+
+ {schedule.name}
+
+
+ {schedule.enabled ? "Enabled" : "Disabled"}
+
+
+
+
+ Cron: {schedule.cronExpression}
+
+ {schedule.scheduleType !== "server" &&
+ schedule.scheduleType !== "dokploy-server" && (
+ <>
+
+ •
+
+
+ {schedule.shellType}
+
+ >
+ )}
+
+ {schedule.command && (
+
+
+
+ {schedule.command}
+
+
+ )}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {
+ toast.success("Schedule run successfully");
+
+ await runManually({
+ scheduleId: schedule.scheduleId,
+ })
+ .then(async () => {
+ await new Promise((resolve) =>
+ setTimeout(resolve, 1500),
+ );
+ refetchSchedules();
+ })
+ .catch(() => {
+ toast.error("Error running schedule");
+ });
+ }}
+ >
+
+
+
+ Run Manual Schedule
+
+
+
+
+
+
{
+ await deleteSchedule({
+ scheduleId: schedule.scheduleId,
+ })
+ .then(() => {
+ utils.schedule.list.invalidate({
+ id,
+ scheduleType,
+ });
+ toast.success("Schedule deleted successfully");
+ })
+ .catch(() => {
+ toast.error("Error deleting schedule");
+ });
+ }}
+ >
+
+
+
+
+
+
+ );
+ })}
+
+ ) : (
+
+
+
+ No scheduled tasks
+
+
+ Create your first scheduled task to automate your workflows
+
+
+
+ )}
+
+
+ );
+};
diff --git a/apps/dokploy/components/dashboard/application/update-application.tsx b/apps/dokploy/components/dashboard/application/update-application.tsx
index 90b63f08e..4d4190fa2 100644
--- a/apps/dokploy/components/dashboard/application/update-application.tsx
+++ b/apps/dokploy/components/dashboard/application/update-application.tsx
@@ -99,7 +99,7 @@ export const UpdateApplication = ({ applicationId }: Props) => {
-
+
Modify Application
Update the application data
@@ -121,7 +121,7 @@ export const UpdateApplication = ({ applicationId }: Props) => {
Name
-
+
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..c66b05850
--- /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
+
+
+
+
+ (
+
+
+ Task Name
+
+
+
+
+
+ A descriptive name for your scheduled task
+
+
+
+ )}
+ />
+
+ (
+
+
+ Schedule
+
+
+
+
+
+
+
+ Cron expression format: minute hour day month
+ weekday
+
+ Example: 0 0 * * * (daily at midnight)
+
+
+
+
+
+
{
+ field.onChange(value);
+ }}
+ >
+
+
+
+
+
+
+ {commonCronExpressions.map((expr) => (
+
+ {expr.label} ({expr.value})
+
+ ))}
+
+
+
+
+
+
+
+
+
+ Choose a predefined schedule or enter a custom cron
+ expression
+
+
+
+ )}
+ />
+
+ (
+
+ Destination
+
+
+
+
+
+
+
+ {destinations?.map((destination) => (
+
+ {destination.name}
+
+ ))}
+
+
+
+ Choose the backup destination where files will be stored
+
+
+
+ )}
+ />
+ {serviceTypeForm === "compose" && (
+ <>
+
+ {errorServices && (
+
+ {errorServices?.message}
+
+ )}
+
(
+
+ Service Name
+
+
+
+
+
+
+
+
+
+ {services?.map((service, index) => (
+
+ {service}
+
+ ))}
+
+ Empty
+
+
+
+
+
+
+ {
+ if (cacheType === "fetch") {
+ refetchServices();
+ } else {
+ setCacheType("fetch");
+ }
+ }}
+ >
+
+
+
+
+
+ Fetch: Will clone the repository and load the
+ services
+
+
+
+
+
+
+
+ {
+ if (cacheType === "cache") {
+ refetchServices();
+ } else {
+ setCacheType("cache");
+ }
+ }}
+ >
+
+
+
+
+
+ Cache: If you previously deployed this
+ compose, it will read the services from the
+ last deployment/fetch from the repository
+
+
+
+
+
+
+
+
+ )}
+ />
+
+ {mountsByService && mountsByService.length > 0 && (
+ (
+
+ Volumes
+
+
+
+
+
+
+
+ {mountsByService?.map((volume) => (
+
+ {volume.Name}
+
+ ))}
+
+
+
+ Choose the volume to backup, if you dont see the
+ volume here, you can type the volume name manually
+
+
+
+ )}
+ />
+ )}
+ >
+ )}
+ {serviceTypeForm === "application" && (
+ (
+
+ Volumes
+
+
+
+
+
+
+
+ {mounts?.map((mount) => (
+
+ {mount.Name}
+
+ ))}
+
+
+
+ Choose the volume to backup, if you dont see the volume
+ here, you can type the volume name manually
+
+
+
+ )}
+ />
+ )}
+
+ (
+
+ Volume Name
+
+
+
+
+ The name of the Docker volume to backup
+
+
+
+ )}
+ />
+
+ (
+
+ Backup Prefix
+
+
+
+
+ Prefix for backup files (optional)
+
+
+
+ )}
+ />
+
+ {/* (
+
+ Keep Latest Count
+
+
+ field.onChange(Number(e.target.value) || undefined)
+ }
+ />
+
+
+ Number of backup files to keep (optional)
+
+
+
+ )}
+ /> */}
+
+ (
+
+
+
+ Turn Off Container During Backup
+
+
+ ⚠️ The container will be temporarily stopped during backup to
+ prevent file corruption. This ensures data integrity but may
+ cause temporary service interruption.
+
+
+ )}
+ />
+
+ (
+
+
+
+ Enabled
+
+
+ )}
+ />
+
+
+ {volumeBackupId ? "Update" : "Create"} Volume Backup
+
+
+
+
+
+ );
+};
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..5b13c61d7
--- /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.
+
+
+
+
+
+ (
+
+ Destination
+
+
+
+
+ {field.value
+ ? destinations.find(
+ (d) => d.destinationId === field.value,
+ )?.name
+ : "Select Destination"}
+
+
+
+
+
+
+
+ No destinations found.
+
+
+ {destinations.map((destination) => (
+ {
+ form.setValue(
+ "destinationId",
+ destination.destinationId,
+ );
+ }}
+ >
+ {destination.name}
+
+
+ ))}
+
+
+
+
+
+
+
+ )}
+ />
+
+ (
+
+
+ Search Backup Files
+ {field.value && (
+
+ {field.value}
+ {
+ e.stopPropagation();
+ e.preventDefault();
+ copy(field.value);
+ toast.success("Backup file copied to clipboard");
+ }}
+ />
+
+ )}
+
+
+
+
+
+
+ {field.value || "Search and select a backup file"}
+
+
+
+
+
+
+
+
+ {isLoading ? (
+
+ Loading backup files...
+
+ ) : files.length === 0 && search ? (
+
+ No backup files found for "{search}"
+
+ ) : files.length === 0 ? (
+
+ No backup files available
+
+ ) : (
+
+
+ {files?.map((file) => (
+ {
+ form.setValue("backupFile", file.Path);
+ if (file.IsDir) {
+ setSearch(`${file.Path}/`);
+ setDebouncedSearchTerm(`${file.Path}/`);
+ } else {
+ setSearch(file.Path);
+ setDebouncedSearchTerm(file.Path);
+ }
+ }}
+ >
+
+
+
+ {file.Path}
+
+
+
+
+
+
+ Size: {formatBytes(file.Size)}
+
+ {file.IsDir && (
+
+ Directory
+
+ )}
+ {file.Hashes?.MD5 && (
+ MD5: {file.Hashes.MD5}
+ )}
+
+
+
+ ))}
+
+
+ )}
+
+
+
+
+
+ )}
+ />
+ (
+
+ Volume Name
+
+
+
+
+
+ )}
+ />
+
+
+
+ Restore
+
+
+
+
+
+ {
+ 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..083f45252
--- /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/delete-service.tsx b/apps/dokploy/components/dashboard/compose/delete-service.tsx
index 212b5ac73..65689afd1 100644
--- a/apps/dokploy/components/dashboard/compose/delete-service.tsx
+++ b/apps/dokploy/components/dashboard/compose/delete-service.tsx
@@ -126,7 +126,7 @@ export const DeleteService = ({ id, type }: Props) => {
-
+
Are you absolutely sure?
diff --git a/apps/dokploy/components/dashboard/compose/deployments/cancel-queues-compose.tsx b/apps/dokploy/components/dashboard/compose/deployments/cancel-queues-compose.tsx
deleted file mode 100644
index a430ae18f..000000000
--- a/apps/dokploy/components/dashboard/compose/deployments/cancel-queues-compose.tsx
+++ /dev/null
@@ -1,66 +0,0 @@
-import {
- AlertDialog,
- AlertDialogAction,
- AlertDialogCancel,
- AlertDialogContent,
- AlertDialogDescription,
- AlertDialogFooter,
- AlertDialogHeader,
- AlertDialogTitle,
- AlertDialogTrigger,
-} from "@/components/ui/alert-dialog";
-import { Button } from "@/components/ui/button";
-import { api } from "@/utils/api";
-import { Paintbrush } from "lucide-react";
-import { toast } from "sonner";
-
-interface Props {
- composeId: string;
-}
-
-export const CancelQueuesCompose = ({ composeId }: Props) => {
- const { mutateAsync, isLoading } = api.compose.cleanQueues.useMutation();
- const { data: isCloud } = api.settings.isCloud.useQuery();
-
- if (isCloud) {
- return null;
- }
- return (
-
-
-
- Cancel Queues
-
-
-
-
-
-
- Are you sure to cancel the incoming deployments?
-
-
- This will cancel all the incoming deployments
-
-
-
- Cancel
- {
- await mutateAsync({
- composeId,
- })
- .then(() => {
- toast.success("Queues are being cleaned");
- })
- .catch((err) => {
- toast.error(err.message);
- });
- }}
- >
- Confirm
-
-
-
-
- );
-};
diff --git a/apps/dokploy/components/dashboard/compose/deployments/refresh-token-compose.tsx b/apps/dokploy/components/dashboard/compose/deployments/refresh-token-compose.tsx
deleted file mode 100644
index b062b0994..000000000
--- a/apps/dokploy/components/dashboard/compose/deployments/refresh-token-compose.tsx
+++ /dev/null
@@ -1,59 +0,0 @@
-import {
- AlertDialog,
- AlertDialogAction,
- AlertDialogCancel,
- AlertDialogContent,
- AlertDialogDescription,
- AlertDialogFooter,
- AlertDialogHeader,
- AlertDialogTitle,
- AlertDialogTrigger,
-} from "@/components/ui/alert-dialog";
-import { api } from "@/utils/api";
-import { RefreshCcw } from "lucide-react";
-import { toast } from "sonner";
-
-interface Props {
- composeId: string;
-}
-export const RefreshTokenCompose = ({ composeId }: Props) => {
- const { mutateAsync } = api.compose.refreshToken.useMutation();
- const utils = api.useUtils();
- return (
-
-
-
-
-
-
- Are you absolutely sure?
-
- This action cannot be undone. This will permanently change the token
- and all the previous tokens will be invalidated
-
-
-
- Cancel
- {
- await mutateAsync({
- composeId,
- })
- .then(() => {
- utils.compose.one.invalidate({
- composeId,
- });
- toast.success("Refresh Token updated");
- })
- .catch(() => {
- toast.error("Error updating the refresh token");
- });
- }}
- >
- Confirm
-
-
-
-
- );
-};
diff --git a/apps/dokploy/components/dashboard/compose/deployments/show-deployment-compose.tsx b/apps/dokploy/components/dashboard/compose/deployments/show-deployment-compose.tsx
deleted file mode 100644
index 7c191a14c..000000000
--- a/apps/dokploy/components/dashboard/compose/deployments/show-deployment-compose.tsx
+++ /dev/null
@@ -1,184 +0,0 @@
-import { Badge } from "@/components/ui/badge";
-import { Checkbox } from "@/components/ui/checkbox";
-import {
- Dialog,
- DialogContent,
- DialogDescription,
- DialogHeader,
- DialogTitle,
-} from "@/components/ui/dialog";
-import { Loader2 } from "lucide-react";
-import { useEffect, useRef, useState } from "react";
-import { TerminalLine } from "../../docker/logs/terminal-line";
-import { type LogLine, parseLogs } from "../../docker/logs/utils";
-
-interface Props {
- logPath: string | null;
- serverId?: string;
- open: boolean;
- onClose: () => void;
- errorMessage?: string;
-}
-export const ShowDeploymentCompose = ({
- logPath,
- open,
- onClose,
- serverId,
- errorMessage,
-}: Props) => {
- const [data, setData] = useState("");
- const [filteredLogs, setFilteredLogs] = useState([]);
- const [showExtraLogs, setShowExtraLogs] = useState(false);
- const wsRef = useRef(null); // Ref to hold WebSocket instance
- const [autoScroll, setAutoScroll] = useState(true);
- const scrollRef = useRef(null);
-
- const scrollToBottom = () => {
- if (autoScroll && scrollRef.current) {
- scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
- }
- };
-
- const handleScroll = () => {
- if (!scrollRef.current) return;
-
- const { scrollTop, scrollHeight, clientHeight } = scrollRef.current;
- const isAtBottom = Math.abs(scrollHeight - scrollTop - clientHeight) < 10;
- setAutoScroll(isAtBottom);
- };
-
- useEffect(() => {
- if (!open || !logPath) return;
-
- setData("");
- const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
-
- const wsUrl = `${protocol}//${window.location.host}/listen-deployment?logPath=${logPath}&serverId=${serverId}`;
- const ws = new WebSocket(wsUrl);
-
- wsRef.current = ws; // Store WebSocket instance in ref
-
- ws.onmessage = (e) => {
- setData((currentData) => currentData + e.data);
- };
-
- ws.onerror = (error) => {
- console.error("WebSocket error: ", error);
- };
-
- ws.onclose = () => {
- wsRef.current = null;
- };
-
- return () => {
- if (wsRef.current?.readyState === WebSocket.OPEN) {
- ws.close();
- wsRef.current = null;
- }
- };
- }, [logPath, open]);
-
- useEffect(() => {
- const logs = parseLogs(data);
- let filteredLogsResult = logs;
- if (serverId) {
- let hideSubsequentLogs = false;
- filteredLogsResult = logs.filter((log) => {
- if (
- log.message.includes(
- "===================================EXTRA LOGS============================================",
- )
- ) {
- hideSubsequentLogs = true;
- return showExtraLogs;
- }
- return showExtraLogs ? true : !hideSubsequentLogs;
- });
- }
-
- setFilteredLogs(filteredLogsResult);
- }, [data, showExtraLogs]);
-
- useEffect(() => {
- scrollToBottom();
-
- if (autoScroll && scrollRef.current) {
- scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
- }
- }, [filteredLogs, autoScroll]);
-
- const optionalErrors = parseLogs(errorMessage || "");
-
- return (
- {
- onClose();
- if (!e) {
- setData("");
- }
-
- if (wsRef.current) {
- if (wsRef.current.readyState === WebSocket.OPEN) {
- wsRef.current.close();
- }
- }
- }}
- >
-
-
- Deployment
-
-
- See all the details of this deployment |{" "}
-
- {filteredLogs.length} lines
-
-
- {serverId && (
-
-
- )}
-
-
-
-
- {filteredLogs.length > 0 ? (
- filteredLogs.map((log: LogLine, index: number) => (
-
- ))
- ) : (
- <>
- {optionalErrors.length > 0 ? (
- optionalErrors.map((log: LogLine, index: number) => (
-
- ))
- ) : (
-
-
-
- )}
- >
- )}
-
-
-
- );
-};
diff --git a/apps/dokploy/components/dashboard/compose/deployments/show-deployments-compose.tsx b/apps/dokploy/components/dashboard/compose/deployments/show-deployments-compose.tsx
deleted file mode 100644
index fce4f33f9..000000000
--- a/apps/dokploy/components/dashboard/compose/deployments/show-deployments-compose.tsx
+++ /dev/null
@@ -1,125 +0,0 @@
-import { DateTooltip } from "@/components/shared/date-tooltip";
-import { StatusTooltip } from "@/components/shared/status-tooltip";
-import { Button } from "@/components/ui/button";
-import {
- Card,
- CardContent,
- CardDescription,
- CardHeader,
- CardTitle,
-} from "@/components/ui/card";
-import { type RouterOutputs, api } from "@/utils/api";
-import { RocketIcon } from "lucide-react";
-import React, { useEffect, useState } from "react";
-import { CancelQueuesCompose } from "./cancel-queues-compose";
-import { RefreshTokenCompose } from "./refresh-token-compose";
-import { ShowDeploymentCompose } from "./show-deployment-compose";
-
-interface Props {
- composeId: string;
-}
-export const ShowDeploymentsCompose = ({ composeId }: Props) => {
- const [activeLog, setActiveLog] = useState<
- RouterOutputs["deployment"]["all"][number] | null
- >(null);
- const { data } = api.compose.one.useQuery({ composeId });
- const { data: deployments } = api.deployment.allByCompose.useQuery(
- { composeId },
- {
- enabled: !!composeId,
- refetchInterval: 5000,
- },
- );
- const [url, setUrl] = React.useState("");
- useEffect(() => {
- setUrl(document.location.origin);
- }, []);
-
- return (
-
-
-
- Deployments
-
- See all the 10 last deployments for this compose
-
-
-
- {/* */}
-
-
-
-
- If you want to re-deploy this application use this URL in the config
- of your git provider or docker
-
-
-
Webhook URL:
-
-
- {`${url}/api/deploy/compose/${data?.refreshToken}`}
-
-
-
-
-
- {data?.deployments?.length === 0 ? (
-
-
-
- No deployments found
-
-
- ) : (
-
- {deployments?.map((deployment) => (
-
-
-
- {deployment.status}
-
-
-
-
- {deployment.title}
-
- {deployment.description && (
-
- {deployment.description}
-
- )}
-
-
-
-
-
-
-
{
- setActiveLog(deployment);
- }}
- >
- View
-
-
-
- ))}
-
- )}
- setActiveLog(null)}
- logPath={activeLog?.logPath || ""}
- errorMessage={activeLog?.errorMessage || ""}
- />
-
-
- );
-};
diff --git a/apps/dokploy/components/dashboard/compose/domains/add-domain.tsx b/apps/dokploy/components/dashboard/compose/domains/add-domain.tsx
deleted file mode 100644
index e18d40d73..000000000
--- a/apps/dokploy/components/dashboard/compose/domains/add-domain.tsx
+++ /dev/null
@@ -1,441 +0,0 @@
-import { AlertBlock } from "@/components/shared/alert-block";
-import { Button } from "@/components/ui/button";
-import {
- Dialog,
- DialogContent,
- DialogDescription,
- DialogFooter,
- DialogHeader,
- DialogTitle,
- DialogTrigger,
-} from "@/components/ui/dialog";
-import {
- Form,
- FormControl,
- FormDescription,
- FormField,
- FormItem,
- FormLabel,
- FormMessage,
-} from "@/components/ui/form";
-import { Input, NumberInput } from "@/components/ui/input";
-import {
- Select,
- SelectContent,
- SelectItem,
- SelectTrigger,
- SelectValue,
-} from "@/components/ui/select";
-import { Switch } from "@/components/ui/switch";
-import { api } from "@/utils/api";
-import { useEffect, useState } from "react";
-import { useForm } from "react-hook-form";
-import { toast } from "sonner";
-
-import {
- Tooltip,
- TooltipContent,
- TooltipProvider,
- TooltipTrigger,
-} from "@/components/ui/tooltip";
-import { domainCompose } from "@/server/db/validations/domain";
-import { zodResolver } from "@hookform/resolvers/zod";
-import { DatabaseZap, Dices, RefreshCw } from "lucide-react";
-import type z from "zod";
-
-type Domain = z.infer;
-
-export type CacheType = "fetch" | "cache";
-
-interface Props {
- composeId: string;
- domainId?: string;
- children: React.ReactNode;
-}
-
-export const AddDomainCompose = ({
- composeId,
- domainId = "",
- children,
-}: Props) => {
- const [isOpen, setIsOpen] = useState(false);
- const [cacheType, setCacheType] = useState("cache");
- const utils = api.useUtils();
- const { data, refetch } = api.domain.one.useQuery(
- {
- domainId,
- },
- {
- enabled: !!domainId,
- },
- );
-
- const { data: compose } = api.compose.one.useQuery(
- {
- composeId,
- },
- {
- enabled: !!composeId,
- },
- );
-
- const {
- data: services,
- isFetching: isLoadingServices,
- error: errorServices,
- refetch: refetchServices,
- } = api.compose.loadServices.useQuery(
- {
- composeId,
- type: cacheType,
- },
- {
- retry: false,
- refetchOnWindowFocus: false,
- },
- );
-
- const { mutateAsync: generateDomain, isLoading: isLoadingGenerate } =
- api.domain.generateDomain.useMutation();
-
- const { mutateAsync, isError, error, isLoading } = domainId
- ? api.domain.update.useMutation()
- : api.domain.create.useMutation();
-
- const form = useForm({
- resolver: zodResolver(domainCompose),
- });
-
- const https = form.watch("https");
-
- useEffect(() => {
- if (data) {
- form.reset({
- ...data,
- /* Convert null to undefined */
- path: data?.path || undefined,
- port: data?.port || undefined,
- serviceName: data?.serviceName || undefined,
- });
- }
-
- if (!domainId) {
- form.reset({});
- }
- }, [form, form.reset, data, isLoading]);
-
- const dictionary = {
- success: domainId ? "Domain Updated" : "Domain Created",
- error: domainId ? "Error updating the domain" : "Error creating the domain",
- submit: domainId ? "Update" : "Create",
- dialogDescription: domainId
- ? "In this section you can edit a domain"
- : "In this section you can add domains",
- };
-
- const onSubmit = async (data: Domain) => {
- await mutateAsync({
- domainId,
- composeId,
- domainType: "compose",
- ...data,
- })
- .then(async () => {
- await utils.domain.byComposeId.invalidate({
- composeId,
- });
- toast.success(dictionary.success);
- if (domainId) {
- refetch();
- }
- setIsOpen(false);
- })
- .catch(() => {
- toast.error(dictionary.error);
- });
- };
- return (
-
-
- {children}
-
-
-
- Domain
- {dictionary.dialogDescription}
-
-
-
- Deploy is required to apply changes after creating or updating a
- domain.
-
- {isError &&
{error?.message} }
-
-
-
-
-
-
- {errorServices && (
-
- {errorServices?.message}
-
- )}
-
-
(
-
- Service Name
-
-
-
-
-
-
-
-
-
- {services?.map((service, index) => (
-
- {service}
-
- ))}
-
- Empty
-
-
-
-
-
-
- {
- if (cacheType === "fetch") {
- refetchServices();
- } else {
- setCacheType("fetch");
- }
- }}
- >
-
-
-
-
-
- Fetch: Will clone the repository and load the
- services
-
-
-
-
-
-
-
- {
- if (cacheType === "cache") {
- refetchServices();
- } else {
- setCacheType("cache");
- }
- }}
- >
-
-
-
-
-
- Cache: If you previously deployed this
- compose, it will read the services from the
- last deployment/fetch from the repository
-
-
-
-
-
-
-
-
- )}
- />
-
-
-
(
-
- Host
-
-
-
-
-
-
-
- {
- generateDomain({
- serverId: compose?.serverId || "",
- appName: compose?.appName || "",
- })
- .then((domain) => {
- field.onChange(domain);
- })
- .catch((err) => {
- toast.error(err.message);
- });
- }}
- >
-
-
-
-
- Generate traefik.me domain
-
-
-
-
-
-
-
- )}
- />
-
- {
- return (
-
- Path
-
-
-
-
-
- );
- }}
- />
-
- {
- return (
-
- Container Port
-
-
-
-
-
- );
- }}
- />
-
- (
-
-
- HTTPS
-
- Automatically provision SSL Certificate.
-
-
-
-
-
-
-
- )}
- />
-
- {https && (
- (
-
- Certificate Provider
-
-
-
-
-
-
-
-
- None
-
- Let's Encrypt
-
-
-
-
-
- )}
- />
- )}
-
-
-
-
-
-
- {dictionary.submit}
-
-
-
-
-
- );
-};
diff --git a/apps/dokploy/components/dashboard/compose/domains/show-domains.tsx b/apps/dokploy/components/dashboard/compose/domains/show-domains.tsx
deleted file mode 100644
index e6468d6fa..000000000
--- a/apps/dokploy/components/dashboard/compose/domains/show-domains.tsx
+++ /dev/null
@@ -1,150 +0,0 @@
-import { DialogAction } from "@/components/shared/dialog-action";
-import { Button } from "@/components/ui/button";
-import {
- Card,
- CardContent,
- CardDescription,
- CardHeader,
- CardTitle,
-} from "@/components/ui/card";
-import { api } from "@/utils/api";
-import { ExternalLink, GlobeIcon, PenBoxIcon, Trash2 } from "lucide-react";
-import Link from "next/link";
-import { toast } from "sonner";
-import { AddDomainCompose } from "./add-domain";
-
-interface Props {
- composeId: string;
-}
-
-export const ShowDomainsCompose = ({ composeId }: Props) => {
- const { data, refetch } = api.domain.byComposeId.useQuery(
- {
- composeId,
- },
- {
- enabled: !!composeId,
- },
- );
-
- const { mutateAsync: deleteDomain, isLoading: isRemoving } =
- api.domain.delete.useMutation();
-
- return (
-
-
-
-
- Domains
-
- Domains are used to access to the application
-
-
-
-
- {data && data?.length > 0 && (
-
-
- Add Domain
-
-
- )}
-
-
-
- {data?.length === 0 ? (
-
-
-
- To access to the application it is required to set at least 1
- domain
-
-
-
- ) : (
-
- {data?.map((item) => {
- return (
-
-
-
- {item.serviceName}
-
-
-
- {item.host}
-
-
-
-
-
-
- {item.path}
- {item.port}
- {item.https ? "HTTPS" : "HTTP"}
-
-
-
-
-
-
-
-
-
{
- await deleteDomain({
- domainId: item.domainId,
- })
- .then((_data) => {
- refetch();
- toast.success("Domain deleted successfully");
- })
- .catch(() => {
- toast.error("Error deleting domain");
- });
- }}
- >
-
-
-
-
-
-
-
- );
- })}
-
- )}
-
-
-
- );
-};
diff --git a/apps/dokploy/components/dashboard/compose/general/actions.tsx b/apps/dokploy/components/dashboard/compose/general/actions.tsx
index a40cc3453..0a4433e7c 100644
--- a/apps/dokploy/components/dashboard/compose/general/actions.tsx
+++ b/apps/dokploy/components/dashboard/compose/general/actions.tsx
@@ -1,8 +1,15 @@
import { DialogAction } from "@/components/shared/dialog-action";
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipProvider,
+ TooltipTrigger,
+} from "@/components/ui/tooltip";
import { api } from "@/utils/api";
-import { Ban, CheckCircle2, Hammer, Terminal } from "lucide-react";
+import * as TooltipPrimitive from "@radix-ui/react-tooltip";
+import { Ban, CheckCircle2, RefreshCcw, Rocket, Terminal } from "lucide-react";
import { useRouter } from "next/router";
import { toast } from "sonner";
import { DockerTerminalModal } from "../../settings/web-server/docker-terminal-modal";
@@ -27,116 +34,180 @@ export const ComposeActions = ({ composeId }: Props) => {
api.compose.stop.useMutation();
return (
-
{
- await deploy({
- composeId: composeId,
- })
- .then(() => {
- toast.success("Compose deployed successfully");
- refetch();
- router.push(
- `/dashboard/project/${data?.project.projectId}/services/compose/${composeId}?tab=deployments`,
- );
- })
- .catch(() => {
- toast.error("Error deploying compose");
- });
- }}
- >
-
- Deploy
-
-
-
{
- await redeploy({
- composeId: composeId,
- })
- .then(() => {
- toast.success("Compose rebuilt successfully");
- refetch();
- })
- .catch(() => {
- toast.error("Error rebuilding compose");
- });
- }}
- >
-
- Rebuild
-
-
-
- {data?.composeType === "docker-compose" &&
- data?.composeStatus === "idle" ? (
+
{
- await start({
+ await deploy({
composeId: composeId,
})
.then(() => {
- toast.success("Compose started successfully");
+ toast.success("Compose deployed successfully");
refetch();
+ router.push(
+ `/dashboard/project/${data?.project.projectId}/services/compose/${composeId}?tab=deployments`,
+ );
})
.catch(() => {
- toast.error("Error starting compose");
+ toast.error("Error deploying compose");
});
}}
>
-
- Start
-
+
+
+
+
+
+ Deploy
+
+
+
+
+ Downloads the source code and performs a complete build
+
+
+
- ) : (
{
- await stop({
+ await redeploy({
composeId: composeId,
})
.then(() => {
- toast.success("Compose stopped successfully");
+ toast.success("Compose reloaded successfully");
refetch();
})
.catch(() => {
- toast.error("Error stopping compose");
+ toast.error("Error reloading compose");
});
}}
>
-
- Stop
-
+
+
+
+
+
+ Reload
+
+
+
+
+ Reload the compose without rebuilding it
+
+
+
- )}
-
+ {data?.composeType === "docker-compose" &&
+ data?.composeStatus === "idle" ? (
+ {
+ await start({
+ composeId: composeId,
+ })
+ .then(() => {
+ toast.success("Compose started successfully");
+ refetch();
+ })
+ .catch(() => {
+ toast.error("Error starting compose");
+ });
+ }}
+ >
+
+
+
+
+
+ Start
+
+
+
+
+
+ Start the compose (requires a previous successful build)
+
+
+
+
+
+
+ ) : (
+ {
+ await stop({
+ composeId: composeId,
+ })
+ .then(() => {
+ toast.success("Compose stopped successfully");
+ refetch();
+ })
+ .catch(() => {
+ toast.error("Error stopping compose");
+ });
+ }}
+ >
+
+
+
+
+
+ Stop
+
+
+
+
+ Stop the currently running compose
+
+
+
+
+
+ )}
+
-
-
+
+
Open Terminal
Autodeploy
{
await update({
@@ -151,7 +222,7 @@ export const ComposeActions = ({ composeId }: Props) => {
toast.error("Error updating Auto Deploy");
});
}}
- className="flex flex-row gap-2 items-center"
+ className="flex flex-row gap-2 items-center data-[state=checked]:bg-primary"
/>
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 725895821..41e40efbe 100644
--- a/apps/dokploy/components/dashboard/compose/general/compose-file-editor.tsx
+++ b/apps/dokploy/components/dashboard/compose/general/compose-file-editor.tsx
@@ -44,8 +44,10 @@ export const ComposeFileEditor = ({ composeId }: Props) => {
resolver: zodResolver(AddComposeFile),
});
+ const composeFile = form.watch("composeFile");
+
useEffect(() => {
- if (data) {
+ if (data && !composeFile) {
form.reset({
composeFile: data.composeFile || "",
});
@@ -75,10 +77,26 @@ export const ComposeFileEditor = ({ composeId }: Props) => {
composeId,
});
})
- .catch((_e) => {
+ .catch(() => {
toast.error("Error updating the Compose config");
});
};
+
+ // Add keyboard shortcut for Ctrl+S/Cmd+S
+ useEffect(() => {
+ const handleKeyDown = (e: KeyboardEvent) => {
+ if ((e.ctrlKey || e.metaKey) && e.key === "s" && !isLoading) {
+ e.preventDefault();
+ form.handleSubmit(onSubmit)();
+ }
+ };
+
+ document.addEventListener("keydown", handleKeyDown);
+ return () => {
+ document.removeEventListener("keydown", handleKeyDown);
+ };
+ }, [form, onSubmit, isLoading]);
+
return (
<>
@@ -97,6 +115,7 @@ export const ComposeFileEditor = ({ composeId }: Props) => {
;
@@ -73,6 +85,8 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
},
bitbucketId: "",
branch: "",
+ watchPaths: [],
+ enableSubmodules: false,
},
resolver: zodResolver(BitbucketProviderSchema),
});
@@ -118,9 +132,11 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
},
composePath: data.composePath,
bitbucketId: data.bitbucketId || "",
+ watchPaths: data.watchPaths || [],
+ enableSubmodules: data.enableSubmodules ?? false,
});
}
- }, [form.reset, data, form]);
+ }, [form.reset, data?.composeId, form]);
const onSubmit = async (data: BitbucketProvider) => {
await mutateAsync({
@@ -132,6 +148,8 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
composeId,
sourceType: "bitbucket",
composeStatus: "idle",
+ watchPaths: data.watchPaths,
+ enableSubmodules: data.enableSubmodules,
})
.then(async () => {
toast.success("Service Provided Saved");
@@ -197,7 +215,20 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
name="repository"
render={({ field }) => (
- Repository
+
+ Repository
+ {field.value.owner && field.value.repo && (
+
+
+ View Repository
+
+ )}
+
@@ -365,6 +396,99 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
)}
/>
+ (
+
+
+
Watch Paths
+
+
+
+
+ ?
+
+
+
+
+ Add paths to watch for changes. When files in these
+ paths change, a new deployment will be triggered.
+
+
+
+
+
+
+ {field.value?.map((path, index) => (
+
+ {path}
+ {
+ const newPaths = [...(field.value || [])];
+ newPaths.splice(index, 1);
+ form.setValue("watchPaths", newPaths);
+ }}
+ />
+
+ ))}
+
+
+
+ {
+ if (e.key === "Enter") {
+ e.preventDefault();
+ const input = e.currentTarget;
+ const value = input.value.trim();
+ if (value) {
+ const newPaths = [...(field.value || []), value];
+ form.setValue("watchPaths", newPaths);
+ input.value = "";
+ }
+ }
+ }}
+ />
+ {
+ const input = document.querySelector(
+ 'input[placeholder="Enter a path to watch (e.g., src/**, dist/*.js)"]',
+ ) as HTMLInputElement;
+ const value = input.value.trim();
+ if (value) {
+ const newPaths = [...(field.value || []), value];
+ form.setValue("watchPaths", newPaths);
+ input.value = "";
+ }
+ }}
+ >
+ Add
+
+
+
+
+
+ )}
+ />
+ (
+
+
+
+
+ Enable Submodules
+
+ )}
+ />
;
@@ -54,6 +66,8 @@ export const SaveGitProviderCompose = ({ composeId }: Props) => {
repositoryURL: "",
composePath: "./docker-compose.yml",
sshKey: undefined,
+ watchPaths: [],
+ enableSubmodules: false,
},
resolver: zodResolver(GitProviderSchema),
});
@@ -65,6 +79,8 @@ export const SaveGitProviderCompose = ({ composeId }: Props) => {
branch: data.customGitBranch || "",
repositoryURL: data.customGitUrl || "",
composePath: data.composePath,
+ watchPaths: data.watchPaths || [],
+ enableSubmodules: data.enableSubmodules ?? false,
});
}
}, [form.reset, data, form]);
@@ -77,6 +93,9 @@ export const SaveGitProviderCompose = ({ composeId }: Props) => {
composeId,
sourceType: "git",
composePath: values.composePath,
+ composeStatus: "idle",
+ watchPaths: values.watchPaths || [],
+ enableSubmodules: values.enableSubmodules,
})
.then(async () => {
toast.success("Git Provider Saved");
@@ -101,11 +120,22 @@ export const SaveGitProviderCompose = ({ composeId }: Props) => {
name="repositoryURL"
render={({ field }) => (
-
- Repository URL
-
+
+ Repository URL
+ {field.value?.startsWith("https://") && (
+
+
+ View Repository
+
+ )}
+
-
+
@@ -191,6 +221,100 @@ export const SaveGitProviderCompose = ({ composeId }: Props) => {
)}
/>
+ (
+
+
+
Watch Paths
+
+
+
+
+ ?
+
+
+
+
+ Add paths to watch for changes. When files in these
+ paths change, a new deployment will be triggered. This
+ will work only when manual webhook is setup.
+
+
+
+
+
+
+ {field.value?.map((path, index) => (
+
+ {path}
+ {
+ const newPaths = [...(field.value || [])];
+ newPaths.splice(index, 1);
+ form.setValue("watchPaths", newPaths);
+ }}
+ />
+
+ ))}
+
+
+
+ {
+ if (e.key === "Enter") {
+ e.preventDefault();
+ const input = e.currentTarget;
+ const value = input.value.trim();
+ if (value) {
+ const newPaths = [...(field.value || []), value];
+ form.setValue("watchPaths", newPaths);
+ input.value = "";
+ }
+ }
+ }}
+ />
+ {
+ const input = document.querySelector(
+ 'input[placeholder="Enter a path to watch (e.g., src/**, dist/*.js)"]',
+ ) as HTMLInputElement;
+ const value = input.value.trim();
+ if (value) {
+ const newPaths = [...(field.value || []), value];
+ form.setValue("watchPaths", newPaths);
+ input.value = "";
+ }
+ }}
+ >
+ Add
+
+
+
+
+
+ )}
+ />
+ (
+
+
+
+
+ Enable Submodules
+
+ )}
+ />
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
new file mode 100644
index 000000000..0b57b03d2
--- /dev/null
+++ b/apps/dokploy/components/dashboard/compose/general/generic/save-gitea-provider-compose.tsx
@@ -0,0 +1,503 @@
+import { GiteaIcon } from "@/components/icons/data-tools-icons";
+import { AlertBlock } from "@/components/shared/alert-block";
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import {
+ Command,
+ CommandEmpty,
+ CommandGroup,
+ CommandInput,
+ CommandItem,
+} from "@/components/ui/command";
+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 {
+ 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 type { Repository } from "@/utils/gitea-utils";
+import { zodResolver } from "@hookform/resolvers/zod";
+import { CheckIcon, ChevronsUpDown, Plus, X } from "lucide-react";
+import Link from "next/link";
+import { useEffect } from "react";
+import { useForm } from "react-hook-form";
+import { toast } from "sonner";
+import { z } from "zod";
+
+const GiteaProviderSchema = z.object({
+ composePath: z.string().min(1),
+ repository: z
+ .object({
+ repo: z.string().min(1, "Repo is required"),
+ owner: z.string().min(1, "Owner is required"),
+ })
+ .required(),
+ branch: z.string().min(1, "Branch is required"),
+ giteaId: z.string().min(1, "Gitea Provider is required"),
+ watchPaths: z.array(z.string()).optional(),
+ enableSubmodules: z.boolean().default(false),
+});
+
+type GiteaProvider = z.infer
;
+
+interface Props {
+ composeId: string;
+}
+
+export const SaveGiteaProviderCompose = ({ composeId }: Props) => {
+ const { data: giteaProviders } = api.gitea.giteaProviders.useQuery();
+ const { data, refetch } = api.compose.one.useQuery({ composeId });
+ const { mutateAsync, isLoading: isSavingGiteaProvider } =
+ api.compose.update.useMutation();
+
+ const form = useForm({
+ defaultValues: {
+ composePath: "./docker-compose.yml",
+ repository: {
+ owner: "",
+ repo: "",
+ },
+ giteaId: "",
+ branch: "",
+ watchPaths: [],
+ enableSubmodules: false,
+ },
+ resolver: zodResolver(GiteaProviderSchema),
+ });
+
+ const repository = form.watch("repository");
+ const giteaId = form.watch("giteaId");
+
+ const { data: giteaUrl } = api.gitea.getGiteaUrl.useQuery(
+ { giteaId },
+ {
+ enabled: !!giteaId,
+ },
+ );
+
+ const {
+ data: repositories,
+ isLoading: isLoadingRepositories,
+ error,
+ } = api.gitea.getGiteaRepositories.useQuery(
+ {
+ giteaId,
+ },
+ {
+ enabled: !!giteaId,
+ },
+ );
+
+ const {
+ data: branches,
+ fetchStatus,
+ status,
+ } = api.gitea.getGiteaBranches.useQuery(
+ {
+ owner: repository?.owner,
+ repositoryName: repository?.repo,
+ giteaId: giteaId,
+ },
+ {
+ enabled: !!repository?.owner && !!repository?.repo && !!giteaId,
+ },
+ );
+
+ useEffect(() => {
+ if (data) {
+ form.reset({
+ branch: data.giteaBranch || "",
+ repository: {
+ repo: data.giteaRepository || "",
+ owner: data.giteaOwner || "",
+ },
+ composePath: data.composePath || "./docker-compose.yml",
+ giteaId: data.giteaId || "",
+ watchPaths: data.watchPaths || [],
+ enableSubmodules: data.enableSubmodules ?? false,
+ });
+ }
+ }, [form.reset, data?.composeId, form]);
+
+ const onSubmit = async (data: GiteaProvider) => {
+ await mutateAsync({
+ giteaBranch: data.branch,
+ giteaRepository: data.repository.repo,
+ giteaOwner: data.repository.owner,
+ composePath: data.composePath,
+ giteaId: data.giteaId,
+ composeId,
+ sourceType: "gitea",
+ composeStatus: "idle",
+ watchPaths: data.watchPaths,
+ enableSubmodules: data.enableSubmodules,
+ } as any)
+ .then(async () => {
+ toast.success("Service Provider Saved");
+ await refetch();
+ })
+ .catch(() => {
+ toast.error("Error saving the Gitea provider");
+ });
+ };
+
+ return (
+
+
+
+ {error && {error?.message} }
+
+
+
(
+
+ Gitea Account
+ {
+ field.onChange(value);
+ form.setValue("repository", {
+ owner: "",
+ repo: "",
+ });
+ form.setValue("branch", "");
+ }}
+ defaultValue={field.value}
+ value={field.value}
+ >
+
+
+
+
+
+
+ {giteaProviders?.map((giteaProvider) => (
+
+ {giteaProvider.gitProvider.name}
+
+ ))}
+
+
+
+
+ )}
+ />
+
+ (
+
+
+ Repository
+ {field.value.owner && field.value.repo && (
+
+
+ View Repository
+
+ )}
+
+
+
+
+
+ {isLoadingRepositories
+ ? "Loading...."
+ : field.value.owner
+ ? repositories?.find(
+ (repo) => repo.name === field.value.repo,
+ )?.name
+ : "Select repository"}
+
+
+
+
+
+
+
+ {isLoadingRepositories && (
+
+ Loading Repositories....
+
+ )}
+ No repositories found.
+
+
+ {repositories?.map((repo) => (
+ {
+ form.setValue("repository", {
+ owner: repo.owner.username,
+ repo: repo.name,
+ });
+ form.setValue("branch", "");
+ }}
+ >
+
+ {repo.name}
+
+ {repo.owner.username}
+
+
+
+
+ ))}
+
+
+
+
+
+ {form.formState.errors.repository && (
+
+ Repository is required
+
+ )}
+
+ )}
+ />
+
+ (
+
+ Branch
+
+
+
+
+ {status === "loading" && fetchStatus === "fetching"
+ ? "Loading...."
+ : field.value
+ ? branches?.find(
+ (branch) => branch.name === field.value,
+ )?.name
+ : "Select branch"}
+
+
+
+
+
+
+
+ No branches found.
+
+
+ {branches?.map((branch) => (
+
+ form.setValue("branch", branch.name)
+ }
+ >
+
+ {branch.name}
+
+
+
+ ))}
+
+
+
+
+
+ {form.formState.errors.branch && (
+
+ Branch is required
+
+ )}
+
+ )}
+ />
+
+ (
+
+ Compose Path
+
+
+
+
+
+ )}
+ />
+
+ (
+
+
+
Watch Paths
+
+
+
+
+ ?
+
+
+
+
+ Add paths to watch for changes. When files in these
+ paths change, a new deployment will be triggered.
+
+
+
+
+
+
+ {field.value?.map((path, index) => (
+
+ {path}
+ {
+ const newPaths = [...(field.value || [])];
+ newPaths.splice(index, 1);
+ form.setValue("watchPaths", newPaths);
+ }}
+ />
+
+ ))}
+
+
+
+
{
+ if (e.key === "Enter") {
+ e.preventDefault();
+ const input = e.currentTarget;
+ const value = input.value.trim();
+ if (value) {
+ const newPaths = [...(field.value || []), value];
+ form.setValue("watchPaths", newPaths);
+ input.value = "";
+ }
+ }
+ }}
+ />
+
{
+ const input = document.querySelector(
+ 'input[placeholder*="Enter a path"]',
+ ) as HTMLInputElement;
+ const path = input.value.trim();
+ if (path) {
+ field.onChange([...(field.value || []), path]);
+ input.value = "";
+ }
+ }}
+ >
+
+
+
+
+
+
+ )}
+ />
+ (
+
+
+
+
+ Enable Submodules
+
+ )}
+ />
+
+
+
+
+ Save
+
+
+
+
+
+ );
+};
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 7787cb3ce..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
@@ -1,3 +1,5 @@
+import { GithubIcon } from "@/components/icons/data-tools-icons";
+import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Command,
@@ -28,10 +30,18 @@ import {
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 { CheckIcon, ChevronsUpDown } from "lucide-react";
+import { CheckIcon, ChevronsUpDown, HelpCircle, X } from "lucide-react";
+import Link from "next/link";
import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
@@ -47,6 +57,9 @@ const GithubProviderSchema = z.object({
.required(),
branch: z.string().min(1, "Branch is required"),
githubId: z.string().min(1, "Github Provider is required"),
+ watchPaths: z.array(z.string()).optional(),
+ triggerType: z.enum(["push", "tag"]).default("push"),
+ enableSubmodules: z.boolean().default(false),
});
type GithubProvider = z.infer;
@@ -71,13 +84,16 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
},
githubId: "",
branch: "",
+ watchPaths: [],
+ triggerType: "push",
+ enableSubmodules: false,
},
resolver: zodResolver(GithubProviderSchema),
});
const repository = form.watch("repository");
const githubId = form.watch("githubId");
-
+ const triggerType = form.watch("triggerType");
const { data: repositories, isLoading: isLoadingRepositories } =
api.github.getGithubRepositories.useQuery(
{
@@ -113,9 +129,12 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
},
composePath: data.composePath,
githubId: data.githubId || "",
+ watchPaths: data.watchPaths || [],
+ triggerType: data.triggerType || "push",
+ enableSubmodules: data.enableSubmodules ?? false,
});
}
- }, [form.reset, data, form]);
+ }, [form.reset, data?.composeId, form]);
const onSubmit = async (data: GithubProvider) => {
await mutateAsync({
@@ -127,6 +146,9 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
githubId: data.githubId,
sourceType: "github",
composeStatus: "idle",
+ watchPaths: data.watchPaths,
+ enableSubmodules: data.enableSubmodules,
+ triggerType: data.triggerType,
})
.then(async () => {
toast.success("Service Provided Saved");
@@ -183,13 +205,25 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
)}
/>
-
(
- Repository
+
+ Repository
+ {field.value.owner && field.value.repo && (
+
+
+ View Repository
+
+ )}
+
@@ -357,6 +391,145 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
)}
/>
+ (
+
+
+
Trigger Type
+
+
+
+
+
+
+
+ Choose when to trigger deployments: on push to the
+ selected branch or when a new tag is created.
+
+
+
+
+
+
+
+
+
+
+
+
+ On Push
+ On Tag
+
+
+
+
+ )}
+ />
+ {triggerType === "push" && (
+ (
+
+
+
Watch Paths
+
+
+
+
+ ?
+
+
+
+
+ Add paths to watch for changes. When files in
+ these paths change, a new deployment will be
+ triggered.
+
+
+
+
+
+
+ {field.value?.map((path, index) => (
+
+ {path}
+ {
+ const newPaths = [...(field.value || [])];
+ newPaths.splice(index, 1);
+ form.setValue("watchPaths", newPaths);
+ }}
+ />
+
+ ))}
+
+
+
+ {
+ if (e.key === "Enter") {
+ e.preventDefault();
+ const input = e.currentTarget;
+ const value = input.value.trim();
+ if (value) {
+ const newPaths = [
+ ...(field.value || []),
+ value,
+ ];
+ form.setValue("watchPaths", newPaths);
+ input.value = "";
+ }
+ }
+ }}
+ />
+ {
+ const input = document.querySelector(
+ 'input[placeholder="Enter a path to watch (e.g., src/**, dist/*.js)"]',
+ ) as HTMLInputElement;
+ const value = input.value.trim();
+ if (value) {
+ const newPaths = [...(field.value || []), value];
+ form.setValue("watchPaths", newPaths);
+ input.value = "";
+ }
+ }}
+ >
+ Add
+
+
+
+
+
+ )}
+ />
+ )}
+ (
+
+
+
+
+ Enable Submodules
+
+ )}
+ />
;
@@ -76,6 +88,8 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
},
gitlabId: "",
branch: "",
+ watchPaths: [],
+ enableSubmodules: false,
},
resolver: zodResolver(GitlabProviderSchema),
});
@@ -124,9 +138,11 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
},
composePath: data.composePath,
gitlabId: data.gitlabId || "",
+ watchPaths: data.watchPaths || [],
+ enableSubmodules: data.enableSubmodules ?? false,
});
}
- }, [form.reset, data, form]);
+ }, [form.reset, data?.composeId, form]);
const onSubmit = async (data: GitlabProvider) => {
await mutateAsync({
@@ -140,6 +156,8 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
gitlabPathNamespace: data.repository.gitlabPathNamespace,
sourceType: "gitlab",
composeStatus: "idle",
+ watchPaths: data.watchPaths,
+ enableSubmodules: data.enableSubmodules,
})
.then(async () => {
toast.success("Service Provided Saved");
@@ -199,13 +217,25 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
)}
/>
-
(
- Repository
+
+ Repository
+ {field.value.owner && field.value.repo && (
+
+
+ View Repository
+
+ )}
+
@@ -250,7 +280,7 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
{repositories?.map((repo) => {
return (
{
form.setValue("repository", {
@@ -271,7 +301,8 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
{
)}
/>
+ (
+
+
+
Watch Paths
+
+
+
+
+ ?
+
+
+
+
+ Add paths to watch for changes. When files in these
+ paths change, a new deployment will be triggered.
+
+
+
+
+
+
+ {field.value?.map((path, index) => (
+
+ {path}
+ {
+ const newPaths = [...(field.value || [])];
+ newPaths.splice(index, 1);
+ form.setValue("watchPaths", newPaths);
+ }}
+ />
+
+ ))}
+
+
+
+ {
+ if (e.key === "Enter") {
+ e.preventDefault();
+ const input = e.currentTarget;
+ const value = input.value.trim();
+ if (value) {
+ const newPaths = [...(field.value || []), value];
+ form.setValue("watchPaths", newPaths);
+ input.value = "";
+ }
+ }
+ }}
+ />
+ {
+ const input = document.querySelector(
+ 'input[placeholder="Enter a path to watch (e.g., src/**, dist/*.js)"]',
+ ) as HTMLInputElement;
+ const value = input.value.trim();
+ if (value) {
+ const newPaths = [...(field.value || []), value];
+ form.setValue("watchPaths", newPaths);
+ input.value = "";
+ }
+ }}
+ >
+ Add
+
+
+
+
+
+ )}
+ />
+ (
+
+
+
+
+ Enable Submodules
+
+ )}
+ />
{
- const { data: githubProviders } = api.github.githubProviders.useQuery();
- const { data: gitlabProviders } = api.gitlab.gitlabProviders.useQuery();
- const { data: bitbucketProviders } =
+ const { data: githubProviders, isLoading: isLoadingGithub } =
+ api.github.githubProviders.useQuery();
+ const { data: gitlabProviders, isLoading: isLoadingGitlab } =
+ api.gitlab.gitlabProviders.useQuery();
+ const { data: bitbucketProviders, isLoading: isLoadingBitbucket } =
api.bitbucket.bitbucketProviders.useQuery();
+ 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 (
+
+
+
+
+
Provider
+
+ Select the source of your code
+
+
+
+
+
+
+
+
+
+
+
+ Loading providers...
+
+
+
+
+ );
+ }
+
+ // 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 (
@@ -54,21 +142,21 @@ export const ShowProviderFormCompose = ({ composeId }: Props) => {
setSab(e as TabState);
}}
>
-
-
+
+
- Github
+ GitHub
- Gitlab
+ GitLab
{
Bitbucket
-
+
+ Gitea
+
{
value="raw"
className="rounded-none border-b-2 gap-2 border-b-transparent data-[state=active]:border-b-2 data-[state=active]:border-b-border"
>
-
+
Raw
+
{githubProviders && githubProviders?.length > 0 ? (
) : (
-
+
To deploy using GitHub, you need to configure your account
@@ -118,7 +212,7 @@ export const ShowProviderFormCompose = ({ composeId }: Props) => {
{gitlabProviders && gitlabProviders?.length > 0 ? (
) : (
-
+
To deploy using GitLab, you need to configure your account
@@ -138,7 +232,7 @@ export const ShowProviderFormCompose = ({ composeId }: Props) => {
{bitbucketProviders && bitbucketProviders?.length > 0 ? (
) : (
-
+
To deploy using Bitbucket, you need to configure your account
@@ -154,6 +248,26 @@ export const ShowProviderFormCompose = ({ composeId }: Props) => {
)}
+
+ {giteaProviders && giteaProviders?.length > 0 ? (
+
+ ) : (
+
+
+
+ To deploy using Gitea, you need to configure your account
+ first. Please, go to{" "}
+
+ Settings
+ {" "}
+ to do so.
+
+
+ )}
+
diff --git a/apps/dokploy/components/dashboard/compose/general/isolated-deployment.tsx b/apps/dokploy/components/dashboard/compose/general/isolated-deployment.tsx
index 3ae2e9fe3..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 (
@@ -147,9 +142,11 @@ export const IsolatedDeployment = ({ composeId }: Props) => {
render={({ field }) => (
- Enable Randomize ({data?.appName})
+
+ Enable Isolated Deployment ({data?.appName})
+
- Enable randomize to the compose file.
+ Enable isolated deployment to the compose file.
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 49606645c..677762b00 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]);
@@ -52,7 +52,7 @@ export const ShowConvertedCompose = ({ composeId }: Props) => {
Preview Compose
-
+
Converted Compose
@@ -62,35 +62,54 @@ export const ShowConvertedCompose = ({ composeId }: Props) => {
{isError && {error?.message} }
-
- {
- mutateAsync({ composeId })
- .then(() => {
- refetch();
- toast.success("Fetched source type");
- })
- .catch((err) => {
- toast.error("Error fetching source type", {
- description: err.message,
- });
- });
- }}
- >
- Refresh
-
-
+
+ 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
+
+
-
-
-
+
+
+
+ >
+ )}
);
diff --git a/apps/dokploy/components/dashboard/compose/general/show-utilities.tsx b/apps/dokploy/components/dashboard/compose/general/show-utilities.tsx
index 214102ce9..6df800494 100644
--- a/apps/dokploy/components/dashboard/compose/general/show-utilities.tsx
+++ b/apps/dokploy/components/dashboard/compose/general/show-utilities.tsx
@@ -23,7 +23,7 @@ export const ShowUtilities = ({ composeId }: Props) => {
Show Utilities
-
+
Utilities
Modify the application data
diff --git a/apps/dokploy/components/dashboard/compose/update-compose.tsx b/apps/dokploy/components/dashboard/compose/update-compose.tsx
index 3120f2d4c..f9c38a6bc 100644
--- a/apps/dokploy/components/dashboard/compose/update-compose.tsx
+++ b/apps/dokploy/components/dashboard/compose/update-compose.tsx
@@ -99,7 +99,7 @@ export const UpdateCompose = ({ composeId }: Props) => {
-
+
Modify Compose
Update the compose data
@@ -121,7 +121,7 @@ export const UpdateCompose = ({ composeId }: Props) => {
Name
-
+
diff --git a/apps/dokploy/components/dashboard/database/backups/add-backup.tsx b/apps/dokploy/components/dashboard/database/backups/add-backup.tsx
deleted file mode 100644
index 5f349b242..000000000
--- a/apps/dokploy/components/dashboard/database/backups/add-backup.tsx
+++ /dev/null
@@ -1,312 +0,0 @@
-import { Button } from "@/components/ui/button";
-import {
- Command,
- CommandEmpty,
- CommandGroup,
- CommandInput,
- CommandItem,
-} from "@/components/ui/command";
-import {
- Dialog,
- DialogContent,
- DialogDescription,
- DialogFooter,
- DialogHeader,
- DialogTitle,
- DialogTrigger,
-} from "@/components/ui/dialog";
-import {
- Form,
- FormControl,
- FormDescription,
- FormField,
- FormItem,
- FormLabel,
- FormMessage,
-} from "@/components/ui/form";
-import { Input } from "@/components/ui/input";
-import {
- Popover,
- PopoverContent,
- PopoverTrigger,
-} from "@/components/ui/popover";
-import { ScrollArea } from "@/components/ui/scroll-area";
-import { Switch } from "@/components/ui/switch";
-import { cn } from "@/lib/utils";
-import { api } from "@/utils/api";
-import { zodResolver } from "@hookform/resolvers/zod";
-import { PlusIcon } from "lucide-react";
-import { CheckIcon, ChevronsUpDown } from "lucide-react";
-import { useEffect } from "react";
-import { useForm } from "react-hook-form";
-import { toast } from "sonner";
-import { z } from "zod";
-
-const AddPostgresBackup1Schema = z.object({
- destinationId: z.string().min(1, "Destination required"),
- schedule: z.string().min(1, "Schedule (Cron) required"),
- // .regex(
- // new RegExp(
- // /^(\*|([0-9]|1[0-9]|2[0-9]|3[0-9]|4[0-9]|5[0-9])|\*\/([0-9]|1[0-9]|2[0-9]|3[0-9]|4[0-9]|5[0-9])) (\*|([0-9]|1[0-9]|2[0-3])|\*\/([0-9]|1[0-9]|2[0-3])) (\*|([1-9]|1[0-9]|2[0-9]|3[0-1])|\*\/([1-9]|1[0-9]|2[0-9]|3[0-1])) (\*|([1-9]|1[0-2])|\*\/([1-9]|1[0-2])) (\*|([0-6])|\*\/([0-6]))$/,
- // ),
- // "Invalid Cron",
- // ),
- prefix: z.string().min(1, "Prefix required"),
- enabled: z.boolean(),
- database: z.string().min(1, "Database required"),
-});
-
-type AddPostgresBackup = z.infer;
-
-interface Props {
- databaseId: string;
- databaseType: "postgres" | "mariadb" | "mysql" | "mongo";
- refetch: () => void;
-}
-
-export const AddBackup = ({ databaseId, databaseType, refetch }: Props) => {
- const { data, isLoading } = api.destination.all.useQuery();
-
- const { mutateAsync: createBackup, isLoading: isCreatingPostgresBackup } =
- api.backup.create.useMutation();
-
- const form = useForm({
- defaultValues: {
- database: "",
- destinationId: "",
- enabled: true,
- prefix: "/",
- schedule: "",
- },
- resolver: zodResolver(AddPostgresBackup1Schema),
- });
-
- useEffect(() => {
- form.reset({
- database: "",
- destinationId: "",
- enabled: true,
- prefix: "/",
- schedule: "",
- });
- }, [form, form.reset, form.formState.isSubmitSuccessful]);
-
- const onSubmit = async (data: AddPostgresBackup) => {
- const getDatabaseId =
- databaseType === "postgres"
- ? {
- postgresId: databaseId,
- }
- : databaseType === "mariadb"
- ? {
- mariadbId: databaseId,
- }
- : databaseType === "mysql"
- ? {
- mysqlId: databaseId,
- }
- : databaseType === "mongo"
- ? {
- mongoId: databaseId,
- }
- : undefined;
-
- await createBackup({
- destinationId: data.destinationId,
- prefix: data.prefix,
- schedule: data.schedule,
- enabled: data.enabled,
- database: data.database,
- databaseType,
- ...getDatabaseId,
- })
- .then(async () => {
- toast.success("Backup Created");
- refetch();
- })
- .catch(() => {
- toast.error("Error creating a backup");
- });
- };
- return (
-
-
-
-
- Create Backup
-
-
-
-
- Create a backup
- Add a new backup
-
-
-
-
-
-
-
- Create
-
-
-
-
-
-
- );
-};
diff --git a/apps/dokploy/components/dashboard/database/backups/handle-backup.tsx b/apps/dokploy/components/dashboard/database/backups/handle-backup.tsx
new file mode 100644
index 000000000..4c5bbe628
--- /dev/null
+++ b/apps/dokploy/components/dashboard/database/backups/handle-backup.tsx
@@ -0,0 +1,828 @@
+import { AlertBlock } from "@/components/shared/alert-block";
+import { Button } from "@/components/ui/button";
+import {
+ Command,
+ CommandEmpty,
+ CommandGroup,
+ CommandInput,
+ CommandItem,
+} from "@/components/ui/command";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "@/components/ui/dialog";
+import {
+ Form,
+ FormControl,
+ FormDescription,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from "@/components/ui/form";
+import { Input } from "@/components/ui/input";
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from "@/components/ui/popover";
+import { ScrollArea } from "@/components/ui/scroll-area";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import { Switch } from "@/components/ui/switch";
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipProvider,
+ TooltipTrigger,
+} from "@/components/ui/tooltip";
+import { cn } from "@/lib/utils";
+import { api } from "@/utils/api";
+import { zodResolver } from "@hookform/resolvers/zod";
+import {
+ DatabaseZap,
+ Info,
+ PenBoxIcon,
+ PlusIcon,
+ RefreshCw,
+} from "lucide-react";
+import { CheckIcon, ChevronsUpDown } from "lucide-react";
+import { useEffect, useState } from "react";
+import { useForm } from "react-hook-form";
+import { toast } from "sonner";
+import { z } from "zod";
+import { commonCronExpressions } from "../../application/schedules/handle-schedules";
+
+type CacheType = "cache" | "fetch";
+
+type DatabaseType = "postgres" | "mariadb" | "mysql" | "mongo" | "web-server";
+
+const Schema = z
+ .object({
+ destinationId: z.string().min(1, "Destination required"),
+ schedule: z.string().min(1, "Schedule (Cron) required"),
+ prefix: z.string().min(1, "Prefix required"),
+ enabled: z.boolean(),
+ database: z.string().min(1, "Database required"),
+ keepLatestCount: z.coerce.number().optional(),
+ serviceName: z.string().nullable(),
+ databaseType: z
+ .enum(["postgres", "mariadb", "mysql", "mongo", "web-server"])
+ .optional(),
+ backupType: z.enum(["database", "compose"]),
+ metadata: z
+ .object({
+ postgres: z
+ .object({
+ databaseUser: z.string(),
+ })
+ .optional(),
+ mariadb: z
+ .object({
+ databaseUser: z.string(),
+ databasePassword: z.string(),
+ })
+ .optional(),
+ mongo: z
+ .object({
+ databaseUser: z.string(),
+ databasePassword: z.string(),
+ })
+ .optional(),
+ mysql: z
+ .object({
+ databaseRootPassword: z.string(),
+ })
+ .optional(),
+ })
+ .optional(),
+ })
+ .superRefine((data, ctx) => {
+ if (data.backupType === "compose" && !data.databaseType) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ message: "Database type is required for compose backups",
+ path: ["databaseType"],
+ });
+ }
+
+ if (data.backupType === "compose" && !data.serviceName) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ message: "Service name is required for compose backups",
+ path: ["serviceName"],
+ });
+ }
+
+ if (data.backupType === "compose" && data.databaseType) {
+ if (data.databaseType === "postgres") {
+ if (!data.metadata?.postgres?.databaseUser) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ message: "Database user is required for PostgreSQL",
+ path: ["metadata", "postgres", "databaseUser"],
+ });
+ }
+ } else if (data.databaseType === "mariadb") {
+ if (!data.metadata?.mariadb?.databaseUser) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ message: "Database user is required for MariaDB",
+ path: ["metadata", "mariadb", "databaseUser"],
+ });
+ }
+ if (!data.metadata?.mariadb?.databasePassword) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ message: "Database password is required for MariaDB",
+ path: ["metadata", "mariadb", "databasePassword"],
+ });
+ }
+ } else if (data.databaseType === "mongo") {
+ if (!data.metadata?.mongo?.databaseUser) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ message: "Database user is required for MongoDB",
+ path: ["metadata", "mongo", "databaseUser"],
+ });
+ }
+ if (!data.metadata?.mongo?.databasePassword) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ message: "Database password is required for MongoDB",
+ path: ["metadata", "mongo", "databasePassword"],
+ });
+ }
+ } else if (data.databaseType === "mysql") {
+ if (!data.metadata?.mysql?.databaseRootPassword) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ message: "Root password is required for MySQL",
+ path: ["metadata", "mysql", "databaseRootPassword"],
+ });
+ }
+ }
+ }
+ });
+
+interface Props {
+ id?: string;
+ backupId?: string;
+ databaseType?: DatabaseType;
+ refetch: () => void;
+ backupType: "database" | "compose";
+}
+
+export const HandleBackup = ({
+ id,
+ backupId,
+ databaseType = "postgres",
+ refetch,
+ backupType = "database",
+}: Props) => {
+ const [isOpen, setIsOpen] = useState(false);
+
+ const { data, isLoading } = api.destination.all.useQuery();
+ const { data: backup } = api.backup.one.useQuery(
+ {
+ backupId: backupId ?? "",
+ },
+ {
+ enabled: !!backupId,
+ },
+ );
+ const [cacheType, setCacheType] = useState("cache");
+ const { mutateAsync: createBackup, isLoading: isCreatingPostgresBackup } =
+ backupId
+ ? api.backup.update.useMutation()
+ : api.backup.create.useMutation();
+
+ const form = useForm>({
+ defaultValues: {
+ database: databaseType === "web-server" ? "dokploy" : "",
+ destinationId: "",
+ enabled: true,
+ prefix: "/",
+ schedule: "",
+ keepLatestCount: undefined,
+ serviceName: null,
+ databaseType: backupType === "compose" ? undefined : databaseType,
+ backupType: backupType,
+ metadata: {},
+ },
+ resolver: zodResolver(Schema),
+ });
+
+ const {
+ data: services,
+ isFetching: isLoadingServices,
+ error: errorServices,
+ refetch: refetchServices,
+ } = api.compose.loadServices.useQuery(
+ {
+ composeId: backup?.composeId ?? id ?? "",
+ type: cacheType,
+ },
+ {
+ retry: false,
+ refetchOnWindowFocus: false,
+ enabled: backupType === "compose" && !!backup?.composeId && !!id,
+ },
+ );
+
+ useEffect(() => {
+ form.reset({
+ database: backup?.database
+ ? backup?.database
+ : databaseType === "web-server"
+ ? "dokploy"
+ : "",
+ destinationId: backup?.destinationId ?? "",
+ enabled: backup?.enabled ?? true,
+ prefix: backup?.prefix ?? "/",
+ schedule: backup?.schedule ?? "",
+ keepLatestCount: backup?.keepLatestCount ?? undefined,
+ serviceName: backup?.serviceName ?? null,
+ databaseType: backup?.databaseType ?? databaseType,
+ backupType: backup?.backupType ?? backupType,
+ metadata: backup?.metadata ?? {},
+ });
+ }, [form, form.reset, backupId, backup]);
+
+ const onSubmit = async (data: z.infer) => {
+ const getDatabaseId =
+ backupType === "compose"
+ ? {
+ composeId: id,
+ }
+ : databaseType === "postgres"
+ ? {
+ postgresId: id,
+ }
+ : databaseType === "mariadb"
+ ? {
+ mariadbId: id,
+ }
+ : databaseType === "mysql"
+ ? {
+ mysqlId: id,
+ }
+ : databaseType === "mongo"
+ ? {
+ mongoId: id,
+ }
+ : databaseType === "web-server"
+ ? {
+ userId: id,
+ }
+ : undefined;
+
+ await createBackup({
+ destinationId: data.destinationId,
+ prefix: data.prefix,
+ schedule: data.schedule,
+ enabled: data.enabled,
+ database: data.database,
+ keepLatestCount: data.keepLatestCount ?? null,
+ databaseType: data.databaseType || databaseType,
+ serviceName: data.serviceName,
+ ...getDatabaseId,
+ backupId: backupId ?? "",
+ backupType,
+ metadata: data.metadata,
+ })
+ .then(async () => {
+ toast.success(`Backup ${backupId ? "Updated" : "Created"}`);
+ refetch();
+ setIsOpen(false);
+ })
+ .catch(() => {
+ toast.error(`Error ${backupId ? "updating" : "creating"} a backup`);
+ });
+ };
+
+ return (
+
+
+ {backupId ? (
+
+
+
+ ) : (
+
+
+ {backupId ? "Update Backup" : "Create Backup"}
+
+ )}
+
+
+
+
+ {backupId ? "Update Backup" : "Create Backup"}
+
+
+ {backupId ? "Update a backup" : "Add a new backup"}
+
+
+
+
+
+
+
+
+ {backupId ? "Update" : "Create"}
+
+
+
+
+
+
+ );
+};
diff --git a/apps/dokploy/components/dashboard/database/backups/restore-backup.tsx b/apps/dokploy/components/dashboard/database/backups/restore-backup.tsx
new file mode 100644
index 000000000..a173f85ad
--- /dev/null
+++ b/apps/dokploy/components/dashboard/database/backups/restore-backup.tsx
@@ -0,0 +1,820 @@
+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 {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipProvider,
+ TooltipTrigger,
+} from "@/components/ui/tooltip";
+import { cn } from "@/lib/utils";
+import { api } from "@/utils/api";
+import { zodResolver } from "@hookform/resolvers/zod";
+import copy from "copy-to-clipboard";
+import { debounce } from "lodash";
+import {
+ CheckIcon,
+ ChevronsUpDown,
+ Copy,
+ DatabaseZap,
+ RefreshCw,
+ RotateCcw,
+} from "lucide-react";
+import { useState } from "react";
+import { useForm } from "react-hook-form";
+import { toast } from "sonner";
+import { z } from "zod";
+import type { ServiceType } from "../../application/advanced/show-resources";
+import { type LogLine, parseLogs } from "../../docker/logs/utils";
+
+type DatabaseType =
+ | Exclude
+ | "web-server";
+
+interface Props {
+ id: string;
+ databaseType?: DatabaseType;
+ serverId?: string | null;
+ backupType?: "database" | "compose";
+}
+
+const RestoreBackupSchema = z
+ .object({
+ destinationId: z
+ .string({
+ required_error: "Please select a destination",
+ })
+ .min(1, {
+ message: "Destination is required",
+ }),
+ backupFile: z
+ .string({
+ required_error: "Please select a backup file",
+ })
+ .min(1, {
+ message: "Backup file is required",
+ }),
+ databaseName: z
+ .string({
+ required_error: "Please enter a database name",
+ })
+ .min(1, {
+ message: "Database name is required",
+ }),
+ databaseType: z
+ .enum(["postgres", "mariadb", "mysql", "mongo", "web-server"])
+ .optional(),
+ backupType: z.enum(["database", "compose"]).default("database"),
+ metadata: z
+ .object({
+ postgres: z
+ .object({
+ databaseUser: z.string(),
+ })
+ .optional(),
+ mariadb: z
+ .object({
+ databaseUser: z.string(),
+ databasePassword: z.string(),
+ })
+ .optional(),
+ mongo: z
+ .object({
+ databaseUser: z.string(),
+ databasePassword: z.string(),
+ })
+ .optional(),
+ mysql: z
+ .object({
+ databaseRootPassword: z.string(),
+ })
+ .optional(),
+ serviceName: z.string().optional(),
+ })
+ .optional(),
+ })
+ .superRefine((data, ctx) => {
+ if (data.backupType === "compose" && !data.databaseType) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ message: "Database type is required for compose backups",
+ path: ["databaseType"],
+ });
+ }
+
+ if (data.backupType === "compose" && !data.metadata?.serviceName) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ message: "Service name is required for compose backups",
+ path: ["metadata", "serviceName"],
+ });
+ }
+
+ if (data.backupType === "compose" && data.databaseType) {
+ if (data.databaseType === "postgres") {
+ if (!data.metadata?.postgres?.databaseUser) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ message: "Database user is required for PostgreSQL",
+ path: ["metadata", "postgres", "databaseUser"],
+ });
+ }
+ } else if (data.databaseType === "mariadb") {
+ if (!data.metadata?.mariadb?.databaseUser) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ message: "Database user is required for MariaDB",
+ path: ["metadata", "mariadb", "databaseUser"],
+ });
+ }
+ if (!data.metadata?.mariadb?.databasePassword) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ message: "Database password is required for MariaDB",
+ path: ["metadata", "mariadb", "databasePassword"],
+ });
+ }
+ } else if (data.databaseType === "mongo") {
+ if (!data.metadata?.mongo?.databaseUser) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ message: "Database user is required for MongoDB",
+ path: ["metadata", "mongo", "databaseUser"],
+ });
+ }
+ if (!data.metadata?.mongo?.databasePassword) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ message: "Database password is required for MongoDB",
+ path: ["metadata", "mongo", "databasePassword"],
+ });
+ }
+ } else if (data.databaseType === "mysql") {
+ if (!data.metadata?.mysql?.databaseRootPassword) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ message: "Root password is required for MySQL",
+ path: ["metadata", "mysql", "databaseRootPassword"],
+ });
+ }
+ }
+ }
+ });
+
+export const formatBytes = (bytes: number): string => {
+ if (bytes === 0) return "0 Bytes";
+ const k = 1024;
+ const sizes = ["Bytes", "KB", "MB", "GB", "TB"];
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
+ return `${Number.parseFloat((bytes / k ** i).toFixed(2))} ${sizes[i]}`;
+};
+
+export const RestoreBackup = ({
+ id,
+ databaseType,
+ serverId,
+ backupType = "database",
+}: Props) => {
+ const [isOpen, setIsOpen] = useState(false);
+ const [search, setSearch] = useState("");
+ const [debouncedSearchTerm, setDebouncedSearchTerm] = useState("");
+
+ const { data: destinations = [] } = api.destination.all.useQuery();
+
+ const form = useForm>({
+ defaultValues: {
+ destinationId: "",
+ backupFile: "",
+ databaseName: databaseType === "web-server" ? "dokploy" : "",
+ databaseType:
+ backupType === "compose" ? ("postgres" as DatabaseType) : databaseType,
+ backupType: backupType,
+ metadata: {},
+ },
+ resolver: zodResolver(RestoreBackupSchema),
+ });
+
+ const destionationId = form.watch("destinationId");
+ const currentDatabaseType = form.watch("databaseType");
+ const metadata = form.watch("metadata");
+
+ const debouncedSetSearch = debounce((value: string) => {
+ setDebouncedSearchTerm(value);
+ }, 350);
+
+ const handleSearchChange = (value: string) => {
+ setSearch(value);
+ debouncedSetSearch(value);
+ };
+
+ const { data: files = [], isLoading } = api.backup.listBackupFiles.useQuery(
+ {
+ destinationId: destionationId,
+ search: debouncedSearchTerm,
+ serverId: serverId ?? "",
+ },
+ {
+ enabled: isOpen && !!destionationId,
+ },
+ );
+
+ const [isDrawerOpen, setIsDrawerOpen] = useState(false);
+ const [filteredLogs, setFilteredLogs] = useState([]);
+ const [isDeploying, setIsDeploying] = useState(false);
+
+ api.backup.restoreBackupWithLogs.useSubscription(
+ {
+ databaseId: id,
+ databaseType: currentDatabaseType as DatabaseType,
+ databaseName: form.watch("databaseName"),
+ backupFile: form.watch("backupFile"),
+ destinationId: form.watch("destinationId"),
+ backupType: backupType,
+ metadata: metadata,
+ },
+ {
+ 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 (data: z.infer) => {
+ if (backupType === "compose" && !data.databaseType) {
+ toast.error("Please select a database type");
+ return;
+ }
+ console.log({ data });
+ setIsDeploying(true);
+ };
+
+ const [cacheType, setCacheType] = useState<"fetch" | "cache">("cache");
+ const {
+ data: services = [],
+ isLoading: isLoadingServices,
+ refetch: refetchServices,
+ } = api.compose.loadServices.useQuery(
+ {
+ composeId: id,
+ type: cacheType,
+ },
+ {
+ retry: false,
+ refetchOnWindowFocus: false,
+ enabled: backupType === "compose",
+ },
+ );
+
+ return (
+
+
+
+
+ Restore Backup
+
+
+
+
+
+
+ Restore Backup
+
+
+ Select a destination and search for backup files
+
+
+
+
+
+ (
+
+ Destination
+
+
+
+
+ {field.value
+ ? destinations.find(
+ (d) => d.destinationId === field.value,
+ )?.name
+ : "Select Destination"}
+
+
+
+
+
+
+
+ No destinations found.
+
+
+ {destinations.map((destination) => (
+ {
+ form.setValue(
+ "destinationId",
+ destination.destinationId,
+ );
+ }}
+ >
+ {destination.name}
+
+
+ ))}
+
+
+
+
+
+
+
+ )}
+ />
+
+ (
+
+
+ Search Backup Files
+ {field.value && (
+
+ {field.value}
+ {
+ e.stopPropagation();
+ e.preventDefault();
+ copy(field.value);
+ toast.success("Backup file copied to clipboard");
+ }}
+ />
+
+ )}
+
+
+
+
+
+
+ {field.value || "Search and select a backup file"}
+
+
+
+
+
+
+
+
+ {isLoading ? (
+
+ Loading backup files...
+
+ ) : files.length === 0 && search ? (
+
+ No backup files found for "{search}"
+
+ ) : files.length === 0 ? (
+
+ No backup files available
+
+ ) : (
+
+
+ {files?.map((file) => (
+ {
+ form.setValue("backupFile", file.Path);
+ if (file.IsDir) {
+ setSearch(`${file.Path}/`);
+ setDebouncedSearchTerm(`${file.Path}/`);
+ } else {
+ setSearch(file.Path);
+ setDebouncedSearchTerm(file.Path);
+ }
+ }}
+ >
+
+
+
+ {file.Path}
+
+
+
+
+
+
+ Size: {formatBytes(file.Size)}
+
+ {file.IsDir && (
+
+ Directory
+
+ )}
+ {file.Hashes?.MD5 && (
+ MD5: {file.Hashes.MD5}
+ )}
+
+
+
+ ))}
+
+
+ )}
+
+
+
+
+
+ )}
+ />
+ (
+
+ Database Name
+
+
+
+
+
+ )}
+ />
+
+ {backupType === "compose" && (
+ <>
+ (
+
+ Database Type
+ {
+ field.onChange(value);
+ form.setValue("metadata", {});
+ }}
+ >
+
+
+
+
+ PostgreSQL
+ MariaDB
+ MongoDB
+ MySQL
+
+
+
+
+ )}
+ />
+
+ (
+
+ Service Name
+
+
+
+
+
+
+
+
+
+ {services?.map((service, index) => (
+
+ {service}
+
+ ))}
+ {(!services || services.length === 0) && (
+
+ Empty
+
+ )}
+
+
+
+
+
+ {
+ if (cacheType === "fetch") {
+ refetchServices();
+ } else {
+ setCacheType("fetch");
+ }
+ }}
+ >
+
+
+
+
+
+ Fetch: Will clone the repository and load the
+ services
+
+
+
+
+
+
+
+ {
+ if (cacheType === "cache") {
+ refetchServices();
+ } else {
+ setCacheType("cache");
+ }
+ }}
+ >
+
+
+
+
+
+ Cache: If you previously deployed this compose,
+ it will read the services from the last
+ deployment/fetch from the repository
+
+
+
+
+
+
+
+
+ )}
+ />
+
+ {currentDatabaseType === "postgres" && (
+ (
+
+ Database User
+
+
+
+
+
+ )}
+ />
+ )}
+
+ {currentDatabaseType === "mariadb" && (
+ <>
+ (
+
+ Database User
+
+
+
+
+
+ )}
+ />
+ (
+
+ Database Password
+
+
+
+
+
+ )}
+ />
+ >
+ )}
+
+ {currentDatabaseType === "mongo" && (
+ <>
+ (
+
+ Database User
+
+
+
+
+
+ )}
+ />
+ (
+
+ Database Password
+
+
+
+
+
+ )}
+ />
+ >
+ )}
+
+ {currentDatabaseType === "mysql" && (
+ (
+
+ Root Password
+
+
+
+
+
+ )}
+ />
+ )}
+ >
+ )}
+
+
+
+ Restore
+
+
+
+
+
+ {
+ setIsDrawerOpen(false);
+ setFilteredLogs([]);
+ setIsDeploying(false);
+ // refetch();
+ }}
+ filteredLogs={filteredLogs}
+ />
+
+
+ );
+};
diff --git a/apps/dokploy/components/dashboard/database/backups/show-backups.tsx b/apps/dokploy/components/dashboard/database/backups/show-backups.tsx
index 6619ceae7..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,43 +20,77 @@ import {
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
+import { cn } from "@/lib/utils";
import { api } from "@/utils/api";
-import { DatabaseBackup, Play, Trash2 } from "lucide-react";
+import {
+ ClipboardList,
+ Database,
+ DatabaseBackup,
+ Play,
+ Trash2,
+} from "lucide-react";
import Link from "next/link";
+import { useState } from "react";
import { toast } from "sonner";
import type { ServiceType } from "../../application/advanced/show-resources";
-import { AddBackup } from "./add-backup";
-import { UpdateBackup } from "./update-backup";
+import { ShowDeploymentsModal } from "../../application/deployments/show-deployments-modal";
+import { HandleBackup } from "./handle-backup";
+import { RestoreBackup } from "./restore-backup";
interface Props {
id: string;
- type: Exclude;
+ databaseType?: Exclude | "web-server";
+ backupType?: "database" | "compose";
}
-export const ShowBackups = ({ id, type }: Props) => {
- const queryMap = {
- postgres: () =>
- api.postgres.one.useQuery({ postgresId: id }, { enabled: !!id }),
- mysql: () => api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }),
- mariadb: () =>
- api.mariadb.one.useQuery({ mariadbId: id }, { enabled: !!id }),
- mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
- };
+export const ShowBackups = ({
+ id,
+ databaseType,
+ backupType = "database",
+}: Props) => {
+ const [activeManualBackup, setActiveManualBackup] = useState<
+ string | undefined
+ >();
+ const queryMap =
+ backupType === "database"
+ ? {
+ postgres: () =>
+ api.postgres.one.useQuery({ postgresId: id }, { enabled: !!id }),
+ mysql: () =>
+ api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }),
+ mariadb: () =>
+ api.mariadb.one.useQuery({ mariadbId: id }, { enabled: !!id }),
+ mongo: () =>
+ api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
+ "web-server": () => api.user.getBackups.useQuery(),
+ }
+ : {
+ compose: () =>
+ api.compose.one.useQuery({ composeId: id }, { enabled: !!id }),
+ };
const { data } = api.destination.all.useQuery();
- const { data: postgres, refetch } = queryMap[type]
- ? queryMap[type]()
+ const key = backupType === "database" ? databaseType : "compose";
+ const query = queryMap[key as keyof typeof queryMap];
+ const { data: postgres, refetch } = query
+ ? query()
: api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id });
- const mutationMap = {
- postgres: () => api.backup.manualBackupPostgres.useMutation(),
- mysql: () => api.backup.manualBackupMySql.useMutation(),
- mariadb: () => api.backup.manualBackupMariadb.useMutation(),
- mongo: () => api.backup.manualBackupMongo.useMutation(),
- };
+ const mutationMap =
+ backupType === "database"
+ ? {
+ postgres: api.backup.manualBackupPostgres.useMutation(),
+ mysql: api.backup.manualBackupMySql.useMutation(),
+ mariadb: api.backup.manualBackupMariadb.useMutation(),
+ mongo: api.backup.manualBackupMongo.useMutation(),
+ "web-server": api.backup.manualBackupWebServer.useMutation(),
+ }
+ : {
+ compose: api.backup.manualBackupCompose.useMutation(),
+ };
- const { mutateAsync: manualBackup, isLoading: isManualBackup } = mutationMap[
- type
- ]
- ? mutationMap[type]()
+ const mutation = mutationMap[key as keyof typeof mutationMap];
+
+ const { mutateAsync: manualBackup, isLoading: isManualBackup } = mutation
+ ? mutation
: api.backup.manualBackupMongo.useMutation();
const { mutateAsync: deleteBackup, isLoading: isRemoving } =
@@ -59,7 +100,10 @@ export const ShowBackups = ({ id, type }: Props) => {
- Backups
+
+
+ Backups
+
Add backups to your database to save the data to a different
provider.
@@ -67,12 +111,27 @@ export const ShowBackups = ({ id, type }: Props) => {
{postgres && postgres?.backups?.length > 0 && (
-
+
+ {databaseType !== "web-server" && (
+
+ )}
+
+
)}
{data?.length === 0 ? (
-
+
To create a backup it is required to set at least 1 provider.
@@ -87,121 +146,230 @@ export const ShowBackups = ({ id, type }: Props) => {
) : (
-
+
{postgres?.backups.length === 0 ? (
No backups configured
-
+
+
+
+
) : (
-
+
+ {backupType === "compose" && (
+
+ Make sure the compose is running before creating a backup.
+
+ )}
- {postgres?.backups.map((backup) => (
-
-
-
-
-
Destination
-
- {backup.destination.name}
-
+ {postgres?.backups.map((backup) => {
+ const serverId =
+ "serverId" in postgres ? postgres.serverId : undefined;
+
+ return (
+
+
+
+
+ {backup.backupType === "compose" && (
+
+ {backup.databaseType === "postgres" && (
+
+ )}
+ {backup.databaseType === "mysql" && (
+
+ )}
+ {backup.databaseType === "mariadb" && (
+
+ )}
+ {backup.databaseType === "mongo" && (
+
+ )}
+
+ )}
+
+ {backup.backupType === "compose" && (
+
+
+ {backup.serviceName}
+
+
+ {backup.databaseType}
+
+
+ )}
+
+
+
+ {backup.enabled ? "Active" : "Inactive"}
+
+
+
+
+
+
+
+
+ Destination
+
+
+ {backup.destination.name}
+
+
+
+
+
+ Database
+
+
+ {backup.database}
+
+
+
+
+
+ Schedule
+
+
+ {backup.schedule}
+
+
+
+
+
+ Prefix Storage
+
+
+ {backup.prefix}
+
+
+
+
+
+ Keep Latest
+
+
+ {backup.keepLatestCount || "All"}
+
+
+
-
- Database
-
- {backup.database}
-
-
-
- Scheduled
-
- {backup.schedule}
-
-
-
- Prefix Storage
-
- {backup.prefix}
-
-
-
- Enabled
-
- {backup.enabled ? "Yes" : "No"}
-
-
-
-
-
-
-
- {
- await manualBackup({
- backupId: backup.backupId as string,
- })
- .then(async () => {
- toast.success(
- "Manual Backup Successful",
- );
- })
- .catch(() => {
- toast.error(
- "Error creating the manual backup",
- );
- });
- }}
- >
-
-
-
- Run Manual Backup
-
-
-
-
{
- await deleteBackup({
- backupId: backup.backupId,
- })
- .then(() => {
- refetch();
- toast.success("Backup deleted successfully");
- })
- .catch(() => {
- toast.error("Error deleting backup");
- });
- }}
- >
-
+
-
-
-
+
+
+
+
+
+
+
+ {
+ setActiveManualBackup(backup.backupId);
+ await manualBackup({
+ backupId: backup.backupId as string,
+ })
+ .then(async () => {
+ toast.success(
+ "Manual Backup Successful",
+ );
+ })
+ .catch(() => {
+ toast.error(
+ "Error creating the manual backup",
+ );
+ });
+ setActiveManualBackup(undefined);
+ }}
+ >
+
+
+
+
+ Run Manual Backup
+
+
+
+
+
+
{
+ await deleteBackup({
+ backupId: backup.backupId,
+ })
+ .then(() => {
+ refetch();
+ toast.success(
+ "Backup deleted successfully",
+ );
+ })
+ .catch(() => {
+ toast.error("Error deleting backup");
+ });
+ }}
+ >
+
+
+
+
+
-
- ))}
+ );
+ })}
)}
diff --git a/apps/dokploy/components/dashboard/database/backups/update-backup.tsx b/apps/dokploy/components/dashboard/database/backups/update-backup.tsx
deleted file mode 100644
index 99f7692a9..000000000
--- a/apps/dokploy/components/dashboard/database/backups/update-backup.tsx
+++ /dev/null
@@ -1,300 +0,0 @@
-import { Button } from "@/components/ui/button";
-import {
- Command,
- CommandEmpty,
- CommandGroup,
- CommandInput,
- CommandItem,
-} from "@/components/ui/command";
-import {
- Dialog,
- DialogContent,
- DialogDescription,
- DialogFooter,
- DialogHeader,
- DialogTitle,
- DialogTrigger,
-} from "@/components/ui/dialog";
-import {
- Form,
- FormControl,
- FormDescription,
- FormField,
- FormItem,
- FormLabel,
- FormMessage,
-} from "@/components/ui/form";
-import { Input } from "@/components/ui/input";
-import {
- Popover,
- PopoverContent,
- PopoverTrigger,
-} from "@/components/ui/popover";
-import { ScrollArea } from "@/components/ui/scroll-area";
-import { Switch } from "@/components/ui/switch";
-import { cn } from "@/lib/utils";
-import { api } from "@/utils/api";
-import { zodResolver } from "@hookform/resolvers/zod";
-import { CheckIcon, ChevronsUpDown, PenBoxIcon } from "lucide-react";
-import { useEffect, useState } from "react";
-import { useForm } from "react-hook-form";
-import { toast } from "sonner";
-import { z } from "zod";
-
-const UpdateBackupSchema = z.object({
- destinationId: z.string().min(1, "Destination required"),
- schedule: z.string().min(1, "Schedule (Cron) required"),
- prefix: z.string().min(1, "Prefix required"),
- enabled: z.boolean(),
- database: z.string().min(1, "Database required"),
-});
-
-type UpdateBackup = z.infer
;
-
-interface Props {
- backupId: string;
- refetch: () => void;
-}
-
-export const UpdateBackup = ({ backupId, refetch }: Props) => {
- const [isOpen, setIsOpen] = useState(false);
- const { data, isLoading } = api.destination.all.useQuery();
- const { data: backup } = api.backup.one.useQuery(
- {
- backupId,
- },
- {
- enabled: !!backupId,
- },
- );
-
- const { mutateAsync, isLoading: isLoadingUpdate } =
- api.backup.update.useMutation();
-
- const form = useForm({
- defaultValues: {
- database: "",
- destinationId: "",
- enabled: true,
- prefix: "/",
- schedule: "",
- },
- resolver: zodResolver(UpdateBackupSchema),
- });
-
- useEffect(() => {
- if (backup) {
- form.reset({
- database: backup.database,
- destinationId: backup.destinationId,
- enabled: backup.enabled || false,
- prefix: backup.prefix,
- schedule: backup.schedule,
- });
- }
- }, [form, form.reset, backup]);
-
- const onSubmit = async (data: UpdateBackup) => {
- await mutateAsync({
- backupId,
- destinationId: data.destinationId,
- prefix: data.prefix,
- schedule: data.schedule,
- enabled: data.enabled,
- database: data.database,
- })
- .then(async () => {
- toast.success("Backup Updated");
- refetch();
- setIsOpen(false);
- })
- .catch(() => {
- toast.error("Error updating the Backup");
- });
- };
-
- return (
-
-
-
-
-
-
-
-
- Update Backup
- Update the backup
-
-
-
-
-
-
-
- Update
-
-
-
-
-
-
- );
-};
diff --git a/apps/dokploy/components/dashboard/docker/config/show-container-config.tsx b/apps/dokploy/components/dashboard/docker/config/show-container-config.tsx
index 1f1591c9d..2b93b1dbe 100644
--- a/apps/dokploy/components/dashboard/docker/config/show-container-config.tsx
+++ b/apps/dokploy/components/dashboard/docker/config/show-container-config.tsx
@@ -42,7 +42,7 @@ export const ShowContainerConfig = ({ containerId, serverId }: Props) => {
See in detail the config of this container
-
+
= ({
const wsUrl = `${protocol}//${
window.location.host
}/docker-container-logs?${params.toString()}`;
- console.log("Connecting to WebSocket:", wsUrl);
const ws = new WebSocket(wsUrl);
const resetNoDataTimeout = () => {
@@ -136,13 +135,19 @@ export const DockerLogsId: React.FC = ({
ws.close();
return;
}
- console.log("WebSocket connected");
resetNoDataTimeout();
};
ws.onmessage = (e) => {
if (!isCurrentConnection) return;
- setRawLogs((prev) => prev + e.data);
+ setRawLogs((prev) => {
+ const updated = prev + e.data;
+ const splitLines = updated.split("\n");
+ if (splitLines.length > lines) {
+ return splitLines.slice(-lines).join("\n");
+ }
+ return updated;
+ });
setIsLoading(false);
if (noDataTimeout) clearTimeout(noDataTimeout);
};
diff --git a/apps/dokploy/components/dashboard/docker/logs/show-docker-modal-logs.tsx b/apps/dokploy/components/dashboard/docker/logs/show-docker-modal-logs.tsx
index 619b25d0c..fc2fb8f67 100644
--- a/apps/dokploy/components/dashboard/docker/logs/show-docker-modal-logs.tsx
+++ b/apps/dokploy/components/dashboard/docker/logs/show-docker-modal-logs.tsx
@@ -40,7 +40,7 @@ export const ShowDockerModalLogs = ({
{children}
-
+
View Logs
View the logs for {containerId}
diff --git a/apps/dokploy/components/dashboard/docker/logs/show-docker-modal-stack-logs.tsx b/apps/dokploy/components/dashboard/docker/logs/show-docker-modal-stack-logs.tsx
index 36719bb07..669369348 100644
--- a/apps/dokploy/components/dashboard/docker/logs/show-docker-modal-stack-logs.tsx
+++ b/apps/dokploy/components/dashboard/docker/logs/show-docker-modal-stack-logs.tsx
@@ -40,7 +40,7 @@ export const ShowDockerModalStackLogs = ({
{children}
-
+
View Logs
View the logs for {containerId}
diff --git a/apps/dokploy/components/dashboard/docker/terminal/docker-terminal-modal.tsx b/apps/dokploy/components/dashboard/docker/terminal/docker-terminal-modal.tsx
index 90aa2b406..97d9f16e8 100644
--- a/apps/dokploy/components/dashboard/docker/terminal/docker-terminal-modal.tsx
+++ b/apps/dokploy/components/dashboard/docker/terminal/docker-terminal-modal.tsx
@@ -60,7 +60,7 @@ export const DockerTerminalModal = ({
event.preventDefault()}
>
diff --git a/apps/dokploy/components/dashboard/impersonation/impersonation-bar.tsx b/apps/dokploy/components/dashboard/impersonation/impersonation-bar.tsx
new file mode 100644
index 000000000..8a9f55c90
--- /dev/null
+++ b/apps/dokploy/components/dashboard/impersonation/impersonation-bar.tsx
@@ -0,0 +1,454 @@
+"use client";
+
+import { Logo } from "@/components/shared/logo";
+import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import {
+ Command,
+ CommandEmpty,
+ CommandGroup,
+ CommandInput,
+ CommandItem,
+ CommandList,
+} from "@/components/ui/command";
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from "@/components/ui/popover";
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipProvider,
+ TooltipTrigger,
+} from "@/components/ui/tooltip";
+import { authClient } from "@/lib/auth-client";
+import { cn } from "@/lib/utils";
+import { api } from "@/utils/api";
+import copy from "copy-to-clipboard";
+import { format } from "date-fns";
+import {
+ Building2,
+ Calendar,
+ CheckIcon,
+ ChevronsUpDown,
+ Copy,
+ CreditCard,
+ Fingerprint,
+ Key,
+ Server,
+ Settings2,
+ Shield,
+ UserIcon,
+ XIcon,
+} from "lucide-react";
+import { useEffect, useState } from "react";
+import { toast } from "sonner";
+
+type User = typeof authClient.$Infer.Session.user;
+
+export const ImpersonationBar = () => {
+ const [users, setUsers] = useState([]);
+ const [selectedUser, setSelectedUser] = useState(null);
+ const [isImpersonating, setIsImpersonating] = useState(false);
+ const [open, setOpen] = useState(false);
+ const [isLoading, setIsLoading] = useState(false);
+ const [showBar, setShowBar] = useState(false);
+ const { data } = api.user.get.useQuery();
+
+ const fetchUsers = async (search?: string) => {
+ try {
+ const session = await authClient.getSession();
+ if (session?.data?.session?.impersonatedBy) {
+ return;
+ }
+ setIsLoading(true);
+ const response = await authClient.admin.listUsers({
+ query: {
+ limit: 30,
+ ...(search && {
+ searchField: "email",
+ searchOperator: "contains",
+ searchValue: search,
+ }),
+ },
+ });
+
+ const filteredUsers = response.data?.users.filter(
+ // @ts-ignore
+ (user) => user.allowImpersonation && data?.user?.email !== user.email,
+ );
+
+ if (!response.error) {
+ // @ts-ignore
+ setUsers(filteredUsers || []);
+ }
+ } catch (error) {
+ console.error("Error fetching users:", error);
+ toast.error("Error loading users");
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ const handleImpersonate = async () => {
+ if (!selectedUser) return;
+
+ try {
+ await authClient.admin.impersonateUser({
+ userId: selectedUser.id,
+ });
+ setIsImpersonating(true);
+ setOpen(false);
+
+ toast.success("Successfully impersonating user", {
+ description: `You are now viewing as ${selectedUser.name || selectedUser.email}`,
+ });
+ window.location.reload();
+ } catch (error) {
+ console.error("Error impersonating user:", error);
+ toast.error("Error impersonating user");
+ }
+ };
+
+ const handleStopImpersonating = async () => {
+ try {
+ await authClient.admin.stopImpersonating();
+ setIsImpersonating(false);
+ setSelectedUser(null);
+ setShowBar(false);
+ toast.success("Stopped impersonating user");
+ window.location.reload();
+ } catch (error) {
+ console.error("Error stopping impersonation:", error);
+ toast.error("Error stopping impersonation");
+ }
+ };
+
+ useEffect(() => {
+ const checkImpersonation = async () => {
+ try {
+ const session = await authClient.getSession();
+ if (session?.data?.session?.impersonatedBy) {
+ setIsImpersonating(true);
+ setShowBar(true);
+ // setSelectedUser(data);
+ }
+ } catch (error) {
+ console.error("Error checking impersonation status:", error);
+ }
+ };
+
+ checkImpersonation();
+ fetchUsers();
+ }, []);
+
+ return (
+
+ <>
+
+
+ setShowBar(!showBar)}
+ >
+
+
+
+
+ {isImpersonating ? "Impersonation Controls" : "User Impersonation"}
+
+
+
+
+
+
+ {!isImpersonating ? (
+
+
+
+
+ {selectedUser ? (
+
+
+
+
+ {selectedUser.name || ""}
+
+
+ {selectedUser.email}
+
+
+
+ ) : (
+ <>
+
+ Select user to impersonate
+ >
+ )}
+
+
+
+
+
+ {
+ fetchUsers(search);
+ }}
+ className="h-9"
+ />
+ {isLoading ? (
+
+ Loading users...
+
+ ) : (
+ <>
+ No users found.
+
+
+ {users.map((user) => (
+ {
+ setSelectedUser(user);
+ setOpen(false);
+ }}
+ >
+
+
+
+
+ {user.name || ""}
+
+
+ {user.email} • {user.role}
+
+
+
+
+
+ ))}
+
+
+ >
+ )}
+
+
+
+
+
+ Impersonate
+
+
+ ) : (
+
+
+
+
+
+ {data?.user?.name?.slice(0, 2).toUpperCase() || "U"}
+
+
+
+
+
+
+ Impersonating
+
+
+ {data?.user?.name || ""}
+
+
+
+
+
+ {data?.user?.email} • {data?.role}
+
+
+
+
+ ID: {data?.user?.id?.slice(0, 8)}
+ {
+ if (data?.id) {
+ copy(data.id);
+ toast.success("ID copied to clipboard");
+ }
+ }}
+ >
+
+
+
+
+
+
+
+ Org: {data?.organizationId?.slice(0, 8)}
+ {
+ if (data?.organizationId) {
+ copy(data.organizationId);
+ toast.success(
+ "Organization ID copied to clipboard",
+ );
+ }
+ }}
+ >
+
+
+
+
+ {data?.user?.stripeCustomerId && (
+
+
+
+ Customer:
+ {data?.user?.stripeCustomerId?.slice(0, 8)}
+ {
+ copy(data?.user?.stripeCustomerId || "");
+ toast.success(
+ "Stripe Customer ID copied to clipboard",
+ );
+ }}
+ >
+
+
+
+
+ )}
+ {data?.user?.stripeSubscriptionId && (
+
+
+
+ Sub: {data?.user?.stripeSubscriptionId?.slice(0, 8)}
+ {
+ copy(data.user.stripeSubscriptionId || "");
+ toast.success(
+ "Stripe Subscription ID copied to clipboard",
+ );
+ }}
+ >
+
+
+
+
+ )}
+ {data?.user?.serversQuantity !== undefined && (
+
+
+ Servers: {data.user.serversQuantity}
+
+ )}
+ {data?.createdAt && (
+
+
+ Created:{" "}
+ {format(new Date(data.createdAt), "MMM d, yyyy")}
+
+ )}
+
+
+
+
+
+ 2FA{" "}
+ {data?.user?.twoFactorEnabled
+ ? "Enabled"
+ : "Disabled"}
+
+
+
+
+ Two-Factor Authentication Status
+
+
+
+
+
+
+
+ Stop Impersonating
+
+
+ )}
+
+
+ >
+
+ );
+};
diff --git a/apps/dokploy/components/dashboard/mariadb/general/show-external-mariadb-credentials.tsx b/apps/dokploy/components/dashboard/mariadb/general/show-external-mariadb-credentials.tsx
index 4a5c43a26..c00af42be 100644
--- a/apps/dokploy/components/dashboard/mariadb/general/show-external-mariadb-credentials.tsx
+++ b/apps/dokploy/components/dashboard/mariadb/general/show-external-mariadb-credentials.tsx
@@ -1,3 +1,4 @@
+import { AlertBlock } from "@/components/shared/alert-block";
import { ToggleVisibilityInput } from "@/components/shared/toggle-visibility-input";
import { Button } from "@/components/ui/button";
import {
@@ -19,6 +20,7 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod";
+import Link from "next/link";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
@@ -106,6 +108,20 @@ export const ShowExternalMariadbCredentials = ({ mariadbId }: Props) => {
+ {!getIp && (
+
+ You need to set an IP address in your{" "}
+
+ {data?.serverId
+ ? "Remote Servers -> Server -> Edit Server -> Update IP Address"
+ : "Web Server -> Server -> Update Server IP"}
+ {" "}
+ to fix the database url connection.
+
+ )}
{
Deploy Settings
- {
- setIsDeploying(true);
- await new Promise((resolve) => setTimeout(resolve, 1000));
- refetch();
- }}
- >
-
- Deploy
-
-
- {
- await reload({
- mariadbId: mariadbId,
- appName: data?.appName || "",
- })
- .then(() => {
- toast.success("Mariadb reloaded successfully");
- refetch();
- })
- .catch(() => {
- toast.error("Error reloading Mariadb");
- });
- }}
- >
-
- Reload
-
-
-
- {data?.applicationStatus === "idle" ? (
+
{
- await start({
- mariadbId: mariadbId,
- })
- .then(() => {
- toast.success("Mariadb started successfully");
- refetch();
- })
- .catch(() => {
- toast.error("Error starting Mariadb");
- });
+ setIsDeploying(true);
+ await new Promise((resolve) => setTimeout(resolve, 1000));
+ refetch();
}}
>
-
- Start
-
+
+
+
+
+
+ Deploy
+
+
+
+
+ Downloads and sets up the MariaDB database
+
+
+
- ) : (
+
+
{
- await stop({
+ await reload({
mariadbId: mariadbId,
+ appName: data?.appName || "",
})
.then(() => {
- toast.success("Mariadb stopped successfully");
+ toast.success("Mariadb reloaded successfully");
refetch();
})
.catch(() => {
- toast.error("Error stopping Mariadb");
+ toast.error("Error reloading Mariadb");
});
}}
>
-
- Stop
-
+
+
+
+
+
+ Reload
+
+
+
+
+ Restart the MariaDB service without rebuilding
+
+
+
+
+ {data?.applicationStatus === "idle" ? (
+
+ {
+ await start({
+ mariadbId: mariadbId,
+ })
+ .then(() => {
+ toast.success("Mariadb started successfully");
+ refetch();
+ })
+ .catch(() => {
+ toast.error("Error starting Mariadb");
+ });
+ }}
+ >
+
+
+
+
+
+ Start
+
+
+
+
+
+ Start the MariaDB database (requires a previous
+ successful setup)
+
+
+
+
+
+
+
+ ) : (
+
+ {
+ await stop({
+ mariadbId: mariadbId,
+ })
+ .then(() => {
+ toast.success("Mariadb stopped successfully");
+ refetch();
+ })
+ .catch(() => {
+ toast.error("Error stopping Mariadb");
+ });
+ }}
+ >
+
+
+
+
+
+ Stop
+
+
+
+
+ Stop the currently running MariaDB database
+
+
+
+
+
+
)}
-
-
- Open Terminal
+
+
+
+
+
+ Open Terminal
+
+
+
+
+ Open a terminal to the MariaDB container
+
+
+
diff --git a/apps/dokploy/components/dashboard/mariadb/update-mariadb.tsx b/apps/dokploy/components/dashboard/mariadb/update-mariadb.tsx
index 48d944898..9d29d1ac4 100644
--- a/apps/dokploy/components/dashboard/mariadb/update-mariadb.tsx
+++ b/apps/dokploy/components/dashboard/mariadb/update-mariadb.tsx
@@ -97,7 +97,7 @@ export const UpdateMariadb = ({ mariadbId }: Props) => {
-
+
Modify MariaDB
Update the MariaDB data
@@ -119,7 +119,7 @@ export const UpdateMariadb = ({ mariadbId }: Props) => {
Name
-
+
diff --git a/apps/dokploy/components/dashboard/mongo/general/show-external-mongo-credentials.tsx b/apps/dokploy/components/dashboard/mongo/general/show-external-mongo-credentials.tsx
index 9fe6e7137..75772bfdf 100644
--- a/apps/dokploy/components/dashboard/mongo/general/show-external-mongo-credentials.tsx
+++ b/apps/dokploy/components/dashboard/mongo/general/show-external-mongo-credentials.tsx
@@ -1,3 +1,4 @@
+import { AlertBlock } from "@/components/shared/alert-block";
import { ToggleVisibilityInput } from "@/components/shared/toggle-visibility-input";
import { Button } from "@/components/ui/button";
import {
@@ -19,6 +20,7 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod";
+import Link from "next/link";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
@@ -106,6 +108,20 @@ export const ShowExternalMongoCredentials = ({ mongoId }: Props) => {
+ {!getIp && (
+
+ You need to set an IP address in your{" "}
+
+ {data?.serverId
+ ? "Remote Servers -> Server -> Edit Server -> Update IP Address"
+ : "Web Server -> Server -> Update Server IP"}
+ {" "}
+ to fix the database url connection.
+
+ )}
{
Deploy Settings
- {
- setIsDeploying(true);
- await new Promise((resolve) => setTimeout(resolve, 1000));
- refetch();
- }}
- >
-
- Deploy
-
-
- {
- await reload({
- mongoId: mongoId,
- appName: data?.appName || "",
- })
- .then(() => {
- toast.success("Mongo reloaded successfully");
- refetch();
- })
- .catch(() => {
- toast.error("Error reloading Mongo");
- });
- }}
- >
-
- Reload
-
-
-
- {data?.applicationStatus === "idle" ? (
+
{
- await start({
+ setIsDeploying(true);
+ await new Promise((resolve) => setTimeout(resolve, 1000));
+ refetch();
+ }}
+ >
+
+
+
+
+
+ Deploy
+
+
+
+
+ Downloads and sets up the MongoDB database
+
+
+
+
+
+ {
+ await reload({
mongoId: mongoId,
+ appName: data?.appName || "",
})
.then(() => {
- toast.success("Mongo started successfully");
+ toast.success("Mongo reloaded successfully");
refetch();
})
.catch(() => {
- toast.error("Error starting Mongo");
+ toast.error("Error reloading Mongo");
});
}}
>
-
- Start
-
+
+
+
+
+
+ Reload
+
+
+
+
+ Restart the MongoDB service without rebuilding
+
+
+
- ) : (
- {
- await stop({
- mongoId: mongoId,
- })
- .then(() => {
- toast.success("Mongo stopped successfully");
- refetch();
+ {data?.applicationStatus === "idle" ? (
+ {
+ await start({
+ mongoId: mongoId,
})
- .catch(() => {
- toast.error("Error stopping Mongo");
- });
- }}
- >
-
- Stop
-
-
-
- )}
+ .then(() => {
+ toast.success("Mongo started successfully");
+ refetch();
+ })
+ .catch(() => {
+ toast.error("Error starting Mongo");
+ });
+ }}
+ >
+
+
+
+
+
+ Start
+
+
+
+
+
+ Start the MongoDB database (requires a previous
+ successful setup)
+
+
+
+
+
+
+ ) : (
+ {
+ await stop({
+ mongoId: mongoId,
+ })
+ .then(() => {
+ toast.success("Mongo stopped successfully");
+ refetch();
+ })
+ .catch(() => {
+ toast.error("Error stopping Mongo");
+ });
+ }}
+ >
+
+
+
+
+
+ Stop
+
+
+
+
+ Stop the currently running MongoDB database
+
+
+
+
+
+ )}
+
-
-
- Open Terminal
+
+
+
+
+
+ Open Terminal
+
+
+
+
+ Open a terminal to the MongoDB container
+
+
+
diff --git a/apps/dokploy/components/dashboard/mongo/update-mongo.tsx b/apps/dokploy/components/dashboard/mongo/update-mongo.tsx
index c2e3616c4..48dbcf4d7 100644
--- a/apps/dokploy/components/dashboard/mongo/update-mongo.tsx
+++ b/apps/dokploy/components/dashboard/mongo/update-mongo.tsx
@@ -99,7 +99,7 @@ export const UpdateMongo = ({ mongoId }: Props) => {
-
+
Modify MongoDB
Update the MongoDB data
@@ -121,7 +121,7 @@ export const UpdateMongo = ({ mongoId }: Props) => {
Name
-
+
diff --git a/apps/dokploy/components/dashboard/monitoring/free/container/show-free-container-monitoring.tsx b/apps/dokploy/components/dashboard/monitoring/free/container/show-free-container-monitoring.tsx
index 968cf9c22..117fae388 100644
--- a/apps/dokploy/components/dashboard/monitoring/free/container/show-free-container-monitoring.tsx
+++ b/apps/dokploy/components/dashboard/monitoring/free/container/show-free-container-monitoring.tsx
@@ -218,7 +218,7 @@ export const ContainerFreeMonitoring = ({
- Used: {currentData.cpu.value}%
+ Used: {currentData.cpu.value}
diff --git a/apps/dokploy/components/dashboard/monitoring/paid/container/show-paid-container-monitoring.tsx b/apps/dokploy/components/dashboard/monitoring/paid/container/show-paid-container-monitoring.tsx
index 3b189c2ac..c9cefa4c3 100644
--- a/apps/dokploy/components/dashboard/monitoring/paid/container/show-paid-container-monitoring.tsx
+++ b/apps/dokploy/components/dashboard/monitoring/paid/container/show-paid-container-monitoring.tsx
@@ -123,7 +123,7 @@ export const ContainerPaidMonitoring = ({ appName, baseUrl, token }: Props) => {
? queryError.message
: "Failed to fetch metrics, Please check your monitoring Instance is Configured correctly."}
-
URL: {baseUrl}
+
URL: {baseUrl}
);
diff --git a/apps/dokploy/components/dashboard/monitoring/paid/servers/show-paid-monitoring.tsx b/apps/dokploy/components/dashboard/monitoring/paid/servers/show-paid-monitoring.tsx
index e92ce03fc..492abc9e0 100644
--- a/apps/dokploy/components/dashboard/monitoring/paid/servers/show-paid-monitoring.tsx
+++ b/apps/dokploy/components/dashboard/monitoring/paid/servers/show-paid-monitoring.tsx
@@ -143,7 +143,7 @@ export const ShowPaidMonitoring = ({
? queryError.message
: "Failed to fetch metrics, Please check your monitoring Instance is Configured correctly."}
-
URL: {BASE_URL}
+
URL: {BASE_URL}
);
diff --git a/apps/dokploy/components/dashboard/mysql/general/show-external-mysql-credentials.tsx b/apps/dokploy/components/dashboard/mysql/general/show-external-mysql-credentials.tsx
index 7a0527b17..73f99b7d0 100644
--- a/apps/dokploy/components/dashboard/mysql/general/show-external-mysql-credentials.tsx
+++ b/apps/dokploy/components/dashboard/mysql/general/show-external-mysql-credentials.tsx
@@ -1,3 +1,4 @@
+import { AlertBlock } from "@/components/shared/alert-block";
import { ToggleVisibilityInput } from "@/components/shared/toggle-visibility-input";
import { Button } from "@/components/ui/button";
import {
@@ -19,6 +20,7 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod";
+import Link from "next/link";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
@@ -106,6 +108,20 @@ export const ShowExternalMysqlCredentials = ({ mysqlId }: Props) => {
+ {!getIp && (
+
+ You need to set an IP address in your{" "}
+
+ {data?.serverId
+ ? "Remote Servers -> Server -> Edit Server -> Update IP Address"
+ : "Web Server -> Server -> Update Server IP"}
+ {" "}
+ to fix the database url connection.
+
+ )}
{
Deploy Settings
- {
- setIsDeploying(true);
- await new Promise((resolve) => setTimeout(resolve, 1000));
- refetch();
- }}
- >
-
- Deploy
-
-
- {
- await reload({
- mysqlId: mysqlId,
- appName: data?.appName || "",
- })
- .then(() => {
- toast.success("Mysql reloaded successfully");
- refetch();
- })
- .catch(() => {
- toast.error("Error reloading Mysql");
- });
- }}
- >
-
- Reload
-
-
-
- {data?.applicationStatus === "idle" ? (
+
{
- await start({
- mysqlId: mysqlId,
- })
- .then(() => {
- toast.success("Mysql started successfully");
- refetch();
- })
- .catch(() => {
- toast.error("Error starting Mysql");
- });
+ setIsDeploying(true);
+ await new Promise((resolve) => setTimeout(resolve, 1000));
+ refetch();
}}
>
-
- Start
-
+
+
+
+
+
+ Deploy
+
+
+
+
+ Downloads and sets up the MySQL database
+
+
+
- ) : (
{
- await stop({
+ await reload({
mysqlId: mysqlId,
+ appName: data?.appName || "",
})
.then(() => {
- toast.success("Mysql stopped successfully");
+ toast.success("MySQL reloaded successfully");
refetch();
})
.catch(() => {
- toast.error("Error stopping Mysql");
+ toast.error("Error reloading MySQL");
});
}}
>
-
- Stop
-
+
+
+
+
+
+ Reload
+
+
+
+
+ Restart the MySQL service without rebuilding
+
+
+
- )}
-
+ {data?.applicationStatus === "idle" ? (
+ {
+ await start({
+ mysqlId: mysqlId,
+ })
+ .then(() => {
+ toast.success("MySQL started successfully");
+ refetch();
+ })
+ .catch(() => {
+ toast.error("Error starting MySQL");
+ });
+ }}
+ >
+
+
+
+
+
+ Start
+
+
+
+
+
+ Start the MySQL database (requires a previous
+ successful setup)
+
+
+
+
+
+
+ ) : (
+ {
+ await stop({
+ mysqlId: mysqlId,
+ })
+ .then(() => {
+ toast.success("MySQL stopped successfully");
+ refetch();
+ })
+ .catch(() => {
+ toast.error("Error stopping MySQL");
+ });
+ }}
+ >
+
+
+
+
+
+ Stop
+
+
+
+
+ Stop the currently running MySQL database
+
+
+
+
+
+ )}
+
-
-
- Open Terminal
+
+
+
+
+
+ Open Terminal
+
+
+
+
+ Open a terminal to the MySQL container
+
+
+
diff --git a/apps/dokploy/components/dashboard/mysql/update-mysql.tsx b/apps/dokploy/components/dashboard/mysql/update-mysql.tsx
index efe1eb11d..9b1296478 100644
--- a/apps/dokploy/components/dashboard/mysql/update-mysql.tsx
+++ b/apps/dokploy/components/dashboard/mysql/update-mysql.tsx
@@ -97,7 +97,7 @@ export const UpdateMysql = ({ mysqlId }: Props) => {
-
+
Modify MySQL
Update the MySQL data
@@ -119,7 +119,7 @@ export const UpdateMysql = ({ mysqlId }: Props) => {
Name
-
+
diff --git a/apps/dokploy/components/dashboard/organization/handle-organization.tsx b/apps/dokploy/components/dashboard/organization/handle-organization.tsx
index 014c37df1..394f3d018 100644
--- a/apps/dokploy/components/dashboard/organization/handle-organization.tsx
+++ b/apps/dokploy/components/dashboard/organization/handle-organization.tsx
@@ -155,7 +155,7 @@ export function AddOrganization({ organizationId }: Props) {
control={form.control}
name="logo"
render={({ field }) => (
-
+
Logo URL
)}
/>
-
+
{organizationId ? "Update organization" : "Create organization"}
diff --git a/apps/dokploy/components/dashboard/postgres/general/show-external-postgres-credentials.tsx b/apps/dokploy/components/dashboard/postgres/general/show-external-postgres-credentials.tsx
index dbd57d0bf..444fa0cee 100644
--- a/apps/dokploy/components/dashboard/postgres/general/show-external-postgres-credentials.tsx
+++ b/apps/dokploy/components/dashboard/postgres/general/show-external-postgres-credentials.tsx
@@ -1,3 +1,4 @@
+import { AlertBlock } from "@/components/shared/alert-block";
import { ToggleVisibilityInput } from "@/components/shared/toggle-visibility-input";
import { Button } from "@/components/ui/button";
import {
@@ -19,6 +20,7 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod";
+import Link from "next/link";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
@@ -108,6 +110,20 @@ export const ShowExternalPostgresCredentials = ({ postgresId }: Props) => {
+ {!getIp && (
+
+ You need to set an IP address in your{" "}
+
+ {data?.serverId
+ ? "Remote Servers -> Server -> Edit Server -> Update IP Address"
+ : "Web Server -> Server -> Update Server IP"}
+ {" "}
+ to fix the database url connection.
+
+ )}
{
);
return (
-
-
-
- General
-
-
- {
- setIsDeploying(true);
-
- await new Promise((resolve) => setTimeout(resolve, 1000));
- refetch();
- }}
- >
-
- Deploy
-
-
-
- {
- await reload({
- postgresId: postgresId,
- appName: data?.appName || "",
- })
- .then(() => {
- toast.success("Postgres reloaded successfully");
+ <>
+
+
+
+ Deploy Settings
+
+
+
+ {
+ setIsDeploying(true);
+ await new Promise((resolve) => setTimeout(resolve, 1000));
refetch();
- })
- .catch(() => {
- toast.error("Error reloading Postgres");
- });
- }}
- >
-
- Reload
-
-
-
- {data?.applicationStatus === "idle" ? (
- {
- await start({
- postgresId: postgresId,
- })
- .then(() => {
- toast.success("Postgres started successfully");
- refetch();
+ }}
+ >
+
+
+
+
+
+ Deploy
+
+
+
+
+ Downloads and sets up the PostgreSQL database
+
+
+
+
+
+ {
+ await reload({
+ postgresId: postgresId,
+ appName: data?.appName || "",
})
- .catch(() => {
- toast.error("Error starting Postgres");
- });
- }}
+ .then(() => {
+ toast.success("PostgreSQL reloaded successfully");
+ refetch();
+ })
+ .catch(() => {
+ toast.error("Error reloading PostgreSQL");
+ });
+ }}
+ >
+
+
+
+
+
+ Reload
+
+
+
+
+ Restart the PostgreSQL service without rebuilding
+
+
+
+
+
+ {data?.applicationStatus === "idle" ? (
+ {
+ await start({
+ postgresId: postgresId,
+ })
+ .then(() => {
+ toast.success("PostgreSQL started successfully");
+ refetch();
+ })
+ .catch(() => {
+ toast.error("Error starting PostgreSQL");
+ });
+ }}
+ >
+
+
+
+
+
+ Start
+
+
+
+
+
+ Start the PostgreSQL database (requires a previous
+ successful setup)
+
+
+
+
+
+
+ ) : (
+ {
+ await stop({
+ postgresId: postgresId,
+ })
+ .then(() => {
+ toast.success("PostgreSQL stopped successfully");
+ refetch();
+ })
+ .catch(() => {
+ toast.error("Error stopping PostgreSQL");
+ });
+ }}
+ >
+
+
+
+
+
+ Stop
+
+
+
+
+ Stop the currently running PostgreSQL database
+
+
+
+
+
+ )}
+
+
-
- Start
-
+
+
+
+
+
+ Open Terminal
+
+
+
+
+ Open a terminal to the PostgreSQL container
+
+
+
-
- ) : (
- {
- await stop({
- postgresId: postgresId,
- })
- .then(() => {
- toast.success("Postgres stopped successfully");
- refetch();
- })
- .catch(() => {
- toast.error("Error stopping Postgres");
- });
- }}
- >
-
- Stop
-
-
-
- )}
-
-
-
-
- Open Terminal
-
-
-
-
-
{
- setIsDrawerOpen(false);
- setFilteredLogs([]);
- setIsDeploying(false);
- refetch();
- }}
- filteredLogs={filteredLogs}
- />
-
+
+
+
+
{
+ setIsDrawerOpen(false);
+ setFilteredLogs([]);
+ setIsDeploying(false);
+ refetch();
+ }}
+ filteredLogs={filteredLogs}
+ />
+
+ >
);
};
diff --git a/apps/dokploy/components/dashboard/postgres/update-postgres.tsx b/apps/dokploy/components/dashboard/postgres/update-postgres.tsx
index 7be6908f1..2695953cd 100644
--- a/apps/dokploy/components/dashboard/postgres/update-postgres.tsx
+++ b/apps/dokploy/components/dashboard/postgres/update-postgres.tsx
@@ -21,7 +21,7 @@ import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod";
-import { PenBoxIcon } from "lucide-react";
+import { PenBox } from "lucide-react";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
@@ -94,12 +94,12 @@ export const UpdatePostgres = ({ postgresId }: Props) => {
-
+
-
+
Modify Postgres
Update the Postgres data
@@ -121,7 +121,7 @@ export const UpdatePostgres = ({ postgresId }: Props) => {
Name
-
+
@@ -151,6 +151,7 @@ export const UpdatePostgres = ({ postgresId }: Props) => {
isLoading={isLoading}
form="hook-form-update-postgres"
type="submit"
+ className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2"
>
Update
diff --git a/apps/dokploy/components/dashboard/project/add-application.tsx b/apps/dokploy/components/dashboard/project/add-application.tsx
index 16c56917d..9ed31464a 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");
});
};
@@ -119,7 +119,7 @@ export const AddApplication = ({ projectId, projectName }: Props) => {
Application
-
+
Create
@@ -145,10 +145,8 @@ export const AddApplication = ({ projectId, projectName }: Props) => {
{...field}
onChange={(e) => {
const val = e.target.value?.trim() || "";
- form.setValue(
- "appName",
- `${slug}-${val.toLowerCase().replaceAll(" ", "-")}`,
- );
+ const serviceName = slugify(val);
+ form.setValue("appName", `${slug}-${serviceName}`);
field.onChange(val);
}}
/>
diff --git a/apps/dokploy/components/dashboard/project/add-compose.tsx b/apps/dokploy/components/dashboard/project/add-compose.tsx
index ea8690a83..a60bfdd70 100644
--- a/apps/dokploy/components/dashboard/project/add-compose.tsx
+++ b/apps/dokploy/components/dashboard/project/add-compose.tsx
@@ -124,7 +124,7 @@ export const AddCompose = ({ projectId, projectName }: Props) => {
Compose
-
+
Create Compose
@@ -152,10 +152,8 @@ export const AddCompose = ({ projectId, projectName }: Props) => {
{...field}
onChange={(e) => {
const val = e.target.value?.trim() || "";
- form.setValue(
- "appName",
- `${slug}-${val.toLowerCase()}`,
- );
+ const serviceName = slugify(val);
+ form.setValue("appName", `${slug}-${serviceName}`);
field.onChange(val);
}}
/>
diff --git a/apps/dokploy/components/dashboard/project/add-database.tsx b/apps/dokploy/components/dashboard/project/add-database.tsx
index b14e2cfa0..b4ac685dc 100644
--- a/apps/dokploy/components/dashboard/project/add-database.tsx
+++ b/apps/dokploy/components/dashboard/project/add-database.tsx
@@ -283,7 +283,7 @@ export const AddDatabase = ({ projectId, projectName }: Props) => {
Database
-
+
Databases
@@ -363,10 +363,8 @@ export const AddDatabase = ({ projectId, projectName }: Props) => {
{...field}
onChange={(e) => {
const val = e.target.value?.trim() || "";
- form.setValue(
- "appName",
- `${slug}-${val.toLowerCase()}`,
- );
+ const serviceName = slugify(val);
+ form.setValue("appName", `${slug}-${serviceName}`);
field.onChange(val);
}}
/>
@@ -494,7 +492,7 @@ export const AddDatabase = ({ projectId, projectName }: Props) => {
diff --git a/apps/dokploy/components/dashboard/project/add-template.tsx b/apps/dokploy/components/dashboard/project/add-template.tsx
index 3a97b0979..53ae30141 100644
--- a/apps/dokploy/components/dashboard/project/add-template.tsx
+++ b/apps/dokploy/components/dashboard/project/add-template.tsx
@@ -1,3 +1,4 @@
+import { GithubIcon } from "@/components/icons/data-tools-icons";
import { AlertBlock } from "@/components/shared/alert-block";
import {
AlertDialog,
@@ -57,32 +58,67 @@ import {
BookText,
CheckIcon,
ChevronsUpDown,
- Github,
Globe,
HelpCircle,
LayoutGrid,
List,
+ Loader2,
PuzzleIcon,
SearchIcon,
} from "lucide-react";
import Link from "next/link";
-import { useState } from "react";
+import { useEffect, useState } from "react";
import { toast } from "sonner";
+const TEMPLATE_BASE_URL_KEY = "dokploy_template_base_url";
+
interface Props {
projectId: string;
+ baseUrl?: string;
}
-export const AddTemplate = ({ projectId }: Props) => {
+export const AddTemplate = ({ projectId, baseUrl }: Props) => {
const [query, setQuery] = useState("");
const [open, setOpen] = useState(false);
const [viewMode, setViewMode] = useState<"detailed" | "icon">("detailed");
const [selectedTags, setSelectedTags] = useState([]);
- const { data } = api.compose.templates.useQuery();
+ const [customBaseUrl, setCustomBaseUrl] = useState(() => {
+ // Try to get from props first, then localStorage
+ if (baseUrl) return baseUrl;
+ if (typeof window !== "undefined") {
+ return localStorage.getItem(TEMPLATE_BASE_URL_KEY) || undefined;
+ }
+ return undefined;
+ });
+
+ // Save to localStorage when customBaseUrl changes
+ useEffect(() => {
+ if (customBaseUrl) {
+ localStorage.setItem(TEMPLATE_BASE_URL_KEY, customBaseUrl);
+ } else {
+ localStorage.removeItem(TEMPLATE_BASE_URL_KEY);
+ }
+ }, [customBaseUrl]);
+
+ const {
+ data,
+ isLoading: isLoadingTemplates,
+ error: errorTemplates,
+ isError: isErrorTemplates,
+ } = api.compose.templates.useQuery(
+ { baseUrl: customBaseUrl },
+ {
+ enabled: open,
+ },
+ );
const { data: isCloud } = api.settings.isCloud.useQuery();
const { data: servers } = api.server.withSSHKey.useQuery();
- const { data: tags, isLoading: isLoadingTags } =
- api.compose.getTags.useQuery();
+ const { data: tags, isLoading: isLoadingTags } = api.compose.getTags.useQuery(
+ { baseUrl: customBaseUrl },
+ {
+ enabled: open,
+ },
+ );
const utils = api.useUtils();
const [serverId, setServerId] = useState(undefined);
@@ -112,7 +148,7 @@ export const AddTemplate = ({ projectId }: Props) => {
Template
-
+
@@ -129,6 +165,14 @@ export const AddTemplate = ({ projectId }: Props) => {
className="w-full sm:w-[200px]"
value={query}
/>
+
+ setCustomBaseUrl(e.target.value || undefined)
+ }
+ className="w-full sm:w-[300px]"
+ value={customBaseUrl || ""}
+ />
{
)}
- {templates.length === 0 ? (
+ {isErrorTemplates && (
+
+ {errorTemplates?.message}
+
+ )}
+
+ {isLoadingTemplates ? (
+
+
+
+ Loading templates...
+
+
+ ) : templates.length === 0 ? (
@@ -248,9 +305,9 @@ export const AddTemplate = ({ projectId }: Props) => {
: "grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 xl:grid-cols-5 2xl:grid-cols-6",
)}
>
- {templates?.map((template, index) => (
+ {templates?.map((template) => (
{
)}
>
- {template.version}
+ {template?.version}
- {/* Template Header */}
{
)}
>
- {template.name}
+ {template?.name}
{viewMode === "detailed" &&
- template.tags.length > 0 && (
+ template?.tags?.length > 0 && (
- {template.tags.map((tag) => (
+ {template?.tags?.map((tag) => (
{
{viewMode === "detailed" && (
- {template.description}
+ {template?.description}
)}
@@ -316,25 +372,27 @@ export const AddTemplate = ({ projectId }: Props) => {
>
{viewMode === "detailed" && (
-
-
-
- {template.links.website && (
+ {template?.links?.github && (
+
+
+ )}
+ {template?.links?.website && (
+
)}
- {template.links.docs && (
+ {template?.links?.docs && (
@@ -363,7 +421,7 @@ export const AddTemplate = ({ projectId }: Props) => {
This will create an application from the{" "}
- {template.name} template and add it to your
+ {template?.name} template and add it to your
project.
@@ -383,8 +441,9 @@ export const AddTemplate = ({ projectId }: Props) => {
side="top"
>
- If no server is selected, the application will be
- deployed on the server where the user is logged in.
+ If no server is selected, the application
+ will be deployed on the server where the
+ user is logged in.
@@ -430,18 +489,19 @@ export const AddTemplate = ({ projectId }: Props) => {
projectId,
serverId: serverId || undefined,
id: template.id,
+ baseUrl: customBaseUrl,
});
toast.promise(promise, {
loading: "Setting up...",
- success: (_data) => {
+ success: () => {
utils.project.one.invalidate({
projectId,
});
setOpen(false);
return `${template.name} template created successfully`;
},
- error: (_err) => {
- return `An error ocurred deploying ${template.name} template`;
+ error: () => {
+ return `An error occurred deploying ${template.name} template`;
},
});
}}
diff --git a/apps/dokploy/components/dashboard/project/ai/step-one.tsx b/apps/dokploy/components/dashboard/project/ai/step-one.tsx
index 109ad4413..e2a6795fe 100644
--- a/apps/dokploy/components/dashboard/project/ai/step-one.tsx
+++ b/apps/dokploy/components/dashboard/project/ai/step-one.tsx
@@ -13,7 +13,6 @@ import {
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { api } from "@/utils/api";
-import { useState } from "react";
const examples = [
"Make a personal blog",
@@ -23,7 +22,7 @@ const examples = [
"Sendgrid service opensource analogue",
];
-export const StepOne = ({ nextStep, setTemplateInfo, templateInfo }: any) => {
+export const StepOne = ({ setTemplateInfo, templateInfo }: any) => {
// Get servers from the API
const { data: servers } = api.server.withSSHKey.useQuery();
diff --git a/apps/dokploy/components/dashboard/project/ai/step-two.tsx b/apps/dokploy/components/dashboard/project/ai/step-two.tsx
index 14a929fce..7b4deb30e 100644
--- a/apps/dokploy/components/dashboard/project/ai/step-two.tsx
+++ b/apps/dokploy/components/dashboard/project/ai/step-two.tsx
@@ -259,7 +259,7 @@ export const StepTwo = ({ templateInfo, setTemplateInfo }: StepProps) => {
Description
-
+
{selectedVariant?.description}
@@ -289,7 +289,7 @@ export const StepTwo = ({ templateInfo, setTemplateInfo }: StepProps) => {
Environment Variables
-
+
{selectedVariant?.envVariables.map((env, index) => (
{
Domains
-
+
{selectedVariant?.domains.map((domain, index) => (
{
AI Assistant
-
+
AI Assistant
diff --git a/apps/dokploy/components/dashboard/project/duplicate-project.tsx b/apps/dokploy/components/dashboard/project/duplicate-project.tsx
new file mode 100644
index 000000000..ffcfeba87
--- /dev/null
+++ b/apps/dokploy/components/dashboard/project/duplicate-project.tsx
@@ -0,0 +1,208 @@
+import { Button } from "@/components/ui/button";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} 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";
+import { useState } from "react";
+import { toast } from "sonner";
+
+export type Services = {
+ appName: string;
+ serverId?: string | null;
+ name: string;
+ type:
+ | "mariadb"
+ | "application"
+ | "postgres"
+ | "mysql"
+ | "mongo"
+ | "redis"
+ | "compose";
+ description?: string | null;
+ id: string;
+ createdAt: string;
+ status?: "idle" | "running" | "done" | "error";
+};
+
+interface DuplicateProjectProps {
+ projectId: string;
+ services: Services[];
+ selectedServiceIds: string[];
+}
+
+export const DuplicateProject = ({
+ projectId,
+ services,
+ selectedServiceIds,
+}: DuplicateProjectProps) => {
+ 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();
+
+ const selectedServices = services.filter((service) =>
+ selectedServiceIds.includes(service.id),
+ );
+
+ const { mutateAsync: duplicateProject, isLoading } =
+ api.project.duplicate.useMutation({
+ onSuccess: async (newProject) => {
+ await utils.project.all.invalidate();
+ toast.success(
+ duplicateType === "new-project"
+ ? "Project duplicated successfully"
+ : "Services duplicated successfully",
+ );
+ setOpen(false);
+ if (duplicateType === "new-project") {
+ router.push(`/dashboard/project/${newProject.projectId}`);
+ }
+ },
+ onError: (error) => {
+ toast.error(error.message);
+ },
+ });
+
+ const handleDuplicate = async () => {
+ if (duplicateType === "new-project" && !name) {
+ toast.error("Project name is required");
+ return;
+ }
+
+ await duplicateProject({
+ sourceProjectId: projectId,
+ name,
+ description,
+ includeServices: true,
+ selectedServices: selectedServices.map((service) => ({
+ id: service.id,
+ type: service.type,
+ })),
+ duplicateInSameProject: duplicateType === "same-project",
+ });
+ };
+
+ return (
+ {
+ setOpen(isOpen);
+ if (!isOpen) {
+ // Reset form when closing
+ setName("");
+ setDescription("");
+ setDuplicateType("new-project");
+ }
+ }}
+ >
+
+
+
+ Duplicate
+
+
+
+
+ Duplicate Services
+
+ Choose where to duplicate the selected services
+
+
+
+
+
+
Duplicate to
+
+
+
+ New project
+
+
+
+ Same project
+
+
+
+
+ {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
+
+ {selectedServices.map((service) => (
+
+
+ {service.name} ({service.type})
+
+
+ ))}
+
+
+
+
+
+ setOpen(false)}
+ disabled={isLoading}
+ >
+ Cancel
+
+
+ {isLoading ? (
+ <>
+
+ {duplicateType === "new-project"
+ ? "Duplicating project..."
+ : "Duplicating services..."}
+ >
+ ) : duplicateType === "new-project" ? (
+ "Duplicate project"
+ ) : (
+ "Duplicate services"
+ )}
+
+
+
+
+ );
+};
diff --git a/apps/dokploy/components/dashboard/projects/handle-project.tsx b/apps/dokploy/components/dashboard/projects/handle-project.tsx
index f5d62dfc6..01d66fbaa 100644
--- a/apps/dokploy/components/dashboard/projects/handle-project.tsx
+++ b/apps/dokploy/components/dashboard/projects/handle-project.tsx
@@ -31,9 +31,25 @@ import { toast } from "sonner";
import { z } from "zod";
const AddProjectSchema = z.object({
- name: z.string().min(1, {
- message: "Name is required",
- }),
+ name: z
+ .string()
+ .min(1, "Project name is required")
+ .refine(
+ (name) => {
+ const trimmedName = name.trim();
+ const validNameRegex =
+ /^[\p{L}\p{N}_-][\p{L}\p{N}\s_.-]*[\p{L}\p{N}_-]$/u;
+ return validNameRegex.test(trimmedName);
+ },
+ {
+ message:
+ "Project name must start and end with a letter, number, hyphen or underscore. Spaces are allowed in between.",
+ },
+ )
+ .refine((name) => !/^\d/.test(name.trim()), {
+ message: "Project name cannot start with a number",
+ })
+ .transform((name) => name.trim()),
description: z.string().optional(),
});
@@ -97,18 +113,6 @@ export const HandleProject = ({ projectId }: Props) => {
);
});
};
- // useEffect(() => {
- // const getUsers = async () => {
- // const users = await authClient.admin.listUsers({
- // query: {
- // limit: 100,
- // },
- // });
- // console.log(users);
- // };
-
- // getUsers();
- // });
return (
@@ -148,7 +152,7 @@ export const HandleProject = ({ projectId }: Props) => {
Name
-
+
diff --git a/apps/dokploy/components/dashboard/projects/project-environment.tsx b/apps/dokploy/components/dashboard/projects/project-environment.tsx
index e43d1af87..21c2c9b58 100644
--- a/apps/dokploy/components/dashboard/projects/project-environment.tsx
+++ b/apps/dokploy/components/dashboard/projects/project-environment.tsx
@@ -94,7 +94,7 @@ export const ProjectEnvironment = ({ projectId, children }: Props) => {
)}
-
+
Project Environment
diff --git a/apps/dokploy/components/dashboard/projects/show.tsx b/apps/dokploy/components/dashboard/projects/show.tsx
index 188ee60d9..03ebe7a85 100644
--- a/apps/dokploy/components/dashboard/projects/show.tsx
+++ b/apps/dokploy/components/dashboard/projects/show.tsx
@@ -115,7 +115,7 @@ export const ShowProjects = () => {
)}
-
+
{filteredProjects?.map((project) => {
const emptyServices =
project?.mariadb.length === 0 &&
@@ -186,7 +186,9 @@ export const ShowProjects = () => {
target="_blank"
href={`${domain.https ? "https" : "http"}://${domain.host}${domain.path}`}
>
-
{domain.host}
+
+ {domain.host}
+
@@ -222,7 +224,9 @@ export const ShowProjects = () => {
target="_blank"
href={`${domain.https ? "https" : "http"}://${domain.host}${domain.path}`}
>
-
{domain.host}
+
+ {domain.host}
+
diff --git a/apps/dokploy/components/dashboard/redis/general/show-external-redis-credentials.tsx b/apps/dokploy/components/dashboard/redis/general/show-external-redis-credentials.tsx
index 75112cf6e..46cb09530 100644
--- a/apps/dokploy/components/dashboard/redis/general/show-external-redis-credentials.tsx
+++ b/apps/dokploy/components/dashboard/redis/general/show-external-redis-credentials.tsx
@@ -1,3 +1,4 @@
+import { AlertBlock } from "@/components/shared/alert-block";
import { ToggleVisibilityInput } from "@/components/shared/toggle-visibility-input";
import { Button } from "@/components/ui/button";
import {
@@ -19,6 +20,7 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod";
+import Link from "next/link";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
@@ -100,6 +102,20 @@ export const ShowExternalRedisCredentials = ({ redisId }: Props) => {
+ {!getIp && (
+
+ You need to set an IP address in your{" "}
+
+ {data?.serverId
+ ? "Remote Servers -> Server -> Edit Server -> Update IP Address"
+ : "Web Server -> Server -> Update Server IP"}
+ {" "}
+ to fix the database url connection.
+
+ )}
{
Deploy Settings
- {
- setIsDeploying(true);
- await new Promise((resolve) => setTimeout(resolve, 1000));
- refetch();
- }}
- >
-
- Deploy
-
-
- {
- await reload({
- redisId: redisId,
- appName: data?.appName || "",
- })
- .then(() => {
- toast.success("Redis reloaded successfully");
- refetch();
- })
- .catch(() => {
- toast.error("Error reloading Redis");
- });
- }}
- >
-
- Reload
-
-
-
- {/* */}
- {data?.applicationStatus === "idle" ? (
+
{
- await start({
- redisId: redisId,
- })
- .then(() => {
- toast.success("Redis started successfully");
- refetch();
- })
- .catch(() => {
- toast.error("Error starting Redis");
- });
+ setIsDeploying(true);
+ await new Promise((resolve) => setTimeout(resolve, 1000));
+ refetch();
}}
>
-
- Start
-
+
+
+
+
+
+ Deploy
+
+
+
+
+ Downloads and sets up the Redis database
+
+
+
- ) : (
{
- await stop({
+ await reload({
redisId: redisId,
+ appName: data?.appName || "",
})
.then(() => {
- toast.success("Redis stopped successfully");
+ toast.success("Redis reloaded successfully");
refetch();
})
.catch(() => {
- toast.error("Error stopping Redis");
+ toast.error("Error reloading Redis");
});
}}
>
-
- Stop
-
+
+
+
+
+
+ Reload
+
+
+
+
+ Restart the Redis service without rebuilding
+
+
+
- )}
-
+ {data?.applicationStatus === "idle" ? (
+ {
+ await start({
+ redisId: redisId,
+ })
+ .then(() => {
+ toast.success("Redis started successfully");
+ refetch();
+ })
+ .catch(() => {
+ toast.error("Error starting Redis");
+ });
+ }}
+ >
+
+
+
+
+
+ Start
+
+
+
+
+
+ Start the Redis database (requires a previous
+ successful setup)
+
+
+
+
+
+
+ ) : (
+ {
+ await stop({
+ redisId: redisId,
+ })
+ .then(() => {
+ toast.success("Redis stopped successfully");
+ refetch();
+ })
+ .catch(() => {
+ toast.error("Error stopping Redis");
+ });
+ }}
+ >
+
+
+
+
+
+ Stop
+
+
+
+
+ Stop the currently running Redis database
+
+
+
+
+
+ )}
+
-
-
- Open Terminal
+
+
+
+
+
+ Open Terminal
+
+
+
+
+ Open a terminal to the Redis container
+
+
+
diff --git a/apps/dokploy/components/dashboard/redis/update-redis.tsx b/apps/dokploy/components/dashboard/redis/update-redis.tsx
index 193aec3b3..b04e1ff45 100644
--- a/apps/dokploy/components/dashboard/redis/update-redis.tsx
+++ b/apps/dokploy/components/dashboard/redis/update-redis.tsx
@@ -97,7 +97,7 @@ export const UpdateRedis = ({ redisId }: Props) => {
-
+
Modify Redis
Update the redis data
@@ -119,7 +119,7 @@ export const UpdateRedis = ({ redisId }: Props) => {
Name
-
+
diff --git a/apps/dokploy/components/dashboard/requests/columns.tsx b/apps/dokploy/components/dashboard/requests/columns.tsx
index 2c0391f80..7bedb42e2 100644
--- a/apps/dokploy/components/dashboard/requests/columns.tsx
+++ b/apps/dokploy/components/dashboard/requests/columns.tsx
@@ -47,7 +47,7 @@ export const columns: ColumnDef[] = [
cell: ({ row }) => {
const log = row.original;
return (
-
+
{log.RequestMethod}{" "}
@@ -86,7 +86,7 @@ export const columns: ColumnDef
[] = [
cell: ({ row }) => {
const log = row.original;
return (
-
+
{format(new Date(log.StartUTC), "yyyy-MM-dd HH:mm:ss")}
diff --git a/apps/dokploy/components/dashboard/requests/request-distribution-chart.tsx b/apps/dokploy/components/dashboard/requests/request-distribution-chart.tsx
index dd2348801..cf4b4ded4 100644
--- a/apps/dokploy/components/dashboard/requests/request-distribution-chart.tsx
+++ b/apps/dokploy/components/dashboard/requests/request-distribution-chart.tsx
@@ -1,10 +1,10 @@
-import { api } from "@/utils/api";
import {
type ChartConfig,
ChartContainer,
ChartTooltip,
ChartTooltipContent,
} from "@/components/ui/chart";
+import { api } from "@/utils/api";
import {
Area,
AreaChart,
diff --git a/apps/dokploy/components/dashboard/requests/show-requests.tsx b/apps/dokploy/components/dashboard/requests/show-requests.tsx
index 134110872..aad4f011f 100644
--- a/apps/dokploy/components/dashboard/requests/show-requests.tsx
+++ b/apps/dokploy/components/dashboard/requests/show-requests.tsx
@@ -25,13 +25,13 @@ import {
import { type RouterOutputs, api } from "@/utils/api";
import { format } from "date-fns";
import {
- ArrowDownUp,
AlertCircle,
- InfoIcon,
+ ArrowDownUp,
Calendar as CalendarIcon,
+ InfoIcon,
} from "lucide-react";
import Link from "next/link";
-import { useState, useEffect } from "react";
+import { useEffect, useState } from "react";
import { toast } from "sonner";
import { RequestDistributionChart } from "./request-distribution-chart";
import { RequestsTable } from "./requests-table";
diff --git a/apps/dokploy/components/dashboard/settings/ai-form.tsx b/apps/dokploy/components/dashboard/settings/ai-form.tsx
index 05ab93a4f..b1923918e 100644
--- a/apps/dokploy/components/dashboard/settings/ai-form.tsx
+++ b/apps/dokploy/components/dashboard/settings/ai-form.tsx
@@ -55,7 +55,7 @@ export const AiForm = () => {
key={config.aiId}
className="flex items-center justify-between bg-sidebar p-1 w-full rounded-lg"
>
-
+
{config.name}
diff --git a/apps/dokploy/components/dashboard/settings/api/add-api-key.tsx b/apps/dokploy/components/dashboard/settings/api/add-api-key.tsx
index a82a9b356..2baa0ff6b 100644
--- a/apps/dokploy/components/dashboard/settings/api/add-api-key.tsx
+++ b/apps/dokploy/components/dashboard/settings/api/add-api-key.tsx
@@ -1,14 +1,22 @@
+import { CodeEditor } from "@/components/shared/code-editor";
import { Button } from "@/components/ui/button";
-import { api } from "@/utils/api";
-import { toast } from "sonner";
import {
Dialog,
DialogContent,
+ DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
- DialogDescription,
} from "@/components/ui/dialog";
+import {
+ Form,
+ FormControl,
+ FormDescription,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import {
Select,
@@ -17,20 +25,14 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
+import { Switch } from "@/components/ui/switch";
+import { api } from "@/utils/api";
+import { zodResolver } from "@hookform/resolvers/zod";
+import copy from "copy-to-clipboard";
import { useState } from "react";
import { useForm } from "react-hook-form";
-import { zodResolver } from "@hookform/resolvers/zod";
+import { toast } from "sonner";
import { z } from "zod";
-import {
- Form,
- FormControl,
- FormField,
- FormItem,
- FormLabel,
- FormMessage,
- FormDescription,
-} from "@/components/ui/form";
-import { Switch } from "@/components/ui/switch";
const formSchema = z.object({
name: z.string().min(1, "Name is required"),
@@ -140,7 +142,7 @@ export const AddApiKey = () => {
Generate New Key
-
+
Generate API Key
@@ -441,13 +443,16 @@ export const AddApiKey = () => {
-
- {newApiKey}
-
+
{
- navigator.clipboard.writeText(newApiKey);
+ copy(newApiKey);
toast.success("API key copied to clipboard");
}}
>
diff --git a/apps/dokploy/components/dashboard/settings/api/show-api-keys.tsx b/apps/dokploy/components/dashboard/settings/api/show-api-keys.tsx
index 6744f1dea..743542ad0 100644
--- a/apps/dokploy/components/dashboard/settings/api/show-api-keys.tsx
+++ b/apps/dokploy/components/dashboard/settings/api/show-api-keys.tsx
@@ -1,3 +1,5 @@
+import { DialogAction } from "@/components/shared/dialog-action";
+import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
@@ -7,13 +9,11 @@ import {
CardTitle,
} from "@/components/ui/card";
import { api } from "@/utils/api";
-import { ExternalLinkIcon, KeyIcon, Trash2, Clock, Tag } from "lucide-react";
+import { formatDistanceToNow } from "date-fns";
+import { Clock, ExternalLinkIcon, KeyIcon, Tag, Trash2 } from "lucide-react";
import Link from "next/link";
import { toast } from "sonner";
-import { formatDistanceToNow } from "date-fns";
-import { DialogAction } from "@/components/shared/dialog-action";
import { AddApiKey } from "./add-api-key";
-import { Badge } from "@/components/ui/badge";
export const ShowApiKeys = () => {
const { data, refetch } = api.user.get.useQuery();
diff --git a/apps/dokploy/components/dashboard/settings/billing/show-billing.tsx b/apps/dokploy/components/dashboard/settings/billing/show-billing.tsx
index 2c20bb81d..1e0e5d3df 100644
--- a/apps/dokploy/components/dashboard/settings/billing/show-billing.tsx
+++ b/apps/dokploy/components/dashboard/settings/billing/show-billing.tsx
@@ -171,7 +171,7 @@ export const ShowBilling = () => {
)}
{isAnnual ? (
-
+
${" "}
{calculatePrice(
serverQuantity,
@@ -180,7 +180,7 @@ export const ShowBilling = () => {
USD
|
-
+
${" "}
{(
calculatePrice(serverQuantity, isAnnual) / 12
@@ -189,7 +189,7 @@ export const ShowBilling = () => {
) : (
-
+
${" "}
{calculatePrice(serverQuantity, isAnnual).toFixed(
2,
diff --git a/apps/dokploy/components/dashboard/settings/billing/show-welcome-dokploy.tsx b/apps/dokploy/components/dashboard/settings/billing/show-welcome-dokploy.tsx
index 64362b25c..845cbe49d 100644
--- a/apps/dokploy/components/dashboard/settings/billing/show-welcome-dokploy.tsx
+++ b/apps/dokploy/components/dashboard/settings/billing/show-welcome-dokploy.tsx
@@ -41,7 +41,7 @@ export const ShowWelcomeDokploy = () => {
return (
<>
-
+
Welcome to Dokploy Cloud 🎉
diff --git a/apps/dokploy/components/dashboard/settings/certificates/add-certificate.tsx b/apps/dokploy/components/dashboard/settings/certificates/add-certificate.tsx
index 58cad7910..c0d059992 100644
--- a/apps/dokploy/components/dashboard/settings/certificates/add-certificate.tsx
+++ b/apps/dokploy/components/dashboard/settings/certificates/add-certificate.tsx
@@ -106,7 +106,7 @@ export const AddCertificate = () => {
Add Certificate
-
+
Add New Certificate
@@ -222,7 +222,7 @@ export const AddCertificate = () => {
/>
-
+
{
key={certificate.certificateId}
className="flex items-center justify-between bg-sidebar p-1 w-full rounded-lg"
>
-
+