Compare commits

..

1 Commits

Author SHA1 Message Date
Mauricio Siu
5a8508e623 feat(caddy): add Caddy setup and configuration management
- Introduced a new Caddy setup file to manage Caddy container initialization and configuration.
- Added constants for Caddy paths in the constants index.
- Implemented functions to create default Caddy configurations and middlewares, enhancing the deployment process.
2025-04-25 00:09:37 -06:00
609 changed files with 12350 additions and 160926 deletions

View File

@@ -1,21 +0,0 @@
## What is this PR about?
Please describe in a short paragraph what this PR is about.
## Checklist
Before submitting this PR, please make sure that:
- [ ] You created a dedicated branch based on the `canary` branch.
- [ ] You have read the suggestions in the CONTRIBUTING.md file https://github.com/Dokploy/dokploy/blob/canary/CONTRIBUTING.md#pull-request
- [ ] You have tested this PR in your local instance.
## Issues related (if applicable)
Close automatically the related issues using the keywords: `closes #ISSUE_NUMBER`, `fixes #ISSUE_NUMBER`, `resolves #ISSUE_NUMBER`
Example: `closes #123`
## Screenshots (if applicable)
If you include a video or screenshot, would be awesome so we can see the changes in action.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

View File

@@ -19,14 +19,17 @@ jobs:
fetch-depth: 0
- name: Get version from package.json
id: package_version
run: echo "VERSION=$(jq -r .version ./apps/dokploy/package.json)" >> $GITHUB_ENV
- name: Get latest GitHub tag
id: latest_tag
run: |
LATEST_TAG=$(git ls-remote --tags origin | awk -F'/' '{print $3}' | sort -V | tail -n1)
echo "LATEST_TAG=$LATEST_TAG" >> $GITHUB_ENV
echo $LATEST_TAG
- name: Compare versions
id: compare_versions
run: |
if [ "${{ env.VERSION }}" != "${{ env.LATEST_TAG }}" ]; then
VERSION_CHANGED="true"
@@ -39,6 +42,7 @@ jobs:
echo "Latest tag: ${{ env.LATEST_TAG }}"
echo "Version changed: $VERSION_CHANGED"
- name: Check if a PR already exists
id: check_pr
run: |
PR_EXISTS=$(gh pr list --state open --base main --head canary --json number --jq '. | length')
echo "PR_EXISTS=$PR_EXISTS" >> $GITHUB_ENV

View File

@@ -2,8 +2,7 @@ name: Build Docker images
on:
push:
branches: [main, canary]
workflow_dispatch:
branches: ["canary", "main", "feat/monitoring"]
jobs:
build-and-push-cloud-image:

View File

@@ -2,8 +2,7 @@ name: Dokploy Docker Build
on:
push:
branches: [main, canary, "fix/re-apply-database-migration-fix"]
workflow_dispatch:
branches: [main, canary, "1061-custom-docker-service-hostname"]
env:
IMAGE_NAME: dokploy/dokploy

View File

@@ -11,12 +11,12 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
uses: actions/checkout@v3
- name: Setup biomeJs
uses: biomejs/setup-biome@v2
- name: Run Biome formatter
run: biome format --write
run: biome format . --write
- uses: autofix-ci/action@635ffb0c9798bd160680f18fd73371e355b85f27 # v1.3.2
- uses: autofix-ci/action@551dded8c6cc8a1054039c8bc0b8b48c51dfc6ef

View File

@@ -4,22 +4,43 @@ on:
pull_request:
branches: [main, canary]
permissions:
contents: read
jobs:
pr-check:
lint-and-typecheck:
runs-on: ubuntu-latest
strategy:
matrix:
job: [build, test, typecheck]
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 20.16.0
node-version: 20.9.0
cache: "pnpm"
- run: pnpm install --frozen-lockfile
- run: pnpm server:build
- run: pnpm ${{ matrix.job }}
- run: pnpm run server:build
- run: pnpm typecheck
build-and-test:
needs: lint-and-typecheck
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 20.9.0
cache: "pnpm"
- run: pnpm install --frozen-lockfile
- run: pnpm run server:build
- run: pnpm build
parallel-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 20.9.0
cache: "pnpm"
- run: pnpm install --frozen-lockfile
- run: pnpm run server:build
- run: pnpm test

2
.nvmrc
View File

@@ -1 +1 @@
20.16.0
20.9.0

View File

@@ -1,3 +0,0 @@
{
"recommendations": ["biomejs.biome"]
}

View File

@@ -1,8 +0,0 @@
{
"editor.formatOnSave": true,
"editor.defaultFormatter": "biomejs.biome",
"editor.codeActionsOnSave": {
"source.fixAll.biome": "explicit",
"source.organizeImports.biome": "explicit"
}
}

View File

@@ -52,7 +52,7 @@ feat: add new feature
Before you start, please make the clone based on the `canary` branch, since the `main` branch is the source of truth and should always reflect the latest stable release, also the PRs will be merged to the `canary` branch.
We use Node v20.16.0 and recommend this specific version. If you have nvm installed, you can run `nvm install 20.16.0 && nvm use` in the root directory.
We use Node v20.9.0 and recommend this specific version. If you have nvm installed, you can run `nvm install 20.9.0 && nvm use` in the root directory.
```bash
git clone https://github.com/dokploy/dokploy.git
@@ -87,8 +87,7 @@ 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.
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
@@ -118,10 +117,10 @@ In the case you lost your password, you can reset it using the following command
pnpm run reset-password
```
If you want to test the webhooks on development mode using localtunnel, make sure to install [`localtunnel`](https://localtunnel.app/)
If you want to test the webhooks on development mode using localtunnel, make sure to install `localtunnel`
```bash
pnpm dlx localtunnel --port 3000
bunx lt --port 3000
```
If you run into permission issues of docker run the following command
@@ -148,12 +147,14 @@ curl -sSL https://railpack.com/install.sh | sh
```bash
# Install Buildpacks
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
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 `canary` branch is the source of truth and should always reflect the latest stable release.
- 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.
@@ -162,18 +163,13 @@ curl -sSL "https://github.com/buildpacks/pack/releases/download/v0.35.0/pack-v0.
- 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.
**Important Considerations for Pull Requests:**
- **Focus and Scope:** Each Pull Request should ideally address a single, well-defined problem or introduce one new feature. This greatly facilitates review and reduces the chances of introducing unintended side effects.
- **Avoid Unfocused Changes:** Please avoid submitting Pull Requests that contain only minor changes such as whitespace adjustments, IDE-generated formatting, or removal of unused variables, unless these are part of a larger, clearly defined refactor or a dedicated "cleanup" Pull Request that addresses a specific `good first issue` or maintenance task.
- **Issue Association:** For any significant change, it's highly recommended to open an issue first to discuss the proposed solution with the community and maintainers. This ensures alignment and avoids duplicated effort. If your PR resolves an existing issue, please link it in the description (e.g., `Fixes #123`, `Closes #456`).
Thank you for your contribution!
## Templates
To add a new template, go to `https://github.com/Dokploy/templates` repository and read the README.md file.
### Recommendations
- Use the same name of the folder as the id of the template.

View File

@@ -1,9 +1,7 @@
# syntax=docker/dockerfile:1
FROM node:20.16.0-slim AS base
FROM node:20.9-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
@@ -31,7 +29,7 @@ WORKDIR /app
# Set production
ENV NODE_ENV=production
RUN apt-get update && apt-get install -y curl unzip zip apache2-utils iproute2 rsync git-lfs && git lfs install && rm -rf /var/lib/apt/lists/*
RUN apt-get update && apt-get install -y curl unzip zip apache2-utils iproute2 && rm -rf /var/lib/apt/lists/*
# Copy only the necessary files
COPY --from=build /prod/dokploy/.next ./.next
@@ -51,18 +49,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.39.0
ARG NIXPACKS_VERSION=1.29.1
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.2.2
ARG RAILPACK_VERSION=0.0.37
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" ]
CMD [ "pnpm", "start" ]

View File

@@ -1,9 +1,7 @@
# syntax=docker/dockerfile:1
FROM node:20.16.0-slim AS base
FROM node:20.9-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

View File

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

View File

@@ -1,9 +1,7 @@
# syntax=docker/dockerfile:1
FROM node:20.16.0-slim AS base
FROM node:20.9-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

View File

@@ -1,9 +1,7 @@
# syntax=docker/dockerfile:1
FROM node:20.16.0-slim AS base
FROM node:20.9-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

View File

@@ -16,29 +16,28 @@ Here's how to install docker on different operating systems:
### Ubuntu
```bash
# Uninstall old versions
for pkg in docker.io docker-doc docker-compose docker-compose-v2 podman-docker containerd runc; do sudo apt-get remove $pkg; done
# Update package index
sudo apt-get update
# Install prerequisites
sudo apt-get install ca-certificates curl
sudo install -m 0755 -d /etc/apt/keyrings
sudo apt-get install \
apt-transport-https \
ca-certificates \
curl \
gnupg \
lsb-release
# 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
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /usr/share/keyrings/docker-archive-keyring.gpg
# Add the repository to Apt sources
# Set up stable repository
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
"deb [arch=amd64 signed-by=/usr/share/keyrings/docker-archive-keyring.gpg] https://download.docker.com/linux/ubuntu \
$(lsb_release -cs) stable" | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
# Install Docker Engine
sudo apt-get update
sudo apt-get install docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
sudo apt-get install docker-ce docker-ce-cli containerd.io
```
## Windows

View File

@@ -2,7 +2,7 @@
## Core License (Apache License 2.0)
Copyright 2025 Mauricio Siu.
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.
@@ -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, 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.
- **Self-Hosted Version Free**: All features of Dokploy, including multi-node support, Docker Compose file support, Preview Deployments and Multi Server, will always be free to use in the self-hosted version.
- **Restriction on Resale**: The multi-node support, Docker Compose file support, Preview Deployments and Multi Server features cannot be sold or offered as a service by any party other than the copyright holder without prior written consent.
- **Modification Distribution**: Any modifications to the multi-node support, Docker Compose file support, Preview Deployments and Multi Server features must be distributed freely and cannot be sold or offered as a service.
For further inquiries or permissions, please contact us directly.

View File

@@ -1,19 +1,23 @@
<div align="center">
<a href="https://dokploy.com">
<img src=".github/sponsors/logo.png" alt="Dokploy - Open Source Alternative to Vercel, Heroku and Netlify." width="100%" />
</a>
<div>
<a href="https://dokploy.com" target="_blank" rel="noopener">
<img style="object-fit: cover;" align="center" width="100%"src=".github/sponsors/logo.png" alt="Dokploy - Open Source Alternative to Vercel, Heroku and Netlify." />
</a>
</div>
</br>
<div align="center">
<div>Join us on Discord for help, feedback, and discussions!</div>
</br>
</br>
<p>Join us on Discord for help, feedback, and discussions!</p>
<a href="https://discord.gg/2tBnJ3jDJc">
<img src="https://discordapp.com/api/guilds/1234073262418563112/widget.png?style=banner2" alt="Discord Shield"/>
</a>
</div>
</div>
<br />
Dokploy is a free, self-hostable Platform as a Service (PaaS) that simplifies the deployment and management of applications and databases.
## Features
### Features
Dokploy includes multiple features to make your life easier.
@@ -43,7 +47,7 @@ curl -sSL https://dokploy.com/install.sh | sh
For detailed documentation, visit [docs.dokploy.com](https://docs.dokploy.com).
## ♥️ Sponsors
## Sponsors
🙏 We're deeply grateful to all our sponsors who make Dokploy possible! Your support helps cover the costs of hosting, testing, and developing new features.
@@ -57,47 +61,57 @@ For detailed documentation, visit [docs.dokploy.com](https://docs.dokploy.com).
### Hero Sponsors 🎖
<div>
<a href="https://www.hostinger.com/vps-hosting?ref=dokploy"><img src=".github/sponsors/hostinger.jpg" alt="Hostinger" width="300"/></a>
<a href="https://www.lxaer.com/?ref=dokploy"><img src=".github/sponsors/lxaer.png" alt="LX Aer" width="100"/></a>
<div style="display: flex; align-items: center; gap: 20px;">
<a href="https://www.hostinger.com/vps-hosting?ref=dokploy" target="_blank" style="display: inline-block; margin-right: 10px;">
<img src=".github/sponsors/hostinger.jpg" alt="Hostinger" height="50"/>
</a>
<a href="https://www.lxaer.com/?ref=dokploy" target="_blank" style="display: inline-block; margin-right: 10px;">
<img src=".github/sponsors/lxaer.png" alt="LX Aer" height="50"/>
</a>
<a href="https://mandarin3d.com/?ref=dokploy" target="_blank" style="display: inline-block;">
<img src=".github/sponsors/mandarin.png" alt="Mandarin" height="50"/>
</a>
<a href="https://lightnode.com/?ref=dokploy" target="_blank" style="display: inline-block;">
<img src=".github/sponsors/light-node.webp" alt="Lightnode" height="70"/>
</a>
</div>
<!-- Premium Supporters 🥇 -->
<!-- Add Premium Supporters here -->
### Premium Supporters 🥇
<div>
<a href="https://supafort.com/?ref=dokploy"><img src="https://supafort.com/build/q-4Ht4rBZR.webp" alt="Supafort.com" width="300"/></a>
<a href="https://agentdock.ai/?ref=dokploy"><img src=".github/sponsors/agentdock.png" alt="agentdock.ai" width="100"/></a>
<div style="display: flex; gap: 30px; flex-wrap: wrap;">
<a href="https://supafort.com/?ref=dokploy" target="_blank"><img src="https://supafort.com/build/q-4Ht4rBZR.webp" alt="Supafort.com" width="190"/></a>
</div>
<!-- Elite Contributors 🥈 -->
<!-- Add Elite Contributors here -->
### Elite Contributors 🥈
<div>
<a href="https://americancloud.com/?ref=dokploy"><img src=".github/sponsors/american-cloud.png" alt="AmericanCloud" width="300"/></a>
<a href="https://tolgee.io/?utm_source=github_dokploy&utm_medium=banner&utm_campaign=dokploy"><img src="https://dokploy.com/tolgee-logo.png" alt="Tolgee" width="100"/></a>
</div>
### Supporting Members 🥉
<div>
<a href="https://cloudblast.io/?ref=dokploy"><img src="https://cloudblast.io/img/logo-icon.193cf13e.svg" width="250px" alt="Cloudblast.io"/></a>
<a href="https://synexa.ai/?ref=dokploy"><img src=".github/sponsors/synexa.png" width="65px" alt="Synexa"/></a>
<div style="display: flex; gap: 30px; flex-wrap: wrap;">
<a href="https://lightspeed.run/?ref=dokploy"><img src="https://github.com/lightspeedrun.png" width="60px" alt="Lightspeed.run"/></a>
<a href="https://cloudblast.io/?ref=dokploy "><img src="https://cloudblast.io/img/logo-icon.193cf13e.svg" width="250px" alt="Cloudblast.io"/></a>
<a href="https://startupfa.me/?ref=dokploy "><img src=".github/sponsors/startupfame.png" width="65px" alt="Startupfame"/></a>
<a href="https://itsdb-center.com?ref=dokploy "><img src=".github/sponsors/its.png" width="65px" alt="Itsdb-center"/></a>
<a href="https://openalternative.co/?ref=dokploy "><img src=".github/sponsors/openalternative.png" width="65px" alt="Openalternative"/></a>
<a href="https://synexa.ai/?ref=dokploy"><img src=".github/sponsors/synexa.png" width="65px" alt="Synexa"/></a>
</div>
### Community Backers 🤝
<div style="display: flex; gap: 30px; flex-wrap: wrap;">
<a href="https://steamsets.com/?ref=dokploy"><img src="https://avatars.githubusercontent.com/u/111978405?s=200&v=4" width="60px" alt="Steamsets.com"/></a>
<a href="https://rivo.gg/?ref=dokploy"><img src="https://avatars.githubusercontent.com/u/126797452?s=200&v=4" width="60px" alt="Rivo.gg"/></a>
<a href="https://photoquest.wedding/?ref=dokploy"><img src="https://photoquest.wedding/favicon/android-chrome-512x512.png" width="60px" alt="Rivo.gg"/></a>
</div>
#### Organizations:
[Sponsors on Open Collective](https://opencollective.com/dokploy)
[![Sponsors on Open Collective](https://opencollective.com/dokploy/organizations.svg?width=890)](https://opencollective.com/dokploy)
#### Individuals:
@@ -106,15 +120,28 @@ For detailed documentation, visit [docs.dokploy.com](https://docs.dokploy.com).
### Contributors 🤝
<a href="https://github.com/dokploy/dokploy/graphs/contributors">
<img src="https://contrib.rocks/image?repo=dokploy/dokploy" alt="Contributors" />
</a>
<img src="https://contrib.rocks/image?repo=dokploy/dokploy" />
</a>
## 📺 Video Tutorial
## Video Tutorial
<a href="https://youtu.be/mznYKPvhcfw">
<img src="https://dokploy.com/banner.png" alt="Watch the video" width="400"/>
<img src="https://dokploy.com/banner.png" alt="Watch the video" width="400" style="border-radius:20px;"/>
</a>
## 🤝 Contributing
<!-- ## Supported OS
- Ubuntu 24.04 LTS
- Ubuntu 23.10
- Ubuntu 22.04 LTS
- Ubuntu 20.04 LTS
- Ubuntu 18.04 LTS
- Debian 12
- Debian 11
- Fedora 40
- Centos 9
- Centos 8 -->
## Contributing
Check out the [Contributing Guide](CONTRIBUTING.md) for more information.

View File

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

View File

@@ -9,30 +9,25 @@
"typecheck": "tsc --noEmit"
},
"dependencies": {
"inngest": "3.40.1",
"@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",
"zod": "^3.25.32"
"@nerimity/mimiqueue": "1.2.3"
},
"devDependencies": {
"@types/node": "^20.17.51",
"typescript": "^5.4.2",
"@types/react": "^18.2.37",
"@types/react-dom": "^18.2.15",
"tsx": "^4.16.2",
"typescript": "^5.8.3"
"@types/node": "^20.11.17",
"tsx": "^4.7.1"
},
"packageManager": "pnpm@9.12.0",
"engines": {
"node": "^20.16.0",
"pnpm": ">=9.12.0"
}
"packageManager": "pnpm@9.5.0"
}

View File

@@ -2,79 +2,21 @@ import { serve } from "@hono/node-server";
import { Hono } from "hono";
import "dotenv/config";
import { zValidator } from "@hono/zod-validator";
import { Inngest } from "inngest";
import { serve as serveInngest } from "inngest/hono";
import { Queue } from "@nerimity/mimiqueue";
import { createClient } from "redis";
import { logger } from "./logger.js";
import { type DeployJob, deployJobSchema } from "./schema.js";
import { deploy } from "./utils.js";
const app = new Hono();
// Initialize Inngest client
export const inngest = new Inngest({
id: "dokploy-deployments",
name: "Dokploy Deployment Service",
const redisClient = createClient({
url: process.env.REDIS_URL,
});
export const deploymentFunction = inngest.createFunction(
{
id: "deploy-application",
name: "Deploy Application",
concurrency: [
{
key: "event.data.serverId",
limit: 1,
},
],
retries: 0,
},
{ event: "deployment/requested" },
async ({ event, step }) => {
const jobData = event.data as DeployJob;
return await step.run("execute-deployment", async () => {
logger.info("Deploying started");
try {
const result = await deploy(jobData);
logger.info("Deployment finished", result);
// Send success event
await inngest.send({
name: "deployment/completed",
data: {
...jobData,
result,
status: "success",
},
});
return result;
} catch (error) {
logger.error("Deployment failed", { jobData, error });
// Send failure event
await inngest.send({
name: "deployment/failed",
data: {
...jobData,
error: error instanceof Error ? error.message : String(error),
status: "failed",
},
});
throw error;
}
});
},
);
app.use(async (c, next) => {
if (c.req.path === "/health" || c.req.path === "/api/inngest") {
if (c.req.path === "/health") {
return next();
}
const authHeader = c.req.header("X-API-Key");
if (process.env.API_KEY !== authHeader) {
@@ -84,55 +26,36 @@ app.use(async (c, next) => {
return next();
});
app.post("/deploy", zValidator("json", deployJobSchema), async (c) => {
app.post("/deploy", zValidator("json", deployJobSchema), (c) => {
const data = c.req.valid("json");
logger.info("Received deployment request", data);
try {
// Send event to Inngest instead of adding to Redis queue
await inngest.send({
name: "deployment/requested",
data,
});
logger.info("Deployment event sent to Inngest", {
serverId: data.serverId,
});
return c.json(
{
message: "Deployment Added to Inngest Queue",
serverId: data.serverId,
},
200,
);
} catch (error) {
console.log("error", error);
logger.error("Failed to send deployment event", error);
return c.json(
{
message: "Failed to queue deployment",
error: error instanceof Error ? error.message : String(error),
},
500,
);
}
queue.add(data, { groupName: data.serverId });
return c.json(
{
message: "Deployment Added",
},
200,
);
});
app.get("/health", async (c) => {
return c.json({ status: "ok" });
});
// Serve Inngest functions endpoint
app.on(
["GET", "POST", "PUT"],
"/api/inngest",
serveInngest({
client: inngest,
functions: [deploymentFunction],
}),
);
const queue = new Queue({
name: "deployments",
process: async (job: DeployJob) => {
logger.info("Deploying job", job);
return await deploy(job);
},
redisClient,
});
(async () => {
await redisClient.connect();
await redisClient.flushAll();
logger.info("Redis Cleaned");
})();
const port = Number.parseInt(process.env.PORT || "3000");
logger.info("Starting Deployments Server with Inngest ✅", port);
logger.info("Starting Deployments Server ✅", port);
serve({ fetch: app.fetch, port });

View File

@@ -64,7 +64,7 @@ export const deploy = async (job: DeployJob) => {
}
}
}
} catch (e) {
} catch (_) {
if (job.applicationType === "application") {
await updateApplicationStatus(job.applicationId, "error");
} else if (job.applicationType === "compose") {
@@ -76,8 +76,6 @@ export const deploy = async (job: DeployJob) => {
previewStatus: "error",
});
}
throw e;
}
return true;

View File

@@ -1 +1 @@
20.16.0
20.9.0

26
apps/dokploy/Dockerfile Normal file
View File

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

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

@@ -0,0 +1,26 @@
# License
## Core License (Apache License 2.0)
Copyright 2024 Mauricio Siu.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and limitations under the License.
## Additional Terms for Specific Features
The following additional terms apply to the multi-node support, Docker Compose file, Preview Deployments and Multi Server features of Dokploy. In the event of a conflict, these provisions shall take precedence over those in the Apache License:
- **Self-Hosted Version Free**: All features of Dokploy, including multi-node support, Docker Compose file support, Preview Deployments and Multi Server, will always be free to use in the self-hosted version.
- **Restriction on Resale**: The multi-node support, Docker Compose file support, Preview Deployments and Multi Server features cannot be sold or offered as a service by any party other than the copyright holder without prior written consent.
- **Modification Distribution**: Any modifications to the multi-node support, Docker Compose file support, Preview Deployments and Multi Server features must be distributed freely and cannot be sold or offered as a service.
For further inquiries or permissions, please contact us directly.

View File

@@ -1,5 +1,5 @@
import type { ComposeSpecification } from "@dokploy/server";
import { addSuffixToAllProperties } from "@dokploy/server";
import type { ComposeSpecification } from "@dokploy/server";
import { load } from "js-yaml";
import { expect, test } from "vitest";

View File

@@ -1,5 +1,6 @@
import { generateRandomHash } from "@dokploy/server";
import { addSuffixToConfigsRoot } from "@dokploy/server";
import type { ComposeSpecification } from "@dokploy/server";
import { addSuffixToConfigsRoot, generateRandomHash } from "@dokploy/server";
import { load } from "js-yaml";
import { expect, test } from "vitest";

View File

@@ -1,8 +1,6 @@
import { generateRandomHash } from "@dokploy/server";
import { addSuffixToConfigsInServices } from "@dokploy/server";
import type { ComposeSpecification } from "@dokploy/server";
import {
addSuffixToConfigsInServices,
generateRandomHash,
} from "@dokploy/server";
import { load } from "js-yaml";
import { expect, test } from "vitest";

View File

@@ -1,5 +1,6 @@
import { generateRandomHash } from "@dokploy/server";
import { addSuffixToAllConfigs } from "@dokploy/server";
import type { ComposeSpecification } from "@dokploy/server";
import { addSuffixToAllConfigs, generateRandomHash } from "@dokploy/server";
import { load } from "js-yaml";
import { expect, test } from "vitest";

View File

@@ -19,8 +19,6 @@ describe("createDomainLabels", () => {
path: "/",
createdAt: "",
previewDeploymentId: "",
internalPath: "/",
stripPath: false,
};
it("should create basic labels for web entrypoint", async () => {
@@ -108,136 +106,4 @@ describe("createDomainLabels", () => {
"traefik.http.services.test-app-1-web.loadbalancer.server.port=3000",
);
});
it("should add stripPath middleware when stripPath is enabled", async () => {
const stripPathDomain = {
...baseDomain,
path: "/api",
stripPath: true,
};
const labels = await createDomainLabels(appName, stripPathDomain, "web");
expect(labels).toContain(
"traefik.http.middlewares.stripprefix-test-app-1.stripprefix.prefixes=/api",
);
expect(labels).toContain(
"traefik.http.routers.test-app-1-web.middlewares=stripprefix-test-app-1",
);
});
it("should add internalPath middleware when internalPath is set", async () => {
const internalPathDomain = {
...baseDomain,
internalPath: "/hello",
};
const webLabels = await createDomainLabels(
appName,
internalPathDomain,
"web",
);
const websecureLabels = await createDomainLabels(
appName,
internalPathDomain,
"websecure",
);
// Middleware definition should only appear in web entrypoint
expect(webLabels).toContain(
"traefik.http.middlewares.addprefix-test-app-1.addprefix.prefix=/hello",
);
expect(websecureLabels).not.toContain(
"traefik.http.middlewares.addprefix-test-app-1.addprefix.prefix=/hello",
);
// Both routers should reference the middleware
expect(webLabels).toContain(
"traefik.http.routers.test-app-1-web.middlewares=addprefix-test-app-1",
);
expect(websecureLabels).toContain(
"traefik.http.routers.test-app-1-websecure.middlewares=addprefix-test-app-1",
);
});
it("should combine HTTPS redirect with internalPath middleware in correct order", async () => {
const combinedDomain = {
...baseDomain,
https: true,
internalPath: "/hello",
};
const webLabels = await createDomainLabels(appName, combinedDomain, "web");
const websecureLabels = await createDomainLabels(
appName,
combinedDomain,
"websecure",
);
// Web entrypoint should have both middlewares with redirect first
expect(webLabels).toContain(
"traefik.http.routers.test-app-1-web.middlewares=redirect-to-https@file,addprefix-test-app-1",
);
// Websecure should only have the addprefix middleware
expect(websecureLabels).toContain(
"traefik.http.routers.test-app-1-websecure.middlewares=addprefix-test-app-1",
);
// Middleware definition should only appear once (in web)
expect(webLabels).toContain(
"traefik.http.middlewares.addprefix-test-app-1.addprefix.prefix=/hello",
);
expect(websecureLabels).not.toContain(
"traefik.http.middlewares.addprefix-test-app-1.addprefix.prefix=/hello",
);
});
it("should combine all middlewares in correct order", async () => {
const fullDomain = {
...baseDomain,
https: true,
path: "/api",
stripPath: true,
internalPath: "/hello",
};
const webLabels = await createDomainLabels(appName, fullDomain, "web");
// Should have all middleware definitions (only in web)
expect(webLabels).toContain(
"traefik.http.middlewares.stripprefix-test-app-1.stripprefix.prefixes=/api",
);
expect(webLabels).toContain(
"traefik.http.middlewares.addprefix-test-app-1.addprefix.prefix=/hello",
);
// Should have middlewares in correct order: redirect, stripprefix, addprefix
expect(webLabels).toContain(
"traefik.http.routers.test-app-1-web.middlewares=redirect-to-https@file,stripprefix-test-app-1,addprefix-test-app-1",
);
});
it("should not add middleware definitions for websecure entrypoint", async () => {
const internalPathDomain = {
...baseDomain,
path: "/api",
stripPath: true,
internalPath: "/hello",
};
const websecureLabels = await createDomainLabels(
appName,
internalPathDomain,
"websecure",
);
// Should not contain any middleware definitions
expect(websecureLabels).not.toContain(
"traefik.http.middlewares.stripprefix-test-app-1.stripprefix.prefixes=/api",
);
expect(websecureLabels).not.toContain(
"traefik.http.middlewares.addprefix-test-app-1.addprefix.prefix=/hello",
);
// But should reference the middlewares
expect(websecureLabels).toContain(
"traefik.http.routers.test-app-1-websecure.middlewares=stripprefix-test-app-1,addprefix-test-app-1",
);
});
});

View File

@@ -1,5 +1,6 @@
import { generateRandomHash } from "@dokploy/server";
import { addSuffixToNetworksRoot } from "@dokploy/server";
import type { ComposeSpecification } from "@dokploy/server";
import { addSuffixToNetworksRoot, generateRandomHash } from "@dokploy/server";
import { load } from "js-yaml";
import { expect, test } from "vitest";

View File

@@ -1,8 +1,6 @@
import { generateRandomHash } from "@dokploy/server";
import { addSuffixToServiceNetworks } from "@dokploy/server";
import type { ComposeSpecification } from "@dokploy/server";
import {
addSuffixToServiceNetworks,
generateRandomHash,
} from "@dokploy/server";
import { load } from "js-yaml";
import { expect, test } from "vitest";

View File

@@ -1,10 +1,10 @@
import type { ComposeSpecification } from "@dokploy/server";
import { generateRandomHash } from "@dokploy/server";
import {
addSuffixToAllNetworks,
addSuffixToNetworksRoot,
addSuffixToServiceNetworks,
generateRandomHash,
} from "@dokploy/server";
import { addSuffixToNetworksRoot } from "@dokploy/server";
import type { ComposeSpecification } from "@dokploy/server";
import { load } from "js-yaml";
import { expect, test } from "vitest";

View File

@@ -1,5 +1,6 @@
import { generateRandomHash } from "@dokploy/server";
import { addSuffixToSecretsRoot } from "@dokploy/server";
import type { ComposeSpecification } from "@dokploy/server";
import { addSuffixToSecretsRoot, generateRandomHash } from "@dokploy/server";
import { load } from "js-yaml";
import { expect, test } from "vitest";

View File

@@ -1,8 +1,6 @@
import { generateRandomHash } from "@dokploy/server";
import { addSuffixToSecretsInServices } from "@dokploy/server";
import type { ComposeSpecification } from "@dokploy/server";
import {
addSuffixToSecretsInServices,
generateRandomHash,
} from "@dokploy/server";
import { load } from "js-yaml";
import { expect, test } from "vitest";

View File

@@ -1,5 +1,5 @@
import type { ComposeSpecification } from "@dokploy/server";
import { addSuffixToAllSecrets } from "@dokploy/server";
import type { ComposeSpecification } from "@dokploy/server";
import { load } from "js-yaml";
import { expect, test } from "vitest";

View File

@@ -1,5 +1,6 @@
import { generateRandomHash } from "@dokploy/server";
import { addSuffixToServiceNames } from "@dokploy/server";
import type { ComposeSpecification } from "@dokploy/server";
import { addSuffixToServiceNames, generateRandomHash } from "@dokploy/server";
import { load } from "js-yaml";
import { expect, test } from "vitest";

View File

@@ -1,5 +1,6 @@
import { generateRandomHash } from "@dokploy/server";
import { addSuffixToServiceNames } from "@dokploy/server";
import type { ComposeSpecification } from "@dokploy/server";
import { addSuffixToServiceNames, generateRandomHash } from "@dokploy/server";
import { load } from "js-yaml";
import { expect, test } from "vitest";

View File

@@ -1,5 +1,6 @@
import { generateRandomHash } from "@dokploy/server";
import { addSuffixToServiceNames } from "@dokploy/server";
import type { ComposeSpecification } from "@dokploy/server";
import { addSuffixToServiceNames, generateRandomHash } from "@dokploy/server";
import { load } from "js-yaml";
import { expect, test } from "vitest";

View File

@@ -1,5 +1,6 @@
import { generateRandomHash } from "@dokploy/server";
import { addSuffixToServiceNames } from "@dokploy/server";
import type { ComposeSpecification } from "@dokploy/server";
import { addSuffixToServiceNames, generateRandomHash } from "@dokploy/server";
import { load } from "js-yaml";
import { expect, test } from "vitest";

View File

@@ -1,5 +1,6 @@
import { generateRandomHash } from "@dokploy/server";
import { addSuffixToServiceNames } from "@dokploy/server";
import type { ComposeSpecification } from "@dokploy/server";
import { addSuffixToServiceNames, generateRandomHash } from "@dokploy/server";
import { load } from "js-yaml";
import { expect, test } from "vitest";

View File

@@ -1,8 +1,8 @@
import type { ComposeSpecification } from "@dokploy/server";
import {
addSuffixToAllServiceNames,
addSuffixToServiceNames,
} from "@dokploy/server";
import type { ComposeSpecification } from "@dokploy/server";
import { load } from "js-yaml";
import { expect, test } from "vitest";

View File

@@ -1,5 +1,6 @@
import { generateRandomHash } from "@dokploy/server";
import { addSuffixToServiceNames } from "@dokploy/server";
import type { ComposeSpecification } from "@dokploy/server";
import { addSuffixToServiceNames, generateRandomHash } from "@dokploy/server";
import { load } from "js-yaml";
import { expect, test } from "vitest";

View File

@@ -1,9 +1,6 @@
import { generateRandomHash } from "@dokploy/server";
import { addSuffixToAllVolumes, addSuffixToVolumesRoot } from "@dokploy/server";
import type { ComposeSpecification } from "@dokploy/server";
import {
addSuffixToAllVolumes,
addSuffixToVolumesRoot,
generateRandomHash,
} from "@dokploy/server";
import { load } from "js-yaml";
import { expect, test } from "vitest";

View File

@@ -1,5 +1,6 @@
import { generateRandomHash } from "@dokploy/server";
import { addSuffixToVolumesRoot } from "@dokploy/server";
import type { ComposeSpecification } from "@dokploy/server";
import { addSuffixToVolumesRoot, generateRandomHash } from "@dokploy/server";
import { load } from "js-yaml";
import { expect, test } from "vitest";

View File

@@ -1,8 +1,6 @@
import { generateRandomHash } from "@dokploy/server";
import { addSuffixToVolumesInServices } from "@dokploy/server";
import type { ComposeSpecification } from "@dokploy/server";
import {
addSuffixToVolumesInServices,
generateRandomHash,
} from "@dokploy/server";
import { load } from "js-yaml";
import { expect, test } from "vitest";

View File

@@ -1,5 +1,5 @@
import type { ComposeSpecification } from "@dokploy/server";
import { addSuffixToAllVolumes } from "@dokploy/server";
import type { ComposeSpecification } from "@dokploy/server";
import { load } from "js-yaml";
import { expect, test } from "vitest";

View File

@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { extractCommitMessage } from "@/pages/api/deploy/[refreshToken]";
import { describe, expect, it } from "vitest";
describe("GitHub Webhook Skip CI", () => {
const mockGithubHeaders = {

View File

@@ -1,12 +1,12 @@
import fs from "node:fs/promises";
import path from "node:path";
import { paths } from "@dokploy/server/constants";
const { APPLICATIONS_PATH } = paths();
import type { ApplicationNested } from "@dokploy/server";
import { unzipDrop } from "@dokploy/server";
import { paths } from "@dokploy/server/constants";
import AdmZip from "adm-zip";
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
const { APPLICATIONS_PATH } = paths();
vi.mock("@dokploy/server/constants", async (importOriginal) => {
const actual = await importOriginal();
return {
@@ -25,21 +25,16 @@ if (typeof window === "undefined") {
}
const baseApp: ApplicationNested = {
railpackVersion: "0.2.2",
applicationId: "",
previewLabels: [],
herokuVersion: "",
giteaBranch: "",
giteaBuildPath: "",
previewRequireCollaboratorPermissions: false,
giteaId: "",
giteaOwner: "",
giteaRepository: "",
cleanCache: false,
watchPaths: [],
enableSubmodules: false,
applicationStatus: "done",
triggerType: "push",
appName: "",
autoDeploy: true,
serverId: "",
@@ -108,7 +103,6 @@ const baseApp: ApplicationNested = {
ports: [],
projectId: "",
publishDirectory: null,
isStaticSpa: null,
redirects: [],
refreshToken: "",
registry: null,
@@ -124,7 +118,6 @@ const baseApp: ApplicationNested = {
updateConfigSwarm: null,
username: null,
dockerContextPath: null,
rollbackActive: false,
};
describe("unzipDrop using real zip files", () => {
@@ -144,7 +137,7 @@ describe("unzipDrop using real zip files", () => {
const outputPath = path.join(APPLICATIONS_PATH, baseApp.appName, "code");
const zip = new AdmZip("./__test__/drop/zips/single-file.zip");
console.log(`Output Path: ${outputPath}`);
const zipBuffer = zip.toBuffer() as Buffer<ArrayBuffer>;
const zipBuffer = zip.toBuffer();
const file = new File([zipBuffer], "single.zip");
await unzipDrop(file, baseApp);
const files = await fs.readdir(outputPath, { withFileTypes: true });
@@ -154,68 +147,67 @@ 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);
// });
// });

View File

@@ -177,77 +177,3 @@ COMPLEX_VAR="'Prefix \"DoubleQuoted\" and \${{project.APP_NAME}}'"
]);
});
});
describe("prepareEnvironmentVariables (self references)", () => {
it("resolves self references correctly", () => {
const serviceEnv = `
ENVIRONMENT=staging
DATABASE_URL=postgres://postgres:postgres@localhost:5432/project_db
SELF_REF=\${{ENVIRONMENT}}
`;
const resolved = prepareEnvironmentVariables(serviceEnv, "");
expect(resolved).toEqual([
"ENVIRONMENT=staging",
"DATABASE_URL=postgres://postgres:postgres@localhost:5432/project_db",
"SELF_REF=staging",
]);
});
it("throws on undefined self references", () => {
const serviceEnv = `
MISSING_VAR=\${{UNDEFINED_VAR}}
`;
expect(() => prepareEnvironmentVariables(serviceEnv, "")).toThrow(
"Invalid service environment variable: UNDEFINED_VAR",
);
});
it("allows overriding and still resolving from self", () => {
const serviceEnv = `
ENVIRONMENT=production
OVERRIDE_ENV=\${{ENVIRONMENT}}
`;
const resolved = prepareEnvironmentVariables(serviceEnv, "");
expect(resolved).toEqual([
"ENVIRONMENT=production",
"OVERRIDE_ENV=production",
]);
});
it("resolves multiple self references inside one value", () => {
const serviceEnv = `
ENVIRONMENT=staging
APP_NAME=MyApp
COMPLEX=\${{APP_NAME}}-\${{ENVIRONMENT}}-\${{APP_NAME}}
`;
const resolved = prepareEnvironmentVariables(serviceEnv, "");
expect(resolved).toEqual([
"ENVIRONMENT=staging",
"APP_NAME=MyApp",
"COMPLEX=MyApp-staging-MyApp",
]);
});
it("handles quotes with self references", () => {
const serviceEnv = `
ENVIRONMENT=production
QUOTED="'\${{ENVIRONMENT}}'"
MIXED="\"Double \${{ENVIRONMENT}}\""
`;
const resolved = prepareEnvironmentVariables(serviceEnv, "");
expect(resolved).toEqual([
"ENVIRONMENT=production",
"QUOTED='production'",
'MIXED="Double production"',
]);
});
});

View File

@@ -1,6 +1,5 @@
import { parseRawConfig, processLogs } from "@dokploy/server";
import { describe, expect, it } from "vitest";
const sampleLogEntry = `{"ClientAddr":"172.19.0.1:56732","ClientHost":"172.19.0.1","ClientPort":"56732","ClientUsername":"-","DownstreamContentSize":0,"DownstreamStatus":304,"Duration":14729375,"OriginContentSize":0,"OriginDuration":14051833,"OriginStatus":304,"Overhead":677542,"RequestAddr":"s222-umami-c381af.traefik.me","RequestContentSize":0,"RequestCount":122,"RequestHost":"s222-umami-c381af.traefik.me","RequestMethod":"GET","RequestPath":"/dashboard?_rsc=1rugv","RequestPort":"-","RequestProtocol":"HTTP/1.1","RequestScheme":"http","RetryAttempts":0,"RouterName":"s222-umami-60e104-47-web@docker","ServiceAddr":"10.0.1.15:3000","ServiceName":"s222-umami-60e104-47-web@docker","ServiceURL":{"Scheme":"http","Opaque":"","User":null,"Host":"10.0.1.15:3000","Path":"","RawPath":"","ForceQuery":false,"RawQuery":"","Fragment":"","RawFragment":""},"StartLocal":"2024-08-25T04:34:37.306691884Z","StartUTC":"2024-08-25T04:34:37.306691884Z","entryPointName":"web","level":"info","msg":"","time":"2024-08-25T04:34:37Z"}`;
describe("processLogs", () => {

View File

@@ -51,35 +51,6 @@ describe("processTemplate", () => {
expect(result.domains).toHaveLength(0);
expect(result.mounts).toHaveLength(0);
});
it("should allow creation of real jwt secret", () => {
const template: CompleteTemplate = {
metadata: {} as any,
variables: {
jwt_secret: "cQsdycq1hDLopQonF6jUTqgQc5WEZTwWLL02J6XJ",
anon_payload: JSON.stringify({
role: "tester",
iss: "dockploy",
iat: "${timestamps:2025-01-01T00:00:00Z}",
exp: "${timestamps:2030-01-01T00:00:00Z}",
}),
anon_key: "${jwt:jwt_secret:anon_payload}",
},
config: {
domains: [],
env: {
ANON_KEY: "${anon_key}",
},
},
};
const result = processTemplate(template, mockSchema);
expect(result.envs).toHaveLength(1);
expect(result.envs).toContain(
"ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpYXQiOiIxNzM1Njg5NjAwIiwiZXhwIjoiMTg5MzQ1NjAwMCIsInJvbGUiOiJ0ZXN0ZXIiLCJpc3MiOiJkb2NrcGxveSJ9.BG5JoxL2_NaTFbPgyZdm3kRWenf_O3su_HIRKGCJ_kY",
);
expect(result.mounts).toHaveLength(0);
expect(result.domains).toHaveLength(0);
});
});
describe("domains processing", () => {

View File

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

View File

@@ -16,8 +16,6 @@ import { beforeEach, expect, test, vi } from "vitest";
const baseAdmin: User = {
https: false,
enablePaidFeatures: false,
allowImpersonation: false,
role: "user",
metricsConfig: {
containers: {
refreshRate: 20,

View File

@@ -1,12 +1,11 @@
import type { ApplicationNested, Domain, Redirect } from "@dokploy/server";
import type { Domain } from "@dokploy/server";
import type { Redirect } from "@dokploy/server";
import type { ApplicationNested } from "@dokploy/server";
import { createRouterConfig } from "@dokploy/server";
import { expect, test } from "vitest";
const baseApp: ApplicationNested = {
railpackVersion: "0.2.2",
rollbackActive: false,
applicationId: "",
previewLabels: [],
herokuVersion: "",
giteaRepository: "",
giteaOwner: "",
@@ -17,8 +16,6 @@ const baseApp: ApplicationNested = {
applicationStatus: "done",
appName: "",
autoDeploy: true,
enableSubmodules: false,
previewRequireCollaboratorPermissions: false,
serverId: "",
branch: null,
dockerBuildStage: "",
@@ -27,7 +24,6 @@ const baseApp: ApplicationNested = {
buildArgs: null,
isPreviewDeploymentsActive: false,
previewBuildArgs: null,
triggerType: "push",
previewCertificateType: "none",
previewEnv: null,
previewHttps: false,
@@ -87,7 +83,6 @@ const baseApp: ApplicationNested = {
ports: [],
projectId: "",
publishDirectory: null,
isStaticSpa: null,
redirects: [],
refreshToken: "",
registry: null,
@@ -120,8 +115,6 @@ const baseDomain: Domain = {
domainType: "application",
uniqueConfigKey: 1,
previewDeploymentId: "",
internalPath: "/",
stripPath: false,
};
const baseRedirect: Redirect = {

View File

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

View File

@@ -1,9 +1,3 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { HelpCircle, Settings } from "lucide-react";
import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { AlertBlock } from "@/components/shared/alert-block";
import { CodeEditor } from "@/components/shared/code-editor";
import { Button } from "@/components/ui/button";
@@ -32,6 +26,12 @@ import {
TooltipTrigger,
} from "@/components/ui/tooltip";
import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod";
import { HelpCircle, Settings } from "lucide-react";
import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
const HealthCheckSwarmSchema = z
.object({
@@ -130,7 +130,7 @@ const createStringToJSONSchema = (schema: z.ZodTypeAny) => {
}
try {
return JSON.parse(str);
} catch {
} catch (_e) {
ctx.addIssue({ code: "custom", message: "Invalid JSON format" });
return z.NEVER;
}
@@ -181,38 +181,21 @@ const addSwarmSettings = z.object({
type AddSwarmSettings = z.infer<typeof addSwarmSettings>;
interface Props {
id: string;
type: "postgres" | "mariadb" | "mongo" | "mysql" | "redis" | "application";
applicationId: string;
}
export const AddSwarmSettings = ({ id, type }: Props) => {
const queryMap = {
postgres: () =>
api.postgres.one.useQuery({ postgresId: id }, { enabled: !!id }),
redis: () => api.redis.one.useQuery({ redisId: id }, { enabled: !!id }),
mysql: () => api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }),
mariadb: () =>
api.mariadb.one.useQuery({ mariadbId: id }, { enabled: !!id }),
application: () =>
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
};
const { data, refetch } = queryMap[type]
? queryMap[type]()
: api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id });
export const AddSwarmSettings = ({ applicationId }: Props) => {
const { data, refetch } = api.application.one.useQuery(
{
applicationId,
},
{
enabled: !!applicationId,
},
);
const mutationMap = {
postgres: () => api.postgres.update.useMutation(),
redis: () => api.redis.update.useMutation(),
mysql: () => api.mysql.update.useMutation(),
mariadb: () => api.mariadb.update.useMutation(),
application: () => api.application.update.useMutation(),
mongo: () => api.mongo.update.useMutation(),
};
const { mutateAsync, isError, error, isLoading } = mutationMap[type]
? mutationMap[type]()
: api.mongo.update.useMutation();
const { mutateAsync, isError, error, isLoading } =
api.application.update.useMutation();
const form = useForm<AddSwarmSettings>({
defaultValues: {
@@ -261,12 +244,7 @@ export const AddSwarmSettings = ({ id, type }: Props) => {
const onSubmit = async (data: AddSwarmSettings) => {
await mutateAsync({
applicationId: id || "",
postgresId: id || "",
redisId: id || "",
mysqlId: id || "",
mariadbId: id || "",
mongoId: id || "",
applicationId,
healthCheckSwarm: data.healthCheckSwarm,
restartPolicySwarm: data.restartPolicySwarm,
placementSwarm: data.placementSwarm,
@@ -292,18 +270,18 @@ export const AddSwarmSettings = ({ id, type }: Props) => {
Swarm Settings
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-5xl">
<DialogHeader>
<DialogContent className="max-h-[85vh] overflow-y-auto sm:max-w-5xl p-0">
<DialogHeader className="p-6">
<DialogTitle>Swarm Settings</DialogTitle>
<DialogDescription>
Update certain settings using a json object.
</DialogDescription>
</DialogHeader>
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
<div>
<div className="px-4">
<AlertBlock type="info">
Changing settings such as placements may cause the logs/monitoring,
backups and other features to be unavailable.
Changing settings such as placements may cause the logs/monitoring
to be unavailable.
</AlertBlock>
</div>
@@ -311,13 +289,13 @@ export const AddSwarmSettings = ({ id, type }: Props) => {
<form
id="hook-form-add-permissions"
onSubmit={form.handleSubmit(onSubmit)}
className="grid grid-cols-1 md:grid-cols-2 w-full gap-4 relative mt-4"
className="grid grid-cols-1 md:grid-cols-2 w-full gap-4 relative"
>
<FormField
control={form.control}
name="healthCheckSwarm"
render={({ field }) => (
<FormItem className="relative ">
<FormItem className="relative max-lg:px-4 lg:pl-6 ">
<FormLabel>Health Check</FormLabel>
<TooltipProvider delayDuration={0}>
<Tooltip>
@@ -373,7 +351,7 @@ export const AddSwarmSettings = ({ id, type }: Props) => {
control={form.control}
name="restartPolicySwarm"
render={({ field }) => (
<FormItem className="relative ">
<FormItem className="relative max-lg:px-4 lg:pr-6 ">
<FormLabel>Restart Policy</FormLabel>
<TooltipProvider delayDuration={0}>
<Tooltip>
@@ -427,7 +405,7 @@ export const AddSwarmSettings = ({ id, type }: Props) => {
control={form.control}
name="placementSwarm"
render={({ field }) => (
<FormItem className="relative ">
<FormItem className="relative max-lg:px-4 lg:pl-6 ">
<FormLabel>Placement</FormLabel>
<TooltipProvider delayDuration={0}>
<Tooltip>
@@ -493,7 +471,7 @@ export const AddSwarmSettings = ({ id, type }: Props) => {
control={form.control}
name="updateConfigSwarm"
render={({ field }) => (
<FormItem className="relative ">
<FormItem className="relative max-lg:px-4 lg:pr-6 ">
<FormLabel>Update Config</FormLabel>
<TooltipProvider delayDuration={0}>
<Tooltip>
@@ -551,7 +529,7 @@ export const AddSwarmSettings = ({ id, type }: Props) => {
control={form.control}
name="rollbackConfigSwarm"
render={({ field }) => (
<FormItem className="relative ">
<FormItem className="relative max-lg:px-4 lg:pl-6 ">
<FormLabel>Rollback Config</FormLabel>
<TooltipProvider delayDuration={0}>
<Tooltip>
@@ -609,7 +587,7 @@ export const AddSwarmSettings = ({ id, type }: Props) => {
control={form.control}
name="modeSwarm"
render={({ field }) => (
<FormItem className="relative ">
<FormItem className="relative max-lg:px-4 lg:pr-6 ">
<FormLabel>Mode</FormLabel>
<TooltipProvider delayDuration={0}>
<Tooltip>
@@ -672,7 +650,7 @@ export const AddSwarmSettings = ({ id, type }: Props) => {
control={form.control}
name="networkSwarm"
render={({ field }) => (
<FormItem className="relative ">
<FormItem className="relative max-lg:px-4 lg:pl-6 ">
<FormLabel>Network</FormLabel>
<TooltipProvider delayDuration={0}>
<Tooltip>
@@ -731,7 +709,7 @@ export const AddSwarmSettings = ({ id, type }: Props) => {
control={form.control}
name="labelsSwarm"
render={({ field }) => (
<FormItem className="relative ">
<FormItem className="relative max-lg:px-4 lg:pr-6 ">
<FormLabel>Labels</FormLabel>
<TooltipProvider delayDuration={0}>
<Tooltip>
@@ -775,7 +753,7 @@ export const AddSwarmSettings = ({ id, type }: Props) => {
)}
/>
<DialogFooter className="flex w-full flex-row justify-end md:col-span-2 m-0 sticky bottom-0 right-0 bg-muted border">
<DialogFooter className="flex w-full flex-row justify-end md:col-span-2 m-0 sticky bottom-0 right-0 bg-muted border p-2 ">
<Button
isLoading={isLoading}
form="hook-form-add-permissions"

View File

@@ -1,10 +1,3 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { Server } 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";
import { AlertBlock } from "@/components/shared/alert-block";
import { Button } from "@/components/ui/button";
import {
@@ -33,57 +26,43 @@ import {
SelectValue,
} from "@/components/ui/select";
import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod";
import { Server } 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";
import { AddSwarmSettings } from "./modify-swarm-settings";
interface Props {
id: string;
type: "postgres" | "mariadb" | "mongo" | "mysql" | "redis" | "application";
applicationId: string;
}
const AddRedirectchema = z.object({
replicas: z.number().min(1, "Replicas must be at least 1"),
registryId: z.string().optional(),
registryId: z.string(),
});
type AddCommand = z.infer<typeof AddRedirectchema>;
export const ShowClusterSettings = ({ id, type }: Props) => {
const queryMap = {
postgres: () =>
api.postgres.one.useQuery({ postgresId: id }, { enabled: !!id }),
redis: () => api.redis.one.useQuery({ redisId: id }, { enabled: !!id }),
mysql: () => api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }),
mariadb: () =>
api.mariadb.one.useQuery({ mariadbId: id }, { enabled: !!id }),
application: () =>
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
};
const { data, refetch } = queryMap[type]
? queryMap[type]()
: api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id });
export const ShowClusterSettings = ({ applicationId }: Props) => {
const { data } = api.application.one.useQuery(
{
applicationId,
},
{ enabled: !!applicationId },
);
const { data: registries } = api.registry.all.useQuery();
const mutationMap = {
postgres: () => api.postgres.update.useMutation(),
redis: () => api.redis.update.useMutation(),
mysql: () => api.mysql.update.useMutation(),
mariadb: () => api.mariadb.update.useMutation(),
application: () => api.application.update.useMutation(),
mongo: () => api.mongo.update.useMutation(),
};
const utils = api.useUtils();
const { mutateAsync, isLoading } = mutationMap[type]
? mutationMap[type]()
: api.mongo.update.useMutation();
const { mutateAsync, isLoading } = api.application.update.useMutation();
const form = useForm<AddCommand>({
defaultValues: {
...(type === "application" && data && "registryId" in data
? {
registryId: data?.registryId || "",
}
: {}),
registryId: data?.registryId || "",
replicas: data?.replicas || 1,
},
resolver: zodResolver(AddRedirectchema),
@@ -92,11 +71,7 @@ export const ShowClusterSettings = ({ id, type }: Props) => {
useEffect(() => {
if (data?.command) {
form.reset({
...(type === "application" && data && "registryId" in data
? {
registryId: data?.registryId || "",
}
: {}),
registryId: data?.registryId || "",
replicas: data?.replicas || 1,
});
}
@@ -104,25 +79,18 @@ export const ShowClusterSettings = ({ id, type }: Props) => {
const onSubmit = async (data: AddCommand) => {
await mutateAsync({
applicationId: id || "",
postgresId: id || "",
redisId: id || "",
mysqlId: id || "",
mariadbId: id || "",
mongoId: id || "",
...(type === "application"
? {
registryId:
data?.registryId === "none" || !data?.registryId
? null
: data?.registryId,
}
: {}),
applicationId,
registryId:
data?.registryId === "none" || !data?.registryId
? null
: data?.registryId,
replicas: data?.replicas,
})
.then(async () => {
toast.success("Command Updated");
await refetch();
await utils.application.one.invalidate({
applicationId,
});
})
.catch(() => {
toast.error("Error updating the command");
@@ -135,10 +103,10 @@ export const ShowClusterSettings = ({ id, type }: Props) => {
<div>
<CardTitle className="text-xl">Cluster Settings</CardTitle>
<CardDescription>
Modify swarm settings for the service.
Add the registry and the replicas of the application
</CardDescription>
</div>
<AddSwarmSettings id={id} type={type} />
<AddSwarmSettings applicationId={applicationId} />
</CardHeader>
<CardContent className="flex flex-col gap-4">
<AlertBlock type="info">
@@ -176,62 +144,58 @@ export const ShowClusterSettings = ({ id, type }: Props) => {
/>
</div>
{type === "application" && (
{registries && registries?.length === 0 ? (
<div className="pt-10">
<div className="flex flex-col items-center gap-3">
<Server className="size-8 text-muted-foreground" />
<span className="text-base text-muted-foreground">
To use a cluster feature, you need to configure at least a
registry first. Please, go to{" "}
<Link
href="/dashboard/settings/cluster"
className="text-foreground"
>
Settings
</Link>{" "}
to do so.
</span>
</div>
</div>
) : (
<>
{registries && registries?.length === 0 ? (
<div className="pt-10">
<div className="flex flex-col items-center gap-3">
<Server className="size-8 text-muted-foreground" />
<span className="text-base text-muted-foreground">
To use a cluster feature, you need to configure at least
a registry first. Please, go to{" "}
<Link
href="/dashboard/settings/cluster"
className="text-foreground"
>
Settings
</Link>{" "}
to do so.
</span>
</div>
</div>
) : (
<>
<FormField
control={form.control}
name="registryId"
render={({ field }) => (
<FormItem>
<FormLabel>Select a registry</FormLabel>
<Select
onValueChange={field.onChange}
defaultValue={field.value}
>
<SelectTrigger>
<SelectValue placeholder="Select a registry" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{registries?.map((registry) => (
<SelectItem
key={registry.registryId}
value={registry.registryId}
>
{registry.registryName}
</SelectItem>
))}
<SelectItem value={"none"}>None</SelectItem>
<SelectLabel>
Registries ({registries?.length})
</SelectLabel>
</SelectGroup>
</SelectContent>
</Select>
</FormItem>
)}
/>
</>
)}
<FormField
control={form.control}
name="registryId"
render={({ field }) => (
<FormItem>
<FormLabel>Select a registry</FormLabel>
<Select
onValueChange={field.onChange}
defaultValue={field.value}
>
<SelectTrigger>
<SelectValue placeholder="Select a registry" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{registries?.map((registry) => (
<SelectItem
key={registry.registryId}
value={registry.registryId}
>
{registry.registryName}
</SelectItem>
))}
<SelectItem value={"none"}>None</SelectItem>
<SelectLabel>
Registries ({registries?.length})
</SelectLabel>
</SelectGroup>
</SelectContent>
</Select>
</FormItem>
)}
/>
</>
)}

View File

@@ -1,8 +1,3 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { Button } from "@/components/ui/button";
import {
Card,
@@ -21,7 +16,11 @@ import {
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
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";
interface Props {
applicationId: string;
}

View File

@@ -1,9 +1,3 @@
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";
import { AlertBlock } from "@/components/shared/alert-block";
import { CodeEditor } from "@/components/shared/code-editor";
import { Button } from "@/components/ui/button";
@@ -33,6 +27,12 @@ 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(),
@@ -107,7 +107,7 @@ export const ShowImport = ({ composeId }: Props) => {
composeId,
});
setShowModal(false);
} catch {
} catch (_error) {
toast.error("Error importing template");
}
};
@@ -126,7 +126,7 @@ export const ShowImport = ({ composeId }: Props) => {
});
setTemplateInfo(result);
setShowModal(true);
} catch {
} catch (_error) {
toast.error("Error processing template");
}
};
@@ -185,7 +185,7 @@ export const ShowImport = ({ composeId }: Props) => {
</Button>
</div>
<Dialog open={showModal} onOpenChange={setShowModal}>
<DialogContent className="max-w-[50vw]">
<DialogContent className="max-h-[80vh] max-w-[50vw] overflow-y-auto">
<DialogHeader>
<DialogTitle className="text-2xl font-bold">
Template Information
@@ -263,7 +263,7 @@ export const ShowImport = ({ composeId }: Props) => {
{templateInfo.template.envs.map((env, index) => (
<div
key={index}
className="rounded-lg truncate border bg-card p-2 font-mono text-sm"
className="rounded-lg border bg-card p-2 font-mono text-sm"
>
{env}
</div>
@@ -328,7 +328,7 @@ export const ShowImport = ({ composeId }: Props) => {
<DialogDescription>Mount File Content</DialogDescription>
</DialogHeader>
<ScrollArea className="h-[45vh] pr-4">
<ScrollArea className="h-[25vh] pr-4">
<CodeEditor
language="yaml"
value={selectedMount?.content || ""}

View File

@@ -1,9 +1,3 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { PenBoxIcon, PlusIcon } from "lucide-react";
import { useEffect, useState } from "react";
import { useForm, useWatch } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { AlertBlock } from "@/components/shared/alert-block";
import { Button } from "@/components/ui/button";
import {
@@ -32,12 +26,15 @@ import {
SelectValue,
} from "@/components/ui/select";
import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod";
import { PenBoxIcon, PlusIcon } from "lucide-react";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
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,15 +77,9 @@ export const HandlePorts = ({
resolver: zodResolver(AddPortSchema),
});
const publishMode = useWatch({
control: form.control,
name: "publishMode",
});
useEffect(() => {
form.reset({
publishedPort: data?.publishedPort ?? 0,
publishMode: data?.publishMode ?? "ingress",
targetPort: data?.targetPort ?? 0,
protocol: data?.protocol ?? "tcp",
});
@@ -129,7 +120,7 @@ export const HandlePorts = ({
<Button>{children}</Button>
)}
</DialogTrigger>
<DialogContent className="sm:max-w-lg">
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-lg">
<DialogHeader>
<DialogTitle>Ports</DialogTitle>
<DialogDescription>
@@ -174,32 +165,6 @@ export const HandlePorts = ({
</FormItem>
)}
/>
<FormField
control={form.control}
name="publishMode"
render={({ field }) => {
return (
<FormItem className="md:col-span-2">
<FormLabel>Published Port Mode</FormLabel>
<Select
onValueChange={field.onChange}
value={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select a publish mode for the port" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value={"ingress"}>Ingress</SelectItem>
<SelectItem value={"host"}>Host</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
);
}}
/>
<FormField
control={form.control}
name="targetPort"
@@ -258,16 +223,6 @@ export const HandlePorts = ({
</div>
</form>
{publishMode === "host" && (
<AlertBlock type="warning" className="mt-4">
<strong>Host Mode Limitation:</strong> When using Host publish
mode, Docker Swarm has limitations that prevent proper container
updates during deployments. Old containers may not be replaced
automatically. Consider using Ingress mode instead, or be prepared
to manually stop/start the application after deployments.
</AlertBlock>
)}
<DialogFooter>
<Button
isLoading={isLoading}

View File

@@ -1,5 +1,3 @@
import { Rss, Trash2 } from "lucide-react";
import { toast } from "sonner";
import { AlertBlock } from "@/components/shared/alert-block";
import { DialogAction } from "@/components/shared/dialog-action";
import { Button } from "@/components/ui/button";
@@ -11,8 +9,9 @@ import {
CardTitle,
} from "@/components/ui/card";
import { api } from "@/utils/api";
import { Rss, Trash2 } from "lucide-react";
import { toast } from "sonner";
import { HandlePorts } from "./handle-ports";
interface Props {
applicationId: string;
}
@@ -61,7 +60,7 @@ export const ShowPorts = ({ applicationId }: Props) => {
{data?.ports.map((port) => (
<div key={port.portId}>
<div className="flex w-full flex-col sm:flex-row sm:items-center justify-between gap-4 sm:gap-10 border rounded-lg p-4">
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 flex-col gap-4 sm:gap-8">
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 flex-col gap-4 sm:gap-8">
<div className="flex flex-col gap-1">
<span className="font-medium">Published Port</span>
<span className="text-sm text-muted-foreground">
@@ -69,13 +68,7 @@ export const ShowPorts = ({ applicationId }: Props) => {
</span>
</div>
<div className="flex flex-col gap-1">
<span className="font-medium">Published Port Mode</span>
<span className="text-sm text-muted-foreground">
{port?.publishMode?.toUpperCase()}
</span>
</div>
<div className="flex flex-col gap-1">
<span className="font-medium">Target Port</span>
<span className="font-medium"> Target Port</span>
<span className="text-sm text-muted-foreground">
{port.targetPort}
</span>

View File

@@ -1,9 +1,3 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { PenBoxIcon, PlusIcon } from "lucide-react";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { AlertBlock } from "@/components/shared/alert-block";
import { Button } from "@/components/ui/button";
import {
@@ -36,6 +30,12 @@ import {
import { Separator } from "@/components/ui/separator";
import { Switch } from "@/components/ui/switch";
import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod";
import { PenBoxIcon, PlusIcon } from "lucide-react";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
const AddRedirectchema = z.object({
regex: z.string().min(1, "Regex required"),
@@ -179,7 +179,7 @@ export const HandleRedirect = ({
<Button>{children}</Button>
)}
</DialogTrigger>
<DialogContent className="sm:max-w-lg">
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-lg">
<DialogHeader>
<DialogTitle>Redirects</DialogTitle>
<DialogDescription>

View File

@@ -1,5 +1,3 @@
import { Split, Trash2 } from "lucide-react";
import { toast } from "sonner";
import { DialogAction } from "@/components/shared/dialog-action";
import { Button } from "@/components/ui/button";
import {
@@ -10,6 +8,8 @@ import {
CardTitle,
} from "@/components/ui/card";
import { api } from "@/utils/api";
import { Split, Trash2 } from "lucide-react";
import { toast } from "sonner";
import { HandleRedirect } from "./handle-redirect";
interface Props {

View File

@@ -1,9 +1,3 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { PenBoxIcon, PlusIcon } from "lucide-react";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { AlertBlock } from "@/components/shared/alert-block";
import { Button } from "@/components/ui/button";
import {
@@ -25,6 +19,12 @@ import {
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod";
import { PenBoxIcon, PlusIcon } from "lucide-react";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
const AddSecuritychema = z.object({
username: z.string().min(1, "Username is required"),
@@ -114,7 +114,7 @@ export const HandleSecurity = ({
<Button>{children}</Button>
)}
</DialogTrigger>
<DialogContent className="sm:max-w-lg">
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-lg">
<DialogHeader>
<DialogTitle>Security</DialogTitle>
<DialogDescription>
@@ -151,7 +151,7 @@ export const HandleSecurity = ({
<FormItem>
<FormLabel>Password</FormLabel>
<FormControl>
<Input placeholder="test" type="password" {...field} />
<Input placeholder="test" {...field} />
</FormControl>
<FormMessage />

View File

@@ -1,7 +1,4 @@
import { LockKeyhole, Trash2 } from "lucide-react";
import { toast } from "sonner";
import { DialogAction } from "@/components/shared/dialog-action";
import { ToggleVisibilityInput } from "@/components/shared/toggle-visibility-input";
import { Button } from "@/components/ui/button";
import {
Card,
@@ -10,9 +7,9 @@ import {
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { api } from "@/utils/api";
import { LockKeyhole, Trash2 } from "lucide-react";
import { toast } from "sonner";
import { HandleSecurity } from "./handle-security";
interface Props {
@@ -61,18 +58,19 @@ export const ShowSecurity = ({ applicationId }: Props) => {
<div className="flex flex-col gap-6 ">
{data?.security.map((security) => (
<div key={security.securityId}>
<div className="flex w-full flex-col md:flex-row justify-between md:items-center gap-4 md:gap-10 border rounded-lg p-4">
<div className="grid grid-cols-1 md:grid-cols-2 flex-col gap-4 md:gap-8">
<div className="flex flex-col gap-2">
<Label>Username</Label>
<Input disabled value={security.username} />
<div className="flex w-full flex-col sm:flex-row justify-between sm:items-center gap-4 sm:gap-10 border rounded-lg p-4">
<div className="grid grid-cols-1 sm:grid-cols-2 flex-col gap-4 sm:gap-8">
<div className="flex flex-col gap-1">
<span className="font-medium">Username</span>
<span className="text-sm text-muted-foreground">
{security.username}
</span>
</div>
<div className="flex flex-col gap-2">
<Label>Password</Label>
<ToggleVisibilityInput
value={security.password}
disabled
/>
<div className="flex flex-col gap-1">
<span className="font-medium">Password</span>
<span className="text-sm text-muted-foreground">
{security.password}
</span>
</div>
</div>
<div className="flex flex-row gap-2">

View File

@@ -1,9 +1,3 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { InfoIcon } from "lucide-react";
import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { AlertBlock } from "@/components/shared/alert-block";
import { Button } from "@/components/ui/button";
import {
@@ -29,6 +23,12 @@ import {
TooltipTrigger,
} from "@/components/ui/tooltip";
import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod";
import { InfoIcon } from "lucide-react";
import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
const addResourcesSchema = z.object({
memoryReservation: z.string().optional(),

View File

@@ -1,4 +1,3 @@
import { File, Loader2 } from "lucide-react";
import { CodeEditor } from "@/components/shared/code-editor";
import {
Card,
@@ -8,8 +7,8 @@ import {
CardTitle,
} from "@/components/ui/card";
import { api } from "@/utils/api";
import { File, Loader2 } from "lucide-react";
import { UpdateTraefikConfig } from "./update-traefik-config";
interface Props {
applicationId: string;
}

View File

@@ -1,9 +1,3 @@
import { zodResolver } from "@hookform/resolvers/zod";
import jsyaml from "js-yaml";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { AlertBlock } from "@/components/shared/alert-block";
import { CodeEditor } from "@/components/shared/code-editor";
import { Button } from "@/components/ui/button";
@@ -25,6 +19,12 @@ import {
FormMessage,
} from "@/components/ui/form";
import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod";
import jsyaml from "js-yaml";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
const UpdateTraefikConfigSchema = z.object({
traefikConfig: z.string(),
@@ -122,7 +122,7 @@ export const UpdateTraefikConfig = ({ applicationId }: Props) => {
<DialogTrigger asChild>
<Button isLoading={isLoading}>Modify</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-4xl">
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-4xl">
<DialogHeader>
<DialogTitle>Update traefik config</DialogTitle>
<DialogDescription>Update the traefik config</DialogDescription>

View File

@@ -1,11 +1,3 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { PlusIcon } from "lucide-react";
import type React from "react";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { AlertBlock } from "@/components/shared/alert-block";
import { CodeEditor } from "@/components/shared/code-editor";
import { Button } from "@/components/ui/button";
import {
@@ -29,7 +21,13 @@ import { Label } from "@/components/ui/label";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { cn } from "@/lib/utils";
import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod";
import { PlusIcon } from "lucide-react";
import type React from "react";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
interface Props {
serviceId: string;
serviceType:
@@ -152,7 +150,7 @@ export const AddVolumes = ({
<DialogTrigger className="" asChild>
<Button>{children}</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-3xl">
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-3xl">
<DialogHeader>
<DialogTitle>Volumes / Mounts</DialogTitle>
</DialogHeader>
@@ -171,23 +169,6 @@ export const AddVolumes = ({
onSubmit={form.handleSubmit(onSubmit)}
className="grid w-full gap-8 "
>
{type === "bind" && (
<AlertBlock>
<div className="space-y-2">
<p>
Make sure the host path is a valid path and exists in the
host machine.
</p>
<p className="text-sm text-muted-foreground">
<strong>Cluster Warning:</strong> If you're using cluster
features, bind mounts may cause deployment failures since
the path must exist on all worker/manager nodes. Consider
using external tools to distribute the folder across nodes
or use named volumes instead.
</p>
</div>
</AlertBlock>
)}
<FormField
control={form.control}
defaultValue={form.control._defaultValues.type}

View File

@@ -1,5 +1,3 @@
import { Package, Trash2 } from "lucide-react";
import { toast } from "sonner";
import { AlertBlock } from "@/components/shared/alert-block";
import { DialogAction } from "@/components/shared/dialog-action";
import { Button } from "@/components/ui/button";
@@ -11,10 +9,11 @@ import {
CardTitle,
} from "@/components/ui/card";
import { api } from "@/utils/api";
import { Package, Trash2 } from "lucide-react";
import { toast } from "sonner";
import type { ServiceType } from "../show-resources";
import { AddVolumes } from "./add-volumes";
import { UpdateVolume } from "./update-volume";
interface Props {
id: string;
type: ServiceType | "compose";
@@ -81,7 +80,7 @@ export const ShowVolumes = ({ id, type }: Props) => {
className="flex w-full flex-col sm:flex-row sm:items-center justify-between gap-4 sm:gap-10 border rounded-lg p-4"
>
{/* <Package className="size-8 self-center text-muted-foreground" /> */}
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 flex-col gap-4 sm:gap-8">
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 flex-col gap-4 sm:gap-8">
<div className="flex flex-col gap-1">
<span className="font-medium">Mount Type</span>
<span className="text-sm text-muted-foreground">
@@ -113,21 +112,21 @@ export const ShowVolumes = ({ id, type }: Props) => {
</span>
</div>
)}
{mount.type === "file" && (
{mount.type === "file" ? (
<div className="flex flex-col gap-1">
<span className="font-medium">File Path</span>
<span className="text-sm text-muted-foreground">
{mount.filePath}
</span>
</div>
) : (
<div className="flex flex-col gap-1">
<span className="font-medium">Mount Path</span>
<span className="text-sm text-muted-foreground">
{mount.mountPath}
</span>
</div>
)}
<div className="flex flex-col gap-1">
<span className="font-medium">Mount Path</span>
<span className="text-sm text-muted-foreground">
{mount.mountPath}
</span>
</div>
</div>
<div className="flex flex-row gap-1">
<UpdateVolume

View File

@@ -1,9 +1,3 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { PenBoxIcon } from "lucide-react";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { AlertBlock } from "@/components/shared/alert-block";
import { CodeEditor } from "@/components/shared/code-editor";
import { Button } from "@/components/ui/button";
@@ -26,6 +20,12 @@ import {
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod";
import { PenBoxIcon } from "lucide-react";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
const mountSchema = z.object({
mountPath: z.string().min(1, "Mount path required"),
@@ -186,7 +186,7 @@ export const UpdateVolume = ({
<PenBoxIcon className="size-3.5 text-primary group-hover:text-blue-500" />
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-3xl">
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-3xl">
<DialogHeader>
<DialogTitle>Update</DialogTitle>
<DialogDescription>Update the mount</DialogDescription>
@@ -247,7 +247,7 @@ export const UpdateVolume = ({
control={form.control}
name="content"
render={({ field }) => (
<FormItem className="max-w-full max-w-[45rem]">
<FormItem>
<FormLabel>Content</FormLabel>
<FormControl>
<FormControl>
@@ -256,7 +256,7 @@ export const UpdateVolume = ({
placeholder={`NODE_ENV=production
PORT=3000
`}
className="h-96 font-mono w-full"
className="h-96 font-mono"
{...field}
/>
</FormControl>

View File

@@ -1,14 +1,6 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { Cog } from "lucide-react";
import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
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,
@@ -21,6 +13,12 @@ import {
import { Input } from "@/components/ui/input";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod";
import { Cog } from "lucide-react";
import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
export enum BuildType {
dockerfile = "dockerfile",
@@ -64,12 +62,10 @@ const mySchema = z.discriminatedUnion("buildType", [
publishDirectory: z.string().optional(),
}),
z.object({
buildType: z.literal(BuildType.railpack),
railpackVersion: z.string().nullable().default("0.2.2"),
buildType: z.literal(BuildType.static),
}),
z.object({
buildType: z.literal(BuildType.static),
isStaticSpa: z.boolean().default(false),
buildType: z.literal(BuildType.railpack),
}),
]);
@@ -86,8 +82,6 @@ interface ApplicationData {
dockerBuildStage?: string | null;
herokuVersion?: string | null;
publishDirectory?: string | null;
isStaticSpa?: boolean | null;
railpackVersion?: string | null | undefined;
}
function isValidBuildType(value: string): value is BuildType {
@@ -120,19 +114,16 @@ const resetData = (data: ApplicationData): AddTemplate => {
case BuildType.static:
return {
buildType: BuildType.static,
isStaticSpa: data.isStaticSpa ?? false,
};
case BuildType.railpack:
return {
buildType: BuildType.railpack,
railpackVersion: data.railpackVersion || null,
};
default: {
default:
const buildType = data.buildType as BuildType;
return {
buildType,
} as AddTemplate;
}
}
};
@@ -182,12 +173,6 @@ export const ShowBuildChooseForm = ({ applicationId }: Props) => {
data.buildType === BuildType.heroku_buildpacks
? data.herokuVersion
: null,
isStaticSpa:
data.buildType === BuildType.static ? data.isStaticSpa : null,
railpackVersion:
data.buildType === BuildType.railpack
? data.railpackVersion || "0.2.2"
: null,
})
.then(async () => {
toast.success("Build type saved");
@@ -215,22 +200,6 @@ export const ShowBuildChooseForm = ({ applicationId }: Props) => {
</CardHeader>
<CardContent>
<Form {...form}>
<AlertBlock>
Builders can consume significant memory and CPU resources
(recommended: 4+ GB RAM and 2+ CPU cores). For production
environments, please review our{" "}
<a
href="https://docs.dokploy.com/docs/core/applications/going-production"
target="_blank"
rel="noreferrer"
className="font-medium underline underline-offset-4"
>
Production Guide
</a>{" "}
for best practices and optimization recommendations. Builders are
suitable for development and prototyping purposes when you have
sufficient resources available.
</AlertBlock>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="grid w-full gap-4 p-2"
@@ -378,49 +347,6 @@ export const ShowBuildChooseForm = ({ applicationId }: Props) => {
)}
/>
)}
{buildType === BuildType.static && (
<FormField
control={form.control}
name="isStaticSpa"
render={({ field }) => (
<FormItem>
<FormControl>
<div className="flex items-center gap-x-2 p-2">
<Checkbox
id="checkboxIsStaticSpa"
value={String(field.value)}
checked={field.value}
onCheckedChange={field.onChange}
/>
<FormLabel htmlFor="checkboxIsStaticSpa">
Single Page Application (SPA)
</FormLabel>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
{buildType === BuildType.railpack && (
<FormField
control={form.control}
name="railpackVersion"
render={({ field }) => (
<FormItem>
<FormLabel>Railpack Version</FormLabel>
<FormControl>
<Input
placeholder="Railpack Version"
{...field}
value={field.value ?? ""}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
<div className="flex w-full justify-end">
<Button isLoading={isLoading} type="submit">
Save

View File

@@ -1,5 +1,3 @@
import { Paintbrush } from "lucide-react";
import { toast } from "sonner";
import {
AlertDialog,
AlertDialogAction,
@@ -13,17 +11,15 @@ import {
} 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 {
id: string;
type: "application" | "compose";
applicationId: string;
}
export const CancelQueues = ({ id, type }: Props) => {
const { mutateAsync, isLoading } =
type === "application"
? api.application.cleanQueues.useMutation()
: api.compose.cleanQueues.useMutation();
export const CancelQueues = ({ applicationId }: Props) => {
const { mutateAsync, isLoading } = api.application.cleanQueues.useMutation();
const { data: isCloud } = api.settings.isCloud.useQuery();
if (isCloud) {
@@ -52,8 +48,7 @@ export const CancelQueues = ({ id, type }: Props) => {
<AlertDialogAction
onClick={async () => {
await mutateAsync({
applicationId: id || "",
composeId: id || "",
applicationId,
})
.then(() => {
toast.success("Queues are being cleaned");

View File

@@ -1,5 +1,3 @@
import { RefreshCcw } from "lucide-react";
import { toast } from "sonner";
import {
AlertDialog,
AlertDialogAction,
@@ -12,16 +10,14 @@ import {
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { api } from "@/utils/api";
import { RefreshCcw } from "lucide-react";
import { toast } from "sonner";
interface Props {
id: string;
type: "application" | "compose";
applicationId: string;
}
export const RefreshToken = ({ id, type }: Props) => {
const { mutateAsync } =
type === "application"
? api.application.refreshToken.useMutation()
: api.compose.refreshToken.useMutation();
export const RefreshToken = ({ applicationId }: Props) => {
const { mutateAsync } = api.application.refreshToken.useMutation();
const utils = api.useUtils();
return (
<AlertDialog>
@@ -41,19 +37,12 @@ export const RefreshToken = ({ id, type }: Props) => {
<AlertDialogAction
onClick={async () => {
await mutateAsync({
applicationId: id || "",
composeId: id || "",
applicationId,
})
.then(() => {
if (type === "application") {
utils.application.one.invalidate({
applicationId: id,
});
} else {
utils.compose.one.invalidate({
composeId: id,
});
}
utils.application.one.invalidate({
applicationId,
});
toast.success("Refresh updated");
})
.catch(() => {

View File

@@ -1,5 +1,3 @@
import { Loader2 } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { Badge } from "@/components/ui/badge";
import { Checkbox } from "@/components/ui/checkbox";
import {
@@ -9,6 +7,8 @@ import {
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";
@@ -124,7 +124,7 @@ export const ShowDeployment = ({
}
}}
>
<DialogContent className={"sm:max-w-5xl"}>
<DialogContent className={"sm:max-w-5xl overflow-y-auto max-h-screen"}>
<DialogHeader>
<DialogTitle>Deployment</DialogTitle>
<DialogDescription className="flex items-center gap-2">

View File

@@ -1,69 +0,0 @@
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogTrigger } from "@/components/ui/dialog";
import type { RouterOutputs } from "@/utils/api";
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 (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
{children ? (
children
) : (
<Button className="sm:w-auto w-full" size="sm" variant="outline">
View Logs
</Button>
)}
</DialogTrigger>
<DialogContent className="sm:max-w-5xl p-0">
<ShowDeployments
id={id}
type={type}
serverId={serverId}
refreshToken={refreshToken}
/>
</DialogContent>
<ShowDeployment
serverId={serverId || ""}
open={Boolean(activeLog && activeLog.logPath !== null)}
onClose={() => setActiveLog(null)}
logPath={activeLog?.logPath || ""}
errorMessage={activeLog?.errorMessage || ""}
/>
</Dialog>
);
};

View File

@@ -1,10 +1,5 @@
import { Clock, Loader2, RefreshCcw, RocketIcon, Settings } from "lucide-react";
import React, { useEffect, useState } from "react";
import { toast } from "sonner";
import { DateTooltip } from "@/components/shared/date-tooltip";
import { DialogAction } from "@/components/shared/dialog-action";
import { StatusTooltip } from "@/components/shared/status-tooltip";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
@@ -13,58 +8,29 @@ import {
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { api, type RouterOutputs } from "@/utils/api";
import { ShowRollbackSettings } from "../rollbacks/show-rollback-settings";
import { type RouterOutputs, api } from "@/utils/api";
import { RocketIcon } from "lucide-react";
import React, { useEffect, useState } from "react";
import { CancelQueues } from "./cancel-queues";
import { RefreshToken } from "./refresh-token";
import { ShowDeployment } from "./show-deployment";
interface Props {
id: string;
type:
| "application"
| "compose"
| "schedule"
| "server"
| "backup"
| "previewDeployment"
| "volumeBackup";
refreshToken?: string;
serverId?: string;
applicationId: string;
}
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) => {
export const ShowDeployments = ({ applicationId }: Props) => {
const [activeLog, setActiveLog] = useState<
RouterOutputs["deployment"]["all"][number] | null
>(null);
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 { data } = api.application.one.useQuery({ applicationId });
const { data: deployments } = api.deployment.all.useQuery(
{ applicationId },
{
enabled: !!applicationId,
refetchInterval: 1000,
},
);
const [url, setUrl] = React.useState("");
useEffect(() => {
@@ -72,57 +38,34 @@ export const ShowDeployments = ({
}, []);
return (
<Card className="bg-background border-none">
<Card className="bg-background">
<CardHeader className="flex flex-row items-center justify-between flex-wrap gap-2">
<div className="flex flex-col gap-2">
<CardTitle className="text-xl">Deployments</CardTitle>
<CardDescription>
See all the 10 last deployments for this {type}
See all the 10 last deployments for this application
</CardDescription>
</div>
<div className="flex flex-row items-center gap-2">
{(type === "application" || type === "compose") && (
<CancelQueues id={id} type={type} />
)}
{type === "application" && (
<ShowRollbackSettings applicationId={id}>
<Button variant="outline">
Configure Rollbacks <Settings className="size-4" />
</Button>
</ShowRollbackSettings>
)}
</div>
<CancelQueues applicationId={applicationId} />
</CardHeader>
<CardContent className="flex flex-col gap-4">
{refreshToken && (
<div className="flex flex-col gap-2 text-sm">
<span>
If you want to re-deploy this application use this URL in the
config of your git provider or docker
</span>
<div className="flex flex-row items-center gap-2 flex-wrap">
<span>Webhook URL: </span>
<div className="flex flex-row items-center gap-2">
<span className="break-all text-muted-foreground">
{`${url}/api/deploy${type === "compose" ? "/compose" : ""}/${refreshToken}`}
</span>
{(type === "application" || type === "compose") && (
<RefreshToken id={id} type={type} />
)}
</div>
<div className="flex flex-col gap-2 text-sm">
<span>
If you want to re-deploy this application use this URL in the config
of your git provider or docker
</span>
<div className="flex flex-row items-center gap-2 flex-wrap">
<span>Webhook URL: </span>
<div className="flex flex-row items-center gap-2">
<span className="break-all text-muted-foreground">
{`${url}/api/deploy/${data?.refreshToken}`}
</span>
<RefreshToken applicationId={applicationId} />
</div>
</div>
)}
{isLoadingDeployments ? (
<div className="flex w-full flex-row items-center justify-center gap-3 pt-10 min-h-[25vh]">
<Loader2 className="size-6 text-muted-foreground animate-spin" />
<span className="text-base text-muted-foreground">
Loading deployments...
</span>
</div>
) : deployments?.length === 0 ? (
<div className="flex w-full flex-col items-center justify-center gap-3 pt-10 min-h-[25vh]">
</div>
{data?.deployments?.length === 0 ? (
<div className="flex w-full flex-col items-center justify-center gap-3 pt-10">
<RocketIcon className="size-8 text-muted-foreground" />
<span className="text-base text-muted-foreground">
No deployments found
@@ -153,99 +96,24 @@ export const ShowDeployments = ({
)}
</div>
<div className="flex flex-col items-end gap-2">
<div className="text-sm capitalize text-muted-foreground flex items-center gap-2">
<div className="text-sm capitalize text-muted-foreground">
<DateTooltip date={deployment.createdAt} />
{deployment.startedAt && deployment.finishedAt && (
<Badge
variant="outline"
className="text-[10px] gap-1 flex items-center"
>
<Clock className="size-3" />
{formatDuration(
Math.floor(
(new Date(deployment.finishedAt).getTime() -
new Date(deployment.startedAt).getTime()) /
1000,
),
)}
</Badge>
)}
</div>
<div className="flex flex-row items-center gap-2">
{deployment.pid && deployment.status === "running" && (
<DialogAction
title="Kill Process"
description="Are you sure you want to kill the process?"
type="default"
onClick={async () => {
await killProcess({
deploymentId: deployment.deploymentId,
})
.then(() => {
toast.success("Process killed successfully");
})
.catch(() => {
toast.error("Error killing process");
});
}}
>
<Button
variant="destructive"
size="sm"
isLoading={isKillingProcess}
>
Kill Process
</Button>
</DialogAction>
)}
<Button
onClick={() => {
setActiveLog(deployment);
}}
>
View
</Button>
{deployment?.rollback &&
deployment.status === "done" &&
type === "application" && (
<DialogAction
title="Rollback to this deployment"
description="Are you sure you want to rollback to this deployment?"
type="default"
onClick={async () => {
await rollback({
rollbackId: deployment.rollback.rollbackId,
})
.then(() => {
toast.success(
"Rollback initiated successfully",
);
})
.catch(() => {
toast.error("Error initiating rollback");
});
}}
>
<Button
variant="secondary"
size="sm"
isLoading={isRollingBack}
>
<RefreshCcw className="size-4 text-primary group-hover:text-red-500" />
Rollback
</Button>
</DialogAction>
)}
</div>
<Button
onClick={() => {
setActiveLog(deployment);
}}
>
View
</Button>
</div>
</div>
))}
</div>
)}
<ShowDeployment
serverId={serverId}
serverId={data?.serverId || ""}
open={Boolean(activeLog && activeLog.logPath !== null)}
onClose={() => setActiveLog(null)}
logPath={activeLog?.logPath || ""}

View File

@@ -0,0 +1,391 @@
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 Link from "next/link";
import type z from "zod";
type Domain = z.infer<typeof domain>;
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 { data: canGenerateTraefikMeDomains } =
api.domain.canGenerateTraefikMeDomains.useQuery({
serverId: application?.serverId || "",
});
console.log("canGenerateTraefikMeDomains", canGenerateTraefikMeDomains);
const form = useForm<Domain>({
resolver: zodResolver(domain),
defaultValues: {
host: "",
path: undefined,
port: undefined,
https: false,
certificateType: undefined,
customCertResolver: undefined,
},
mode: "onChange",
});
const certificateType = form.watch("certificateType");
const https = form.watch("https");
useEffect(() => {
if (data) {
form.reset({
...data,
/* Convert null to undefined */
path: data?.path || undefined,
port: data?.port || undefined,
certificateType: data?.certificateType || undefined,
customCertResolver: data?.customCertResolver || undefined,
});
}
if (!domainId) {
form.reset({
host: "",
path: undefined,
port: undefined,
https: false,
certificateType: undefined,
customCertResolver: undefined,
});
}
}, [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,
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 (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger className="" asChild>
{children}
</DialogTrigger>
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-2xl">
<DialogHeader>
<DialogTitle>Domain</DialogTitle>
<DialogDescription>{dictionary.dialogDescription}</DialogDescription>
</DialogHeader>
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
<Form {...form}>
<form
id="hook-form"
onSubmit={form.handleSubmit(onSubmit)}
className="grid w-full gap-8 "
>
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<FormField
control={form.control}
name="host"
render={({ field }) => (
<FormItem>
{!canGenerateTraefikMeDomains &&
field.value.includes("traefik.me") && (
<AlertBlock type="warning">
You need to set an IP address in your{" "}
<Link
href="/dashboard/settings/server"
className="text-primary"
>
{application?.serverId
? "Remote Servers -> Server -> Edit Server -> Update IP Address"
: "Web Server -> Server -> Update Server IP"}
</Link>{" "}
to make your traefik.me domain work.
</AlertBlock>
)}
<FormLabel>Host</FormLabel>
<div className="flex gap-2">
<FormControl>
<Input placeholder="api.dokploy.com" {...field} />
</FormControl>
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="secondary"
type="button"
isLoading={isLoadingGenerate}
onClick={() => {
generateDomain({
appName: application?.appName || "",
serverId: application?.serverId || "",
})
.then((domain) => {
field.onChange(domain);
})
.catch((err) => {
toast.error(err.message);
});
}}
>
<Dices className="size-4 text-muted-foreground" />
</Button>
</TooltipTrigger>
<TooltipContent
side="left"
sideOffset={5}
className="max-w-[10rem]"
>
<p>Generate traefik.me domain</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="path"
render={({ field }) => {
return (
<FormItem>
<FormLabel>Path</FormLabel>
<FormControl>
<Input placeholder={"/"} {...field} />
</FormControl>
<FormMessage />
</FormItem>
);
}}
/>
<FormField
control={form.control}
name="port"
render={({ field }) => {
return (
<FormItem>
<FormLabel>Container Port</FormLabel>
<FormControl>
<NumberInput placeholder={"3000"} {...field} />
</FormControl>
<FormMessage />
</FormItem>
);
}}
/>
<FormField
control={form.control}
name="https"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between p-3 mt-4 border rounded-lg shadow-sm">
<div className="space-y-0.5">
<FormLabel>HTTPS</FormLabel>
<FormDescription>
Automatically provision SSL Certificate.
</FormDescription>
<FormMessage />
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
{https && (
<>
<FormField
control={form.control}
name="certificateType"
render={({ field }) => {
return (
<FormItem>
<FormLabel>Certificate Provider</FormLabel>
<Select
onValueChange={(value) => {
field.onChange(value);
if (value !== "custom") {
form.setValue(
"customCertResolver",
undefined,
);
}
}}
value={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select a certificate provider" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value={"none"}>None</SelectItem>
<SelectItem value={"letsencrypt"}>
Let's Encrypt
</SelectItem>
<SelectItem value={"custom"}>Custom</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
);
}}
/>
{certificateType === "custom" && (
<FormField
control={form.control}
name="customCertResolver"
render={({ field }) => {
return (
<FormItem>
<FormLabel>Custom Certificate Resolver</FormLabel>
<FormControl>
<Input
className="w-full"
placeholder="Enter your custom certificate resolver"
{...field}
value={field.value || ""}
onChange={(e) => {
field.onChange(e);
form.trigger("customCertResolver");
}}
/>
</FormControl>
<FormMessage />
</FormItem>
);
}}
/>
)}
</>
)}
</div>
</div>
</form>
<DialogFooter>
<Button isLoading={isLoading} form="hook-form" type="submit">
{dictionary.submit}
</Button>
</DialogFooter>
</Form>
</DialogContent>
</Dialog>
);
};

View File

@@ -1,109 +0,0 @@
import { Copy, HelpCircle, Server } from "lucide-react";
import { toast } from "sonner";
import { AlertBlock } from "@/components/shared/alert-block";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
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 (
<Dialog>
<DialogTrigger>
<Button variant="ghost" size="icon" className="group">
<HelpCircle className="size-4" />
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-2xl">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Server className="size-5" />
DNS Configuration Guide
</DialogTitle>
<DialogDescription>
Follow these steps to configure your DNS records for {domain.host}
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-4">
<AlertBlock type="info">
To make your domain accessible, you need to configure your DNS
records with your domain provider (e.g., Cloudflare, GoDaddy,
NameCheap).
</AlertBlock>
<div className="flex flex-col gap-6">
<div className="rounded-lg border p-4">
<h3 className="font-medium mb-2">1. Add A Record</h3>
<div className="flex flex-col gap-3">
<p className="text-sm text-muted-foreground">
Create an A record that points your domain to the server's IP
address:
</p>
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between gap-2 bg-muted p-3 rounded-md">
<div>
<p className="text-sm font-medium">Type: A</p>
<p className="text-sm">
Name: @ or {domain.host.split(".")[0]}
</p>
<p className="text-sm">
Value: {serverIp || "Your server IP"}
</p>
</div>
<Button
variant="ghost"
size="icon"
onClick={() => copyToClipboard(serverIp || "")}
disabled={!serverIp}
>
<Copy className="size-4" />
</Button>
</div>
</div>
</div>
</div>
<div className="rounded-lg border p-4">
<h3 className="font-medium mb-2">2. Verify Configuration</h3>
<div className="flex flex-col gap-3">
<p className="text-sm text-muted-foreground">
After configuring your DNS records:
</p>
<ul className="list-disc list-inside space-y-1 text-sm">
<li>Wait for DNS propagation (usually 15-30 minutes)</li>
<li>
Test your domain by visiting:{" "}
{domain.https ? "https://" : "http://"}
{domain.host}
{domain.path || "/"}
</li>
<li>Use a DNS lookup tool to verify your records</li>
</ul>
</div>
</div>
</div>
</div>
</DialogContent>
</Dialog>
);
};

View File

@@ -1,719 +0,0 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { DatabaseZap, Dices, RefreshCw } from "lucide-react";
import Link from "next/link";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import z from "zod";
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";
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<typeof domain>;
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<CacheType>("cache");
const [isManualInput, setIsManualInput] = useState(false);
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<Domain>({
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 (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger className="" asChild>
{children}
</DialogTrigger>
<DialogContent className="sm:max-w-2xl">
<DialogHeader>
<DialogTitle>Domain</DialogTitle>
<DialogDescription>{dictionary.dialogDescription}</DialogDescription>
</DialogHeader>
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
<Form {...form}>
<form
id="hook-form"
onSubmit={form.handleSubmit(onSubmit)}
className="grid w-full gap-8 "
>
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<div className="flex flex-row items-end w-full gap-4">
{domainType === "compose" && (
<div className="flex flex-col gap-2 w-full">
{errorServices && (
<AlertBlock
type="warning"
className="[overflow-wrap:anywhere]"
>
{errorServices?.message}
</AlertBlock>
)}
<FormField
control={form.control}
name="serviceName"
render={({ field }) => (
<FormItem className="w-full">
<FormLabel>Service Name</FormLabel>
<div className="flex gap-2">
{isManualInput ? (
<FormControl>
<Input
placeholder="Enter service name manually"
{...field}
className="w-full"
/>
</FormControl>
) : (
<Select
onValueChange={field.onChange}
defaultValue={field.value || ""}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select a service name" />
</SelectTrigger>
</FormControl>
<SelectContent>
{services?.map((service, index) => (
<SelectItem
value={service}
key={`${service}-${index}`}
>
{service}
</SelectItem>
))}
<SelectItem value="none" disabled>
Empty
</SelectItem>
</SelectContent>
</Select>
)}
{!isManualInput && (
<>
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="secondary"
type="button"
isLoading={isLoadingServices}
onClick={() => {
if (cacheType === "fetch") {
refetchServices();
} else {
setCacheType("fetch");
}
}}
>
<RefreshCw className="size-4 text-muted-foreground" />
</Button>
</TooltipTrigger>
<TooltipContent
side="left"
sideOffset={5}
className="max-w-[10rem]"
>
<p>
Fetch: Will clone the repository and
load the services
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="secondary"
type="button"
isLoading={isLoadingServices}
onClick={() => {
if (cacheType === "cache") {
refetchServices();
} else {
setCacheType("cache");
}
}}
>
<DatabaseZap className="size-4 text-muted-foreground" />
</Button>
</TooltipTrigger>
<TooltipContent
side="left"
sideOffset={5}
className="max-w-[10rem]"
>
<p>
Cache: If you previously deployed this
compose, it will read the services
from the last deployment/fetch from
the repository
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</>
)}
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="secondary"
type="button"
onClick={() => {
setIsManualInput(!isManualInput);
if (!isManualInput) {
field.onChange("");
}
}}
>
{isManualInput ? (
<RefreshCw className="size-4 text-muted-foreground" />
) : (
<span className="text-xs text-muted-foreground">
Manual
</span>
)}
</Button>
</TooltipTrigger>
<TooltipContent
side="left"
sideOffset={5}
className="max-w-[10rem]"
>
<p>
{isManualInput
? "Switch to service selection"
: "Enter service name manually"}
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<FormMessage />
</FormItem>
)}
/>
</div>
)}
</div>
<FormField
control={form.control}
name="host"
render={({ field }) => (
<FormItem>
{!canGenerateTraefikMeDomains &&
field.value.includes("traefik.me") && (
<AlertBlock type="warning">
You need to set an IP address in your{" "}
<Link
href="/dashboard/settings/server"
className="text-primary"
>
{application?.serverId
? "Remote Servers -> Server -> Edit Server -> Update IP Address"
: "Web Server -> Server -> Update Server IP"}
</Link>{" "}
to make your traefik.me domain work.
</AlertBlock>
)}
<FormLabel>Host</FormLabel>
<div className="flex gap-2">
<FormControl>
<Input placeholder="api.dokploy.com" {...field} />
</FormControl>
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="secondary"
type="button"
isLoading={isLoadingGenerate}
onClick={() => {
generateDomain({
appName: application?.appName || "",
serverId: application?.serverId || "",
})
.then((domain) => {
field.onChange(domain);
})
.catch((err) => {
toast.error(err.message);
});
}}
>
<Dices className="size-4 text-muted-foreground" />
</Button>
</TooltipTrigger>
<TooltipContent
side="left"
sideOffset={5}
className="max-w-[10rem]"
>
<p>Generate traefik.me domain</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="path"
render={({ field }) => {
return (
<FormItem>
<FormLabel>Path</FormLabel>
<FormControl>
<Input placeholder={"/"} {...field} />
</FormControl>
<FormMessage />
</FormItem>
);
}}
/>
<FormField
control={form.control}
name="internalPath"
render={({ field }) => {
return (
<FormItem>
<FormLabel>Internal Path</FormLabel>
<FormDescription>
The path where your application expects to receive
requests internally (defaults to "/")
</FormDescription>
<FormControl>
<Input placeholder={"/"} {...field} />
</FormControl>
<FormMessage />
</FormItem>
);
}}
/>
<FormField
control={form.control}
name="stripPath"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between p-3 border rounded-lg shadow-sm">
<div className="space-y-0.5">
<FormLabel>Strip Path</FormLabel>
<FormDescription>
Remove the external path from the request before
forwarding to the application
</FormDescription>
<FormMessage />
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="port"
render={({ field }) => {
return (
<FormItem>
<FormLabel>Container Port</FormLabel>
<FormDescription>
The port where your application is running inside the
container (e.g., 3000 for Node.js, 80 for Nginx, 8080
for Java)
</FormDescription>
<FormControl>
<NumberInput placeholder={"3000"} {...field} />
</FormControl>
<FormMessage />
</FormItem>
);
}}
/>
<FormField
control={form.control}
name="https"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between p-3 mt-4 border rounded-lg shadow-sm">
<div className="space-y-0.5">
<FormLabel>HTTPS</FormLabel>
<FormDescription>
Automatically provision SSL Certificate.
</FormDescription>
<FormMessage />
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
{https && (
<>
<FormField
control={form.control}
name="certificateType"
render={({ field }) => {
return (
<FormItem>
<FormLabel>Certificate Provider</FormLabel>
<Select
onValueChange={(value) => {
field.onChange(value);
if (value !== "custom") {
form.setValue(
"customCertResolver",
undefined,
);
}
}}
value={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select a certificate provider" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value={"none"}>None</SelectItem>
<SelectItem value={"letsencrypt"}>
Let's Encrypt
</SelectItem>
<SelectItem value={"custom"}>Custom</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
);
}}
/>
{certificateType === "custom" && (
<FormField
control={form.control}
name="customCertResolver"
render={({ field }) => {
return (
<FormItem>
<FormLabel>Custom Certificate Resolver</FormLabel>
<FormControl>
<Input
className="w-full"
placeholder="Enter your custom certificate resolver"
{...field}
value={field.value || ""}
onChange={(e) => {
field.onChange(e);
form.trigger("customCertResolver");
}}
/>
</FormControl>
<FormMessage />
</FormItem>
);
}}
/>
)}
</>
)}
</div>
</div>
</form>
<DialogFooter>
<Button isLoading={isLoading} form="hook-form" type="submit">
{dictionary.submit}
</Button>
</DialogFooter>
</Form>
</DialogContent>
</Dialog>
);
};

View File

@@ -1,20 +1,4 @@
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 { DialogAction } from "@/components/shared/dialog-action";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
@@ -23,120 +7,29 @@ import {
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { api } from "@/utils/api";
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<string, ValidationState>;
import { ExternalLink, GlobeIcon, PenBoxIcon, Trash2 } from "lucide-react";
import Link from "next/link";
import { toast } from "sonner";
import { AddDomain } from "./add-domain";
interface Props {
id: string;
type: "application" | "compose";
applicationId: string;
}
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<ValidationStates>(
{},
export const ShowDomains = ({ applicationId }: Props) => {
const { data, refetch } = api.domain.byApplicationId.useQuery(
{
applicationId,
},
{
enabled: !!applicationId,
},
);
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 (
<div className="flex w-full flex-col gap-5 ">
<Card className="bg-background">
@@ -150,7 +43,7 @@ export const ShowDomains = ({ id, type }: Props) => {
<div className="flex flex-row gap-4 flex-wrap">
{data && data?.length > 0 && (
<AddDomain id={id} type={type}>
<AddDomain applicationId={applicationId}>
<Button>
<GlobeIcon className="size-4" /> Add Domain
</Button>
@@ -159,22 +52,15 @@ export const ShowDomains = ({ id, type }: Props) => {
</div>
</CardHeader>
<CardContent className="flex w-full flex-row gap-4">
{isLoadingDomains ? (
<div className="flex w-full flex-row gap-4 min-h-[40vh] justify-center items-center">
<Loader2 className="size-5 animate-spin text-muted-foreground" />
<span className="text-base text-muted-foreground">
Loading domains...
</span>
</div>
) : data?.length === 0 ? (
<div className="flex w-full flex-col items-center justify-center gap-3 min-h-[40vh]">
{data?.length === 0 ? (
<div className="flex w-full flex-col items-center justify-center gap-3">
<GlobeIcon className="size-8 text-muted-foreground" />
<span className="text-base text-muted-foreground">
To access the application it is required to set at least 1
domain
</span>
<div className="flex flex-row gap-4 flex-wrap">
<AddDomain id={id} type={type}>
<AddDomain applicationId={applicationId}>
<Button>
<GlobeIcon className="size-4" /> Add Domain
</Button>
@@ -182,216 +68,73 @@ export const ShowDomains = ({ id, type }: Props) => {
</div>
</div>
) : (
<div className="grid grid-cols-1 gap-4 xl:grid-cols-2 w-full min-h-[40vh] ">
<div className="flex w-full flex-col gap-4">
{data?.map((item) => {
const validationState = validationStates[item.host];
return (
<Card
<div
key={item.domainId}
className="relative overflow-hidden w-full border transition-all hover:shadow-md bg-transparent h-fit"
className="flex w-full items-center justify-between gap-4 border p-4 md:px-6 rounded-lg flex-wrap"
>
<CardContent className="p-6">
<div className="flex flex-col gap-4">
{/* Service & Domain Info */}
<div className="flex items-center justify-between flex-wrap gap-y-2">
{item.serviceName && (
<Badge variant="outline" className="w-fit">
<Server className="size-3 mr-1" />
{item.serviceName}
</Badge>
)}
<div className="flex gap-2 flex-wrap">
{!item.host.includes("traefik.me") && (
<DnsHelperModal
domain={{
host: item.host,
https: item.https,
path: item.path || undefined,
}}
serverIp={
application?.server?.ipAddress?.toString() ||
ip?.toString()
}
/>
)}
<AddDomain
id={id}
type={type}
domainId={item.domainId}
>
<Button
variant="ghost"
size="icon"
className="group hover:bg-blue-500/10"
>
<PenBoxIcon className="size-3.5 text-primary group-hover:text-blue-500" />
</Button>
</AddDomain>
<DialogAction
title="Delete Domain"
description="Are you sure you want to delete this domain?"
type="destructive"
onClick={async () => {
await deleteDomain({
domainId: item.domainId,
})
.then((_data) => {
refetch();
toast.success(
"Domain deleted successfully",
);
})
.catch(() => {
toast.error("Error deleting domain");
});
}}
>
<Button
variant="ghost"
size="icon"
className="group hover:bg-red-500/10"
isLoading={isRemoving}
>
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
</Button>
</DialogAction>
</div>
</div>
<div className="w-full break-all">
<Link
className="flex items-center gap-2 text-base font-medium hover:underline"
target="_blank"
href={`${item.https ? "https" : "http"}://${item.host}${item.path}`}
>
{item.host}
<ExternalLink className="size-4 min-w-4" />
</Link>
</div>
<Link
className="md:basis-1/2 flex gap-2 items-center hover:underline transition-all w-full"
target="_blank"
href={`${item.https ? "https" : "http"}://${item.host}${item.path}`}
>
<span className="truncate max-w-full text-sm">
{item.host}
</span>
<ExternalLink className="size-4 min-w-4" />
</Link>
{/* Domain Details */}
<div className="flex flex-wrap gap-3">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Badge variant="secondary">
<InfoIcon className="size-3 mr-1" />
Path: {item.path || "/"}
</Badge>
</TooltipTrigger>
<TooltipContent>
<p>URL path for this service</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Badge variant="secondary">
<InfoIcon className="size-3 mr-1" />
Port: {item.port}
</Badge>
</TooltipTrigger>
<TooltipContent>
<p>Container port exposed</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Badge
variant={item.https ? "outline" : "secondary"}
>
{item.https ? "HTTPS" : "HTTP"}
</Badge>
</TooltipTrigger>
<TooltipContent>
<p>
{item.https
? "Secure HTTPS connection"
: "Standard HTTP connection"}
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
{item.certificateType && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Badge variant="outline">
Cert: {item.certificateType}
</Badge>
</TooltipTrigger>
<TooltipContent>
<p>SSL Certificate Provider</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Badge
variant="outline"
className={
validationState?.isValid
? "bg-green-500/10 text-green-500 cursor-pointer"
: validationState?.error
? "bg-red-500/10 text-red-500 cursor-pointer"
: "bg-yellow-500/10 text-yellow-500 cursor-pointer"
}
onClick={() =>
handleValidateDomain(item.host)
}
>
{validationState?.isLoading ? (
<>
<Loader2 className="size-3 mr-1 animate-spin" />
Checking DNS...
</>
) : validationState?.isValid ? (
<>
<CheckCircle2 className="size-3 mr-1" />
{validationState.message &&
validationState.cdnProvider
? `Behind ${validationState.cdnProvider}`
: "DNS Valid"}
</>
) : validationState?.error ? (
<>
<XCircle className="size-3 mr-1" />
{validationState.error}
</>
) : (
<>
<RefreshCw className="size-3 mr-1" />
Validate DNS
</>
)}
</Badge>
</TooltipTrigger>
<TooltipContent className="max-w-xs">
{validationState?.error ? (
<div className="flex flex-col gap-1">
<p className="font-medium text-red-500">
Error:
</p>
<p>{validationState.error}</p>
</div>
) : (
"Click to validate DNS configuration"
)}
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<div className="flex gap-8">
<div className="flex gap-8 opacity-50 items-center h-10 text-center text-sm font-medium">
<span>{item.path}</span>
<span>{item.port}</span>
<span>{item.https ? "HTTPS" : "HTTP"}</span>
</div>
</CardContent>
</Card>
<div className="flex gap-2">
<AddDomain
applicationId={applicationId}
domainId={item.domainId}
>
<Button
variant="ghost"
size="icon"
className="group hover:bg-blue-500/10 "
>
<PenBoxIcon className="size-3.5 text-primary group-hover:text-blue-500" />
</Button>
</AddDomain>
<DialogAction
title="Delete Domain"
description="Are you sure you want to delete this domain?"
type="destructive"
onClick={async () => {
await deleteDomain({
domainId: item.domainId,
})
.then(() => {
refetch();
toast.success("Domain deleted successfully");
})
.catch(() => {
toast.error("Error deleting domain");
});
}}
>
<Button
variant="ghost"
size="icon"
className="group hover:bg-red-500/10"
isLoading={isRemoving}
>
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
</Button>
</DialogAction>
</div>
</div>
</div>
);
})}
</div>

View File

@@ -1,9 +1,3 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { EyeIcon, EyeOffIcon } from "lucide-react";
import { type CSSProperties, useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { CodeEditor } from "@/components/shared/code-editor";
import { Button } from "@/components/ui/button";
import {
@@ -22,6 +16,12 @@ import {
} from "@/components/ui/form";
import { Toggle } from "@/components/ui/toggle";
import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod";
import { EyeIcon, EyeOffIcon } from "lucide-react";
import { type CSSProperties, useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import type { ServiceType } from "../advanced/show-resources";
const addEnvironmentSchema = z.object({

View File

@@ -1,13 +1,13 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
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";
const addEnvironmentSchema = z.object({
env: z.string(),

View File

@@ -1,10 +1,3 @@
import { zodResolver } from "@hookform/resolvers/zod";
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";
import { z } from "zod";
import { BitbucketIcon } from "@/components/icons/data-tools-icons";
import { AlertBlock } from "@/components/shared/alert-block";
import { Badge } from "@/components/ui/badge";
@@ -38,7 +31,6 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import {
Tooltip,
TooltipContent,
@@ -47,6 +39,13 @@ import {
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod";
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";
import { z } from "zod";
const BitbucketProviderSchema = z.object({
buildPath: z.string().min(1, "Path is required").default("/"),
@@ -59,7 +58,6 @@ const BitbucketProviderSchema = z.object({
branch: z.string().min(1, "Branch is required"),
bitbucketId: z.string().min(1, "Bitbucket Provider is required"),
watchPaths: z.array(z.string()).optional(),
enableSubmodules: z.boolean().optional(),
});
type BitbucketProvider = z.infer<typeof BitbucketProviderSchema>;
@@ -86,7 +84,6 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
bitbucketId: "",
branch: "",
watchPaths: [],
enableSubmodules: false,
},
resolver: zodResolver(BitbucketProviderSchema),
});
@@ -133,10 +130,9 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
buildPath: data.bitbucketBuildPath || "/",
bitbucketId: data.bitbucketId || "",
watchPaths: data.watchPaths || [],
enableSubmodules: data.enableSubmodules || false,
});
}
}, [form.reset, data?.applicationId, form]);
}, [form.reset, data, form]);
const onSubmit = async (data: BitbucketProvider) => {
await mutateAsync({
@@ -147,7 +143,6 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
bitbucketId: data.bitbucketId,
applicationId,
watchPaths: data.watchPaths || [],
enableSubmodules: data.enableSubmodules || false,
})
.then(async () => {
toast.success("Service Provided Saved");
@@ -435,7 +430,7 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
<FormControl>
<div className="flex gap-2">
<Input
placeholder="Enter a path to watch (e.g., src/**, dist/*.js)"
placeholder="Enter a path to watch (e.g., src/*, dist/*)"
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
@@ -454,7 +449,7 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
variant="secondary"
onClick={() => {
const input = document.querySelector(
'input[placeholder="Enter a path to watch (e.g., src/**, dist/*.js)"]',
'input[placeholder="Enter a path to watch (e.g., src/*, dist/*)"]',
) as HTMLInputElement;
const value = input.value.trim();
if (value) {
@@ -472,21 +467,6 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
</FormItem>
)}
/>
<FormField
control={form.control}
name="enableSubmodules"
render={({ field }) => (
<FormItem className="flex items-center space-x-2">
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<FormLabel className="!mt-0">Enable Submodules</FormLabel>
</FormItem>
)}
/>
</div>
<div className="flex w-full justify-end">
<Button

View File

@@ -1,8 +1,3 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { Button } from "@/components/ui/button";
import {
Form,
@@ -14,6 +9,11 @@ import {
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
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";
const DockerProviderSchema = z.object({
dockerImage: z.string().min(1, {
@@ -53,7 +53,7 @@ export const SaveDockerProvider = ({ applicationId }: Props) => {
registryURL: data.registryUrl || "",
});
}
}, [form.reset, data?.applicationId, form]);
}, [form.reset, data, form]);
const onSubmit = async (values: DockerProvider) => {
await mutateAsync({

View File

@@ -1,8 +1,3 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { TrashIcon } from "lucide-react";
import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Dropzone } from "@/components/ui/dropzone";
import {
@@ -16,6 +11,11 @@ import {
import { Input } from "@/components/ui/input";
import { api } from "@/utils/api";
import { type UploadFile, uploadFileSchema } from "@/utils/schema";
import { zodResolver } from "@hookform/resolvers/zod";
import { TrashIcon } from "lucide-react";
import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
interface Props {
applicationId: string;

View File

@@ -1,13 +1,3 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { KeyRoundIcon, LockIcon, X } from "lucide-react";
import Link from "next/link";
import { useRouter } from "next/router";
import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { GitIcon } from "@/components/icons/data-tools-icons";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Form,
@@ -27,7 +17,6 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import {
Tooltip,
TooltipContent,
@@ -35,6 +24,17 @@ import {
TooltipTrigger,
} from "@/components/ui/tooltip";
import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod";
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("/"),
@@ -44,7 +44,6 @@ const GitProviderSchema = z.object({
branch: z.string().min(1, "Branch required"),
sshKey: z.string().optional(),
watchPaths: z.array(z.string()).optional(),
enableSubmodules: z.boolean().default(false),
});
type GitProvider = z.infer<typeof GitProviderSchema>;
@@ -68,7 +67,6 @@ export const SaveGitProvider = ({ applicationId }: Props) => {
repositoryURL: "",
sshKey: undefined,
watchPaths: [],
enableSubmodules: false,
},
resolver: zodResolver(GitProviderSchema),
});
@@ -81,7 +79,6 @@ export const SaveGitProvider = ({ applicationId }: Props) => {
buildPath: data.customGitBuildPath || "/",
repositoryURL: data.customGitUrl || "",
watchPaths: data.watchPaths || [],
enableSubmodules: data.enableSubmodules ?? false,
});
}
}, [form.reset, data, form]);
@@ -94,7 +91,6 @@ export const SaveGitProvider = ({ applicationId }: Props) => {
customGitSSHKeyId: values.sshKey === "none" ? null : values.sshKey,
applicationId,
watchPaths: values.watchPaths || [],
enableSubmodules: values.enableSubmodules,
})
.then(async () => {
toast.success("Git Provider Saved");
@@ -261,7 +257,7 @@ export const SaveGitProvider = ({ applicationId }: Props) => {
<FormControl>
<div className="flex gap-2">
<Input
placeholder="Enter a path to watch (e.g., src/**, dist/*.js)"
placeholder="Enter a path to watch (e.g., src/*, dist/*)"
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
@@ -280,7 +276,7 @@ export const SaveGitProvider = ({ applicationId }: Props) => {
variant="secondary"
onClick={() => {
const input = document.querySelector(
'input[placeholder="Enter a path to watch (e.g., src/**, dist/*.js)"]',
'input[placeholder="Enter a path to watch (e.g., src/*, dist/*)"]',
) as HTMLInputElement;
const value = input.value.trim();
if (value) {
@@ -298,22 +294,6 @@ export const SaveGitProvider = ({ applicationId }: Props) => {
</FormItem>
)}
/>
<FormField
control={form.control}
name="enableSubmodules"
render={({ field }) => (
<FormItem className="flex items-center space-x-2">
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<FormLabel className="!mt-0">Enable Submodules</FormLabel>
</FormItem>
)}
/>
</div>
<div className="flex flex-row justify-end">

View File

@@ -1,10 +1,3 @@
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";
import { GiteaIcon } from "@/components/icons/data-tools-icons";
import { AlertBlock } from "@/components/shared/alert-block";
import { Badge } from "@/components/ui/badge";
@@ -38,7 +31,6 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import {
Tooltip,
TooltipContent,
@@ -47,6 +39,13 @@ import {
} 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;
@@ -75,7 +74,6 @@ const GiteaProviderSchema = z.object({
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<typeof GiteaProviderSchema>;
@@ -101,7 +99,6 @@ export const SaveGiteaProvider = ({ applicationId }: Props) => {
giteaId: "",
branch: "",
watchPaths: [],
enableSubmodules: false,
},
resolver: zodResolver(GiteaProviderSchema),
});
@@ -155,10 +152,9 @@ export const SaveGiteaProvider = ({ applicationId }: Props) => {
buildPath: data.giteaBuildPath || "/",
giteaId: data.giteaId || "",
watchPaths: data.watchPaths || [],
enableSubmodules: data.enableSubmodules || false,
});
}
}, [form.reset, data?.applicationId, form]);
}, [form.reset, data, form]);
const onSubmit = async (data: GiteaProvider) => {
await mutateAsync({
@@ -169,7 +165,6 @@ export const SaveGiteaProvider = ({ applicationId }: Props) => {
giteaId: data.giteaId,
applicationId,
watchPaths: data.watchPaths,
enableSubmodules: data.enableSubmodules || false,
})
.then(async () => {
toast.success("Service Provider Saved");
@@ -381,9 +376,6 @@ export const SaveGiteaProvider = ({ applicationId }: Props) => {
<CommandEmpty>No branch found.</CommandEmpty>
<CommandGroup>
{branches && branches.length === 0 && (
<CommandItem>No branches found.</CommandItem>
)}
{branches?.map((branch: GiteaBranch) => (
<CommandItem
value={branch.name}
@@ -470,7 +462,7 @@ export const SaveGiteaProvider = ({ applicationId }: Props) => {
<div className="flex gap-2">
<FormControl>
<Input
placeholder="Enter a path to watch (e.g., src/**, dist/*.js)"
placeholder="Enter a path to watch (e.g., src/*, dist/*)"
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
@@ -506,21 +498,6 @@ export const SaveGiteaProvider = ({ applicationId }: Props) => {
</FormItem>
)}
/>
<FormField
control={form.control}
name="enableSubmodules"
render={({ field }) => (
<FormItem className="flex items-center space-x-2">
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<FormLabel className="!mt-0">Enable Submodules</FormLabel>
</FormItem>
)}
/>
</div>
<div className="flex w-full justify-end">
<Button

View File

@@ -1,10 +1,3 @@
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";
import { GithubIcon } from "@/components/icons/data-tools-icons";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
@@ -37,7 +30,6 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import {
Tooltip,
TooltipContent,
@@ -46,6 +38,13 @@ import {
} 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";
const GithubProviderSchema = z.object({
buildPath: z.string().min(1, "Path is required").default("/"),
@@ -58,8 +57,6 @@ const GithubProviderSchema = z.object({
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<typeof GithubProviderSchema>;
@@ -84,15 +81,12 @@ 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(
@@ -130,11 +124,9 @@ 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?.applicationId, form]);
}, [form.reset, data, form]);
const onSubmit = async (data: GithubProvider) => {
await mutateAsync({
@@ -145,8 +137,6 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
buildPath: data.buildPath,
githubId: data.githubId,
watchPaths: data.watchPaths || [],
triggerType: data.triggerType,
enableSubmodules: data.enableSubmodules,
})
.then(async () => {
toast.success("Service Provided Saved");
@@ -391,11 +381,11 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
/>
<FormField
control={form.control}
name="triggerType"
name="watchPaths"
render={({ field }) => (
<FormItem className="md:col-span-2">
<div className="flex items-center gap-2 ">
<FormLabel>Trigger Type</FormLabel>
<div className="flex items-center gap-2">
<FormLabel>Watch Paths</FormLabel>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
@@ -403,127 +393,68 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
</TooltipTrigger>
<TooltipContent>
<p>
Choose when to trigger deployments: on push to the
selected branch or when a new tag is created.
Add paths to watch for changes. When files in these
paths change, a new deployment will be triggered.
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<Select
onValueChange={field.onChange}
defaultValue={field.value}
value={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select a trigger type" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="push">On Push</SelectItem>
<SelectItem value="tag">On Tag</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
{triggerType === "push" && (
<FormField
control={form.control}
name="watchPaths"
render={({ field }) => (
<FormItem className="md:col-span-2">
<div className="flex items-center gap-2">
<FormLabel>Watch Paths</FormLabel>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<HelpCircle className="size-4 text-muted-foreground hover:text-foreground transition-colors cursor-pointer" />
</TooltipTrigger>
<TooltipContent>
<p>
Add paths to watch for changes. When files in
these paths change, a new deployment will be
triggered.
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<div className="flex flex-wrap gap-2 mb-2">
{field.value?.map((path, index) => (
<Badge
key={index}
variant="secondary"
className="flex items-center gap-1"
>
{path}
<X
className="size-3 cursor-pointer hover:text-destructive"
onClick={() => {
const newPaths = [...(field.value || [])];
newPaths.splice(index, 1);
field.onChange(newPaths);
}}
/>
</Badge>
))}
</div>
<div className="flex gap-2">
<FormControl>
<Input
placeholder="Enter a path to watch (e.g., src/**, dist/*.js)"
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
const input = e.currentTarget;
const path = input.value.trim();
if (path) {
field.onChange([...(field.value || []), path]);
input.value = "";
}
}
<div className="flex flex-wrap gap-2 mb-2">
{field.value?.map((path, index) => (
<Badge
key={index}
variant="secondary"
className="flex items-center gap-1"
>
{path}
<X
className="size-3 cursor-pointer hover:text-destructive"
onClick={() => {
const newPaths = [...(field.value || [])];
newPaths.splice(index, 1);
field.onChange(newPaths);
}}
/>
</FormControl>
<Button
type="button"
variant="outline"
size="icon"
onClick={() => {
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 = "";
</Badge>
))}
</div>
<div className="flex gap-2">
<FormControl>
<Input
placeholder="Enter a path to watch (e.g., src/*, dist/*)"
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
const input = e.currentTarget;
const path = input.value.trim();
if (path) {
field.onChange([...(field.value || []), path]);
input.value = "";
}
}
}}
>
<Plus className="size-4" />
</Button>
</div>
<FormMessage />
</FormItem>
)}
/>
)}
<FormField
control={form.control}
name="enableSubmodules"
render={({ field }) => (
<FormItem className="flex items-center space-x-2">
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<FormLabel className="!mt-0">Enable Submodules</FormLabel>
/>
</FormControl>
<Button
type="button"
variant="outline"
size="icon"
onClick={() => {
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 = "";
}
}}
>
<Plus className="size-4" />
</Button>
</div>
<FormMessage />
</FormItem>
)}
/>

View File

@@ -1,10 +1,3 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { CheckIcon, ChevronsUpDown, HelpCircle, Plus, X } from "lucide-react";
import Link from "next/link";
import { useEffect, useMemo } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { GitlabIcon } from "@/components/icons/data-tools-icons";
import { AlertBlock } from "@/components/shared/alert-block";
import { Badge } from "@/components/ui/badge";
@@ -38,7 +31,6 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import {
Tooltip,
TooltipContent,
@@ -47,6 +39,13 @@ import {
} 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";
const GitlabProviderSchema = z.object({
buildPath: z.string().min(1, "Path is required").default("/"),
@@ -61,7 +60,6 @@ const GitlabProviderSchema = z.object({
branch: z.string().min(1, "Branch is required"),
gitlabId: z.string().min(1, "Gitlab Provider is required"),
watchPaths: z.array(z.string()).optional(),
enableSubmodules: z.boolean().default(false),
});
type GitlabProvider = z.infer<typeof GitlabProviderSchema>;
@@ -88,7 +86,6 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
},
gitlabId: "",
branch: "",
enableSubmodules: false,
},
resolver: zodResolver(GitlabProviderSchema),
});
@@ -96,16 +93,6 @@ 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,
@@ -148,10 +135,9 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
buildPath: data.gitlabBuildPath || "/",
gitlabId: data.gitlabId || "",
watchPaths: data.watchPaths || [],
enableSubmodules: data.enableSubmodules ?? false,
});
}
}, [form.reset, data?.applicationId, form]);
}, [form.reset, data, form]);
const onSubmit = async (data: GitlabProvider) => {
await mutateAsync({
@@ -164,7 +150,6 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
gitlabProjectId: data.repository.id,
gitlabPathNamespace: data.repository.gitlabPathNamespace,
watchPaths: data.watchPaths || [],
enableSubmodules: data.enableSubmodules,
})
.then(async () => {
toast.success("Service Provided Saved");
@@ -234,7 +219,7 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
<FormLabel>Repository</FormLabel>
{field.value.owner && field.value.repo && (
<Link
href={`${gitlabUrl}/${field.value.owner}/${field.value.repo}`}
href={`https://gitlab.com/${field.value.owner}/${field.value.repo}`}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 text-sm text-muted-foreground hover:text-primary"
@@ -288,7 +273,7 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
{repositories?.map((repo) => {
return (
<CommandItem
value={repo.url}
value={repo.name}
key={repo.url}
onSelect={() => {
form.setValue("repository", {
@@ -309,8 +294,7 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
<CheckIcon
className={cn(
"ml-auto h-4 w-4",
repo.url ===
field.value.gitlabPathNamespace
repo.name === field.value.repo
? "opacity-100"
: "opacity-0",
)}
@@ -463,7 +447,7 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
<div className="flex gap-2">
<FormControl>
<Input
placeholder="Enter a path to watch (e.g., src/**, dist/*.js)"
placeholder="Enter a path to watch (e.g., src/*, dist/*)"
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
@@ -499,21 +483,6 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
</FormItem>
)}
/>
<FormField
control={form.control}
name="enableSubmodules"
render={({ field }) => (
<FormItem className="flex items-center space-x-2">
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<FormLabel className="!mt-0">Enable Submodules</FormLabel>
</FormItem>
)}
/>
</div>
<div className="flex w-full justify-end">
<Button

View File

@@ -1,7 +1,3 @@
import { GitBranch, Loader2, UploadCloud } from "lucide-react";
import Link from "next/link";
import { useState } from "react";
import { toast } from "sonner";
import { SaveDockerProvider } from "@/components/dashboard/application/general/generic/save-docker-provider";
import { SaveGitProvider } from "@/components/dashboard/application/general/generic/save-git-provider";
import { SaveGiteaProvider } from "@/components/dashboard/application/general/generic/save-gitea-provider";
@@ -9,18 +5,20 @@ import { SaveGithubProvider } from "@/components/dashboard/application/general/g
import {
BitbucketIcon,
DockerIcon,
GitIcon,
GiteaIcon,
GithubIcon,
GitIcon,
GitlabIcon,
} from "@/components/icons/data-tools-icons";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { api } from "@/utils/api";
import { GitBranch, UploadCloud } from "lucide-react";
import Link from "next/link";
import { useState } from "react";
import { SaveBitbucketProvider } from "./save-bitbucket-provider";
import { SaveDragNDrop } from "./save-drag-n-drop";
import { SaveGitlabProvider } from "./save-gitlab-provider";
import { UnauthorizedGitProvider } from "./unauthorized-git-provider";
type TabState =
| "github"
@@ -36,100 +34,14 @@ interface Props {
}
export const ShowProviderForm = ({ applicationId }: Props) => {
const { data: githubProviders, isLoading: isLoadingGithub } =
api.github.githubProviders.useQuery();
const { data: gitlabProviders, isLoading: isLoadingGitlab } =
api.gitlab.gitlabProviders.useQuery();
const { data: bitbucketProviders, isLoading: isLoadingBitbucket } =
const { data: githubProviders } = api.github.githubProviders.useQuery();
const { data: gitlabProviders } = api.gitlab.gitlabProviders.useQuery();
const { data: bitbucketProviders } =
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: giteaProviders } = api.gitea.giteaProviders.useQuery();
const { data: application } = api.application.one.useQuery({ applicationId });
const [tab, setSab] = useState<TabState>(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 (
<Card className="group relative w-full bg-transparent">
<CardHeader>
<CardTitle className="flex items-start justify-between">
<div className="flex flex-col gap-2">
<span className="flex flex-col space-y-0.5">Provider</span>
<p className="flex items-center text-sm font-normal text-muted-foreground">
Select the source of your code
</p>
</div>
<div className="hidden space-y-1 text-sm font-normal md:block">
<GitBranch className="size-6 text-muted-foreground" />
</div>
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex min-h-[25vh] items-center justify-center">
<div className="flex items-center gap-2 text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
<span>Loading providers...</span>
</div>
</div>
</CardContent>
</Card>
);
}
// Check if user doesn't have access to the current git provider
if (
application &&
!application.hasGitProviderAccess &&
application.sourceType !== "docker" &&
application.sourceType !== "drop"
) {
return (
<Card className="group relative w-full bg-transparent">
<CardHeader>
<CardTitle className="flex items-start justify-between">
<div className="flex flex-col gap-2">
<span className="flex flex-col space-y-0.5">Provider</span>
<p className="flex items-center text-sm font-normal text-muted-foreground">
Repository connection through unauthorized provider
</p>
</div>
<div className="hidden space-y-1 text-sm font-normal md:block">
<GitBranch className="size-6 text-muted-foreground" />
</div>
</CardTitle>
</CardHeader>
<CardContent>
<UnauthorizedGitProvider
service={application}
onDisconnect={handleDisconnect}
/>
</CardContent>
</Card>
);
}
return (
<Card className="group relative w-full bg-transparent">
<CardHeader>
@@ -153,8 +65,8 @@ export const ShowProviderForm = ({ applicationId }: Props) => {
setSab(e as TabState);
}}
>
<div className="flex flex-row items-center justify-between w-full overflow-auto">
<TabsList className="flex gap-4 justify-start bg-transparent">
<div className="flex flex-row items-center justify-between w-full gap-4">
<TabsList className="md:grid md:w-fit md:grid-cols-7 max-md:overflow-x-scroll justify-start bg-transparent overflow-y-hidden">
<TabsTrigger
value="github"
className="rounded-none border-b-2 gap-2 border-b-transparent data-[state=active]:border-b-2 data-[state=active]:border-b-border"
@@ -211,7 +123,7 @@ export const ShowProviderForm = ({ applicationId }: Props) => {
{githubProviders && githubProviders?.length > 0 ? (
<SaveGithubProvider applicationId={applicationId} />
) : (
<div className="flex flex-col items-center gap-3 min-h-[25vh] justify-center">
<div className="flex flex-col items-center gap-3 min-h-[15vh] justify-center">
<GithubIcon className="size-8 text-muted-foreground" />
<span className="text-base text-muted-foreground">
To deploy using GitHub, you need to configure your account
@@ -231,7 +143,7 @@ export const ShowProviderForm = ({ applicationId }: Props) => {
{gitlabProviders && gitlabProviders?.length > 0 ? (
<SaveGitlabProvider applicationId={applicationId} />
) : (
<div className="flex flex-col items-center gap-3 min-h-[25vh] justify-center">
<div className="flex flex-col items-center gap-3 min-h-[15vh] justify-center">
<GitlabIcon className="size-8 text-muted-foreground" />
<span className="text-base text-muted-foreground">
To deploy using GitLab, you need to configure your account
@@ -251,7 +163,7 @@ export const ShowProviderForm = ({ applicationId }: Props) => {
{bitbucketProviders && bitbucketProviders?.length > 0 ? (
<SaveBitbucketProvider applicationId={applicationId} />
) : (
<div className="flex flex-col items-center gap-3 min-h-[25vh] justify-center">
<div className="flex flex-col items-center gap-3 min-h-[15vh] justify-center">
<BitbucketIcon className="size-8 text-muted-foreground" />
<span className="text-base text-muted-foreground">
To deploy using Bitbucket, you need to configure your account
@@ -271,7 +183,7 @@ export const ShowProviderForm = ({ applicationId }: Props) => {
{giteaProviders && giteaProviders?.length > 0 ? (
<SaveGiteaProvider applicationId={applicationId} />
) : (
<div className="flex flex-col items-center gap-3 min-h-[25vh] justify-center">
<div className="flex flex-col items-center gap-3 min-h-[15vh] justify-center">
<GiteaIcon className="size-8 text-muted-foreground" />
<span className="text-base text-muted-foreground">
To deploy using Gitea, you need to configure your account

View File

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

View File

@@ -1,14 +1,3 @@
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 { ShowBuildChooseForm } from "@/components/dashboard/application/build/show";
import { ShowProviderForm } from "@/components/dashboard/application/general/generic/show";
import { DialogAction } from "@/components/shared/dialog-action";
@@ -22,8 +11,18 @@ import {
TooltipTrigger,
} from "@/components/ui/tooltip";
import { api } from "@/utils/api";
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";
interface Props {
applicationId: string;
}
@@ -79,7 +78,7 @@ export const ShowGeneralApplication = ({ applicationId }: Props) => {
>
<Button
variant="default"
// isLoading={data?.applicationStatus === "running"}
isLoading={data?.applicationStatus === "running"}
className="flex items-center gap-1.5 group focus-visible:ring-2 focus-visible:ring-offset-2"
>
<Tooltip>

View File

@@ -1,6 +1,3 @@
import { Loader2 } from "lucide-react";
import dynamic from "next/dynamic";
import { useEffect, useState } from "react";
import { Badge } from "@/components/ui/badge";
import {
Card,
@@ -21,6 +18,9 @@ import {
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { api } from "@/utils/api";
import { Loader2 } from "lucide-react";
import dynamic from "next/dynamic";
import { useEffect, useState } from "react";
export const DockerLogs = dynamic(
() =>
import("@/components/dashboard/docker/logs/docker-logs-id").then(

View File

@@ -1,9 +1,3 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { Dices } from "lucide-react";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import type z from "zod";
import { AlertBlock } from "@/components/shared/alert-block";
import { Button } from "@/components/ui/button";
import {
@@ -39,8 +33,15 @@ import {
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { domain } from "@/server/db/validations/domain";
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<typeof domain>;
@@ -137,7 +138,7 @@ export const AddPreviewDomain = ({
<DialogTrigger className="" asChild>
{children}
</DialogTrigger>
<DialogContent className="sm:max-w-2xl">
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-2xl">
<DialogHeader>
<DialogTitle>Domain</DialogTitle>
<DialogDescription>{dictionary.dialogDescription}</DialogDescription>

View File

@@ -0,0 +1,100 @@
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 (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
{trigger ? (
trigger
) : (
<Button className="sm:w-auto w-full" size="sm" variant="outline">
View Builds
</Button>
)}
</DialogTrigger>
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-5xl">
<DialogHeader>
<DialogTitle>Preview Builds</DialogTitle>
<DialogDescription>
See all the preview builds for this application on this Pull Request
</DialogDescription>
</DialogHeader>
<div className="grid gap-4">
{deployments?.map((deployment) => (
<div
key={deployment.deploymentId}
className="flex items-center justify-between rounded-lg border p-4 gap-2"
>
<div className="flex flex-col">
<span className="flex items-center gap-4 font-medium capitalize text-foreground">
{deployment.status}
<StatusTooltip
status={deployment?.status}
className="size-2.5"
/>
</span>
<span className="text-sm text-muted-foreground">
{deployment.title}
</span>
{deployment.description && (
<span className="break-all text-sm text-muted-foreground">
{deployment.description}
</span>
)}
</div>
<div className="flex flex-col items-end gap-2">
<div className="text-sm capitalize text-muted-foreground">
<DateTooltip date={deployment.createdAt} />
</div>
<Button
onClick={() => {
setActiveLog(deployment);
}}
>
View
</Button>
</div>
</div>
))}
</div>
</DialogContent>
<ShowDeployment
serverId={serverId || ""}
open={Boolean(activeLog && activeLog.logPath !== null)}
onClose={() => setActiveLog(null)}
logPath={activeLog?.logPath || ""}
errorMessage={activeLog?.errorMessage || ""}
/>
</Dialog>
);
};

View File

@@ -1,13 +1,3 @@
import {
ExternalLink,
FileText,
GitPullRequest,
Loader2,
PenSquare,
RocketIcon,
Trash2,
} from "lucide-react";
import { toast } from "sonner";
import { GithubIcon } from "@/components/icons/data-tools-icons";
import { DateTooltip } from "@/components/shared/date-tooltip";
import { DialogAction } from "@/components/shared/dialog-action";
@@ -23,9 +13,19 @@ import {
} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { api } from "@/utils/api";
import {
ExternalLink,
FileText,
GitPullRequest,
Layers,
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,16 +38,13 @@ export const ShowPreviewDeployments = ({ applicationId }: Props) => {
const { mutateAsync: deletePreviewDeployment, isLoading } =
api.previewDeployment.delete.useMutation();
const {
data: previewDeployments,
refetch: refetchPreviewDeployments,
isLoading: isLoadingPreviewDeployments,
} = api.previewDeployment.all.useQuery(
{ applicationId },
{
enabled: !!applicationId,
},
);
const { data: previewDeployments, refetch: refetchPreviewDeployments } =
api.previewDeployment.all.useQuery(
{ applicationId },
{
enabled: !!applicationId,
},
);
const handleDeletePreviewDeployment = async (previewDeploymentId: string) => {
deletePreviewDeployment({
@@ -83,15 +80,8 @@ export const ShowPreviewDeployments = ({ applicationId }: Props) => {
each pull request you create.
</span>
</div>
{isLoadingPreviewDeployments ? (
<div className="flex w-full flex-row items-center justify-center gap-3 min-h-[35vh]">
<Loader2 className="size-5 text-muted-foreground animate-spin" />
<span className="text-base text-muted-foreground">
Loading preview deployments...
</span>
</div>
) : !previewDeployments?.length ? (
<div className="flex w-full flex-col items-center justify-center gap-3 min-h-[35vh]">
{!previewDeployments?.length ? (
<div className="flex w-full flex-col items-center justify-center gap-3 pt-10">
<RocketIcon className="size-8 text-muted-foreground" />
<span className="text-base text-muted-foreground">
No preview deployments found
@@ -178,10 +168,19 @@ export const ShowPreviewDeployments = ({ applicationId }: Props) => {
</Button>
</ShowModalLogs>
<ShowDeploymentsModal
id={deployment.previewDeploymentId}
type="previewDeployment"
<ShowPreviewBuilds
deployments={deployment.deployments || []}
serverId={data?.serverId || ""}
trigger={
<Button
variant="outline"
size="sm"
className="gap-2"
>
<Layers className="size-4" />
Builds
</Button>
}
/>
<AddPreviewDomain

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