Compare commits

..

1 Commits

Author SHA1 Message Date
Mauricio Siu
6eaafcb572 refactor: wip 2024-12-08 22:08:27 -06:00
1129 changed files with 47329 additions and 319667 deletions

119
.circleci/config.yml Normal file
View File

@@ -0,0 +1,119 @@
version: 2.1
jobs:
build-amd64:
machine:
image: ubuntu-2004:current
steps:
- checkout
- run:
name: Prepare .env file
command: |
cp apps/dokploy/.env.production.example .env.production
cp apps/dokploy/.env.production.example apps/dokploy/.env.production
- run:
name: Build and push AMD64 image
command: |
docker login -u $DOCKERHUB_USERNAME -p $DOCKERHUB_TOKEN
if [ "${CIRCLE_BRANCH}" == "main" ]; then
TAG="latest"
elif [ "${CIRCLE_BRANCH}" == "canary" ]; then
TAG="canary"
else
TAG="feature"
fi
docker build --platform linux/amd64 -t dokploy/dokploy:${TAG}-amd64 .
docker push dokploy/dokploy:${TAG}-amd64
build-arm64:
machine:
image: ubuntu-2004:current
resource_class: arm.large
steps:
- checkout
- run:
name: Prepare .env file
command: |
cp apps/dokploy/.env.production.example .env.production
cp apps/dokploy/.env.production.example apps/dokploy/.env.production
- run:
name: Build and push ARM64 image
command: |
docker login -u $DOCKERHUB_USERNAME -p $DOCKERHUB_TOKEN
if [ "${CIRCLE_BRANCH}" == "main" ]; then
TAG="latest"
elif [ "${CIRCLE_BRANCH}" == "canary" ]; then
TAG="canary"
else
TAG="feature"
fi
docker build --platform linux/arm64 -t dokploy/dokploy:${TAG}-arm64 .
docker push dokploy/dokploy:${TAG}-arm64
combine-manifests:
docker:
- image: cimg/node:18.18.0
steps:
- checkout
- setup_remote_docker
- run:
name: Create and push multi-arch manifest
command: |
docker login -u $DOCKERHUB_USERNAME -p $DOCKERHUB_TOKEN
if [ "${CIRCLE_BRANCH}" == "main" ]; then
VERSION=$(node -p "require('./apps/dokploy/package.json').version")
echo $VERSION
TAG="latest"
docker manifest create dokploy/dokploy:${TAG} \
dokploy/dokploy:${TAG}-amd64 \
dokploy/dokploy:${TAG}-arm64
docker manifest push dokploy/dokploy:${TAG}
docker manifest create dokploy/dokploy:${VERSION} \
dokploy/dokploy:${TAG}-amd64 \
dokploy/dokploy:${TAG}-arm64
docker manifest push dokploy/dokploy:${VERSION}
elif [ "${CIRCLE_BRANCH}" == "canary" ]; then
TAG="canary"
docker manifest create dokploy/dokploy:${TAG} \
dokploy/dokploy:${TAG}-amd64 \
dokploy/dokploy:${TAG}-arm64
docker manifest push dokploy/dokploy:${TAG}
else
TAG="feature"
docker manifest create dokploy/dokploy:${TAG} \
dokploy/dokploy:${TAG}-amd64 \
dokploy/dokploy:${TAG}-arm64
docker manifest push dokploy/dokploy:${TAG}
fi
workflows:
build-all:
jobs:
- build-amd64:
filters:
branches:
only:
- main
- canary
- 379-preview-deployment
- build-arm64:
filters:
branches:
only:
- main
- canary
- 379-preview-deployment
- combine-manifests:
requires:
- build-amd64
- build-arm64
filters:
branches:
only:
- main
- canary
- 379-preview-deployment

View File

@@ -1,6 +1,6 @@
name: Bug Report
description: Create a bug report
labels: ["needs-triage🔍"]
labels: ["bug"]
body:
- type: markdown
attributes:
@@ -62,7 +62,6 @@ body:
- "Docker"
- "Remote server"
- "Local Development"
- "Cloud Version"
validations:
required: true
- type: dropdown

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

View File

@@ -1,83 +0,0 @@
name: Auto PR to main when version changes
on:
push:
branches:
- canary
permissions:
contents: write
pull-requests: write
jobs:
create-pr:
runs-on: ubuntu-latest
steps:
- name: Checkout Repository
uses: actions/checkout@v4
with:
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"
else
VERSION_CHANGED="false"
fi
echo "VERSION_CHANGED=$VERSION_CHANGED" >> $GITHUB_ENV
echo "Comparing versions:"
echo "Current version: ${{ env.VERSION }}"
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
env:
GH_TOKEN: ${{ secrets.GH_PAT }}
- name: Create Pull Request
if: env.VERSION_CHANGED == 'true' && env.PR_EXISTS == '0'
run: |
git config --global user.name "github-actions[bot]"
git config --global user.email "41898282+github-actions[bot]@users.noreply.github.com"
git fetch origin main
git checkout canary
git push origin canary
gh pr create \
--title "🚀 Release ${{ env.VERSION }}" \
--body '
This PR promotes changes from `canary` to `main` for version ${{ env.VERSION }}.
### 🔍 Changes Include:
- Version bump to ${{ env.VERSION }}
- All changes from canary branch
### ✅ Pre-merge Checklist:
- [ ] All tests passing
- [ ] Documentation updated
- [ ] Docker images built and tested
> 🤖 This PR was automatically generated by [GitHub Actions](https://github.com/actions)' \
--base main \
--head canary \
--label "release" --label "automated pr" || true \
--reviewer siumauricio \
--assignee siumauricio
env:
GH_TOKEN: ${{ github.token }}

View File

@@ -2,7 +2,7 @@ name: Build Docker images
on:
push:
branches: ["canary", "main", "feat/monitoring"]
branches: ["canary", "main"]
jobs:
build-and-push-cloud-image:
@@ -17,7 +17,7 @@ jobs:
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push Docker image
uses: docker/build-push-action@v4
with:
@@ -53,7 +53,8 @@ jobs:
push: true
tags: |
siumauricio/schedule:${{ github.ref_name == 'main' && 'latest' || 'canary' }}
platforms: linux/amd64
platforms: linux/amd64
build-and-push-server-image:
runs-on: ubuntu-latest
@@ -76,4 +77,4 @@ jobs:
push: true
tags: |
siumauricio/server:${{ github.ref_name == 'main' && 'latest' || 'canary' }}
platforms: linux/amd64
platforms: linux/amd64

View File

@@ -1,161 +0,0 @@
name: Dokploy Docker Build
on:
push:
branches: [main, canary, "1061-custom-docker-service-hostname"]
env:
IMAGE_NAME: dokploy/dokploy
jobs:
docker-amd:
runs-on: ubuntu-22.04
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Set tag and version
id: meta
run: |
if [ "${{ github.ref }}" = "refs/heads/main" ]; then
TAG="latest"
VERSION=$(node -p "require('./apps/dokploy/package.json').version")
elif [ "${{ github.ref }}" = "refs/heads/canary" ]; then
TAG="canary"
else
TAG="feature"
fi
echo "tags=${IMAGE_NAME}:${TAG}-amd64" >> $GITHUB_OUTPUT
- name: Prepare env file
run: |
cp apps/dokploy/.env.production.example .env.production
cp apps/dokploy/.env.production.example apps/dokploy/.env.production
- name: Build and push
uses: docker/build-push-action@v5
with:
context: .
platforms: linux/amd64
push: true
tags: ${{ steps.meta.outputs.tags }}
docker-arm:
runs-on: ubuntu-24.04-arm
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Set tag and version
id: meta
run: |
VERSION=$(node -p "require('./apps/dokploy/package.json').version")
if [ "${{ github.ref }}" = "refs/heads/main" ]; then
TAG="latest"
VERSION=$(node -p "require('./apps/dokploy/package.json').version")
elif [ "${{ github.ref }}" = "refs/heads/canary" ]; then
TAG="canary"
else
TAG="feature"
fi
echo "tags=${IMAGE_NAME}:${TAG}-arm64" >> $GITHUB_OUTPUT
- name: Prepare env file
run: |
cp apps/dokploy/.env.production.example .env.production
cp apps/dokploy/.env.production.example apps/dokploy/.env.production
- name: Build and push
uses: docker/build-push-action@v5
with:
context: .
platforms: linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
combine-manifests:
needs: [docker-amd, docker-arm]
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Create and push manifests
run: |
if [ "${{ github.ref }}" = "refs/heads/main" ]; then
VERSION=$(node -p "require('./apps/dokploy/package.json').version")
TAG="latest"
docker buildx imagetools create -t ${IMAGE_NAME}:${TAG} \
${IMAGE_NAME}:${TAG}-amd64 \
${IMAGE_NAME}:${TAG}-arm64
docker buildx imagetools create -t ${IMAGE_NAME}:${VERSION} \
${IMAGE_NAME}:${TAG}-amd64 \
${IMAGE_NAME}:${TAG}-arm64
elif [ "${{ github.ref }}" = "refs/heads/canary" ]; then
TAG="canary"
docker buildx imagetools create -t ${IMAGE_NAME}:${TAG} \
${IMAGE_NAME}:${TAG}-amd64 \
${IMAGE_NAME}:${TAG}-arm64
else
TAG="feature"
docker buildx imagetools create -t ${IMAGE_NAME}:${TAG} \
${IMAGE_NAME}:${TAG}-amd64 \
${IMAGE_NAME}:${TAG}-arm64
fi
generate-release:
needs: [combine-manifests]
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Get version
id: get_version
run: |
VERSION=$(node -p "require('./apps/dokploy/package.json').version")
echo "version=$VERSION" >> $GITHUB_OUTPUT
- name: Create Release
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ steps.get_version.outputs.version }}
name: ${{ steps.get_version.outputs.version }}
generate_release_notes: true
draft: false
prerelease: false
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -1,22 +0,0 @@
name: autofix.ci
on:
push:
branches: [canary]
pull_request:
branches: [canary]
jobs:
format:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Setup biomeJs
uses: biomejs/setup-biome@v2
- name: Run Biome formatter
run: biome format . --write
- uses: autofix-ci/action@551dded8c6cc8a1054039c8bc0b8b48c51dfc6ef

View File

@@ -1,118 +0,0 @@
name: Dokploy Monitoring Build
on:
push:
branches: [main, canary]
env:
IMAGE_NAME: dokploy/monitoring
jobs:
docker-amd:
runs-on: ubuntu-22.04
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Set tag
id: meta
run: |
if [ "${{ github.ref }}" = "refs/heads/main" ]; then
TAG="latest"
elif [ "${{ github.ref }}" = "refs/heads/canary" ]; then
TAG="canary"
else
TAG="feature"
fi
echo "tags=${IMAGE_NAME}:${TAG}-amd64" >> $GITHUB_OUTPUT
- name: Build and push
uses: docker/build-push-action@v5
with:
context: .
file: ./Dockerfile.monitoring
platforms: linux/amd64
push: true
tags: ${{ steps.meta.outputs.tags }}
docker-arm:
runs-on: ubuntu-24.04-arm
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Set
id: meta
run: |
if [ "${{ github.ref }}" = "refs/heads/main" ]; then
TAG="latest"
elif [ "${{ github.ref }}" = "refs/heads/canary" ]; then
TAG="canary"
else
TAG="feature"
fi
echo "tags=${IMAGE_NAME}:${TAG}-arm64" >> $GITHUB_OUTPUT
- name: Build and push
uses: docker/build-push-action@v5
with:
context: .
file: ./Dockerfile.monitoring
platforms: linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
combine-manifests:
needs: [docker-amd, docker-arm]
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Create and push manifests
run: |
if [ "${{ github.ref }}" = "refs/heads/main" ]; then
TAG="latest"
docker buildx imagetools create -t ${IMAGE_NAME}:${TAG} \
${IMAGE_NAME}:${TAG}-amd64 \
${IMAGE_NAME}:${TAG}-arm64
elif [ "${{ github.ref }}" = "refs/heads/canary" ]; then
TAG="canary"
docker buildx imagetools create -t ${IMAGE_NAME}:${TAG} \
${IMAGE_NAME}:${TAG}-amd64 \
${IMAGE_NAME}:${TAG}-arm64
else
TAG="feature"
docker buildx imagetools create -t ${IMAGE_NAME}:${TAG} \
${IMAGE_NAME}:${TAG}-amd64 \
${IMAGE_NAME}:${TAG}-arm64
fi

View File

@@ -12,7 +12,7 @@ jobs:
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 20.16.0
node-version: 18.18.0
cache: "pnpm"
- run: pnpm install --frozen-lockfile
- run: pnpm run server:build
@@ -26,7 +26,7 @@ jobs:
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 20.16.0
node-version: 18.18.0
cache: "pnpm"
- run: pnpm install --frozen-lockfile
- run: pnpm run server:build
@@ -39,7 +39,7 @@ jobs:
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 20.16.0
node-version: 18.18.0
cache: "pnpm"
- run: pnpm install --frozen-lockfile
- run: pnpm run server:build

4
.gitignore vendored
View File

@@ -34,11 +34,9 @@ yarn-debug.log*
yarn-error.log*
# Editor
.vscode
.idea
# Misc
.DS_Store
*.pem
.db

2
.nvmrc
View File

@@ -1 +1 @@
20.16.0
18.18.0

View File

@@ -14,10 +14,12 @@ We have a few guidelines to follow when contributing to this project:
## Commit Convention
Before you create a Pull Request, please make sure your commit message follows the [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/) specification.
### Commit Message Format
```
<type>[optional scope]: <description>
@@ -52,8 +54,6 @@ 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.
```bash
git clone https://github.com/dokploy/dokploy.git
cd dokploy
@@ -61,9 +61,9 @@ pnpm install
cp apps/dokploy/.env.example apps/dokploy/.env
```
## Requirements
## Development
- [Docker](/GUIDES.md#docker)
Is required to have **Docker** installed on your machine.
### Setup
@@ -73,10 +73,9 @@ Run the command that will spin up all the required services and files.
pnpm run dokploy:setup
```
Run this script
Run this script
```bash
pnpm run server:script
pnpm run server:script
```
Now run the development server.
@@ -87,8 +86,6 @@ pnpm run dokploy:dev
Go to http://localhost:3000 to see the development server
Note: this project uses Biome. If your editor is configured to use another formatter such as Prettier, it's recommended to either change it to use Biome or turn it off.
## Build
```bash
@@ -140,14 +137,9 @@ curl -sSL https://nixpacks.com/install.sh -o install.sh \
&& ./install.sh
```
```bash
# Install Railpack
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
@@ -165,7 +157,85 @@ 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.
To add a new template, go to `templates` folder and create a new folder with the name of the template.
Let's take the example of `plausible` template.
1. create a folder in `templates/plausible`
2. create a `docker-compose.yml` file inside the folder with the content of compose.
3. create a `index.ts` file inside the folder with the following code as base:
4. When creating a pull request, please provide a video of the template working in action.
```typescript
// EXAMPLE
import {
generateHash,
generateRandomDomain,
type Template,
type Schema,
type DomainSchema,
} from "../utils";
export function generate(schema: Schema): Template {
// do your stuff here, like create a new domain, generate random passwords, mounts.
const mainServiceHash = generateHash(schema.projectName);
const mainDomain = generateRandomDomain(schema);
const secretBase = generateBase64(64);
const toptKeyBase = generateBase64(32);
const domains: DomainSchema[] = [
{
host: mainDomain,
port: 8000,
serviceName: "plausible",
},
];
const envs = [
`BASE_URL=http://${mainDomain}`,
`SECRET_KEY_BASE=${secretBase}`,
`TOTP_VAULT_KEY=${toptKeyBase}`,
`HASH=${mainServiceHash}`,
];
const mounts: Template["mounts"] = [
{
mountPath: "./clickhouse/clickhouse-config.xml",
content: `some content......`,
},
];
return {
envs,
mounts,
domains,
};
}
```
4. Now you need to add the information about the template to the `templates/templates.ts` is a object with the following properties:
**Make sure the id of the template is the same as the folder name and don't have any spaces, only slugified names and lowercase.**
```typescript
{
id: "plausible",
name: "Plausible",
version: "v2.1.0",
description:
"Plausible is a open source, self-hosted web analytics platform that lets you track website traffic and user behavior.",
logo: "plausible.svg", // we defined the name and the extension of the logo
links: {
github: "https://github.com/plausible/plausible",
website: "https://plausible.io/",
docs: "https://plausible.io/docs",
},
tags: ["analytics"],
load: () => import("./plausible/index").then((m) => m.generate),
},
```
5. Add the logo or image of the template to `public/templates/plausible.svg`
### Recommendations
@@ -177,3 +247,4 @@ To add a new template, go to `https://github.com/Dokploy/templates` repository a
## Docs & Website
To contribute to the Dokploy docs or website, please go to this [repository](https://github.com/Dokploy/website).

View File

@@ -1,5 +1,4 @@
# syntax=docker/dockerfile:1
FROM node:20.9-slim AS base
FROM node:18-slim AS base
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN corepack enable
@@ -8,7 +7,7 @@ FROM base AS build
COPY . /usr/src/app
WORKDIR /usr/src/app
RUN apt-get update && apt-get install -y python3 make g++ git python3-pip pkg-config libsecret-1-dev && rm -rf /var/lib/apt/lists/*
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
@@ -30,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 apache2-utils && rm -rf /var/lib/apt/lists/*
# Copy only the necessary files
COPY --from=build /prod/dokploy/.next ./.next
@@ -49,19 +48,13 @@ 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
RUN curl -sSL https://nixpacks.com/install.sh -o install.sh \
&& chmod +x install.sh \
&& ./install.sh \
&& pnpm install -g tsx
# Install Railpack
ARG RAILPACK_VERSION=0.0.64
RUN curl -sSL https://railpack.com/install.sh | bash
# Install buildpacks
COPY --from=buildpacksio/pack:0.35.0 /usr/local/bin/pack /usr/local/bin/pack
EXPOSE 3000
CMD [ "pnpm", "start" ]
CMD [ "pnpm", "start" ]

View File

@@ -1,5 +1,4 @@
# syntax=docker/dockerfile:1
FROM node:20.9-slim AS base
FROM node:18-slim AS base
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN corepack enable
@@ -8,7 +7,7 @@ FROM base AS build
COPY . /usr/src/app
WORKDIR /usr/src/app
RUN apt-get update && apt-get install -y python3 make g++ git python3-pip pkg-config libsecret-1-dev && rm -rf /var/lib/apt/lists/*
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 --filter=@dokploy/server --filter=./apps/dokploy install --frozen-lockfile

View File

@@ -1,42 +0,0 @@
# syntax=docker/dockerfile:1
# Build stage
FROM golang:1.21-alpine3.19 AS builder
# Instalar dependencias necesarias
RUN apk add --no-cache gcc musl-dev sqlite-dev
# Establecer el directorio de trabajo
WORKDIR /app
# Copiar todo el código fuente primero
COPY . .
# Movernos al directorio de la aplicación golang
WORKDIR /app/apps/monitoring
# Descargar dependencias
RUN go mod download
# Compilar la aplicación
RUN CGO_ENABLED=1 GOOS=linux go build -o main main.go
# Etapa final
FROM alpine:3.19
# Instalar SQLite y otras dependencias necesarias
RUN apk add --no-cache sqlite-libs docker-cli
WORKDIR /app
# Copiar el binario compilado y el archivo monitor.go
COPY --from=builder /app/apps/monitoring/main ./main
COPY --from=builder /app/apps/monitoring/main.go ./monitor.go
# COPY --from=builder /app/apps/golang/.env ./.env
# Exponer el puerto
ENV PORT=3001
EXPOSE 3001
# Ejecutar la aplicación
CMD ["./main"]

View File

@@ -1,5 +1,4 @@
# syntax=docker/dockerfile:1
FROM node:20.9-slim AS base
FROM node:18-slim AS base
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN corepack enable
@@ -8,7 +7,7 @@ FROM base AS build
COPY . /usr/src/app
WORKDIR /usr/src/app
RUN apt-get update && apt-get install -y python3 make g++ git python3-pip pkg-config libsecret-1-dev && rm -rf /var/lib/apt/lists/*
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 --filter=@dokploy/server --filter=./apps/schedules install --frozen-lockfile

View File

@@ -1,5 +1,4 @@
# syntax=docker/dockerfile:1
FROM node:20.9-slim AS base
FROM node:18-slim AS base
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN corepack enable
@@ -8,7 +7,7 @@ FROM base AS build
COPY . /usr/src/app
WORKDIR /usr/src/app
RUN apt-get update && apt-get install -y python3 make g++ git python3-pip pkg-config libsecret-1-dev && rm -rf /var/lib/apt/lists/*
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 --filter=@dokploy/server --filter=./apps/api install --frozen-lockfile

View File

@@ -1,49 +0,0 @@
# Docker
Here's how to install docker on different operating systems:
## macOS
1. Visit [Docker Desktop for Mac](https://www.docker.com/products/docker-desktop)
2. Download the Docker Desktop installer
3. Double-click the downloaded `.dmg` file
4. Drag Docker to your Applications folder
5. Open Docker Desktop from Applications
6. Follow the onboarding tutorial if desired
## Linux
### Ubuntu
```bash
# Update package index
sudo apt-get update
# Install prerequisites
sudo apt-get install \
apt-transport-https \
ca-certificates \
curl \
gnupg \
lsb-release
# Add Docker's official GPG key
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /usr/share/keyrings/docker-archive-keyring.gpg
# Set up stable repository
echo \
"deb [arch=amd64 signed-by=/usr/share/keyrings/docker-archive-keyring.gpg] https://download.docker.com/linux/ubuntu \
$(lsb_release -cs) stable" | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
# Install Docker Engine
sudo apt-get update
sudo apt-get install docker-ce docker-ce-cli containerd.io
```
## Windows
1. Enable WSL2 if not already enabled
2. Visit [Docker Desktop for Windows](https://www.docker.com/products/docker-desktop)
3. Download the installer
4. Run the installer and follow the prompts
5. Start Docker Desktop from the Start menu

View File

@@ -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

@@ -71,39 +71,16 @@ For detailed documentation, visit [docs.dokploy.com](https://docs.dokploy.com).
<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 🥇
<div style="display: flex; align-items: center; gap: 20px;">
<a href="https://supafort.com/?ref=dokploy" target="_blank" style="display: inline-block; margin-right: 20px;">
<img src="https://supafort.com/build/q-4Ht4rBZR.webp" alt="Supafort.com" height="50"/>
</a>
<a href="https://agentdock.ai/?ref=dokploy" target="_blank" style="display: inline-block; margin-right: 50px;">
<img src=".github/sponsors/agentdock.png" alt="agentdock.ai" height="70"/>
</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 🥈
<div style="display: flex; align-items: center; gap: 20px;">
<a href="https://americancloud.com/?ref=dokploy" target="_blank" style="display: inline-block; padding: 10px; border-radius: 10px;">
<img src=".github/sponsors/american-cloud.png" alt="AmericanCloud" height="70"/>
</a>
</div>
<!-- Elite Contributors 🥈 -->
<!-- Add Elite Contributors here -->
### Supporting Members 🥉
@@ -112,13 +89,8 @@ For detailed documentation, visit [docs.dokploy.com](https://docs.dokploy.com).
<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;">
@@ -148,6 +120,19 @@ For detailed documentation, visit [docs.dokploy.com](https://docs.dokploy.com).
<img src="https://dokploy.com/banner.png" alt="Watch the video" width="400" style="border-radius:20px;"/>
</a>
<!-- ## 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

@@ -5,29 +5,29 @@
"scripts": {
"dev": "PORT=4000 tsx watch src/index.ts",
"build": "tsc --project tsconfig.json",
"start": "node dist/index.js",
"start": "node --experimental-specifier-resolution=node dist/index.js",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@dokploy/server": "workspace:*",
"@hono/node-server": "^1.12.1",
"@hono/zod-validator": "0.3.0",
"@nerimity/mimiqueue": "1.2.3",
"dotenv": "^16.3.1",
"hono": "^4.5.8",
"pino": "9.4.0",
"pino-pretty": "11.2.2",
"@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.23.4"
"@nerimity/mimiqueue": "1.2.3"
},
"devDependencies": {
"@types/node": "^20.11.17",
"typescript": "^5.4.2",
"@types/react": "^18.2.37",
"@types/react-dom": "^18.2.15",
"tsx": "^4.7.1",
"typescript": "^5.4.2"
"@types/node": "^20.11.17",
"tsx": "^4.7.1"
},
"packageManager": "pnpm@9.5.0"
}

View File

@@ -4,9 +4,9 @@ import "dotenv/config";
import { zValidator } from "@hono/zod-validator";
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";
import { logger } from "./logger";
import { type DeployJob, deployJobSchema } from "./schema";
import { deploy } from "./utils";
const app = new Hono();
const redisClient = createClient({
@@ -28,7 +28,7 @@ app.use(async (c, next) => {
app.post("/deploy", zValidator("json", deployJobSchema), (c) => {
const data = c.req.valid("json");
queue.add(data, { groupName: data.serverId });
const res = queue.add(data, { groupName: data.serverId });
return c.json(
{
message: "Deployment Added",

View File

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

View File

@@ -1 +1 @@
20.16.0
18.18.0

View File

@@ -0,0 +1,242 @@
# Contributing
Hey, thanks for your interest in contributing to Dokploy! We appreciate your help and taking your time to contribute.
Before you start, please first discuss the feature/bug you want to add with the owners and comunity via github issues.
We have a few guidelines to follow when contributing to this project:
- [Commit Convention](#commit-convention)
- [Setup](#setup)
- [Development](#development)
- [Build](#build)
- [Pull Request](#pull-request)
## Commit Convention
Before you craete a Pull Request, please make sure your commit message follows the [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/) specification.
### Commit Message Format
```
<type>[optional scope]: <description>
[optional body]
[optional footer(s)]
```
#### Type
Must be one of the following:
* **feat**: A new feature
* **fix**: A bug fix
* **docs**: Documentation only changes
* **style**: Changes that do not affect the meaning of the code (white-space, formatting, missing semi-colons, etc)
* **refactor**: A code change that neither fixes a bug nor adds a feature
* **perf**: A code change that improves performance
* **test**: Adding missing tests or correcting existing tests
* **build**: Changes that affect the build system or external dependencies (example scopes: gulp, broccoli, npm)
* **ci**: Changes to our CI configuration files and scripts (example scopes: Travis, Circle, BrowserStack, SauceLabs)
* **chore**: Other changes that don't modify `src` or `test` files
* **revert**: Reverts a previous commit
Example:
```
feat: add new feature
```
## Setup
Before you start, please make the clone based on the `canary` branch, since the `main` branch is the source of truth and should always reflect the latest stable release, also the PRs will be merged to the `canary` branch.
```bash
git clone https://github.com/dokploy/dokploy.git
cd dokploy
pnpm install
cp .env.example .env
```
## Development
Is required to have **Docker** installed on your machine.
### Setup
Run the command that will spin up all the required services and files.
```bash
pnpm run setup
```
Now run the development server.
```bash
pnpm run dev
```
Go to http://localhost:3000 to see the development server
## Build
```bash
pnpm run build
```
## Docker
To build the docker image
```bash
pnpm run docker:build
```
To push the docker image
```bash
pnpm run docker:push
```
## Password Reset
In the case you lost your password, you can reset it using the following command
```bash
pnpm run reset-password
```
If you want to test the webhooks on development mode using localtunnel, make sure to install `localtunnel`
```bash
bunx lt --port 3000
```
If you run into permission issues of docker run the following command
```bash
sudo chown -R USERNAME dokploy or sudo chown -R $(whoami) ~/.docker
```
## Application deploy
In case you want to deploy the application on your machine and you selected nixpacks or buildpacks, you need to install first.
```bash
# Install Nixpacks
curl -sSL https://nixpacks.com/install.sh -o install.sh \
&& chmod +x install.sh \
&& ./install.sh
```
```bash
# Install Buildpacks
curl -sSL "https://github.com/buildpacks/pack/releases/download/v0.32.1/pack-v0.32.1-linux.tgz" | tar -C /usr/local/bin/ --no-same-owner -xzv pack
```
## Pull Request
- The `main` branch is the source of truth and should always reflect the latest stable release.
- Create a new branch for each feature or bug fix.
- Make sure to add tests for your changes.
- Make sure to update the documentation for any changes Go to the [docs.dokploy.com](https://docs.dokploy.com) website to see the changes.
- When creating a pull request, please provide a clear and concise description of the changes made.
- If you include a video or screenshot, would be awesome so we can see the changes in action.
- If your pull request fixes an open issue, please reference the issue in the pull request description.
- Once your pull request is merged, you will be automatically added as a contributor to the project.
Thank you for your contribution!
## Templates
To add a new template, go to `templates` folder and create a new folder with the name of the template.
Let's take the example of `plausible` template.
1. create a folder in `templates/plausible`
2. create a `docker-compose.yml` file inside the folder with the content of compose.
3. create a `index.ts` file inside the folder with the following code as base:
4. When creating a pull request, please provide a video of the template working in action.
```typescript
// EXAMPLE
import {
generateHash,
generateRandomDomain,
type Template,
type Schema,
} from "../utils";
export function generate(schema: Schema): Template {
// do your stuff here, like create a new domain, generate random passwords, mounts.
const mainServiceHash = generateHash(schema.projectName);
const randomDomain = generateRandomDomain(schema);
const secretBase = generateBase64(64);
const toptKeyBase = generateBase64(32);
const envs = [
// If you want to show a domain in the UI, please add the prefix _HOST at the end of the variable name.
`PLAUSIBLE_HOST=${randomDomain}`,
"PLAUSIBLE_PORT=8000",
`BASE_URL=http://${randomDomain}`,
`SECRET_KEY_BASE=${secretBase}`,
`TOTP_VAULT_KEY=${toptKeyBase}`,
`HASH=${mainServiceHash}`,
];
const mounts: Template["mounts"] = [
{
mountPath: "./clickhouse/clickhouse-config.xml",
content: `some content......`,
},
];
return {
envs,
mounts,
};
}
```
4. Now you need to add the information about the template to the `templates/templates.ts` is a object with the following properties:
**Make sure the id of the template is the same as the folder name and don't have any spaces, only slugified names and lowercase.**
```typescript
{
id: "plausible",
name: "Plausible",
version: "v2.1.0",
description:
"Plausible is a open source, self-hosted web analytics platform that lets you track website traffic and user behavior.",
logo: "plausible.svg", // we defined the name and the extension of the logo
links: {
github: "https://github.com/plausible/plausible",
website: "https://plausible.io/",
docs: "https://plausible.io/docs",
},
tags: ["analytics"],
load: () => import("./plausible/index").then((m) => m.generate),
},
```
5. Add the logo or image of the template to `public/templates/plausible.svg`
### Recomendations
- Use the same name of the folder as the id of the template.
- The logo should be in the public folder.
- If you want to show a domain in the UI, please add the prefix _HOST at the end of the variable name.
- Test first on a vps or a server to make sure the template works.

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" ]

View File

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

View File

@@ -9,7 +9,6 @@ describe("createDomainLabels", () => {
port: 8080,
https: false,
uniqueConfigKey: 1,
customCertResolver: null,
certificateType: "none",
applicationId: "",
composeId: "",

View File

@@ -293,6 +293,29 @@ networks:
dokploy-network:
`;
const expectedComposeFile7 = `
version: "3.8"
services:
web:
image: nginx:latest
networks:
- dokploy-network
networks:
dokploy-network:
driver: bridge
driver_opts:
com.docker.network.driver.mtu: 1200
backend:
driver: bridge
attachable: true
external_network:
external: true
name: dokploy-network
`;
test("It shoudn't add suffix to dokploy-network", () => {
const composeData = load(composeFile7) as ComposeSpecification;

View File

@@ -1,7 +1,7 @@
import { generateRandomHash } from "@dokploy/server";
import { addSuffixToSecretsRoot } from "@dokploy/server";
import type { ComposeSpecification } from "@dokploy/server";
import { load } from "js-yaml";
import { dump, load } from "js-yaml";
import { expect, test } from "vitest";
test("Generate random hash with 8 characters", () => {

View File

@@ -1006,7 +1006,7 @@ services:
volumes:
db-config-testhash:
`);
`) as ComposeSpecification;
test("Expect to change the suffix in all the possible places (4 Try)", () => {
const composeData = load(composeFileComplex) as ComposeSpecification;
@@ -1115,60 +1115,3 @@ test("Expect to change the suffix in all the possible places (5 Try)", () => {
expect(updatedComposeData).toEqual(expectedDockerComposeExample1);
});
const composeFileBackrest = `
services:
backrest:
image: garethgeorge/backrest:v1.7.3
restart: unless-stopped
ports:
- 9898
environment:
- BACKREST_PORT=9898
- BACKREST_DATA=/data
- BACKREST_CONFIG=/config/config.json
- XDG_CACHE_HOME=/cache
- TZ=\${TZ}
volumes:
- backrest/data:/data
- backrest/config:/config
- backrest/cache:/cache
- /:/userdata:ro
volumes:
backrest:
backrest-cache:
`;
const expectedDockerComposeBackrest = load(`
services:
backrest:
image: garethgeorge/backrest:v1.7.3
restart: unless-stopped
ports:
- 9898
environment:
- BACKREST_PORT=9898
- BACKREST_DATA=/data
- BACKREST_CONFIG=/config/config.json
- XDG_CACHE_HOME=/cache
- TZ=\${TZ}
volumes:
- backrest-testhash/data:/data
- backrest-testhash/config:/config
- backrest-testhash/cache:/cache
- /:/userdata:ro
volumes:
backrest-testhash:
backrest-cache-testhash:
`) as ComposeSpecification;
test("Should handle volume paths with subdirectories correctly", () => {
const composeData = load(composeFileBackrest) as ComposeSpecification;
const suffix = "testhash";
const updatedComposeData = addSuffixToAllVolumes(composeData, suffix);
expect(updatedComposeData).toEqual(expectedDockerComposeBackrest);
});

View File

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

View File

@@ -1,98 +0,0 @@
import { extractCommitMessage } from "@/pages/api/deploy/[refreshToken]";
import { describe, expect, it } from "vitest";
describe("GitHub Webhook Skip CI", () => {
const mockGithubHeaders = {
"x-github-event": "push",
};
const createMockBody = (message: string) => ({
head_commit: {
message,
},
});
const skipKeywords = [
"[skip ci]",
"[ci skip]",
"[no ci]",
"[skip actions]",
"[actions skip]",
];
it("should detect skip keywords in commit message", () => {
for (const keyword of skipKeywords) {
const message = `feat: add new feature ${keyword}`;
const commitMessage = extractCommitMessage(
mockGithubHeaders,
createMockBody(message),
);
expect(commitMessage.includes(keyword)).toBe(true);
}
});
it("should not detect skip keywords in normal commit message", () => {
const message = "feat: add new feature";
const commitMessage = extractCommitMessage(
mockGithubHeaders,
createMockBody(message),
);
for (const keyword of skipKeywords) {
expect(commitMessage.includes(keyword)).toBe(false);
}
});
it("should handle different webhook sources", () => {
// GitHub
expect(
extractCommitMessage(
{ "x-github-event": "push" },
{ head_commit: { message: "[skip ci] test" } },
),
).toBe("[skip ci] test");
// GitLab
expect(
extractCommitMessage(
{ "x-gitlab-event": "push" },
{ commits: [{ message: "[skip ci] test" }] },
),
).toBe("[skip ci] test");
// Bitbucket
expect(
extractCommitMessage(
{ "x-event-key": "repo:push" },
{
push: {
changes: [{ new: { target: { message: "[skip ci] test" } } }],
},
},
),
).toBe("[skip ci] test");
// Gitea
expect(
extractCommitMessage(
{ "x-gitea-event": "push" },
{ commits: [{ message: "[skip ci] test" }] },
),
).toBe("[skip ci] test");
});
it("should handle missing commit message", () => {
expect(extractCommitMessage(mockGithubHeaders, {})).toBe("NEW COMMIT");
expect(extractCommitMessage({ "x-gitlab-event": "push" }, {})).toBe(
"NEW COMMIT",
);
expect(
extractCommitMessage(
{ "x-event-key": "repo:push" },
{ push: { changes: [] } },
),
).toBe("NEW COMMIT");
expect(extractCommitMessage({ "x-gitea-event": "push" }, {})).toBe(
"NEW COMMIT",
);
});
});

View File

@@ -27,16 +27,7 @@ if (typeof window === "undefined") {
const baseApp: ApplicationNested = {
applicationId: "",
herokuVersion: "",
giteaBranch: "",
giteaBuildPath: "",
giteaId: "",
giteaOwner: "",
giteaRepository: "",
cleanCache: false,
watchPaths: [],
enableSubmodules: false,
applicationStatus: "done",
triggerType: "push",
appName: "",
autoDeploy: true,
serverId: "",
@@ -46,7 +37,6 @@ const baseApp: ApplicationNested = {
isPreviewDeploymentsActive: false,
previewBuildArgs: null,
previewCertificateType: "none",
previewCustomCertResolver: null,
previewEnv: null,
previewHttps: false,
previewPath: "/",
@@ -55,7 +45,7 @@ const baseApp: ApplicationNested = {
previewWildcard: "",
project: {
env: "",
organizationId: "",
adminId: "",
name: "",
description: "",
createdAt: "",
@@ -105,7 +95,6 @@ const baseApp: ApplicationNested = {
ports: [],
projectId: "",
publishDirectory: null,
isStaticSpa: null,
redirects: [],
refreshToken: "",
registry: null,
@@ -121,7 +110,6 @@ const baseApp: ApplicationNested = {
updateConfigSwarm: null,
username: null,
dockerContextPath: null,
rollbackActive: false,
};
describe("unzipDrop using real zip files", () => {
@@ -151,68 +139,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

@@ -1,497 +0,0 @@
import type { Schema } from "@dokploy/server/templates";
import type { CompleteTemplate } from "@dokploy/server/templates/processors";
import { processTemplate } from "@dokploy/server/templates/processors";
import { describe, expect, it } from "vitest";
describe("processTemplate", () => {
// Mock schema for testing
const mockSchema: Schema = {
projectName: "test",
serverIp: "127.0.0.1",
};
describe("variables processing", () => {
it("should process basic variables with utility functions", () => {
const template: CompleteTemplate = {
metadata: {} as any,
variables: {
main_domain: "${domain}",
secret_base: "${base64:64}",
totp_key: "${base64:32}",
password: "${password:32}",
hash: "${hash:16}",
},
config: {
domains: [],
env: {},
},
};
const result = processTemplate(template, mockSchema);
expect(result.envs).toHaveLength(0);
expect(result.domains).toHaveLength(0);
expect(result.mounts).toHaveLength(0);
});
it("should allow referencing variables in other variables", () => {
const template: CompleteTemplate = {
metadata: {} as any,
variables: {
main_domain: "${domain}",
api_domain: "api.${main_domain}",
},
config: {
domains: [],
env: {},
},
};
const result = processTemplate(template, mockSchema);
expect(result.envs).toHaveLength(0);
expect(result.domains).toHaveLength(0);
expect(result.mounts).toHaveLength(0);
});
it("should allow creation of real jwt secret", () => {
const template: CompleteTemplate = {
metadata: {} as any,
variables: {
jwt_secret: "cQsdycq1hDLopQonF6jUTqgQc5WEZTwWLL02J6XJ",
anon_payload: JSON.stringify({
role: "tester",
iss: "dockploy",
iat: "${timestamps:2025-01-01T00:00:00Z}",
exp: "${timestamps:2030-01-01T00:00:00Z}",
}),
anon_key: "${jwt:jwt_secret:anon_payload}",
},
config: {
domains: [],
env: {
ANON_KEY: "${anon_key}",
},
},
};
const result = processTemplate(template, mockSchema);
expect(result.envs).toHaveLength(1);
expect(result.envs).toContain(
"ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpYXQiOiIxNzM1Njg5NjAwIiwiZXhwIjoiMTg5MzQ1NjAwMCIsInJvbGUiOiJ0ZXN0ZXIiLCJpc3MiOiJkb2NrcGxveSJ9.BG5JoxL2_NaTFbPgyZdm3kRWenf_O3su_HIRKGCJ_kY",
);
expect(result.mounts).toHaveLength(0);
expect(result.domains).toHaveLength(0);
});
});
describe("domains processing", () => {
it("should process domains with explicit host", () => {
const template: CompleteTemplate = {
metadata: {} as any,
variables: {
main_domain: "${domain}",
},
config: {
domains: [
{
serviceName: "plausible",
port: 8000,
host: "${main_domain}",
},
],
env: {},
},
};
const result = processTemplate(template, mockSchema);
expect(result.domains).toHaveLength(1);
const domain = result.domains[0];
expect(domain).toBeDefined();
if (!domain) return;
expect(domain).toMatchObject({
serviceName: "plausible",
port: 8000,
});
expect(domain.host).toBeDefined();
expect(domain.host).toContain(mockSchema.projectName);
});
it("should generate random domain if host is not specified", () => {
const template: CompleteTemplate = {
metadata: {} as any,
variables: {},
config: {
domains: [
{
serviceName: "plausible",
port: 8000,
},
],
env: {},
},
};
const result = processTemplate(template, mockSchema);
expect(result.domains).toHaveLength(1);
const domain = result.domains[0];
expect(domain).toBeDefined();
if (!domain || !domain.host) return;
expect(domain.host).toBeDefined();
expect(domain.host).toContain(mockSchema.projectName);
});
it("should allow using ${domain} directly in host", () => {
const template: CompleteTemplate = {
metadata: {} as any,
variables: {},
config: {
domains: [
{
serviceName: "plausible",
port: 8000,
host: "${domain}",
},
],
env: {},
},
};
const result = processTemplate(template, mockSchema);
expect(result.domains).toHaveLength(1);
const domain = result.domains[0];
expect(domain).toBeDefined();
if (!domain || !domain.host) return;
expect(domain.host).toBeDefined();
expect(domain.host).toContain(mockSchema.projectName);
});
});
describe("environment variables processing", () => {
it("should process env vars with variable references", () => {
const template: CompleteTemplate = {
metadata: {} as any,
variables: {
main_domain: "${domain}",
secret_base: "${base64:64}",
},
config: {
domains: [],
env: {
BASE_URL: "http://${main_domain}",
SECRET_KEY_BASE: "${secret_base}",
},
},
};
const result = processTemplate(template, mockSchema);
expect(result.envs).toHaveLength(2);
const baseUrl = result.envs.find((env: string) =>
env.startsWith("BASE_URL="),
);
const secretKey = result.envs.find((env: string) =>
env.startsWith("SECRET_KEY_BASE="),
);
expect(baseUrl).toBeDefined();
expect(secretKey).toBeDefined();
if (!baseUrl || !secretKey) return;
expect(baseUrl).toContain(mockSchema.projectName);
const base64Value = secretKey.split("=")[1];
expect(base64Value).toBeDefined();
if (!base64Value) return;
expect(base64Value).toMatch(/^[A-Za-z0-9+/]+={0,2}$/);
expect(base64Value.length).toBeGreaterThanOrEqual(86);
expect(base64Value.length).toBeLessThanOrEqual(88);
});
it("should process env vars when provided as an array", () => {
const template: CompleteTemplate = {
metadata: {} as any,
variables: {},
config: {
domains: [],
env: [
'CLOUDFLARE_TUNNEL_TOKEN="<INSERT TOKEN>"',
'ANOTHER_VAR="some value"',
"DOMAIN=${domain}",
],
mounts: [],
},
};
const result = processTemplate(template, mockSchema);
expect(result.envs).toHaveLength(3);
// Should preserve exact format for static values
expect(result.envs[0]).toBe('CLOUDFLARE_TUNNEL_TOKEN="<INSERT TOKEN>"');
expect(result.envs[1]).toBe('ANOTHER_VAR="some value"');
// Should process variables in array items
expect(result.envs[2]).toContain(mockSchema.projectName);
});
it("should allow using utility functions directly in env vars", () => {
const template: CompleteTemplate = {
metadata: {} as any,
variables: {},
config: {
domains: [],
env: {
RANDOM_DOMAIN: "${domain}",
SECRET_KEY: "${base64:32}",
},
},
};
const result = processTemplate(template, mockSchema);
expect(result.envs).toHaveLength(2);
const randomDomainEnv = result.envs.find((env: string) =>
env.startsWith("RANDOM_DOMAIN="),
);
const secretKeyEnv = result.envs.find((env: string) =>
env.startsWith("SECRET_KEY="),
);
expect(randomDomainEnv).toBeDefined();
expect(secretKeyEnv).toBeDefined();
if (!randomDomainEnv || !secretKeyEnv) return;
expect(randomDomainEnv).toContain(mockSchema.projectName);
const base64Value = secretKeyEnv.split("=")[1];
expect(base64Value).toBeDefined();
if (!base64Value) return;
expect(base64Value).toMatch(/^[A-Za-z0-9+/]+={0,2}$/);
expect(base64Value.length).toBeGreaterThanOrEqual(42);
expect(base64Value.length).toBeLessThanOrEqual(44);
});
it("should handle boolean values in env vars when provided as an array", () => {
const template: CompleteTemplate = {
metadata: {} as any,
variables: {},
config: {
domains: [],
env: [
"ENABLE_USER_SIGN_UP=false",
"DEBUG_MODE=true",
"SOME_NUMBER=42",
],
mounts: [],
},
};
const result = processTemplate(template, mockSchema);
expect(result.envs).toHaveLength(3);
expect(result.envs).toContain("ENABLE_USER_SIGN_UP=false");
expect(result.envs).toContain("DEBUG_MODE=true");
expect(result.envs).toContain("SOME_NUMBER=42");
});
it("should handle boolean values in env vars when provided as an object", () => {
const template: CompleteTemplate = {
metadata: {} as any,
variables: {},
config: {
domains: [],
env: {
ENABLE_USER_SIGN_UP: false,
DEBUG_MODE: true,
SOME_NUMBER: 42,
},
},
};
const result = processTemplate(template, mockSchema);
expect(result.envs).toHaveLength(3);
expect(result.envs).toContain("ENABLE_USER_SIGN_UP=false");
expect(result.envs).toContain("DEBUG_MODE=true");
expect(result.envs).toContain("SOME_NUMBER=42");
});
});
describe("mounts processing", () => {
it("should process mounts with variable references", () => {
const template: CompleteTemplate = {
metadata: {} as any,
variables: {
config_path: "/etc/config",
secret_key: "${base64:32}",
},
config: {
domains: [],
env: {},
mounts: [
{
filePath: "${config_path}/config.xml",
content: "secret_key=${secret_key}",
},
],
},
};
const result = processTemplate(template, mockSchema);
expect(result.mounts).toHaveLength(1);
const mount = result.mounts[0];
expect(mount).toBeDefined();
if (!mount) return;
expect(mount.filePath).toContain("/etc/config");
expect(mount.content).toMatch(/secret_key=[A-Za-z0-9+/]{32}/);
});
it("should allow using utility functions directly in mount content", () => {
const template: CompleteTemplate = {
metadata: {} as any,
variables: {},
config: {
domains: [],
env: {},
mounts: [
{
filePath: "/config/secrets.txt",
content: "random_domain=${domain}\nsecret=${base64:32}",
},
],
},
};
const result = processTemplate(template, mockSchema);
expect(result.mounts).toHaveLength(1);
const mount = result.mounts[0];
expect(mount).toBeDefined();
if (!mount) return;
expect(mount.content).toContain(mockSchema.projectName);
expect(mount.content).toMatch(/secret=[A-Za-z0-9+/]{32}/);
});
});
describe("complex template processing", () => {
it("should process a complete template with all features", () => {
const template: CompleteTemplate = {
metadata: {} as any,
variables: {
main_domain: "${domain}",
secret_base: "${base64:64}",
totp_key: "${base64:32}",
},
config: {
domains: [
{
serviceName: "plausible",
port: 8000,
host: "${main_domain}",
},
{
serviceName: "api",
port: 3000,
host: "api.${main_domain}",
},
],
env: {
BASE_URL: "http://${main_domain}",
SECRET_KEY_BASE: "${secret_base}",
TOTP_VAULT_KEY: "${totp_key}",
},
mounts: [
{
filePath: "/config/app.conf",
content: `
domain=\${main_domain}
secret=\${secret_base}
totp=\${totp_key}
`,
},
],
},
};
const result = processTemplate(template, mockSchema);
// Check domains
expect(result.domains).toHaveLength(2);
const [domain1, domain2] = result.domains;
expect(domain1).toBeDefined();
expect(domain2).toBeDefined();
if (!domain1 || !domain2) return;
expect(domain1.host).toBeDefined();
expect(domain1.host).toContain(mockSchema.projectName);
expect(domain2.host).toContain("api.");
expect(domain2.host).toContain(mockSchema.projectName);
// Check env vars
expect(result.envs).toHaveLength(3);
const baseUrl = result.envs.find((env: string) =>
env.startsWith("BASE_URL="),
);
const secretKey = result.envs.find((env: string) =>
env.startsWith("SECRET_KEY_BASE="),
);
const totpKey = result.envs.find((env: string) =>
env.startsWith("TOTP_VAULT_KEY="),
);
expect(baseUrl).toBeDefined();
expect(secretKey).toBeDefined();
expect(totpKey).toBeDefined();
if (!baseUrl || !secretKey || !totpKey) return;
expect(baseUrl).toContain(mockSchema.projectName);
// Check base64 lengths and format
const secretKeyValue = secretKey.split("=")[1];
const totpKeyValue = totpKey.split("=")[1];
expect(secretKeyValue).toBeDefined();
expect(totpKeyValue).toBeDefined();
if (!secretKeyValue || !totpKeyValue) return;
expect(secretKeyValue).toMatch(/^[A-Za-z0-9+/]+={0,2}$/);
expect(secretKeyValue.length).toBeGreaterThanOrEqual(86);
expect(secretKeyValue.length).toBeLessThanOrEqual(88);
expect(totpKeyValue).toMatch(/^[A-Za-z0-9+/]+={0,2}$/);
expect(totpKeyValue.length).toBeGreaterThanOrEqual(42);
expect(totpKeyValue.length).toBeLessThanOrEqual(44);
// Check mounts
expect(result.mounts).toHaveLength(1);
const mount = result.mounts[0];
expect(mount).toBeDefined();
if (!mount) return;
expect(mount.content).toContain(mockSchema.projectName);
expect(mount.content).toMatch(/secret=[A-Za-z0-9+/]{86,88}/);
expect(mount.content).toMatch(/totp=[A-Za-z0-9+/]{42,44}/);
});
});
describe("Should populate envs, domains and mounts in the case we didn't used any variable", () => {
it("should populate envs, domains and mounts in the case we didn't used any variable", () => {
const template: CompleteTemplate = {
metadata: {} as any,
variables: {},
config: {
domains: [
{
serviceName: "plausible",
port: 8000,
host: "${hash}",
},
],
env: {
BASE_URL: "http://${domain}",
SECRET_KEY_BASE: "${password:32}",
TOTP_VAULT_KEY: "${base64:128}",
},
mounts: [
{
filePath: "/config/secrets.txt",
content: "random_domain=${domain}\nsecret=${password:32}",
},
],
},
};
const result = processTemplate(template, mockSchema);
expect(result.envs).toHaveLength(3);
expect(result.domains).toHaveLength(1);
expect(result.mounts).toHaveLength(1);
});
});
});

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

@@ -5,7 +5,7 @@ vi.mock("node:fs", () => ({
default: fs,
}));
import type { FileConfig, User } from "@dokploy/server";
import type { Admin, FileConfig } from "@dokploy/server";
import {
createDefaultServerTraefikConfig,
loadOrCreateConfig,
@@ -13,60 +13,20 @@ import {
} from "@dokploy/server";
import { beforeEach, expect, test, vi } from "vitest";
const baseAdmin: User = {
https: false,
enablePaidFeatures: false,
allowImpersonation: false,
role: "user",
metricsConfig: {
containers: {
refreshRate: 20,
services: {
include: [],
exclude: [],
},
},
server: {
type: "Dokploy",
cronJob: "",
port: 4500,
refreshRate: 20,
retentionDays: 2,
token: "",
thresholds: {
cpu: 0,
memory: 0,
},
urlCallback: "",
},
},
cleanupCacheApplications: false,
cleanupCacheOnCompose: false,
cleanupCacheOnPreviews: false,
createdAt: new Date(),
const baseAdmin: Admin = {
createdAt: "",
authId: "",
adminId: "string",
serverIp: null,
certificateType: "none",
host: null,
letsEncryptEmail: null,
sshPrivateKey: null,
enableDockerCleanup: false,
logCleanupCron: null,
enableLogRotation: false,
serversQuantity: 0,
stripeCustomerId: "",
stripeSubscriptionId: "",
banExpires: new Date(),
banned: true,
banReason: "",
email: "",
expirationDate: "",
id: "",
isRegistered: false,
name: "",
createdAt2: new Date().toISOString(),
emailVerified: false,
image: "",
updatedAt: new Date(),
twoFactorEnabled: false,
};
beforeEach(() => {
@@ -76,6 +36,7 @@ beforeEach(() => {
test("Should read the configuration file", () => {
const config: FileConfig = loadOrCreateConfig("dokploy");
expect(config.http?.routers?.["dokploy-router-app"]?.service).toBe(
"dokploy-service-app",
);
@@ -85,7 +46,6 @@ test("Should apply redirect-to-https", () => {
updateServerTraefik(
{
...baseAdmin,
https: true,
certificateType: "letsencrypt",
},
"example.com",
@@ -117,6 +77,8 @@ test("Should not touch config without host", () => {
});
test("Should remove websecure if https rollback to http", () => {
const originalConfig: FileConfig = loadOrCreateConfig("dokploy");
updateServerTraefik(
{ ...baseAdmin, certificateType: "letsencrypt" },
"example.com",

View File

@@ -5,39 +5,28 @@ import { createRouterConfig } from "@dokploy/server";
import { expect, test } from "vitest";
const baseApp: ApplicationNested = {
rollbackActive: false,
applicationId: "",
herokuVersion: "",
giteaRepository: "",
giteaOwner: "",
giteaBranch: "",
giteaBuildPath: "",
giteaId: "",
cleanCache: false,
applicationStatus: "done",
appName: "",
autoDeploy: true,
enableSubmodules: false,
serverId: "",
branch: null,
dockerBuildStage: "",
registryUrl: "",
watchPaths: [],
buildArgs: null,
isPreviewDeploymentsActive: false,
previewBuildArgs: null,
triggerType: "push",
previewCertificateType: "none",
previewEnv: null,
previewHttps: false,
previewPath: "/",
previewPort: 3000,
previewLimit: 0,
previewCustomCertResolver: null,
previewWildcard: "",
project: {
env: "",
organizationId: "",
adminId: "",
name: "",
description: "",
createdAt: "",
@@ -86,7 +75,6 @@ const baseApp: ApplicationNested = {
ports: [],
projectId: "",
publishDirectory: null,
isStaticSpa: null,
redirects: [],
refreshToken: "",
registry: null,
@@ -115,7 +103,6 @@ const baseDomain: Domain = {
port: null,
serviceName: "",
composeId: "",
customCertResolver: null,
domainType: "application",
uniqueConfigKey: 1,
previewDeploymentId: "",

View File

@@ -1,61 +0,0 @@
import { normalizeS3Path } from "@dokploy/server/utils/backups/utils";
import { describe, expect, test } from "vitest";
describe("normalizeS3Path", () => {
test("should handle empty and whitespace-only prefix", () => {
expect(normalizeS3Path("")).toBe("");
expect(normalizeS3Path("/")).toBe("");
expect(normalizeS3Path(" ")).toBe("");
expect(normalizeS3Path("\t")).toBe("");
expect(normalizeS3Path("\n")).toBe("");
expect(normalizeS3Path(" \n \t ")).toBe("");
});
test("should trim whitespace from prefix", () => {
expect(normalizeS3Path(" prefix")).toBe("prefix/");
expect(normalizeS3Path("prefix ")).toBe("prefix/");
expect(normalizeS3Path(" prefix ")).toBe("prefix/");
expect(normalizeS3Path("\tprefix\t")).toBe("prefix/");
expect(normalizeS3Path(" prefix/nested ")).toBe("prefix/nested/");
});
test("should remove leading slashes", () => {
expect(normalizeS3Path("/prefix")).toBe("prefix/");
expect(normalizeS3Path("///prefix")).toBe("prefix/");
});
test("should remove trailing slashes", () => {
expect(normalizeS3Path("prefix/")).toBe("prefix/");
expect(normalizeS3Path("prefix///")).toBe("prefix/");
});
test("should remove both leading and trailing slashes", () => {
expect(normalizeS3Path("/prefix/")).toBe("prefix/");
expect(normalizeS3Path("///prefix///")).toBe("prefix/");
});
test("should handle nested paths", () => {
expect(normalizeS3Path("prefix/nested")).toBe("prefix/nested/");
expect(normalizeS3Path("/prefix/nested/")).toBe("prefix/nested/");
expect(normalizeS3Path("///prefix/nested///")).toBe("prefix/nested/");
});
test("should preserve middle slashes", () => {
expect(normalizeS3Path("prefix/nested/deep")).toBe("prefix/nested/deep/");
expect(normalizeS3Path("/prefix/nested/deep/")).toBe("prefix/nested/deep/");
});
test("should handle special characters", () => {
expect(normalizeS3Path("prefix-with-dashes")).toBe("prefix-with-dashes/");
expect(normalizeS3Path("prefix_with_underscores")).toBe(
"prefix_with_underscores/",
);
expect(normalizeS3Path("prefix.with.dots")).toBe("prefix.with.dots/");
});
test("should handle the cases from the bug report", () => {
expect(normalizeS3Path("instance-backups/")).toBe("instance-backups/");
expect(normalizeS3Path("/instance-backups/")).toBe("instance-backups/");
expect(normalizeS3Path("instance-backups")).toBe("instance-backups/");
});
});

View File

@@ -13,7 +13,6 @@ export default defineConfig({
NODE: "test",
},
},
plugins: [tsconfigPaths()],
resolve: {
alias: {
"@dokploy/server": path.resolve(

View File

@@ -0,0 +1,124 @@
import { Button } from "@/components/ui/button";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { CardTitle } from "@/components/ui/card";
import {
InputOTP,
InputOTPGroup,
InputOTPSlot,
} from "@/components/ui/input-otp";
import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod";
import { AlertTriangle } from "lucide-react";
import { useRouter } from "next/router";
import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
const Login2FASchema = z.object({
pin: z.string().min(6, {
message: "Pin is required",
}),
});
type Login2FA = z.infer<typeof Login2FASchema>;
interface Props {
authId: string;
}
export const Login2FA = ({ authId }: Props) => {
const { push } = useRouter();
const { mutateAsync, isLoading, isError, error } =
api.auth.verifyLogin2FA.useMutation();
const form = useForm<Login2FA>({
defaultValues: {
pin: "",
},
resolver: zodResolver(Login2FASchema),
});
useEffect(() => {
form.reset({
pin: "",
});
}, [form, form.reset, form.formState.isSubmitSuccessful]);
const onSubmit = async (data: Login2FA) => {
await mutateAsync({
pin: data.pin,
id: authId,
})
.then(() => {
toast.success("Signin successfully", {
duration: 2000,
});
push("/dashboard/projects");
})
.catch(() => {
toast.error("Signin failed", {
duration: 2000,
});
});
};
return (
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="grid w-full gap-4"
>
{isError && (
<div className="flex flex-row gap-4 rounded-lg items-center bg-red-50 p-2 dark:bg-red-950">
<AlertTriangle className="text-red-600 dark:text-red-400" />
<span className="text-sm text-red-600 dark:text-red-400">
{error?.message}
</span>
</div>
)}
<CardTitle className="text-xl font-bold">2FA Setup</CardTitle>
<FormField
control={form.control}
name="pin"
render={({ field }) => (
<FormItem className="flex flex-col justify-center max-sm:items-center">
<FormLabel>Pin</FormLabel>
<FormControl>
<InputOTP maxLength={6} {...field}>
<InputOTPGroup>
<InputOTPSlot index={0} />
<InputOTPSlot index={1} />
<InputOTPSlot index={2} />
<InputOTPSlot index={3} />
<InputOTPSlot index={4} />
<InputOTPSlot index={5} />
</InputOTPGroup>
</InputOTP>
</FormControl>
<FormDescription>
Please enter the 6 digits code provided by your authenticator
app.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<Button isLoading={isLoading} type="submit">
Submit 2FA
</Button>
</form>
</Form>
);
};

View File

@@ -130,7 +130,7 @@ const createStringToJSONSchema = (schema: z.ZodTypeAny) => {
}
try {
return JSON.parse(str);
} catch (_e) {
} catch (e) {
ctx.addIssue({ code: "custom", message: "Invalid JSON format" });
return z.NEVER;
}
@@ -259,7 +259,7 @@ export const AddSwarmSettings = ({ applicationId }: Props) => {
refetch();
})
.catch(() => {
toast.error("Error updating the swarm settings");
toast.error("Error to update the swarm settings");
});
};
return (

View File

@@ -29,6 +29,7 @@ import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod";
import { Server } from "lucide-react";
import Link from "next/link";
import React from "react";
import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
@@ -40,7 +41,7 @@ interface Props {
}
const AddRedirectchema = z.object({
replicas: z.number().min(1, "Replicas must be at least 1"),
replicas: z.number(),
registryId: z.string(),
});
@@ -93,7 +94,7 @@ export const ShowClusterSettings = ({ applicationId }: Props) => {
});
})
.catch(() => {
toast.error("Error updating the command");
toast.error("Error to update the command");
});
};
@@ -130,11 +131,9 @@ export const ShowClusterSettings = ({ applicationId }: Props) => {
placeholder="1"
{...field}
onChange={(e) => {
const value = e.target.value;
field.onChange(value === "" ? 0 : Number(value));
field.onChange(Number(e.target.value));
}}
type="number"
value={field.value || ""}
/>
</FormControl>

View File

@@ -17,6 +17,7 @@ import {
import { Input } from "@/components/ui/input";
import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod";
import React from "react";
import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
@@ -70,7 +71,7 @@ export const AddCommand = ({ applicationId }: Props) => {
});
})
.catch(() => {
toast.error("Error updating the command");
toast.error("Error to update the command");
});
};
@@ -80,8 +81,7 @@ export const AddCommand = ({ applicationId }: Props) => {
<div>
<CardTitle className="text-xl">Run Command</CardTitle>
<CardDescription>
Run a custom command in the container after the application
initialized
Run a custom command in the container
</CardDescription>
</div>
</CardHeader>

View File

@@ -1,347 +0,0 @@
import { AlertBlock } from "@/components/shared/alert-block";
import { CodeEditor } from "@/components/shared/code-editor";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Separator } from "@/components/ui/separator";
import { Textarea } from "@/components/ui/textarea";
import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod";
import { Code2, Globe2, HardDrive } from "lucide-react";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
const ImportSchema = z.object({
base64: z.string(),
});
type ImportType = z.infer<typeof ImportSchema>;
interface Props {
composeId: string;
}
export const ShowImport = ({ composeId }: Props) => {
const [showModal, setShowModal] = useState(false);
const [showMountContent, setShowMountContent] = useState(false);
const [selectedMount, setSelectedMount] = useState<{
filePath: string;
content: string;
} | null>(null);
const [templateInfo, setTemplateInfo] = useState<{
compose: string;
template: {
domains: Array<{
serviceName: string;
port: number;
path?: string;
host?: string;
}>;
envs: string[];
mounts: Array<{
filePath: string;
content: string;
}>;
};
} | null>(null);
const utils = api.useUtils();
const { mutateAsync: processTemplate, isLoading: isLoadingTemplate } =
api.compose.processTemplate.useMutation();
const {
mutateAsync: importTemplate,
isLoading: isImporting,
isSuccess: isImportSuccess,
} = api.compose.import.useMutation();
const form = useForm<ImportType>({
defaultValues: {
base64: "",
},
resolver: zodResolver(ImportSchema),
});
useEffect(() => {
form.reset({
base64: "",
});
}, [isImportSuccess]);
const onSubmit = async () => {
const base64 = form.getValues("base64");
if (!base64) {
toast.error("Please enter a base64 template");
return;
}
try {
await importTemplate({
composeId,
base64,
});
toast.success("Template imported successfully");
await utils.compose.one.invalidate({
composeId,
});
setShowModal(false);
} catch (_error) {
toast.error("Error importing template");
}
};
const handleLoadTemplate = async () => {
const base64 = form.getValues("base64");
if (!base64) {
toast.error("Please enter a base64 template");
return;
}
try {
const result = await processTemplate({
composeId,
base64,
});
setTemplateInfo(result);
setShowModal(true);
} catch (_error) {
toast.error("Error processing template");
}
};
const handleShowMountContent = (mount: {
filePath: string;
content: string;
}) => {
setSelectedMount(mount);
setShowMountContent(true);
};
return (
<>
<Card className="bg-background">
<CardHeader>
<CardTitle className="text-xl">Import</CardTitle>
<CardDescription>Import your Template configuration</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<AlertBlock type="warning">
Warning: Importing a template will remove all existing environment
variables, mounts, and domains from this service.
</AlertBlock>
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="grid w-full gap-4"
>
<FormField
control={form.control}
name="base64"
render={({ field }) => (
<FormItem>
<FormLabel>Configuration (Base64)</FormLabel>
<FormControl>
<Textarea
placeholder="Enter your Base64 configuration here..."
className="font-mono min-h-[200px]"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="flex justify-end gap-2">
<Button
type="button"
className="w-fit"
variant="outline"
isLoading={isLoadingTemplate}
onClick={handleLoadTemplate}
>
Load
</Button>
</div>
<Dialog open={showModal} onOpenChange={setShowModal}>
<DialogContent className="max-h-[80vh] max-w-[50vw] overflow-y-auto">
<DialogHeader>
<DialogTitle className="text-2xl font-bold">
Template Information
</DialogTitle>
<DialogDescription className="space-y-2">
<p>Review the template information before importing</p>
<AlertBlock type="warning">
Warning: This will remove all existing environment
variables, mounts, and domains from this service.
</AlertBlock>
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-6">
<div className="space-y-4">
<div className="flex items-center gap-2">
<Code2 className="h-5 w-5 text-primary" />
<h3 className="text-lg font-semibold">
Docker Compose
</h3>
</div>
<CodeEditor
language="yaml"
value={templateInfo?.compose || ""}
className="font-mono"
readOnly
/>
</div>
<Separator />
{templateInfo?.template.domains &&
templateInfo.template.domains.length > 0 && (
<div className="space-y-4">
<div className="flex items-center gap-2">
<Globe2 className="h-5 w-5 text-primary" />
<h3 className="text-lg font-semibold">Domains</h3>
</div>
<div className="grid grid-cols-1 gap-3">
{templateInfo.template.domains.map(
(domain, index) => (
<div
key={index}
className="rounded-lg border bg-card p-3 text-card-foreground shadow-sm"
>
<div className="font-medium">
{domain.serviceName}
</div>
<div className="text-sm text-muted-foreground space-y-1">
<div>Port: {domain.port}</div>
{domain.host && (
<div>Host: {domain.host}</div>
)}
{domain.path && (
<div>Path: {domain.path}</div>
)}
</div>
</div>
),
)}
</div>
</div>
)}
{templateInfo?.template.envs &&
templateInfo.template.envs.length > 0 && (
<div className="space-y-4">
<div className="flex items-center gap-2">
<Code2 className="h-5 w-5 text-primary" />
<h3 className="text-lg font-semibold">
Environment Variables
</h3>
</div>
<div className="grid grid-cols-1 gap-2">
{templateInfo.template.envs.map((env, index) => (
<div
key={index}
className="rounded-lg truncate border bg-card p-2 font-mono text-sm"
>
{env}
</div>
))}
</div>
</div>
)}
{templateInfo?.template.mounts &&
templateInfo.template.mounts.length > 0 && (
<div className="space-y-4">
<div className="flex items-center gap-2">
<HardDrive className="h-5 w-5 text-primary" />
<h3 className="text-lg font-semibold">Mounts</h3>
</div>
<div className="grid grid-cols-1 gap-2">
{templateInfo.template.mounts.map(
(mount, index) => (
<div
key={index}
className="rounded-lg border bg-card p-2 font-mono text-sm hover:bg-accent cursor-pointer transition-colors"
onClick={() => handleShowMountContent(mount)}
>
{mount.filePath}
</div>
),
)}
</div>
</div>
)}
</div>
<div className="flex justify-end gap-2 pt-4">
<Button
variant="outline"
onClick={() => setShowModal(false)}
>
Cancel
</Button>
<Button
isLoading={isImporting}
type="submit"
onClick={form.handleSubmit(onSubmit)}
className="w-fit"
>
Import
</Button>
</div>
</DialogContent>
</Dialog>
</form>
</Form>
</CardContent>
</Card>
<Dialog open={showMountContent} onOpenChange={setShowMountContent}>
<DialogContent className="max-w-[50vw]">
<DialogHeader>
<DialogTitle className="text-xl font-bold">
{selectedMount?.filePath}
</DialogTitle>
<DialogDescription>Mount File Content</DialogDescription>
</DialogHeader>
<ScrollArea className="h-[45vh] pr-4">
<CodeEditor
language="yaml"
value={selectedMount?.content || ""}
className="font-mono"
readOnly
/>
</ScrollArea>
<div className="flex justify-end gap-2 pt-4">
<Button onClick={() => setShowMountContent(false)}>Close</Button>
</div>
</DialogContent>
</Dialog>
</>
);
};

View File

@@ -27,7 +27,7 @@ import {
} from "@/components/ui/select";
import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod";
import { PenBoxIcon, PlusIcon } from "lucide-react";
import { PlusIcon } from "lucide-react";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
@@ -45,29 +45,18 @@ type AddPort = z.infer<typeof AddPortSchema>;
interface Props {
applicationId: string;
portId?: string;
children?: React.ReactNode;
}
export const HandlePorts = ({
export const AddPort = ({
applicationId,
portId,
children = <PlusIcon className="h-4 w-4" />,
}: Props) => {
const [isOpen, setIsOpen] = useState(false);
const utils = api.useUtils();
const { data } = api.port.one.useQuery(
{
portId: portId ?? "",
},
{
enabled: !!portId,
},
);
const { mutateAsync, isLoading, error, isError } = portId
? api.port.update.useMutation()
: api.port.create.useMutation();
const { mutateAsync, isLoading, error, isError } =
api.port.create.useMutation();
const form = useForm<AddPort>({
defaultValues: {
@@ -79,46 +68,32 @@ export const HandlePorts = ({
useEffect(() => {
form.reset({
publishedPort: data?.publishedPort ?? 0,
targetPort: data?.targetPort ?? 0,
protocol: data?.protocol ?? "tcp",
publishedPort: 0,
targetPort: 0,
});
}, [form, form.reset, form.formState.isSubmitSuccessful, data]);
}, [form, form.reset, form.formState.isSubmitSuccessful]);
const onSubmit = async (data: AddPort) => {
await mutateAsync({
applicationId,
...data,
portId: portId || "",
})
.then(async () => {
toast.success(portId ? "Port Updated" : "Port Created");
toast.success("Port Created");
await utils.application.one.invalidate({
applicationId,
});
setIsOpen(false);
})
.catch(() => {
toast.error(
portId ? "Error updating the port" : "Error creating the port",
);
toast.error("Error to create the port");
});
};
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
{portId ? (
<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>
) : (
<Button>{children}</Button>
)}
<Button>{children}</Button>
</DialogTrigger>
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-lg">
<DialogHeader>
@@ -229,7 +204,7 @@ export const HandlePorts = ({
form="hook-form-add-port"
type="submit"
>
{portId ? "Update" : "Create"}
Create
</Button>
</DialogFooter>
</Form>

View File

@@ -0,0 +1,63 @@
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import { api } from "@/utils/api";
import { TrashIcon } from "lucide-react";
import { toast } from "sonner";
interface Props {
portId: string;
}
export const DeletePort = ({ portId }: Props) => {
const utils = api.useUtils();
const { mutateAsync, isLoading } = api.port.delete.useMutation();
return (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="ghost" isLoading={isLoading}>
<TrashIcon className="size-4 text-muted-foreground " />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
<AlertDialogDescription>
This action cannot be undone. This will permanently delete the port
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={async () => {
await mutateAsync({
portId,
})
.then((data) => {
utils.application.one.invalidate({
applicationId: data?.applicationId,
});
toast.success("Port delete succesfully");
})
.catch(() => {
toast.error("Error to delete the port");
});
}}
>
Confirm
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
};

View File

@@ -1,6 +1,4 @@
import { AlertBlock } from "@/components/shared/alert-block";
import { DialogAction } from "@/components/shared/dialog-action";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
@@ -9,24 +7,23 @@ 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";
import { Rss } from "lucide-react";
import React from "react";
import { AddPort } from "./add-port";
import { DeletePort } from "./delete-port";
import { UpdatePort } from "./update-port";
interface Props {
applicationId: string;
}
export const ShowPorts = ({ applicationId }: Props) => {
const { data, refetch } = api.application.one.useQuery(
const { data } = api.application.one.useQuery(
{
applicationId,
},
{ enabled: !!applicationId },
);
const { mutateAsync: deletePort, isLoading: isRemoving } =
api.port.delete.useMutation();
return (
<Card className="bg-background">
<CardHeader className="flex flex-row justify-between flex-wrap gap-4">
@@ -38,7 +35,7 @@ export const ShowPorts = ({ applicationId }: Props) => {
</div>
{data && data?.ports.length > 0 && (
<HandlePorts applicationId={applicationId}>Add Port</HandlePorts>
<AddPort applicationId={applicationId}>Add Port</AddPort>
)}
</CardHeader>
<CardContent className="flex flex-col gap-4">
@@ -48,7 +45,7 @@ export const ShowPorts = ({ applicationId }: Props) => {
<span className="text-base text-muted-foreground">
No ports configured
</span>
<HandlePorts applicationId={applicationId}>Add Port</HandlePorts>
<AddPort applicationId={applicationId}>Add Port</AddPort>
</div>
) : (
<div className="flex flex-col pt-2 gap-4">
@@ -81,36 +78,8 @@ export const ShowPorts = ({ applicationId }: Props) => {
</div>
</div>
<div className="flex flex-row gap-4">
<HandlePorts
applicationId={applicationId}
portId={port.portId}
/>
<DialogAction
title="Delete Port"
description="Are you sure you want to delete this port?"
type="destructive"
onClick={async () => {
await deletePort({
portId: port.portId,
})
.then(() => {
refetch();
toast.success("Port deleted successfully");
})
.catch(() => {
toast.error("Error deleting port");
});
}}
>
<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>
<UpdatePort portId={port.portId} />
<DeletePort portId={port.portId} />
</div>
</div>
</div>

View File

@@ -0,0 +1,195 @@
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,
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 { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod";
import { PenBoxIcon, Pencil } from "lucide-react";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
const UpdatePortSchema = z.object({
publishedPort: z.number().int().min(1).max(65535),
targetPort: z.number().int().min(1).max(65535),
protocol: z.enum(["tcp", "udp"], {
required_error: "Protocol is required",
invalid_type_error: "Protocol must be a valid protocol",
}),
});
type UpdatePort = z.infer<typeof UpdatePortSchema>;
interface Props {
portId: string;
}
export const UpdatePort = ({ portId }: Props) => {
const [isOpen, setIsOpen] = useState(false);
const utils = api.useUtils();
const { data } = api.port.one.useQuery(
{
portId,
},
{
enabled: !!portId,
},
);
const { mutateAsync, isLoading, error, isError } =
api.port.update.useMutation();
const form = useForm<UpdatePort>({
defaultValues: {},
resolver: zodResolver(UpdatePortSchema),
});
useEffect(() => {
if (data) {
form.reset({
publishedPort: data.publishedPort,
targetPort: data.targetPort,
protocol: data.protocol,
});
}
}, [form, form.reset, data]);
const onSubmit = async (data: UpdatePort) => {
await mutateAsync({
portId,
publishedPort: data.publishedPort,
targetPort: data.targetPort,
protocol: data.protocol,
})
.then(async (response) => {
toast.success("Port Updated");
await utils.application.one.invalidate({
applicationId: response?.applicationId,
});
setIsOpen(false);
})
.catch(() => {
toast.error("Error to update the port");
});
};
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
<Button variant="ghost" isLoading={isLoading}>
<PenBoxIcon className="size-4 text-muted-foreground" />
</Button>
</DialogTrigger>
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-lg">
<DialogHeader>
<DialogTitle>Update</DialogTitle>
<DialogDescription>Update the port</DialogDescription>
</DialogHeader>
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
<Form {...form}>
<form
id="hook-form-update-redirect"
onSubmit={form.handleSubmit(onSubmit)}
className="grid w-full gap-4"
>
<div className="flex flex-col gap-4">
<FormField
control={form.control}
name="publishedPort"
render={({ field }) => (
<FormItem>
<FormLabel>Published Port</FormLabel>
<FormControl>
<NumberInput placeholder="1-65535" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="targetPort"
render={({ field }) => (
<FormItem>
<FormLabel>Target Port</FormLabel>
<FormControl>
<NumberInput placeholder="1-65535" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="protocol"
render={({ field }) => {
return (
<FormItem className="md:col-span-2">
<FormLabel>Protocol</FormLabel>
<Select
onValueChange={field.onChange}
value={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select a protocol" />
</SelectTrigger>
</FormControl>
<SelectContent defaultValue={"none"}>
<SelectItem value={"none"} disabled>
None
</SelectItem>
<SelectItem value={"tcp"}>TCP</SelectItem>
<SelectItem value={"udp"}>UDP</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
);
}}
/>
</div>
</form>
<DialogFooter>
<Button
isLoading={isLoading}
form="hook-form-update-redirect"
type="submit"
>
Update
</Button>
</DialogFooter>
</Form>
</DialogContent>
</Dialog>
);
};

View File

@@ -31,7 +31,7 @@ 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 { PlusIcon } from "lucide-react";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
@@ -77,32 +77,19 @@ const redirectPresets = [
interface Props {
applicationId: string;
redirectId?: string;
children?: React.ReactNode;
}
export const HandleRedirect = ({
export const AddRedirect = ({
applicationId,
redirectId,
children = <PlusIcon className="w-4 h-4" />,
}: Props) => {
const [isOpen, setIsOpen] = useState(false);
const [presetSelected, setPresetSelected] = useState("");
const { data, refetch } = api.redirects.one.useQuery(
{
redirectId: redirectId || "",
},
{
enabled: !!redirectId,
},
);
const utils = api.useUtils();
const { mutateAsync, isLoading, error, isError } = redirectId
? api.redirects.update.useMutation()
: api.redirects.create.useMutation();
const { mutateAsync, isLoading, error, isError } =
api.redirects.create.useMutation();
const form = useForm<AddRedirect>({
defaultValues: {
@@ -115,35 +102,29 @@ export const HandleRedirect = ({
useEffect(() => {
form.reset({
permanent: data?.permanent || false,
regex: data?.regex || "",
replacement: data?.replacement || "",
permanent: false,
regex: "",
replacement: "",
});
}, [form, form.reset, form.formState.isSubmitSuccessful, data]);
}, [form, form.reset, form.formState.isSubmitSuccessful]);
const onSubmit = async (data: AddRedirect) => {
await mutateAsync({
applicationId,
...data,
redirectId: redirectId || "",
})
.then(async () => {
toast.success(redirectId ? "Redirect Updated" : "Redirect Created");
toast.success("Redirect Created");
await utils.application.one.invalidate({
applicationId,
});
refetch();
await utils.application.readTraefikConfig.invalidate({
applicationId,
});
onDialogToggle(false);
})
.catch(() => {
toast.error(
redirectId
? "Error updating the redirect"
: "Error creating the redirect",
);
toast.error("Error to create the redirect");
});
};
@@ -167,17 +148,7 @@ export const HandleRedirect = ({
return (
<Dialog open={isOpen} onOpenChange={onDialogToggle}>
<DialogTrigger asChild>
{redirectId ? (
<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>
) : (
<Button>{children}</Button>
)}
<Button>{children}</Button>
</DialogTrigger>
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-lg">
<DialogHeader>
@@ -272,7 +243,7 @@ export const HandleRedirect = ({
form="hook-form-add-redirect"
type="submit"
>
{redirectId ? "Update" : "Create"}
Create
</Button>
</DialogFooter>
</Form>

View File

@@ -0,0 +1,66 @@
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import { api } from "@/utils/api";
import { TrashIcon } from "lucide-react";
import { toast } from "sonner";
interface Props {
redirectId: string;
}
export const DeleteRedirect = ({ redirectId }: Props) => {
const utils = api.useUtils();
const { mutateAsync, isLoading } = api.redirects.delete.useMutation();
return (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="ghost" isLoading={isLoading}>
<TrashIcon className="size-4 text-muted-foreground " />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
<AlertDialogDescription>
This action cannot be undone. This will permanently delete the
redirect
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={async () => {
await mutateAsync({
redirectId,
})
.then((data) => {
utils.application.one.invalidate({
applicationId: data?.applicationId,
});
utils.application.readTraefikConfig.invalidate({
applicationId: data?.applicationId,
});
toast.success("Redirect delete succesfully");
})
.catch(() => {
toast.error("Error to delete the redirect");
});
}}
>
Confirm
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
};

View File

@@ -1,5 +1,3 @@
import { DialogAction } from "@/components/shared/dialog-action";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
@@ -8,27 +6,23 @@ 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";
import { Split } from "lucide-react";
import React from "react";
import { AddRedirect } from "./add-redirect";
import { DeleteRedirect } from "./delete-redirect";
import { UpdateRedirect } from "./update-redirect";
interface Props {
applicationId: string;
}
export const ShowRedirects = ({ applicationId }: Props) => {
const { data, refetch } = api.application.one.useQuery(
const { data } = api.application.one.useQuery(
{
applicationId,
},
{ enabled: !!applicationId },
);
const { mutateAsync: deleteRedirect, isLoading: isRemoving } =
api.redirects.delete.useMutation();
const utils = api.useUtils();
return (
<Card className="bg-background">
<CardHeader className="flex flex-row justify-between flex-wrap gap-4">
@@ -41,9 +35,7 @@ export const ShowRedirects = ({ applicationId }: Props) => {
</div>
{data && data?.redirects.length > 0 && (
<HandleRedirect applicationId={applicationId}>
Add Redirect
</HandleRedirect>
<AddRedirect applicationId={applicationId}>Add Redirect</AddRedirect>
)}
</CardHeader>
<CardContent className="flex flex-col gap-4">
@@ -53,9 +45,9 @@ export const ShowRedirects = ({ applicationId }: Props) => {
<span className="text-base text-muted-foreground">
No redirects configured
</span>
<HandleRedirect applicationId={applicationId}>
<AddRedirect applicationId={applicationId}>
Add Redirect
</HandleRedirect>
</AddRedirect>
</div>
) : (
<div className="flex flex-col pt-2">
@@ -84,40 +76,8 @@ export const ShowRedirects = ({ applicationId }: Props) => {
</div>
</div>
<div className="flex flex-row gap-4">
<HandleRedirect
redirectId={redirect.redirectId}
applicationId={applicationId}
/>
<DialogAction
title="Delete Redirect"
description="Are you sure you want to delete this redirect?"
type="destructive"
onClick={async () => {
await deleteRedirect({
redirectId: redirect.redirectId,
})
.then(() => {
refetch();
utils.application.readTraefikConfig.invalidate({
applicationId,
});
toast.success("Redirect deleted successfully");
})
.catch(() => {
toast.error("Error deleting redirect");
});
}}
>
<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>
<UpdateRedirect redirectId={redirect.redirectId} />
<DeleteRedirect redirectId={redirect.redirectId} />
</div>
</div>
</div>

View File

@@ -0,0 +1,182 @@
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 } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod";
import { PenBoxIcon, Pencil } from "lucide-react";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
const UpdateRedirectSchema = z.object({
regex: z.string().min(1, "Regex required"),
permanent: z.boolean().default(false),
replacement: z.string().min(1, "Replacement required"),
});
type UpdateRedirect = z.infer<typeof UpdateRedirectSchema>;
interface Props {
redirectId: string;
}
export const UpdateRedirect = ({ redirectId }: Props) => {
const utils = api.useUtils();
const [isOpen, setIsOpen] = useState(false);
const { data } = api.redirects.one.useQuery(
{
redirectId,
},
{
enabled: !!redirectId,
},
);
const { mutateAsync, isLoading, error, isError } =
api.redirects.update.useMutation();
const form = useForm<UpdateRedirect>({
defaultValues: {
permanent: false,
regex: "",
replacement: "",
},
resolver: zodResolver(UpdateRedirectSchema),
});
useEffect(() => {
if (data) {
form.reset({
permanent: data.permanent || false,
regex: data.regex || "",
replacement: data.replacement || "",
});
}
}, [form, form.reset, data]);
const onSubmit = async (data: UpdateRedirect) => {
await mutateAsync({
redirectId,
permanent: data.permanent,
regex: data.regex,
replacement: data.replacement,
})
.then(async (response) => {
toast.success("Redirect Updated");
await utils.application.one.invalidate({
applicationId: response?.applicationId,
});
setIsOpen(false);
})
.catch(() => {
toast.error("Error to update the redirect");
});
};
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
<Button variant="ghost" isLoading={isLoading}>
<PenBoxIcon className="size-4 text-muted-foreground" />
</Button>
</DialogTrigger>
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-lg">
<DialogHeader>
<DialogTitle>Update</DialogTitle>
<DialogDescription>Update the redirect</DialogDescription>
</DialogHeader>
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
<Form {...form}>
<form
id="hook-form-update-redirect"
onSubmit={form.handleSubmit(onSubmit)}
className="grid w-full gap-4"
>
<div className="flex flex-col gap-4">
<FormField
control={form.control}
name="regex"
render={({ field }) => (
<FormItem>
<FormLabel>Regex</FormLabel>
<FormControl>
<Input placeholder="^http://localhost/(.*)" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="replacement"
render={({ field }) => (
<FormItem>
<FormLabel>Replacement</FormLabel>
<FormControl>
<Input placeholder="http://mydomain/$${1}" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="permanent"
render={({ field }) => (
<FormItem className="mt-4 flex flex-row items-center justify-between rounded-lg border p-3 shadow-sm">
<div className="space-y-0.5">
<FormLabel>Permanent</FormLabel>
<FormDescription>
Set the permanent option to true to apply a permanent
redirection.
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
</div>
</form>
<DialogFooter>
<Button
isLoading={isLoading}
form="hook-form-update-redirect"
type="submit"
>
Update
</Button>
</DialogFooter>
</Form>
</DialogContent>
</Dialog>
);
};

View File

@@ -20,7 +20,7 @@ import {
import { Input } from "@/components/ui/input";
import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod";
import { PenBoxIcon, PlusIcon } from "lucide-react";
import { PlusIcon } from "lucide-react";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
@@ -35,29 +35,17 @@ type AddSecurity = z.infer<typeof AddSecuritychema>;
interface Props {
applicationId: string;
securityId?: string;
children?: React.ReactNode;
}
export const HandleSecurity = ({
export const AddSecurity = ({
applicationId,
securityId,
children = <PlusIcon className="h-4 w-4" />,
}: Props) => {
const utils = api.useUtils();
const [isOpen, setIsOpen] = useState(false);
const { data } = api.security.one.useQuery(
{
securityId: securityId ?? "",
},
{
enabled: !!securityId,
},
);
const { mutateAsync, isLoading, error, isError } = securityId
? api.security.update.useMutation()
: api.security.create.useMutation();
const { mutateAsync, isLoading, error, isError } =
api.security.create.useMutation();
const form = useForm<AddSecurity>({
defaultValues: {
@@ -68,20 +56,16 @@ export const HandleSecurity = ({
});
useEffect(() => {
form.reset({
username: data?.username || "",
password: data?.password || "",
});
}, [form, form.reset, form.formState.isSubmitSuccessful, data]);
form.reset();
}, [form, form.reset, form.formState.isSubmitSuccessful]);
const onSubmit = async (data: AddSecurity) => {
await mutateAsync({
applicationId,
...data,
securityId: securityId || "",
})
.then(async () => {
toast.success(securityId ? "Security Updated" : "Security Created");
toast.success("Security Created");
await utils.application.one.invalidate({
applicationId,
});
@@ -91,34 +75,20 @@ export const HandleSecurity = ({
setIsOpen(false);
})
.catch(() => {
toast.error(
securityId
? "Error updating the security"
: "Error creating security",
);
toast.error("Error to create the security");
});
};
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
{securityId ? (
<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>
) : (
<Button>{children}</Button>
)}
<Button>{children}</Button>
</DialogTrigger>
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-lg">
<DialogHeader>
<DialogTitle>Security</DialogTitle>
<DialogDescription>
{securityId ? "Update" : "Add"} security to your application
Add security to your application
</DialogDescription>
</DialogHeader>
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
@@ -167,7 +137,7 @@ export const HandleSecurity = ({
form="hook-form-add-security"
type="submit"
>
{securityId ? "Update" : "Create"}
Create
</Button>
</DialogFooter>
</Form>

View File

@@ -0,0 +1,66 @@
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import { api } from "@/utils/api";
import { TrashIcon } from "lucide-react";
import { toast } from "sonner";
interface Props {
securityId: string;
}
export const DeleteSecurity = ({ securityId }: Props) => {
const utils = api.useUtils();
const { mutateAsync, isLoading } = api.security.delete.useMutation();
return (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="ghost" isLoading={isLoading}>
<TrashIcon className="size-4 text-muted-foreground " />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
<AlertDialogDescription>
This action cannot be undone. This will permanently delete the
security
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={async () => {
await mutateAsync({
securityId,
})
.then((data) => {
utils.application.one.invalidate({
applicationId: data?.applicationId,
});
utils.application.readTraefikConfig.invalidate({
applicationId: data?.applicationId,
});
toast.success("Security delete succesfully");
})
.catch(() => {
toast.error("Error to delete the security");
});
}}
>
Confirm
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
};

View File

@@ -1,5 +1,3 @@
import { DialogAction } from "@/components/shared/dialog-action";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
@@ -8,26 +6,23 @@ import {
CardTitle,
} from "@/components/ui/card";
import { api } from "@/utils/api";
import { LockKeyhole, Trash2 } from "lucide-react";
import { toast } from "sonner";
import { HandleSecurity } from "./handle-security";
import { LockKeyhole } from "lucide-react";
import React from "react";
import { AddSecurity } from "./add-security";
import { DeleteSecurity } from "./delete-security";
import { UpdateSecurity } from "./update-security";
interface Props {
applicationId: string;
}
export const ShowSecurity = ({ applicationId }: Props) => {
const { data, refetch } = api.application.one.useQuery(
const { data } = api.application.one.useQuery(
{
applicationId,
},
{ enabled: !!applicationId },
);
const { mutateAsync: deleteSecurity, isLoading: isRemoving } =
api.security.delete.useMutation();
const utils = api.useUtils();
return (
<Card className="bg-background">
<CardHeader className="flex flex-row justify-between flex-wrap gap-4">
@@ -37,9 +32,7 @@ export const ShowSecurity = ({ applicationId }: Props) => {
</div>
{data && data?.security.length > 0 && (
<HandleSecurity applicationId={applicationId}>
Add Security
</HandleSecurity>
<AddSecurity applicationId={applicationId}>Add Security</AddSecurity>
)}
</CardHeader>
<CardContent className="flex flex-col gap-4">
@@ -49,9 +42,9 @@ export const ShowSecurity = ({ applicationId }: Props) => {
<span className="text-base text-muted-foreground">
No security configured
</span>
<HandleSecurity applicationId={applicationId}>
<AddSecurity applicationId={applicationId}>
Add Security
</HandleSecurity>
</AddSecurity>
</div>
) : (
<div className="flex flex-col pt-2">
@@ -74,39 +67,8 @@ export const ShowSecurity = ({ applicationId }: Props) => {
</div>
</div>
<div className="flex flex-row gap-2">
<HandleSecurity
securityId={security.securityId}
applicationId={applicationId}
/>
<DialogAction
title="Delete Security"
description="Are you sure you want to delete this security?"
type="destructive"
onClick={async () => {
await deleteSecurity({
securityId: security.securityId,
})
.then(() => {
refetch();
utils.application.readTraefikConfig.invalidate({
applicationId,
});
toast.success("Security deleted successfully");
})
.catch(() => {
toast.error("Error deleting security");
});
}}
>
<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>
<UpdateSecurity securityId={security.securityId} />
<DeleteSecurity securityId={security.securityId} />
</div>
</div>
</div>

View File

@@ -0,0 +1,155 @@
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,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod";
import { PenBoxIcon, Pencil } from "lucide-react";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
const UpdateSecuritySchema = z.object({
username: z.string().min(1, "Username is required"),
password: z.string().min(1, "Password is required"),
});
type UpdateSecurity = z.infer<typeof UpdateSecuritySchema>;
interface Props {
securityId: string;
}
export const UpdateSecurity = ({ securityId }: Props) => {
const [isOpen, setIsOpen] = useState(false);
const utils = api.useUtils();
const { data } = api.security.one.useQuery(
{
securityId,
},
{
enabled: !!securityId,
},
);
const { mutateAsync, isLoading, error, isError } =
api.security.update.useMutation();
const form = useForm<UpdateSecurity>({
defaultValues: {
username: "",
password: "",
},
resolver: zodResolver(UpdateSecuritySchema),
});
useEffect(() => {
if (data) {
form.reset({
username: data.username || "",
password: data.password || "",
});
}
}, [form, form.reset, data]);
const onSubmit = async (data: UpdateSecurity) => {
await mutateAsync({
securityId,
username: data.username,
password: data.password,
})
.then(async (response) => {
toast.success("Security Updated");
await utils.application.one.invalidate({
applicationId: response?.applicationId,
});
setIsOpen(false);
})
.catch(() => {
toast.error("Error to update the security");
});
};
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
<Button variant="ghost" isLoading={isLoading}>
<PenBoxIcon className="size-4 text-muted-foreground" />
</Button>
</DialogTrigger>
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-lg">
<DialogHeader>
<DialogTitle>Update</DialogTitle>
<DialogDescription>Update the security</DialogDescription>
</DialogHeader>
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
<Form {...form}>
<form
id="hook-form-update-security"
onSubmit={form.handleSubmit(onSubmit)}
className="grid w-full gap-4 "
>
<div className="flex flex-col gap-4">
<FormField
control={form.control}
name="username"
render={({ field }) => (
<FormItem>
<FormLabel>Username</FormLabel>
<FormControl>
<Input placeholder="test1" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>Password</FormLabel>
<FormControl>
<Input placeholder="test" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
</form>
<DialogFooter>
<Button
isLoading={isLoading}
form="hook-form-update-security"
type="submit"
>
Update
</Button>
</DialogFooter>
</Form>
</DialogContent>
</Dialog>
);
};

View File

@@ -0,0 +1,231 @@
import { AlertBlock } from "@/components/shared/alert-block";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod";
import React, { useEffect } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
const addResourcesApplication = z.object({
memoryReservation: z.number().nullable().optional(),
cpuLimit: z.number().nullable().optional(),
memoryLimit: z.number().nullable().optional(),
cpuReservation: z.number().nullable().optional(),
});
interface Props {
applicationId: string;
}
type AddResourcesApplication = z.infer<typeof addResourcesApplication>;
export const ShowApplicationResources = ({ applicationId }: Props) => {
const { data, refetch } = api.application.one.useQuery(
{
applicationId,
},
{ enabled: !!applicationId },
);
const { mutateAsync, isLoading } = api.application.update.useMutation();
const form = useForm<AddResourcesApplication>({
defaultValues: {},
resolver: zodResolver(addResourcesApplication),
});
useEffect(() => {
if (data) {
form.reset({
cpuLimit: data?.cpuLimit || undefined,
cpuReservation: data?.cpuReservation || undefined,
memoryLimit: data?.memoryLimit || undefined,
memoryReservation: data?.memoryReservation || undefined,
});
}
}, [data, form, form.reset]);
const onSubmit = async (formData: AddResourcesApplication) => {
await mutateAsync({
applicationId,
cpuLimit: formData.cpuLimit || null,
cpuReservation: formData.cpuReservation || null,
memoryLimit: formData.memoryLimit || null,
memoryReservation: formData.memoryReservation || null,
})
.then(async () => {
toast.success("Resources Updated");
await refetch();
})
.catch(() => {
toast.error("Error to Update the resources");
});
};
return (
<Card className="bg-background">
<CardHeader>
<CardTitle className="text-xl">Resources</CardTitle>
<CardDescription>
If you want to decrease or increase the resources to a specific.
application or database
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<AlertBlock type="info">
Please remember to click Redeploy after modify the resources to apply
the changes.
</AlertBlock>
<Form {...form}>
<form
id="hook-form"
onSubmit={form.handleSubmit(onSubmit)}
className="grid w-full gap-8 "
>
<div className="grid w-full md:grid-cols-2 gap-4">
<FormField
control={form.control}
name="memoryReservation"
render={({ field }) => (
<FormItem>
<FormLabel>Memory Reservation</FormLabel>
<FormControl>
<Input
placeholder="256 MB"
{...field}
value={field.value?.toString() || ""}
onChange={(e) => {
const value = e.target.value;
if (value === "") {
field.onChange(null);
} else {
const number = Number.parseInt(value, 10);
if (!Number.isNaN(number)) {
field.onChange(number);
}
}
}}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="memoryLimit"
render={({ field }) => {
return (
<FormItem>
<FormLabel>Memory Limit</FormLabel>
<FormControl>
<Input
placeholder={"1024 MB"}
{...field}
value={field.value?.toString() || ""}
onChange={(e) => {
const value = e.target.value;
if (value === "") {
field.onChange(null);
} else {
const number = Number.parseInt(value, 10);
if (!Number.isNaN(number)) {
field.onChange(number);
}
}
}}
/>
</FormControl>
<FormMessage />
</FormItem>
);
}}
/>
<FormField
control={form.control}
name="cpuLimit"
render={({ field }) => {
return (
<FormItem>
<FormLabel>Cpu Limit</FormLabel>
<FormControl>
<Input
placeholder={"2"}
{...field}
type="number"
value={field.value?.toString() || ""}
onChange={(e) => {
const value = e.target.value;
if (
value === "" ||
/^[0-9]*\.?[0-9]*$/.test(value)
) {
const float = Number.parseFloat(value);
field.onChange(float);
}
}}
/>
</FormControl>
<FormMessage />
</FormItem>
);
}}
/>
<FormField
control={form.control}
name="cpuReservation"
render={({ field }) => {
return (
<FormItem>
<FormLabel>Cpu Reservation</FormLabel>
<FormControl>
<Input
placeholder={"1"}
{...field}
type="number"
value={field.value?.toString() || ""}
onChange={(e) => {
const value = e.target.value;
if (
value === "" ||
/^[0-9]*\.?[0-9]*$/.test(value)
) {
const float = Number.parseFloat(value);
field.onChange(float);
}
}}
/>
</FormControl>
<FormMessage />
</FormItem>
);
}}
/>
</div>
<div className="flex w-full justify-end">
<Button isLoading={isLoading} type="submit">
Save
</Button>
</div>
</form>
</Form>
</CardContent>
</Card>
);
};

View File

@@ -1,287 +0,0 @@
import { AlertBlock } from "@/components/shared/alert-block";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import {
Tooltip,
TooltipContent,
TooltipProvider,
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(),
cpuLimit: z.string().optional(),
memoryLimit: z.string().optional(),
cpuReservation: z.string().optional(),
});
export type ServiceType =
| "postgres"
| "mongo"
| "redis"
| "mysql"
| "mariadb"
| "application";
interface Props {
id: string;
type: ServiceType | "application";
}
type AddResources = z.infer<typeof addResourcesSchema>;
export const ShowResources = ({ 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 });
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, isLoading } = mutationMap[type]
? mutationMap[type]()
: api.mongo.update.useMutation();
const form = useForm<AddResources>({
defaultValues: {
cpuLimit: "",
cpuReservation: "",
memoryLimit: "",
memoryReservation: "",
},
resolver: zodResolver(addResourcesSchema),
});
useEffect(() => {
if (data) {
form.reset({
cpuLimit: data?.cpuLimit || undefined,
cpuReservation: data?.cpuReservation || undefined,
memoryLimit: data?.memoryLimit || undefined,
memoryReservation: data?.memoryReservation || undefined,
});
}
}, [data, form, form.reset]);
const onSubmit = async (formData: AddResources) => {
await mutateAsync({
mongoId: id || "",
postgresId: id || "",
redisId: id || "",
mysqlId: id || "",
mariadbId: id || "",
applicationId: id || "",
cpuLimit: formData.cpuLimit || null,
cpuReservation: formData.cpuReservation || null,
memoryLimit: formData.memoryLimit || null,
memoryReservation: formData.memoryReservation || null,
})
.then(async () => {
toast.success("Resources Updated");
await refetch();
})
.catch(() => {
toast.error("Error updating the resources");
});
};
return (
<Card className="bg-background">
<CardHeader>
<CardTitle className="text-xl">Resources</CardTitle>
<CardDescription>
If you want to decrease or increase the resources to a specific.
application or database
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<AlertBlock type="info">
Please remember to click Redeploy after modify the resources to apply
the changes.
</AlertBlock>
<Form {...form}>
<form
id="hook-form"
onSubmit={form.handleSubmit(onSubmit)}
className="grid w-full gap-8 "
>
<div className="grid w-full md:grid-cols-2 gap-4">
<FormField
control={form.control}
name="memoryLimit"
render={({ field }) => {
return (
<FormItem>
<div className="flex items-center gap-2">
<FormLabel>Memory Limit</FormLabel>
<TooltipProvider>
<Tooltip delayDuration={0}>
<TooltipTrigger>
<InfoIcon className="h-4 w-4 text-muted-foreground" />
</TooltipTrigger>
<TooltipContent>
<p>
Memory hard limit in bytes. Example: 1GB =
1073741824 bytes
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<FormControl>
<Input
placeholder="1073741824 (1GB in bytes)"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
);
}}
/>
<FormField
control={form.control}
name="memoryReservation"
render={({ field }) => (
<FormItem>
<div className="flex items-center gap-2">
<FormLabel>Memory Reservation</FormLabel>
<TooltipProvider>
<Tooltip delayDuration={0}>
<TooltipTrigger>
<InfoIcon className="h-4 w-4 text-muted-foreground" />
</TooltipTrigger>
<TooltipContent>
<p>
Memory soft limit in bytes. Example: 256MB =
268435456 bytes
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<FormControl>
<Input
placeholder="268435456 (256MB in bytes)"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="cpuLimit"
render={({ field }) => {
return (
<FormItem>
<div className="flex items-center gap-2">
<FormLabel>CPU Limit</FormLabel>
<TooltipProvider>
<Tooltip delayDuration={0}>
<TooltipTrigger>
<InfoIcon className="h-4 w-4 text-muted-foreground" />
</TooltipTrigger>
<TooltipContent>
<p>
CPU quota in units of 10^-9 CPUs. Example: 2
CPUs = 2000000000
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<FormControl>
<Input
placeholder="2000000000 (2 CPUs)"
{...field}
value={field.value?.toString() || ""}
/>
</FormControl>
<FormMessage />
</FormItem>
);
}}
/>
<FormField
control={form.control}
name="cpuReservation"
render={({ field }) => {
return (
<FormItem>
<div className="flex items-center gap-2">
<FormLabel>CPU Reservation</FormLabel>
<TooltipProvider>
<Tooltip delayDuration={0}>
<TooltipTrigger>
<InfoIcon className="h-4 w-4 text-muted-foreground" />
</TooltipTrigger>
<TooltipContent>
<p>
CPU shares (relative weight). Example: 1 CPU =
1000000000
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<FormControl>
<Input placeholder="1000000000 (1 CPU)" {...field} />
</FormControl>
<FormMessage />
</FormItem>
);
}}
/>
</div>
<div className="flex w-full justify-end">
<Button isLoading={isLoading} type="submit">
Save
</Button>
</div>
</form>
</Form>
</CardContent>
</Card>
);
};

View File

@@ -8,6 +8,7 @@ import {
} from "@/components/ui/card";
import { api } from "@/utils/api";
import { File, Loader2 } from "lucide-react";
import React from "react";
import { UpdateTraefikConfig } from "./update-traefik-config";
interface Props {
applicationId: string;

View File

@@ -105,7 +105,7 @@ export const UpdateTraefikConfig = ({ applicationId }: Props) => {
form.reset();
})
.catch(() => {
toast.error("Error updating the Traefik config");
toast.error("Error to update the traefik config");
});
};

View File

@@ -107,7 +107,7 @@ export const AddVolumes = ({
setIsOpen(false);
})
.catch(() => {
toast.error("Error creating the Bind mount");
toast.error("Error to create the Bind mount");
});
} else if (data.type === "volume") {
await mutateAsync({
@@ -122,7 +122,7 @@ export const AddVolumes = ({
setIsOpen(false);
})
.catch(() => {
toast.error("Error creating the Volume mount");
toast.error("Error to create the Volume mount");
});
} else if (data.type === "file") {
await mutateAsync({
@@ -138,7 +138,7 @@ export const AddVolumes = ({
setIsOpen(false);
})
.catch(() => {
toast.error("Error creating the File mount");
toast.error("Error to create the File mount");
});
}

View File

@@ -0,0 +1,61 @@
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import { api } from "@/utils/api";
import { TrashIcon } from "lucide-react";
import React from "react";
import { toast } from "sonner";
interface Props {
mountId: string;
refetch: () => void;
}
export const DeleteVolume = ({ mountId, refetch }: Props) => {
const { mutateAsync, isLoading } = api.mounts.remove.useMutation();
return (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="ghost" isLoading={isLoading}>
<TrashIcon className="size-4 text-muted-foreground " />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
<AlertDialogDescription>
This action cannot be undone. This will permanently delete the mount
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={async () => {
await mutateAsync({
mountId,
})
.then(() => {
refetch();
toast.success("Mount deleted succesfully");
})
.catch(() => {
toast.error("Error to delete the mount");
});
}}
>
Confirm
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
};

View File

@@ -1,6 +1,4 @@
import { AlertBlock } from "@/components/shared/alert-block";
import { DialogAction } from "@/components/shared/dialog-action";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
@@ -9,48 +7,40 @@ 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 { Package } from "lucide-react";
import React from "react";
import { AddVolumes } from "./add-volumes";
import { DeleteVolume } from "./delete-volume";
import { UpdateVolume } from "./update-volume";
interface Props {
id: string;
type: ServiceType | "compose";
applicationId: string;
}
export const ShowVolumes = ({ 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 }),
compose: () =>
api.compose.one.useQuery({ composeId: id }, { enabled: !!id }),
};
const { data, refetch } = queryMap[type]
? queryMap[type]()
: api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id });
const { mutateAsync: deleteVolume, isLoading: isRemoving } =
api.mounts.remove.useMutation();
export const ShowVolumes = ({ applicationId }: Props) => {
const { data, refetch } = api.application.one.useQuery(
{
applicationId,
},
{ enabled: !!applicationId },
);
return (
<Card className="bg-background">
<CardHeader className="flex flex-row justify-between flex-wrap gap-4">
<div>
<CardTitle className="text-xl">Volumes</CardTitle>
<CardDescription>
If you want to persist data in this service use the following config
to setup the volumes
If you want to persist data in this application use the following
config to setup the volumes
</CardDescription>
</div>
{data && data?.mounts.length > 0 && (
<AddVolumes serviceId={id} refetch={refetch} serviceType={type}>
<AddVolumes
serviceId={applicationId}
refetch={refetch}
serviceType="application"
>
Add Volume
</AddVolumes>
)}
@@ -62,13 +52,17 @@ export const ShowVolumes = ({ id, type }: Props) => {
<span className="text-base text-muted-foreground">
No volumes/mounts configured
</span>
<AddVolumes serviceId={id} refetch={refetch} serviceType={type}>
<AddVolumes
serviceId={applicationId}
refetch={refetch}
serviceType="application"
>
Add Volume
</AddVolumes>
</div>
) : (
<div className="flex flex-col pt-2 gap-4">
<AlertBlock type="warning">
<AlertBlock type="info">
Please remember to click Redeploy after adding, editing, or
deleting a mount to apply the changes.
</AlertBlock>
@@ -79,8 +73,7 @@ export const ShowVolumes = ({ id, type }: Props) => {
key={mount.mountId}
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-3 flex-col gap-4 sm:gap-8">
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 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">
@@ -97,12 +90,21 @@ export const ShowVolumes = ({ id, type }: Props) => {
)}
{mount.type === "file" && (
<div className="flex flex-col gap-1">
<span className="font-medium">Content</span>
<span className="text-sm text-muted-foreground line-clamp-[10] whitespace-break-spaces">
{mount.content}
</span>
</div>
<>
<div className="flex flex-col gap-1">
<span className="font-medium">Content</span>
<span className="text-sm text-muted-foreground">
{mount.content}
</span>
</div>
<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>
</>
)}
{mount.type === "bind" && (
<div className="flex flex-col gap-1">
@@ -112,55 +114,21 @@ export const ShowVolumes = ({ id, type }: Props) => {
</span>
</div>
)}
{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
mountId={mount.mountId}
type={mount.type}
refetch={refetch}
serviceType={type}
serviceType="application"
/>
<DialogAction
title="Delete Volume"
description="Are you sure you want to delete this volume?"
type="destructive"
onClick={async () => {
await deleteVolume({
mountId: mount.mountId,
})
.then(() => {
refetch();
toast.success("Volume deleted successfully");
})
.catch(() => {
toast.error("Error deleting volume");
});
}}
>
<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>
<DeleteVolume mountId={mount.mountId} refetch={refetch} />
</div>
</div>
</div>

View File

@@ -21,7 +21,7 @@ import {
import { Input } from "@/components/ui/input";
import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod";
import { PenBoxIcon } from "lucide-react";
import { Pencil } from "lucide-react";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
@@ -77,7 +77,7 @@ export const UpdateVolume = ({
serviceType,
}: Props) => {
const [isOpen, setIsOpen] = useState(false);
const _utils = api.useUtils();
const utils = api.useUtils();
const { data } = api.mounts.one.useQuery(
{
mountId,
@@ -139,7 +139,7 @@ export const UpdateVolume = ({
setIsOpen(false);
})
.catch(() => {
toast.error("Error updating the Bind mount");
toast.error("Error to update the Bind mount");
});
} else if (data.type === "volume") {
await mutateAsync({
@@ -153,7 +153,7 @@ export const UpdateVolume = ({
setIsOpen(false);
})
.catch(() => {
toast.error("Error updating the Volume mount");
toast.error("Error to update the Volume mount");
});
} else if (data.type === "file") {
await mutateAsync({
@@ -168,7 +168,7 @@ export const UpdateVolume = ({
setIsOpen(false);
})
.catch(() => {
toast.error("Error updating the File mount");
toast.error("Error to update the File mount");
});
}
refetch();
@@ -177,13 +177,8 @@ export const UpdateVolume = ({
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
<Button
variant="ghost"
size="icon"
className="group hover:bg-blue-500/10 "
isLoading={isLoading}
>
<PenBoxIcon className="size-3.5 text-primary group-hover:text-blue-500" />
<Button variant="ghost" isLoading={isLoading}>
<Pencil className="size-4 text-muted-foreground" />
</Button>
</DialogTrigger>
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-3xl">
@@ -247,7 +242,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 +251,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,8 +1,5 @@
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,
@@ -22,27 +19,17 @@ import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
export enum BuildType {
enum BuildType {
dockerfile = "dockerfile",
heroku_buildpacks = "heroku_buildpacks",
paketo_buildpacks = "paketo_buildpacks",
nixpacks = "nixpacks",
static = "static",
railpack = "railpack",
}
const buildTypeDisplayMap: Record<BuildType, string> = {
[BuildType.dockerfile]: "Dockerfile",
[BuildType.railpack]: "Railpack",
[BuildType.nixpacks]: "Nixpacks",
[BuildType.heroku_buildpacks]: "Heroku Buildpacks",
[BuildType.paketo_buildpacks]: "Paketo Buildpacks",
[BuildType.static]: "Static",
};
const mySchema = z.discriminatedUnion("buildType", [
z.object({
buildType: z.literal(BuildType.dockerfile),
buildType: z.literal("dockerfile"),
dockerfile: z
.string({
required_error: "Dockerfile path is required",
@@ -53,92 +40,36 @@ const mySchema = z.discriminatedUnion("buildType", [
dockerBuildStage: z.string().nullable().default(""),
}),
z.object({
buildType: z.literal(BuildType.heroku_buildpacks),
buildType: z.literal("heroku_buildpacks"),
herokuVersion: z.string().nullable().default(""),
}),
z.object({
buildType: z.literal(BuildType.paketo_buildpacks),
buildType: z.literal("paketo_buildpacks"),
}),
z.object({
buildType: z.literal(BuildType.nixpacks),
buildType: z.literal("nixpacks"),
publishDirectory: z.string().optional(),
}),
z.object({
buildType: z.literal(BuildType.railpack),
}),
z.object({
buildType: z.literal(BuildType.static),
isStaticSpa: z.boolean().default(false),
buildType: z.literal("static"),
}),
]);
type AddTemplate = z.infer<typeof mySchema>;
interface Props {
applicationId: string;
}
interface ApplicationData {
buildType: BuildType;
dockerfile?: string | null;
dockerContextPath?: string | null;
dockerBuildStage?: string | null;
herokuVersion?: string | null;
publishDirectory?: string | null;
isStaticSpa?: boolean | null;
}
function isValidBuildType(value: string): value is BuildType {
return Object.values(BuildType).includes(value as BuildType);
}
const resetData = (data: ApplicationData): AddTemplate => {
switch (data.buildType) {
case BuildType.dockerfile:
return {
buildType: BuildType.dockerfile,
dockerfile: data.dockerfile || "",
dockerContextPath: data.dockerContextPath || "",
dockerBuildStage: data.dockerBuildStage || "",
};
case BuildType.heroku_buildpacks:
return {
buildType: BuildType.heroku_buildpacks,
herokuVersion: data.herokuVersion || "",
};
case BuildType.nixpacks:
return {
buildType: BuildType.nixpacks,
publishDirectory: data.publishDirectory || undefined,
};
case BuildType.paketo_buildpacks:
return {
buildType: BuildType.paketo_buildpacks,
};
case BuildType.static:
return {
buildType: BuildType.static,
isStaticSpa: data.isStaticSpa ?? false,
};
case BuildType.railpack:
return {
buildType: BuildType.railpack,
};
default: {
const buildType = data.buildType as BuildType;
return {
buildType,
} as AddTemplate;
}
}
};
export const ShowBuildChooseForm = ({ applicationId }: Props) => {
const { mutateAsync, isLoading } =
api.application.saveBuildType.useMutation();
const { data, refetch } = api.application.one.useQuery(
{ applicationId },
{ enabled: !!applicationId },
{
applicationId,
},
{
enabled: !!applicationId,
},
);
const form = useForm<AddTemplate>({
@@ -149,45 +80,53 @@ export const ShowBuildChooseForm = ({ applicationId }: Props) => {
});
const buildType = form.watch("buildType");
useEffect(() => {
if (data) {
const typedData: ApplicationData = {
...data,
buildType: isValidBuildType(data.buildType)
? (data.buildType as BuildType)
: BuildType.nixpacks, // fallback
};
form.reset(resetData(typedData));
if (data.buildType === "dockerfile") {
form.reset({
buildType: data.buildType,
...(data.buildType && {
dockerfile: data.dockerfile || "",
dockerContextPath: data.dockerContextPath || "",
dockerBuildStage: data.dockerBuildStage || "",
}),
});
} else if (data.buildType === "heroku_buildpacks") {
form.reset({
buildType: data.buildType,
...(data.buildType && {
herokuVersion: data.herokuVersion || "",
}),
});
} else {
form.reset({
buildType: data.buildType,
publishDirectory: data.publishDirectory || undefined,
});
}
}
}, [data, form]);
}, [form.formState.isSubmitSuccessful, form.reset, data, form]);
const onSubmit = async (data: AddTemplate) => {
await mutateAsync({
applicationId,
buildType: data.buildType,
publishDirectory:
data.buildType === BuildType.nixpacks ? data.publishDirectory : null,
dockerfile:
data.buildType === BuildType.dockerfile ? data.dockerfile : null,
data.buildType === "nixpacks" ? data.publishDirectory : null,
dockerfile: data.buildType === "dockerfile" ? data.dockerfile : null,
dockerContextPath:
data.buildType === BuildType.dockerfile ? data.dockerContextPath : null,
data.buildType === "dockerfile" ? data.dockerContextPath : null,
dockerBuildStage:
data.buildType === BuildType.dockerfile ? data.dockerBuildStage : null,
data.buildType === "dockerfile" ? data.dockerBuildStage : null,
herokuVersion:
data.buildType === BuildType.heroku_buildpacks
? data.herokuVersion
: null,
isStaticSpa:
data.buildType === BuildType.static ? data.isStaticSpa : null,
data.buildType === "heroku_buildpacks" ? data.herokuVersion : null,
})
.then(async () => {
toast.success("Build type saved");
await refetch();
})
.catch(() => {
toast.error("Error saving the build type");
toast.error("Error to save the build type");
});
};
@@ -208,22 +147,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"
@@ -232,167 +155,184 @@ export const ShowBuildChooseForm = ({ applicationId }: Props) => {
control={form.control}
name="buildType"
defaultValue={form.control._defaultValues.buildType}
render={({ field }) => (
<FormItem className="space-y-3">
<FormLabel>Build Type</FormLabel>
<FormControl>
<RadioGroup
onValueChange={field.onChange}
value={field.value}
className="flex flex-col space-y-1"
>
{Object.entries(buildTypeDisplayMap).map(
([value, label]) => (
<FormItem
key={value}
className="flex items-center space-x-3 space-y-0"
>
<FormControl>
<RadioGroupItem value={value} />
</FormControl>
<FormLabel className="font-normal">
{label}
{value === BuildType.railpack && (
<Badge className="ml-2 px-1 text-xs">New</Badge>
)}
</FormLabel>
</FormItem>
),
)}
</RadioGroup>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{buildType === BuildType.heroku_buildpacks && (
<FormField
control={form.control}
name="herokuVersion"
render={({ field }) => (
<FormItem>
<FormLabel>Heroku Version (Optional)</FormLabel>
render={({ field }) => {
return (
<FormItem className="space-y-3">
<FormLabel>Build Type</FormLabel>
<FormControl>
<Input
placeholder="Heroku Version (Default: 24)"
{...field}
value={field.value ?? ""}
/>
<RadioGroup
onValueChange={field.onChange}
value={field.value}
className="flex flex-col space-y-1"
>
<FormItem className="flex items-center space-x-3 space-y-0">
<FormControl>
<RadioGroupItem value="dockerfile" />
</FormControl>
<FormLabel className="font-normal">
Dockerfile
</FormLabel>
</FormItem>
<FormItem className="flex items-center space-x-3 space-y-0">
<FormControl>
<RadioGroupItem value="nixpacks" />
</FormControl>
<FormLabel className="font-normal">
Nixpacks
</FormLabel>
</FormItem>
<FormItem className="flex items-center space-x-3 space-y-0">
<FormControl>
<RadioGroupItem value="heroku_buildpacks" />
</FormControl>
<FormLabel className="font-normal">
Heroku Buildpacks
</FormLabel>
</FormItem>
<FormItem className="flex items-center space-x-3 space-y-0">
<FormControl>
<RadioGroupItem value="paketo_buildpacks" />
</FormControl>
<FormLabel className="font-normal">
Paketo Buildpacks
</FormLabel>
</FormItem>
<FormItem className="flex items-center space-x-3 space-y-0">
<FormControl>
<RadioGroupItem value="static" />
</FormControl>
<FormLabel className="font-normal">Static</FormLabel>
</FormItem>
</RadioGroup>
</FormControl>
<FormMessage />
</FormItem>
)}
);
}}
/>
{buildType === "heroku_buildpacks" && (
<FormField
control={form.control}
name="herokuVersion"
render={({ field }) => {
return (
<FormItem>
<FormLabel>Heroku Version (Optional)</FormLabel>
<FormControl>
<Input
placeholder={"Heroku Version (Default: 24)"}
{...field}
value={field.value ?? ""}
/>
</FormControl>
<FormMessage />
</FormItem>
);
}}
/>
)}
{buildType === BuildType.dockerfile && (
{buildType === "dockerfile" && (
<>
<FormField
control={form.control}
name="dockerfile"
render={({ field }) => (
<FormItem>
<FormLabel>Docker File</FormLabel>
<FormControl>
<Input
placeholder="Path of your docker file"
{...field}
value={field.value ?? ""}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
render={({ field }) => {
return (
<FormItem>
<FormLabel>Docker File</FormLabel>
<FormControl>
<Input
placeholder={"Path of your docker file"}
{...field}
value={field.value ?? ""}
/>
</FormControl>
<FormMessage />
</FormItem>
);
}}
/>
<FormField
control={form.control}
name="dockerContextPath"
render={({ field }) => (
<FormItem>
<FormLabel>Docker Context Path</FormLabel>
<FormControl>
<Input
placeholder="Path of your docker context (default: .)"
{...field}
value={field.value ?? ""}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
render={({ field }) => {
return (
<FormItem>
<FormLabel>Docker Context Path</FormLabel>
<FormControl>
<Input
placeholder={
"Path of your docker context default: ."
}
{...field}
value={field.value ?? ""}
/>
</FormControl>
<FormMessage />
</FormItem>
);
}}
/>
<FormField
control={form.control}
name="dockerBuildStage"
render={({ field }) => (
render={({ field }) => {
return (
<FormItem>
<div className="space-y-0.5">
<FormLabel>Docker Build Stage</FormLabel>
<FormDescription>
Allows you to target a specific stage in a
Multi-stage Dockerfile. If empty, Docker defaults to
build the last defined stage.
</FormDescription>
</div>
<FormControl>
<Input
placeholder={"E.g. production"}
{...field}
value={field.value ?? ""}
/>
</FormControl>
</FormItem>
);
}}
/>
</>
)}
{buildType === "nixpacks" && (
<FormField
control={form.control}
name="publishDirectory"
render={({ field }) => {
return (
<FormItem>
<div className="space-y-0.5">
<FormLabel>Docker Build Stage</FormLabel>
<FormLabel>Publish Directory</FormLabel>
<FormDescription>
Allows you to target a specific stage in a Multi-stage
Dockerfile. If empty, Docker defaults to build the
last defined stage.
Allows you to serve a single directory via NGINX after
the build phase. Useful if the final build assets
should be served as a static site.
</FormDescription>
</div>
<FormControl>
<Input
placeholder="E.g. production"
placeholder={"Publish Directory"}
{...field}
value={field.value ?? ""}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</>
)}
{buildType === BuildType.nixpacks && (
<FormField
control={form.control}
name="publishDirectory"
render={({ field }) => (
<FormItem>
<div className="space-y-0.5">
<FormLabel>Publish Directory</FormLabel>
<FormDescription>
Allows you to serve a single directory via NGINX after
the build phase. Useful if the final build assets should
be served as a static site.
</FormDescription>
</div>
<FormControl>
<Input
placeholder="Publish Directory"
{...field}
value={field.value ?? ""}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
{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>
)}
);
}}
/>
)}
<div className="flex w-full justify-end">

View File

@@ -0,0 +1,143 @@
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod";
import { TrashIcon } from "lucide-react";
import { useRouter } from "next/router";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
const deleteApplicationSchema = z.object({
projectName: z.string().min(1, {
message: "Application name is required",
}),
});
type DeleteApplication = z.infer<typeof deleteApplicationSchema>;
interface Props {
applicationId: string;
}
export const DeleteApplication = ({ applicationId }: Props) => {
const [isOpen, setIsOpen] = useState(false);
const { mutateAsync, isLoading } = api.application.delete.useMutation();
const { data } = api.application.one.useQuery(
{ applicationId },
{ enabled: !!applicationId },
);
const { push } = useRouter();
const form = useForm<DeleteApplication>({
defaultValues: {
projectName: "",
},
resolver: zodResolver(deleteApplicationSchema),
});
const onSubmit = async (formData: DeleteApplication) => {
const expectedName = `${data?.name}/${data?.appName}`;
if (formData.projectName === expectedName) {
await mutateAsync({
applicationId,
})
.then((data) => {
push(`/dashboard/project/${data?.projectId}`);
toast.success("Application deleted successfully");
setIsOpen(false);
})
.catch(() => {
toast.error("Error deleting the application");
});
} else {
form.setError("projectName", {
message: "Project name does not match",
});
}
};
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
<Button variant="ghost" isLoading={isLoading}>
<TrashIcon className="size-4 text-muted-foreground" />
</Button>
</DialogTrigger>
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-lg">
<DialogHeader>
<DialogTitle>Are you absolutely sure?</DialogTitle>
<DialogDescription>
This action cannot be undone. This will permanently delete the
application. If you are sure please enter the application name to
delete this application.
</DialogDescription>
</DialogHeader>
<div className="grid gap-4">
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
id="hook-form-delete-application"
className="grid w-full gap-4"
>
<FormField
control={form.control}
name="projectName"
render={({ field }) => (
<FormItem>
<FormLabel>
To confirm, type "{data?.name}/{data?.appName}" in the box
below
</FormLabel>
<FormControl>
<Input
placeholder="Enter application name to confirm"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</div>
<DialogFooter>
<Button
variant="secondary"
onClick={() => {
setIsOpen(false);
}}
>
Cancel
</Button>
<Button
isLoading={isLoading}
form="hook-form-delete-application"
type="submit"
variant="destructive"
>
Confirm
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};

View File

@@ -15,21 +15,11 @@ 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();
const { data: isCloud } = api.settings.isCloud.useQuery();
if (isCloud) {
return null;
}
export const CancelQueues = ({ applicationId }: Props) => {
const { mutateAsync, isLoading } = api.application.cleanQueues.useMutation();
return (
<AlertDialog>
<AlertDialogTrigger asChild>
@@ -52,8 +42,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

@@ -11,17 +11,14 @@ import {
} from "@/components/ui/alert-dialog";
import { api } from "@/utils/api";
import { RefreshCcw } from "lucide-react";
import React from "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,23 +38,16 @@ 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(() => {
toast.error("Error updating the refresh token");
toast.error("Error to update the refresh token");
});
}}
>

View File

@@ -1,5 +1,3 @@
import { Badge } from "@/components/ui/badge";
import { Checkbox } from "@/components/ui/checkbox";
import {
Dialog,
DialogContent,
@@ -7,45 +5,18 @@ 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";
interface Props {
logPath: string | null;
open: boolean;
onClose: () => void;
serverId?: string;
errorMessage?: string;
}
export const ShowDeployment = ({
logPath,
open,
onClose,
serverId,
errorMessage,
}: Props) => {
export const ShowDeployment = ({ logPath, open, onClose, serverId }: Props) => {
const [data, setData] = useState("");
const [showExtraLogs, setShowExtraLogs] = useState(false);
const [filteredLogs, setFilteredLogs] = useState<LogLine[]>([]);
const endOfLogsRef = useRef<HTMLDivElement>(null);
const wsRef = useRef<WebSocket | null>(null); // Ref to hold WebSocket instance
const [autoScroll, setAutoScroll] = useState(true);
const scrollRef = useRef<HTMLDivElement>(null);
const scrollToBottom = () => {
if (autoScroll && scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
}
};
const handleScroll = () => {
if (!scrollRef.current) return;
const { scrollTop, scrollHeight, clientHeight } = scrollRef.current;
const isAtBottom = Math.abs(scrollHeight - scrollTop - clientHeight) < 10;
setAutoScroll(isAtBottom);
};
useEffect(() => {
if (!open || !logPath) return;
@@ -77,36 +48,13 @@ export const ShowDeployment = ({
};
}, [logPath, open]);
useEffect(() => {
const logs = parseLogs(data);
let filteredLogsResult = logs;
if (serverId) {
let hideSubsequentLogs = false;
filteredLogsResult = logs.filter((log) => {
if (
log.message.includes(
"===================================EXTRA LOGS============================================",
)
) {
hideSubsequentLogs = true;
return showExtraLogs;
}
return showExtraLogs ? true : !hideSubsequentLogs;
});
}
setFilteredLogs(filteredLogsResult);
}, [data, showExtraLogs]);
const scrollToBottom = () => {
endOfLogsRef.current?.scrollIntoView({ behavior: "smooth" });
};
useEffect(() => {
scrollToBottom();
if (autoScroll && scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
}
}, [filteredLogs, autoScroll]);
const optionalErrors = parseLogs(errorMessage || "");
}, [data]);
return (
<Dialog
@@ -127,57 +75,18 @@ export const ShowDeployment = ({
<DialogContent className={"sm:max-w-5xl overflow-y-auto max-h-screen"}>
<DialogHeader>
<DialogTitle>Deployment</DialogTitle>
<DialogDescription className="flex items-center gap-2">
<span>
See all the details of this deployment |{" "}
<Badge variant="blank" className="text-xs">
{filteredLogs.length} lines
</Badge>
</span>
{serverId && (
<div className="flex items-center space-x-2">
<Checkbox
id="show-extra-logs"
checked={showExtraLogs}
onCheckedChange={(checked) =>
setShowExtraLogs(checked as boolean)
}
/>
<label
htmlFor="show-extra-logs"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
Show Extra Logs
</label>
</div>
)}
<DialogDescription>
See all the details of this deployment
</DialogDescription>
</DialogHeader>
<div
ref={scrollRef}
onScroll={handleScroll}
className="h-[720px] overflow-y-auto space-y-0 border p-4 bg-[#fafafa] dark:bg-[#050506] rounded custom-logs-scrollbar"
>
{" "}
{filteredLogs.length > 0 ? (
filteredLogs.map((log: LogLine, index: number) => (
<TerminalLine key={index} log={log} noTimestamp />
))
) : (
<>
{optionalErrors.length > 0 ? (
optionalErrors.map((log: LogLine, index: number) => (
<TerminalLine key={`extra-${index}`} log={log} noTimestamp />
))
) : (
<div className="flex justify-center items-center h-full text-muted-foreground">
<Loader2 className="h-6 w-6 animate-spin" />
</div>
)}
</>
)}
<div className="text-wrap rounded-lg border p-4 text-sm sm:max-w-[59rem]">
<code>
<pre className="whitespace-pre-wrap break-words">
{data || "Loading..."}
</pre>
<div ref={endOfLogsRef} />
</code>
</div>
</DialogContent>
</Dialog>

View File

@@ -1,69 +0,0 @@
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogTrigger } from "@/components/ui/dialog";
import type { RouterOutputs } from "@/utils/api";
import { useState } from "react";
import { ShowDeployment } from "../deployments/show-deployment";
import { ShowDeployments } from "./show-deployments";
interface Props {
id: string;
type:
| "application"
| "compose"
| "schedule"
| "server"
| "backup"
| "previewDeployment";
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="max-h-screen overflow-y-auto 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,6 +1,5 @@
import { DateTooltip } from "@/components/shared/date-tooltip";
import { StatusTooltip } from "@/components/shared/status-tooltip";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
@@ -9,59 +8,26 @@ import {
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { type RouterOutputs, api } from "@/utils/api";
import { Clock, Loader2, RocketIcon, Settings, RefreshCcw } from "lucide-react";
import { 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";
import { ShowRollbackSettings } from "../rollbacks/show-rollback-settings";
import { DialogAction } from "@/components/shared/dialog-action";
import { toast } from "sonner";
interface Props {
id: string;
type:
| "application"
| "compose"
| "schedule"
| "server"
| "backup"
| "previewDeployment";
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) => {
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();
export const ShowDeployments = ({ applicationId }: Props) => {
const [activeLog, setActiveLog] = useState<string | null>(null);
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(() => {
@@ -69,57 +35,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
@@ -127,14 +70,15 @@ export const ShowDeployments = ({
</div>
) : (
<div className="flex flex-col gap-4">
{deployments?.map((deployment, index) => (
{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">
{index + 1}. {deployment.status}
{deployment.status}
<StatusTooltip
status={deployment?.status}
className="size-2.5"
@@ -150,77 +94,27 @@ 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">
<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.logPath);
}}
>
View
</Button>
</div>
</div>
))}
</div>
)}
<ShowDeployment
serverId={serverId}
open={Boolean(activeLog && activeLog.logPath !== null)}
serverId={data?.serverId || ""}
open={activeLog !== null}
onClose={() => setActiveLog(null)}
logPath={activeLog?.logPath || ""}
errorMessage={activeLog?.errorMessage || ""}
logPath={activeLog}
/>
</CardContent>
</Card>

View File

@@ -0,0 +1,303 @@
import { AlertBlock } from "@/components/shared/alert-block";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input, NumberInput } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { api } from "@/utils/api";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { domain } from "@/server/db/validations/domain";
import { zodResolver } from "@hookform/resolvers/zod";
import { Dices } from "lucide-react";
import type z from "zod";
type Domain = z.infer<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 form = useForm<Domain>({
resolver: zodResolver(domain),
});
useEffect(() => {
if (data) {
form.reset({
...data,
/* Convert null to undefined */
path: data?.path || undefined,
port: data?.port || undefined,
});
}
if (!domainId) {
form.reset({});
}
}, [form, form.reset, data, isLoading]);
const dictionary = {
success: domainId ? "Domain Updated" : "Domain Created",
error: domainId
? "Error to update the domain"
: "Error to create 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>
<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>
)}
/>
{form.getValues().https && (
<FormField
control={form.control}
name="certificateType"
render={({ field }) => (
<FormItem className="col-span-2">
<FormLabel>Certificate</FormLabel>
<Select
onValueChange={field.onChange}
defaultValue={field.value || ""}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select a certificate" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="none">None</SelectItem>
<SelectItem value={"letsencrypt"}>
Letsencrypt (Default)
</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
)}
</div>
</div>
</form>
<DialogFooter>
<Button isLoading={isLoading} form="hook-form" type="submit">
{dictionary.submit}
</Button>
</DialogFooter>
</Form>
</DialogContent>
</Dialog>
);
};

View File

@@ -0,0 +1,73 @@
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import { api } from "@/utils/api";
import { TrashIcon } from "lucide-react";
import React from "react";
import { toast } from "sonner";
interface Props {
domainId: string;
}
export const DeleteDomain = ({ domainId }: Props) => {
const { mutateAsync, isLoading } = api.domain.delete.useMutation();
const utils = api.useUtils();
return (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="ghost" isLoading={isLoading}>
<TrashIcon className="size-4 text-muted-foreground " />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
<AlertDialogDescription>
This action cannot be undone. This will permanently delete the
domain
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={async () => {
await mutateAsync({
domainId,
})
.then((data) => {
if (data?.applicationId) {
utils.domain.byApplicationId.invalidate({
applicationId: data?.applicationId,
});
utils.application.readTraefikConfig.invalidate({
applicationId: data?.applicationId,
});
} else if (data?.composeId) {
utils.domain.byComposeId.invalidate({
composeId: data?.composeId,
});
}
toast.success("Domain delete succesfully");
})
.catch(() => {
toast.error("Error to delete Domain");
});
}}
>
Confirm
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
};

View File

@@ -1,109 +0,0 @@
import { AlertBlock } from "@/components/shared/alert-block";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Copy, HelpCircle, Server } from "lucide-react";
import { toast } from "sonner";
interface Props {
domain: {
host: string;
https: boolean;
path?: string;
};
serverIp?: string;
}
export const DnsHelperModal = ({ domain, serverIp }: Props) => {
const copyToClipboard = (text: string) => {
navigator.clipboard.writeText(text);
toast.success("Copied to clipboard!");
};
return (
<Dialog>
<DialogTrigger>
<Button variant="ghost" size="icon" className="group">
<HelpCircle className="size-4" />
</Button>
</DialogTrigger>
<DialogContent className="max-h-screen overflow-y-auto 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,596 +0,0 @@
import { AlertBlock } from "@/components/shared/alert-block";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input, NumberInput } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { api } from "@/utils/api";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { zodResolver } from "@hookform/resolvers/zod";
import { DatabaseZap, Dices, RefreshCw } from "lucide-react";
import Link from "next/link";
import z from "zod";
export type CacheType = "fetch" | "cache";
export const domain = z
.object({
host: z.string().min(1, { message: "Add a hostname" }),
path: z.string().min(1).optional(),
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",
});
}
});
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 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,
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,
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,
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="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">
<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">
<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>
<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>
</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="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,5 +1,3 @@
import { DialogAction } from "@/components/shared/dialog-action";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
@@ -8,135 +6,26 @@ import {
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { Input } from "@/components/ui/input";
import { api } from "@/utils/api";
import {
CheckCircle2,
ExternalLink,
GlobeIcon,
InfoIcon,
Loader2,
PenBoxIcon,
RefreshCw,
Server,
Trash2,
XCircle,
} from "lucide-react";
import { ExternalLink, GlobeIcon, PenBoxIcon } from "lucide-react";
import Link from "next/link";
import { useState } from "react";
import { toast } from "sonner";
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 { AddDomain } from "./add-domain";
import { DeleteDomain } from "./delete-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 } = 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 +39,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 +48,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 +64,42 @@ 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 gap-4 max-sm:flex-wrap border p-4 rounded-lg"
>
<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
target="_blank"
href={`${item.https ? "https" : "http"}://${item.host}${item.path}`}
>
<ExternalLink className="size-5" />
</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>
</CardContent>
</Card>
<Input disabled value={item.host} />
<Button variant="outline" disabled>
{item.path}
</Button>
<Button variant="outline" disabled>
{item.port}
</Button>
<Button variant="outline" disabled>
{item.https ? "HTTPS" : "HTTP"}
</Button>
<div className="flex flex-row gap-1">
<AddDomain
applicationId={applicationId}
domainId={item.domainId}
>
<Button variant="ghost">
<PenBoxIcon className="size-4 text-muted-foreground" />
</Button>
</AddDomain>
<DeleteDomain domainId={item.domainId} />
</div>
</div>
);
})}
</div>

View File

@@ -1,10 +1,9 @@
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { Card, CardContent } 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";
@@ -35,32 +34,16 @@ export const ShowEnvironment = ({ applicationId }: Props) => {
const form = useForm<EnvironmentSchema>({
defaultValues: {
env: "",
buildArgs: "",
env: data?.env || "",
buildArgs: data?.buildArgs || "",
},
resolver: zodResolver(addEnvironmentSchema),
});
// Watch form values
const currentEnv = form.watch("env");
const currentBuildArgs = form.watch("buildArgs");
const hasChanges =
currentEnv !== (data?.env || "") ||
currentBuildArgs !== (data?.buildArgs || "");
useEffect(() => {
if (data) {
form.reset({
env: data.env || "",
buildArgs: data.buildArgs || "",
});
}
}, [data, form]);
const onSubmit = async (formData: EnvironmentSchema) => {
const onSubmit = async (data: EnvironmentSchema) => {
mutateAsync({
env: formData.env,
buildArgs: formData.buildArgs,
env: data.env,
buildArgs: data.buildArgs,
applicationId,
})
.then(async () => {
@@ -68,37 +51,21 @@ export const ShowEnvironment = ({ applicationId }: Props) => {
await refetch();
})
.catch(() => {
toast.error("Error adding environment");
toast.error("Error to add environment");
});
};
const handleCancel = () => {
form.reset({
env: data?.env || "",
buildArgs: data?.buildArgs || "",
});
};
return (
<Card className="bg-background px-6 pb-6">
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="flex w-full flex-col gap-4"
>
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="flex w-full flex-col gap-5 "
>
<Card className="bg-background p-6">
<Secrets
name="env"
title="Environment Settings"
description={
<span>
You can add environment variables to your resource.
{hasChanges && (
<span className="text-yellow-500 ml-2">
(You have unsaved changes)
</span>
)}
</span>
}
description="You can add environment variables to your resource."
placeholder={["NODE_ENV=production", "PORT=3000"].join("\n")}
/>
{data?.buildType === "dockerfile" && (
@@ -122,23 +89,15 @@ export const ShowEnvironment = ({ applicationId }: Props) => {
placeholder="NPM_TOKEN=xyz"
/>
)}
<div className="flex flex-row justify-end gap-2">
{hasChanges && (
<Button type="button" variant="outline" onClick={handleCancel}>
Cancel
<CardContent>
<div className="flex flex-row justify-end">
<Button isLoading={isLoading} className="w-fit" type="submit">
Save
</Button>
)}
<Button
isLoading={isLoading}
className="w-fit"
type="submit"
disabled={!hasChanges}
>
Save
</Button>
</div>
</form>
</Form>
</Card>
</div>
</CardContent>
</Card>
</form>
</Form>
);
};

View File

@@ -0,0 +1,69 @@
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import { api } from "@/utils/api";
import { toast } from "sonner";
interface Props {
applicationId: string;
}
export const DeployApplication = ({ applicationId }: Props) => {
const { data, refetch } = api.application.one.useQuery(
{
applicationId,
},
{ enabled: !!applicationId },
);
const { mutateAsync: deploy } = api.application.deploy.useMutation();
return (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button isLoading={data?.applicationStatus === "running"}>
Deploy
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
<AlertDialogDescription>
This will deploy the application
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={async () => {
await deploy({
applicationId,
})
.then(async () => {
toast.success("Application deployed succesfully");
await refetch();
})
.catch(() => {
toast.error("Error to deploy Application");
});
await refetch();
}}
>
Confirm
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
};

View File

@@ -1,6 +1,4 @@
import { BitbucketIcon } from "@/components/icons/data-tools-icons";
import { AlertBlock } from "@/components/shared/alert-block";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Command,
@@ -31,18 +29,10 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod";
import { CheckIcon, ChevronsUpDown, X } from "lucide-react";
import Link from "next/link";
import { CheckIcon, ChevronsUpDown } from "lucide-react";
import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
@@ -58,8 +48,6 @@ const BitbucketProviderSchema = z.object({
.required(),
branch: z.string().min(1, "Branch is required"),
bitbucketId: z.string().min(1, "Bitbucket Provider is required"),
watchPaths: z.array(z.string()).optional(),
enableSubmodules: z.boolean().optional(),
});
type BitbucketProvider = z.infer<typeof BitbucketProviderSchema>;
@@ -85,8 +73,6 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
},
bitbucketId: "",
branch: "",
watchPaths: [],
enableSubmodules: false,
},
resolver: zodResolver(BitbucketProviderSchema),
});
@@ -98,6 +84,7 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
data: repositories,
isLoading: isLoadingRepositories,
error,
isError,
} = api.bitbucket.getBitbucketRepositories.useQuery(
{
bitbucketId,
@@ -132,11 +119,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({
@@ -146,15 +131,13 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
bitbucketBuildPath: data.buildPath,
bitbucketId: data.bitbucketId,
applicationId,
watchPaths: data.watchPaths || [],
enableSubmodules: data.enableSubmodules || false,
})
.then(async () => {
toast.success("Service Provided Saved");
await refetch();
})
.catch(() => {
toast.error("Error saving the Bitbucket provider");
toast.error("Error to save the Bitbucket provider");
});
};
@@ -213,20 +196,7 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
name="repository"
render={({ field }) => (
<FormItem className="md:col-span-2 flex flex-col">
<div className="flex items-center justify-between">
<FormLabel>Repository</FormLabel>
{field.value.owner && field.value.repo && (
<Link
href={`https://bitbucket.org/${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"
>
<BitbucketIcon className="h-4 w-4" />
<span>View Repository</span>
</Link>
)}
</div>
<FormLabel>Repository</FormLabel>
<Popover>
<PopoverTrigger asChild>
<FormControl>
@@ -265,7 +235,7 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
<CommandGroup>
{repositories?.map((repo) => (
<CommandItem
value={repo.name}
value={repo.url}
key={repo.url}
onSelect={() => {
form.setValue("repository", {
@@ -275,12 +245,7 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
form.setValue("branch", "");
}}
>
<span className="flex items-center gap-2">
<span>{repo.name}</span>
<span className="text-muted-foreground text-xs">
{repo.owner.username}
</span>
</span>
{repo.name}
<CheckIcon
className={cn(
"ml-auto h-4 w-4",
@@ -394,99 +359,6 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
</FormItem>
)}
/>
<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>
<div className="size-4 rounded-full bg-muted flex items-center justify-center text-[10px] font-bold">
?
</div>
</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">
{path}
<X
className="ml-1 size-3 cursor-pointer"
onClick={() => {
const newPaths = [...(field.value || [])];
newPaths.splice(index, 1);
form.setValue("watchPaths", newPaths);
}}
/>
</Badge>
))}
</div>
<FormControl>
<div className="flex gap-2">
<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 value = input.value.trim();
if (value) {
const newPaths = [...(field.value || []), value];
form.setValue("watchPaths", newPaths);
input.value = "";
}
}
}}
/>
<Button
type="button"
variant="secondary"
onClick={() => {
const input = document.querySelector(
'input[placeholder="Enter a path to watch (e.g., src/**, dist/*.js)"]',
) as HTMLInputElement;
const value = input.value.trim();
if (value) {
const newPaths = [...(field.value || []), value];
form.setValue("watchPaths", newPaths);
input.value = "";
}
}}
>
Add
</Button>
</div>
</FormControl>
<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>
</FormItem>
)}
/>
</div>
<div className="flex w-full justify-end">
<Button

View File

@@ -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({
@@ -68,7 +68,7 @@ export const SaveDockerProvider = ({ applicationId }: Props) => {
await refetch();
})
.catch(() => {
toast.error("Error saving the Docker provider");
toast.error("Error to save the Docker provider");
});
};
@@ -115,11 +115,7 @@ export const SaveDockerProvider = ({ applicationId }: Props) => {
<FormItem>
<FormLabel>Username</FormLabel>
<FormControl>
<Input
placeholder="Username"
autoComplete="username"
{...field}
/>
<Input placeholder="username" {...field} />
</FormControl>
<FormMessage />
</FormItem>
@@ -134,12 +130,7 @@ export const SaveDockerProvider = ({ applicationId }: Props) => {
<FormItem>
<FormLabel>Password</FormLabel>
<FormControl>
<Input
placeholder="Password"
autoComplete="one-time-code"
{...field}
type="password"
/>
<Input placeholder="Password" {...field} type="password" />
</FormControl>
<FormMessage />
</FormItem>

View File

@@ -56,7 +56,7 @@ export const SaveDragNDrop = ({ applicationId }: Props) => {
await refetch();
})
.catch(() => {
toast.error("Error saving the deployment");
toast.error("Error to save the deployment");
});
};

View File

@@ -17,35 +17,23 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod";
import { KeyRoundIcon, LockIcon, X } from "lucide-react";
import Link from "next/link";
import { KeyRoundIcon, LockIcon } from "lucide-react";
import { useRouter } from "next/router";
import { GitIcon } from "@/components/icons/data-tools-icons";
import { Badge } from "@/components/ui/badge";
import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
const GitProviderSchema = z.object({
buildPath: z.string().min(1, "Path is required").default("/"),
repositoryURL: z.string().min(1, {
message: "Repository URL is required",
}),
branch: z.string().min(1, "Branch required"),
buildPath: z.string().min(1, "Build Path required"),
sshKey: z.string().optional(),
watchPaths: z.array(z.string()).optional(),
enableSubmodules: z.boolean().default(false),
});
type GitProvider = z.infer<typeof GitProviderSchema>;
@@ -68,8 +56,6 @@ export const SaveGitProvider = ({ applicationId }: Props) => {
buildPath: "/",
repositoryURL: "",
sshKey: undefined,
watchPaths: [],
enableSubmodules: false,
},
resolver: zodResolver(GitProviderSchema),
});
@@ -81,8 +67,6 @@ export const SaveGitProvider = ({ applicationId }: Props) => {
branch: data.customGitBranch || "",
buildPath: data.customGitBuildPath || "/",
repositoryURL: data.customGitUrl || "",
watchPaths: data.watchPaths || [],
enableSubmodules: data.enableSubmodules ?? false,
});
}
}, [form.reset, data, form]);
@@ -94,15 +78,13 @@ export const SaveGitProvider = ({ applicationId }: Props) => {
customGitUrl: values.repositoryURL,
customGitSSHKeyId: values.sshKey === "none" ? null : values.sshKey,
applicationId,
watchPaths: values.watchPaths || [],
enableSubmodules: values.enableSubmodules,
})
.then(async () => {
toast.success("Git Provider Saved");
await refetch();
})
.catch(() => {
toast.error("Error saving the Git provider");
toast.error("Error to save the Git provider");
});
};
@@ -120,22 +102,9 @@ export const SaveGitProvider = ({ applicationId }: Props) => {
name="repositoryURL"
render={({ field }) => (
<FormItem>
<div className="flex items-center justify-between">
<FormLabel>Repository URL</FormLabel>
{field.value?.startsWith("https://") && (
<Link
href={field.value}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 text-sm text-muted-foreground hover:text-primary"
>
<GitIcon className="h-4 w-4" />
<span>View Repository</span>
</Link>
)}
</div>
<FormLabel>Repository URL</FormLabel>
<FormControl>
<Input placeholder="Repository URL" {...field} />
<Input placeholder="git@bitbucket.org" {...field} />
</FormControl>
<FormMessage />
</FormItem>
@@ -191,22 +160,19 @@ export const SaveGitProvider = ({ applicationId }: Props) => {
</Button>
)}
</div>
<div className="space-y-4">
<FormField
control={form.control}
name="branch"
render={({ field }) => (
<FormItem>
<FormLabel>Branch</FormLabel>
<FormControl>
<Input placeholder="Branch" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name="branch"
render={({ field }) => (
<FormItem>
<FormLabel>Branch</FormLabel>
<FormControl>
<Input placeholder="Branch" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="buildPath"
@@ -220,101 +186,6 @@ export const SaveGitProvider = ({ applicationId }: Props) => {
</FormItem>
)}
/>
<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>
<div className="size-4 rounded-full bg-muted flex items-center justify-center text-[10px] font-bold">
?
</div>
</TooltipTrigger>
<TooltipContent className="max-w-[300px]">
<p>
Add paths to watch for changes. When files in these
paths change, a new deployment will be triggered. This
will work only when manual webhook is setup.
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<div className="flex flex-wrap gap-2 mb-2">
{field.value?.map((path, index) => (
<Badge key={index} variant="secondary">
{path}
<X
className="ml-1 size-3 cursor-pointer"
onClick={() => {
const newPaths = [...(field.value || [])];
newPaths.splice(index, 1);
form.setValue("watchPaths", newPaths);
}}
/>
</Badge>
))}
</div>
<FormControl>
<div className="flex gap-2">
<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 value = input.value.trim();
if (value) {
const newPaths = [...(field.value || []), value];
form.setValue("watchPaths", newPaths);
input.value = "";
}
}
}}
/>
<Button
type="button"
variant="secondary"
onClick={() => {
const input = document.querySelector(
'input[placeholder="Enter a path to watch (e.g., src/**, dist/*.js)"]',
) as HTMLInputElement;
const value = input.value.trim();
if (value) {
const newPaths = [...(field.value || []), value];
form.setValue("watchPaths", newPaths);
input.value = "";
}
}}
>
Add
</Button>
</div>
</FormControl>
<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>
</FormItem>
)}
/>
</div>
<div className="flex flex-row justify-end">

View File

@@ -1,538 +0,0 @@
import { GiteaIcon } from "@/components/icons/data-tools-icons";
import { AlertBlock } from "@/components/shared/alert-block";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
} from "@/components/ui/command";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod";
import { CheckIcon, ChevronsUpDown, HelpCircle, Plus, X } from "lucide-react";
import Link from "next/link";
import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
interface GiteaRepository {
name: string;
url: string;
id: number;
owner: {
username: string;
};
}
interface GiteaBranch {
name: string;
commit: {
id: string;
};
}
const GiteaProviderSchema = z.object({
buildPath: z.string().min(1, "Path is required").default("/"),
repository: z
.object({
repo: z.string().min(1, "Repo is required"),
owner: z.string().min(1, "Owner is required"),
})
.required(),
branch: z.string().min(1, "Branch is required"),
giteaId: z.string().min(1, "Gitea Provider is required"),
watchPaths: z.array(z.string()).default([]),
enableSubmodules: z.boolean().optional(),
});
type GiteaProvider = z.infer<typeof GiteaProviderSchema>;
interface Props {
applicationId: string;
}
export const SaveGiteaProvider = ({ applicationId }: Props) => {
const { data: giteaProviders } = api.gitea.giteaProviders.useQuery();
const { data, refetch } = api.application.one.useQuery({ applicationId });
const { mutateAsync, isLoading: isSavingGiteaProvider } =
api.application.saveGiteaProvider.useMutation();
const form = useForm<GiteaProvider>({
defaultValues: {
buildPath: "/",
repository: {
owner: "",
repo: "",
},
giteaId: "",
branch: "",
watchPaths: [],
enableSubmodules: false,
},
resolver: zodResolver(GiteaProviderSchema),
});
const repository = form.watch("repository");
const giteaId = form.watch("giteaId");
const { data: giteaUrl } = api.gitea.getGiteaUrl.useQuery(
{ giteaId },
{
enabled: !!giteaId,
},
);
const {
data: repositories,
isLoading: isLoadingRepositories,
error,
} = api.gitea.getGiteaRepositories.useQuery(
{
giteaId,
},
{
enabled: !!giteaId,
},
);
const {
data: branches,
fetchStatus,
status,
} = api.gitea.getGiteaBranches.useQuery(
{
owner: repository?.owner,
repositoryName: repository?.repo,
giteaId: giteaId,
},
{
enabled: !!repository?.owner && !!repository?.repo && !!giteaId,
},
);
useEffect(() => {
if (data) {
form.reset({
branch: data.giteaBranch || "",
repository: {
repo: data.giteaRepository || "",
owner: data.giteaOwner || "",
},
buildPath: data.giteaBuildPath || "/",
giteaId: data.giteaId || "",
watchPaths: data.watchPaths || [],
enableSubmodules: data.enableSubmodules || false,
});
}
}, [form.reset, data?.applicationId, form]);
const onSubmit = async (data: GiteaProvider) => {
await mutateAsync({
giteaBranch: data.branch,
giteaRepository: data.repository.repo,
giteaOwner: data.repository.owner,
giteaBuildPath: data.buildPath,
giteaId: data.giteaId,
applicationId,
watchPaths: data.watchPaths,
enableSubmodules: data.enableSubmodules || false,
})
.then(async () => {
toast.success("Service Provider Saved");
await refetch();
})
.catch(() => {
toast.error("Error saving the Gitea provider");
});
};
return (
<div>
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="grid w-full gap-4 py-3"
>
{error && <AlertBlock type="error">{error?.message}</AlertBlock>}
<div className="grid md:grid-cols-2 gap-4">
<FormField
control={form.control}
name="giteaId"
render={({ field }) => (
<FormItem className="md:col-span-2 flex flex-col">
<FormLabel>Gitea Account</FormLabel>
<Select
onValueChange={(value) => {
field.onChange(value);
form.setValue("repository", {
owner: "",
repo: "",
});
form.setValue("branch", "");
}}
defaultValue={field.value}
value={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select a Gitea Account" />
</SelectTrigger>
</FormControl>
<SelectContent>
{giteaProviders?.map((giteaProvider) => (
<SelectItem
key={giteaProvider.giteaId}
value={giteaProvider.giteaId}
>
{giteaProvider.gitProvider.name}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="repository"
render={({ field }) => (
<FormItem className="md:col-span-2 flex flex-col">
<div className="flex items-center justify-between">
<FormLabel>Repository</FormLabel>
{field.value.owner && field.value.repo && (
<Link
href={`${giteaUrl}/${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"
>
<GiteaIcon className="h-4 w-4" />
<span>View Repository</span>
</Link>
)}
</div>
<Popover>
<PopoverTrigger asChild>
<FormControl>
<Button
variant="outline"
className={cn(
"w-full justify-between !bg-input",
!field.value && "text-muted-foreground",
)}
>
{isLoadingRepositories
? "Loading...."
: field.value.owner
? repositories?.find(
(repo: GiteaRepository) =>
repo.name === field.value.repo,
)?.name
: "Select repository"}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent className="p-0" align="start">
<Command>
<CommandInput
placeholder="Search repository..."
className="h-9"
/>
{isLoadingRepositories && (
<span className="py-6 text-center text-sm">
Loading Repositories....
</span>
)}
<CommandEmpty>No repositories found.</CommandEmpty>
<ScrollArea className="h-96">
<CommandGroup>
{repositories && repositories.length === 0 && (
<CommandEmpty>
No repositories found.
</CommandEmpty>
)}
{repositories?.map((repo: GiteaRepository) => {
return (
<CommandItem
value={repo.name}
key={repo.url}
onSelect={() => {
form.setValue("repository", {
owner: repo.owner.username as string,
repo: repo.name,
});
form.setValue("branch", "");
}}
>
<span className="flex items-center gap-2">
<span>{repo.name}</span>
<span className="text-muted-foreground text-xs">
{repo.owner.username}
</span>
</span>
<CheckIcon
className={cn(
"ml-auto h-4 w-4",
repo.name === field.value.repo
? "opacity-100"
: "opacity-0",
)}
/>
</CommandItem>
);
})}
</CommandGroup>
</ScrollArea>
</Command>
</PopoverContent>
</Popover>
{form.formState.errors.repository && (
<p className={cn("text-sm font-medium text-destructive")}>
Repository is required
</p>
)}
</FormItem>
)}
/>
<FormField
control={form.control}
name="branch"
render={({ field }) => (
<FormItem className="block w-full">
<FormLabel>Branch</FormLabel>
<Popover>
<PopoverTrigger asChild>
<FormControl>
<Button
variant="outline"
className={cn(
" w-full justify-between !bg-input",
!field.value && "text-muted-foreground",
)}
>
{status === "loading" && fetchStatus === "fetching"
? "Loading...."
: field.value
? branches?.find(
(branch: GiteaBranch) =>
branch.name === field.value,
)?.name
: "Select branch"}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent className="p-0" align="start">
<Command>
<CommandInput
placeholder="Search branch..."
className="h-9"
/>
{status === "loading" && fetchStatus === "fetching" && (
<span className="py-6 text-center text-sm text-muted-foreground">
Loading Branches....
</span>
)}
{!repository?.owner && (
<span className="py-6 text-center text-sm text-muted-foreground">
Select a repository
</span>
)}
<ScrollArea className="h-96">
<CommandEmpty>No branch found.</CommandEmpty>
<CommandGroup>
{branches && branches.length === 0 && (
<CommandItem>No branches found.</CommandItem>
)}
{branches?.map((branch: GiteaBranch) => (
<CommandItem
value={branch.name}
key={branch.commit.id}
onSelect={() => {
form.setValue("branch", branch.name);
}}
>
{branch.name}
<CheckIcon
className={cn(
"ml-auto h-4 w-4",
branch.name === field.value
? "opacity-100"
: "opacity-0",
)}
/>
</CommandItem>
))}
</CommandGroup>
</ScrollArea>
</Command>
</PopoverContent>
<FormMessage />
</Popover>
</FormItem>
)}
/>
<FormField
control={form.control}
name="buildPath"
render={({ field }) => (
<FormItem>
<FormLabel>Build Path</FormLabel>
<FormControl>
<Input placeholder="/" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<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: string, index: number) => (
<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 = "";
}
}
}}
/>
</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>
)}
/>
<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
isLoading={isSavingGiteaProvider}
type="submit"
className="w-fit"
>
Save
</Button>
</div>
</form>
</Form>
</div>
);
};

View File

@@ -1,5 +1,3 @@
import { GithubIcon } from "@/components/icons/data-tools-icons";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Command,
@@ -30,18 +28,10 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod";
import { CheckIcon, ChevronsUpDown, HelpCircle, Plus, X } from "lucide-react";
import Link from "next/link";
import { CheckIcon, ChevronsUpDown } from "lucide-react";
import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
@@ -57,9 +47,6 @@ const GithubProviderSchema = z.object({
.required(),
branch: z.string().min(1, "Branch is required"),
githubId: z.string().min(1, "Github Provider is required"),
watchPaths: z.array(z.string()).optional(),
triggerType: z.enum(["push", "tag"]).default("push"),
enableSubmodules: z.boolean().default(false),
});
type GithubProvider = z.infer<typeof GithubProviderSchema>;
@@ -84,15 +71,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(
@@ -129,12 +113,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({
@@ -144,16 +125,13 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
owner: data.repository.owner,
buildPath: data.buildPath,
githubId: data.githubId,
watchPaths: data.watchPaths || [],
triggerType: data.triggerType,
enableSubmodules: data.enableSubmodules,
})
.then(async () => {
toast.success("Service Provided Saved");
await refetch();
})
.catch(() => {
toast.error("Error saving the github provider");
toast.error("Error to save the github provider");
});
};
@@ -209,20 +187,7 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
name="repository"
render={({ field }) => (
<FormItem className="md:col-span-2 flex flex-col">
<div className="flex items-center justify-between">
<FormLabel>Repository</FormLabel>
{field.value.owner && field.value.repo && (
<Link
href={`https://github.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"
>
<GithubIcon className="h-4 w-4" />
<span>View Repository</span>
</Link>
)}
</div>
<FormLabel>Repository</FormLabel>
<Popover>
<PopoverTrigger asChild>
<FormControl>
@@ -261,7 +226,7 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
<CommandGroup>
{repositories?.map((repo) => (
<CommandItem
value={repo.name}
value={repo.url}
key={repo.url}
onSelect={() => {
form.setValue("repository", {
@@ -271,12 +236,7 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
form.setValue("branch", "");
}}
>
<span className="flex items-center gap-2">
<span>{repo.name}</span>
<span className="text-muted-foreground text-xs">
{repo.owner.login}
</span>
</span>
{repo.name}
<CheckIcon
className={cn(
"ml-auto h-4 w-4",
@@ -385,145 +345,8 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
<FormControl>
<Input placeholder="/" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="triggerType"
render={({ field }) => (
<FormItem className="md:col-span-2">
<div className="flex items-center gap-2 ">
<FormLabel>Trigger Type</FormLabel>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<HelpCircle className="size-4 text-muted-foreground hover:text-foreground transition-colors cursor-pointer" />
</TooltipTrigger>
<TooltipContent>
<p>
Choose when to trigger deployments: on push to the
selected branch or when a new tag is created.
</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 = "";
}
}
}}
/>
</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>
)}
/>
)}
<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>
<FormMessage />
</FormItem>
)}
/>

View File

@@ -1,6 +1,4 @@
import { GitlabIcon } from "@/components/icons/data-tools-icons";
import { AlertBlock } from "@/components/shared/alert-block";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Command,
@@ -31,18 +29,10 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod";
import { CheckIcon, ChevronsUpDown, HelpCircle, Plus, X } from "lucide-react";
import Link from "next/link";
import { CheckIcon, ChevronsUpDown } from "lucide-react";
import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
@@ -60,8 +50,6 @@ const GitlabProviderSchema = z.object({
.required(),
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 +76,6 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
},
gitlabId: "",
branch: "",
enableSubmodules: false,
},
resolver: zodResolver(GitlabProviderSchema),
});
@@ -137,11 +124,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({
@@ -153,15 +138,13 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
applicationId,
gitlabProjectId: data.repository.id,
gitlabPathNamespace: data.repository.gitlabPathNamespace,
watchPaths: data.watchPaths || [],
enableSubmodules: data.enableSubmodules,
})
.then(async () => {
toast.success("Service Provided Saved");
await refetch();
})
.catch(() => {
toast.error("Error saving the gitlab provider");
toast.error("Error to save the gitlab provider");
});
};
@@ -220,20 +203,7 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
name="repository"
render={({ field }) => (
<FormItem className="md:col-span-2 flex flex-col">
<div className="flex items-center justify-between">
<FormLabel>Repository</FormLabel>
{field.value.owner && field.value.repo && (
<Link
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"
>
<GitlabIcon className="h-4 w-4" />
<span>View Repository</span>
</Link>
)}
</div>
<FormLabel>Repository</FormLabel>
<Popover>
<PopoverTrigger asChild>
<FormControl>
@@ -278,7 +248,7 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
{repositories?.map((repo) => {
return (
<CommandItem
value={repo.name}
value={repo.url}
key={repo.url}
onSelect={() => {
form.setValue("repository", {
@@ -290,12 +260,7 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
form.setValue("branch", "");
}}
>
<span className="flex items-center gap-2">
<span>{repo.name}</span>
<span className="text-muted-foreground text-xs">
{repo.owner.username}
</span>
</span>
{repo.name}
<CheckIcon
className={cn(
"ml-auto h-4 w-4",
@@ -405,104 +370,11 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
<FormControl>
<Input placeholder="/" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<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 = "";
}
}
}}
/>
</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>
)}
/>
<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,135 +1,37 @@
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";
import { SaveGithubProvider } from "@/components/dashboard/application/general/generic/save-github-provider";
import {
BitbucketIcon,
DockerIcon,
GitIcon,
GiteaIcon,
GithubIcon,
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, Loader2, UploadCloud } from "lucide-react";
import { GitBranch, LockIcon, UploadCloud } from "lucide-react";
import Link from "next/link";
import { useState } from "react";
import { toast } from "sonner";
import { SaveBitbucketProvider } from "./save-bitbucket-provider";
import { SaveDragNDrop } from "./save-drag-n-drop";
import { SaveGitlabProvider } from "./save-gitlab-provider";
import { UnauthorizedGitProvider } from "./unauthorized-git-provider";
type TabState =
| "github"
| "docker"
| "git"
| "drop"
| "gitlab"
| "bitbucket"
| "gitea";
type TabState = "github" | "docker" | "git" | "drop" | "gitlab" | "bitbucket";
interface Props {
applicationId: string;
}
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: 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,7 +55,7 @@ export const ShowProviderForm = ({ applicationId }: Props) => {
setSab(e as TabState);
}}
>
<div className="flex flex-row items-center justify-between w-full gap-4">
<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"
@@ -176,13 +78,6 @@ export const ShowProviderForm = ({ applicationId }: Props) => {
<BitbucketIcon className="size-4 text-current fill-current" />
Bitbucket
</TabsTrigger>
<TabsTrigger
value="gitea"
className="rounded-none border-b-2 gap-2 border-b-transparent data-[state=active]:border-b-2 data-[state=active]:border-b-border"
>
<GiteaIcon className="size-4 text-current fill-current" />
Gitea
</TabsTrigger>
<TabsTrigger
value="docker"
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 +106,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 +126,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 +146,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
@@ -267,26 +162,6 @@ export const ShowProviderForm = ({ applicationId }: Props) => {
</div>
)}
</TabsContent>
<TabsContent value="gitea" className="w-full p-2">
{giteaProviders && giteaProviders?.length > 0 ? (
<SaveGiteaProvider applicationId={applicationId} />
) : (
<div className="flex flex-col items-center gap-3 min-h-[25vh] 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
first. Please, go to{" "}
<Link
href="/dashboard/settings/git-providers"
className="text-foreground"
>
Settings
</Link>{" "}
to do so.
</span>
</div>
)}
</TabsContent>
<TabsContent value="docker" className="w-full p-2">
<SaveDockerProvider applicationId={applicationId} />
</TabsContent>

View File

@@ -1,149 +0,0 @@
import {
BitbucketIcon,
GitIcon,
GiteaIcon,
GithubIcon,
GitlabIcon,
} from "@/components/icons/data-tools-icons";
import { DialogAction } from "@/components/shared/dialog-action";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import type { RouterOutputs } from "@/utils/api";
import { AlertCircle, GitBranch, Unlink } from "lucide-react";
interface Props {
service:
| RouterOutputs["application"]["one"]
| RouterOutputs["compose"]["one"];
onDisconnect: () => void;
}
export const UnauthorizedGitProvider = ({ service, onDisconnect }: Props) => {
const getProviderIcon = (sourceType: string) => {
switch (sourceType) {
case "github":
return <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

@@ -0,0 +1,70 @@
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import { api } from "@/utils/api";
import { RefreshCcw } from "lucide-react";
import { toast } from "sonner";
interface Props {
applicationId: string;
appName: string;
}
export const ResetApplication = ({ applicationId, appName }: Props) => {
const { refetch } = api.application.one.useQuery(
{
applicationId,
},
{ enabled: !!applicationId },
);
const { mutateAsync: reload, isLoading } =
api.application.reload.useMutation();
return (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="secondary" isLoading={isLoading}>
Reload
<RefreshCcw className="size-4" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
<AlertDialogDescription>
This will reload the application
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={async () => {
await reload({
applicationId,
appName,
})
.then(() => {
toast.success("Service Reloaded");
})
.catch(() => {
toast.error("Error to reload the service");
});
await refetch();
}}
>
Confirm
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
};

View File

@@ -1,34 +1,23 @@
import { ShowBuildChooseForm } from "@/components/dashboard/application/build/show";
import { ShowProviderForm } from "@/components/dashboard/application/general/generic/show";
import { DialogAction } from "@/components/shared/dialog-action";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Switch } from "@/components/ui/switch";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { api } from "@/utils/api";
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
import {
Ban,
CheckCircle2,
Hammer,
RefreshCcw,
Rocket,
Terminal,
} from "lucide-react";
import { useRouter } from "next/router";
import { Terminal } from "lucide-react";
import React from "react";
import { toast } from "sonner";
import { DockerTerminalModal } from "../../settings/web-server/docker-terminal-modal";
import { RedbuildApplication } from "../rebuild-application";
import { StartApplication } from "../start-application";
import { StopApplication } from "../stop-application";
import { DeployApplication } from "./deploy-application";
import { ResetApplication } from "./reset-application";
interface Props {
applicationId: string;
}
export const ShowGeneralApplication = ({ applicationId }: Props) => {
const router = useRouter();
const { data, refetch } = api.application.one.useQuery(
{
applicationId,
@@ -36,17 +25,6 @@ export const ShowGeneralApplication = ({ applicationId }: Props) => {
{ enabled: !!applicationId },
);
const { mutateAsync: update } = api.application.update.useMutation();
const { mutateAsync: start, isLoading: isStarting } =
api.application.start.useMutation();
const { mutateAsync: stop, isLoading: isStopping } =
api.application.stop.useMutation();
const { mutateAsync: deploy } = api.application.deploy.useMutation();
const { mutateAsync: reload, isLoading: isReloading } =
api.application.reload.useMutation();
const { mutateAsync: redeploy } = api.application.redeploy.useMutation();
return (
<>
@@ -55,224 +33,31 @@ export const ShowGeneralApplication = ({ applicationId }: Props) => {
<CardTitle className="text-xl">Deploy Settings</CardTitle>
</CardHeader>
<CardContent className="flex flex-row gap-4 flex-wrap">
<TooltipProvider delayDuration={0} disableHoverableContent={false}>
<DialogAction
title="Deploy Application"
description="Are you sure you want to deploy this application?"
type="default"
onClick={async () => {
await deploy({
applicationId: applicationId,
})
.then(() => {
toast.success("Application deployed successfully");
refetch();
router.push(
`/dashboard/project/${data?.projectId}/services/application/${applicationId}?tab=deployments`,
);
})
.catch(() => {
toast.error("Error deploying application");
});
}}
>
<Button
variant="default"
isLoading={data?.applicationStatus === "running"}
className="flex items-center gap-1.5 group focus-visible:ring-2 focus-visible:ring-offset-2"
>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center">
<Rocket className="size-4 mr-1" />
Deploy
</div>
</TooltipTrigger>
<TooltipPrimitive.Portal>
<TooltipContent sideOffset={5} className="z-[60]">
<p>
Downloads the source code and performs a complete build
</p>
</TooltipContent>
</TooltipPrimitive.Portal>
</Tooltip>
</Button>
</DialogAction>
<DialogAction
title="Reload Application"
description="Are you sure you want to reload this application?"
type="default"
onClick={async () => {
await reload({
applicationId: applicationId,
appName: data?.appName || "",
})
.then(() => {
toast.success("Application reloaded successfully");
refetch();
})
.catch(() => {
toast.error("Error reloading application");
});
}}
>
<Button
variant="secondary"
isLoading={isReloading}
className="flex items-center gap-1.5 group focus-visible:ring-2 focus-visible:ring-offset-2"
>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center">
<RefreshCcw className="size-4 mr-1" />
Reload
</div>
</TooltipTrigger>
<TooltipPrimitive.Portal>
<TooltipContent sideOffset={5} className="z-[60]">
<p>Reload the application without rebuilding it</p>
</TooltipContent>
</TooltipPrimitive.Portal>
</Tooltip>
</Button>
</DialogAction>
<DialogAction
title="Rebuild Application"
description="Are you sure you want to rebuild this application?"
type="default"
onClick={async () => {
await redeploy({
applicationId: applicationId,
})
.then(() => {
toast.success("Application rebuilt successfully");
refetch();
})
.catch(() => {
toast.error("Error rebuilding application");
});
}}
>
<Button
variant="secondary"
isLoading={data?.applicationStatus === "running"}
className="flex items-center gap-1.5 group focus-visible:ring-2 focus-visible:ring-offset-2"
>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center">
<Hammer className="size-4 mr-1" />
Rebuild
</div>
</TooltipTrigger>
<TooltipPrimitive.Portal>
<TooltipContent sideOffset={5} className="z-[60]">
<p>
Only rebuilds the application without downloading new
code
</p>
</TooltipContent>
</TooltipPrimitive.Portal>
</Tooltip>
</Button>
</DialogAction>
<DeployApplication applicationId={applicationId} />
<ResetApplication
applicationId={applicationId}
appName={data?.appName || ""}
/>
{data?.applicationStatus === "idle" ? (
<DialogAction
title="Start Application"
description="Are you sure you want to start this application?"
type="default"
onClick={async () => {
await start({
applicationId: applicationId,
})
.then(() => {
toast.success("Application started successfully");
refetch();
})
.catch(() => {
toast.error("Error starting application");
});
}}
>
<Button
variant="secondary"
isLoading={isStarting}
className="flex items-center gap-1.5 group focus-visible:ring-2 focus-visible:ring-offset-2"
>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center">
<CheckCircle2 className="size-4 mr-1" />
Start
</div>
</TooltipTrigger>
<TooltipPrimitive.Portal>
<TooltipContent sideOffset={5} className="z-[60]">
<p>
Start the application (requires a previous successful
build)
</p>
</TooltipContent>
</TooltipPrimitive.Portal>
</Tooltip>
</Button>
</DialogAction>
) : (
<DialogAction
title="Stop Application"
description="Are you sure you want to stop this application?"
onClick={async () => {
await stop({
applicationId: applicationId,
})
.then(() => {
toast.success("Application stopped successfully");
refetch();
})
.catch(() => {
toast.error("Error stopping application");
});
}}
>
<Button
variant="destructive"
isLoading={isStopping}
className="flex items-center gap-1.5 group focus-visible:ring-2 focus-visible:ring-offset-2"
>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center">
<Ban className="size-4 mr-1" />
Stop
</div>
</TooltipTrigger>
<TooltipPrimitive.Portal>
<TooltipContent sideOffset={5} className="z-[60]">
<p>Stop the currently running application</p>
</TooltipContent>
</TooltipPrimitive.Portal>
</Tooltip>
</Button>
</DialogAction>
)}
</TooltipProvider>
<RedbuildApplication applicationId={applicationId} />
{data?.applicationStatus === "idle" ? (
<StartApplication applicationId={applicationId} />
) : (
<StopApplication applicationId={applicationId} />
)}
<DockerTerminalModal
appName={data?.appName || ""}
serverId={data?.serverId || ""}
>
<Button
variant="outline"
className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2"
>
<Terminal className="size-4 mr-1" />
<Button variant="outline">
<Terminal />
Open Terminal
</Button>
</DockerTerminalModal>
<div className="flex flex-row items-center gap-2 rounded-md px-4 py-2 border">
<span className="text-sm font-medium">Autodeploy</span>
<Switch
aria-label="Toggle autodeploy"
aria-label="Toggle italic"
checked={data?.autoDeploy || false}
onCheckedChange={async (enabled) => {
await update({
@@ -284,32 +69,10 @@ export const ShowGeneralApplication = ({ applicationId }: Props) => {
await refetch();
})
.catch(() => {
toast.error("Error updating Auto Deploy");
toast.error("Error to update Auto Deploy");
});
}}
className="flex flex-row gap-2 items-center data-[state=checked]:bg-primary"
/>
</div>
<div className="flex flex-row items-center gap-2 rounded-md px-4 py-2 border">
<span className="text-sm font-medium">Clean Cache</span>
<Switch
aria-label="Toggle clean cache"
checked={data?.cleanCache || false}
onCheckedChange={async (enabled) => {
await update({
applicationId,
cleanCache: enabled,
})
.then(async () => {
toast.success("Clean Cache Updated");
await refetch();
})
.catch(() => {
toast.error("Error updating Clean Cache");
});
}}
className="flex flex-row gap-2 items-center data-[state=checked]:bg-primary"
className="flex flex-row gap-2 items-center"
/>
</div>
</CardContent>

View File

@@ -1,4 +1,3 @@
import { Badge } from "@/components/ui/badge";
import {
Card,
CardContent,
@@ -16,7 +15,6 @@ import {
SelectTrigger,
SelectValue,
} 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";
@@ -31,67 +29,28 @@ export const DockerLogs = dynamic(
},
);
export const badgeStateColor = (state: string) => {
switch (state) {
case "running":
return "green";
case "exited":
case "shutdown":
return "red";
case "accepted":
case "created":
return "blue";
default:
return "default";
}
};
interface Props {
appName: string;
serverId?: string;
}
export const ShowDockerLogs = ({ appName, serverId }: Props) => {
const { data, isLoading } = api.docker.getContainersByAppNameMatch.useQuery(
{
appName,
serverId,
},
{
enabled: !!appName,
},
);
const [containerId, setContainerId] = useState<string | undefined>();
const [option, setOption] = useState<"swarm" | "native">("native");
const { data: services, isLoading: servicesLoading } =
api.docker.getServiceContainersByAppName.useQuery(
{
appName,
serverId,
},
{
enabled: !!appName && option === "swarm",
},
);
const { data: containers, isLoading: containersLoading } =
api.docker.getContainersByAppNameMatch.useQuery(
{
appName,
serverId,
},
{
enabled: !!appName && option === "native",
},
);
useEffect(() => {
if (option === "native") {
if (containers && containers?.length > 0) {
setContainerId(containers[0]?.containerId);
}
} else {
if (services && services?.length > 0) {
setContainerId(services[0]?.containerId);
}
if (data && data?.length > 0) {
setContainerId(data[0]?.containerId);
}
}, [option, services, containers]);
const isLoading = option === "native" ? containersLoading : servicesLoading;
const containersLenght =
option === "native" ? containers?.length : services?.length;
}, [data]);
return (
<Card className="bg-background">
@@ -103,21 +62,7 @@ export const ShowDockerLogs = ({ appName, serverId }: Props) => {
</CardHeader>
<CardContent className="flex flex-col gap-4">
<div className="flex flex-row justify-between items-center gap-2">
<Label>Select a container to view logs</Label>
<div className="flex flex-row gap-2 items-center">
<span className="text-sm text-muted-foreground">
{option === "native" ? "Native" : "Swarm"}
</span>
<Switch
checked={option === "native"}
onCheckedChange={(checked) => {
setOption(checked ? "native" : "swarm");
}}
/>
</div>
</div>
<Label>Select a container to view logs</Label>
<Select onValueChange={setContainerId} value={containerId}>
<SelectTrigger>
{isLoading ? (
@@ -131,45 +76,22 @@ export const ShowDockerLogs = ({ appName, serverId }: Props) => {
</SelectTrigger>
<SelectContent>
<SelectGroup>
{option === "native" ? (
<div>
{containers?.map((container) => (
<SelectItem
key={container.containerId}
value={container.containerId}
>
{container.name} ({container.containerId}){" "}
<Badge variant={badgeStateColor(container.state)}>
{container.state}
</Badge>
</SelectItem>
))}
</div>
) : (
<>
{services?.map((container) => (
<SelectItem
key={container.containerId}
value={container.containerId}
>
{container.name} ({container.containerId}@{container.node}
)
<Badge variant={badgeStateColor(container.state)}>
{container.state}
</Badge>
</SelectItem>
))}
</>
)}
<SelectLabel>Containers ({containersLenght})</SelectLabel>
{data?.map((container) => (
<SelectItem
key={container.containerId}
value={container.containerId}
>
{container.name} ({container.containerId}) {container.state}
</SelectItem>
))}
<SelectLabel>Containers ({data?.length})</SelectLabel>
</SelectGroup>
</SelectContent>
</Select>
<DockerLogs
serverId={serverId || ""}
id="terminal"
containerId={containerId || "select-a-container"}
runType={option}
/>
</CardContent>
</Card>

View File

@@ -94,7 +94,6 @@ export const AddPreviewDomain = ({
/* Convert null to undefined */
path: data?.path || undefined,
port: data?.port || undefined,
customCertResolver: data?.customCertResolver || undefined,
});
}
@@ -105,7 +104,9 @@ export const AddPreviewDomain = ({
const dictionary = {
success: domainId ? "Domain Updated" : "Domain Created",
error: domainId ? "Error updating the domain" : "Error creating the domain",
error: domainId
? "Error to update the domain"
: "Error to create the domain",
submit: domainId ? "Update" : "Create",
dialogDescription: domainId
? "In this section you can edit a domain"
@@ -264,21 +265,21 @@ export const AddPreviewDomain = ({
name="certificateType"
render={({ field }) => (
<FormItem className="col-span-2">
<FormLabel>Certificate Provider</FormLabel>
<FormLabel>Certificate</FormLabel>
<Select
onValueChange={field.onChange}
defaultValue={field.value || ""}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select a certificate provider" />
<SelectValue placeholder="Select a certificate" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="none">None</SelectItem>
<SelectItem value={"letsencrypt"}>
Let's Encrypt
Letsencrypt (Default)
</SelectItem>
</SelectContent>
</Select>

View File

@@ -0,0 +1,87 @@
import { DateTooltip } from "@/components/shared/date-tooltip";
import { StatusTooltip } from "@/components/shared/status-tooltip";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
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;
}
export const ShowPreviewBuilds = ({ deployments, serverId }: Props) => {
const [activeLog, setActiveLog] = useState<string | null>(null);
const [isOpen, setIsOpen] = useState(false);
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
<Button 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.logPath);
}}
>
View
</Button>
</div>
</div>
))}
</div>
</DialogContent>
<ShowDeployment
serverId={serverId || ""}
open={activeLog !== null}
onClose={() => setActiveLog(null)}
logPath={activeLog}
/>
</Dialog>
);
};

View File

@@ -1,8 +1,5 @@
import { GithubIcon } from "@/components/icons/data-tools-icons";
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,
@@ -11,56 +8,41 @@ import {
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import { api } from "@/utils/api";
import {
ExternalLink,
FileText,
GitPullRequest,
Loader2,
PenSquare,
RocketIcon,
Trash2,
} from "lucide-react";
import { Pencil, RocketIcon } from "lucide-react";
import React, { useEffect, useState } from "react";
import { toast } from "sonner";
import { ShowDeployment } from "../deployments/show-deployment";
import Link from "next/link";
import { ShowModalLogs } from "../../settings/web-server/show-modal-logs";
import { ShowDeploymentsModal } from "../deployments/show-deployments-modal";
import { DialogAction } from "@/components/shared/dialog-action";
import { AddPreviewDomain } from "./add-preview-domain";
import { GithubIcon } from "@/components/icons/data-tools-icons";
import { ShowPreviewSettings } from "./show-preview-settings";
import { ShowPreviewBuilds } from "./show-preview-builds";
interface Props {
applicationId: string;
}
export const ShowPreviewDeployments = ({ applicationId }: Props) => {
const [activeLog, setActiveLog] = useState<string | null>(null);
const { data } = api.application.one.useQuery({ applicationId });
const { mutateAsync: deletePreviewDeployment, isLoading } =
api.previewDeployment.delete.useMutation();
const {
data: previewDeployments,
refetch: refetchPreviewDeployments,
isLoading: isLoadingPreviewDeployments,
} = api.previewDeployment.all.useQuery(
{ applicationId },
{
enabled: !!applicationId,
},
);
const handleDeletePreviewDeployment = async (previewDeploymentId: string) => {
deletePreviewDeployment({
previewDeploymentId: previewDeploymentId,
})
.then(() => {
refetchPreviewDeployments();
toast.success("Preview deployment deleted");
})
.catch((error) => {
toast.error(error.message);
});
};
const { data: previewDeployments, refetch: refetchPreviewDeployments } =
api.previewDeployment.all.useQuery(
{ applicationId },
{
enabled: !!applicationId,
},
);
// const [url, setUrl] = React.useState("");
// useEffect(() => {
// setUrl(document.location.origin);
// }, []);
return (
<Card className="bg-background">
@@ -83,15 +65,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]">
{data?.previewDeployments?.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 preview deployments found
@@ -99,122 +74,120 @@ export const ShowPreviewDeployments = ({ applicationId }: Props) => {
</div>
) : (
<div className="flex flex-col gap-4">
{previewDeployments?.map((deployment) => {
const deploymentUrl = `${deployment.domain?.https ? "https" : "http"}://${deployment.domain?.host}${deployment.domain?.path || "/"}`;
const status = deployment.previewStatus;
{previewDeployments?.map((previewDeployment) => {
const { deployments, domain } = previewDeployment;
return (
<div
key={deployment.previewDeploymentId}
className="group relative overflow-hidden border rounded-lg transition-colors"
key={previewDeployment?.previewDeploymentId}
className="flex flex-col justify-between rounded-lg border p-4 gap-2"
>
<div
className={`absolute left-0 top-0 w-1 h-full ${
status === "done"
? "bg-green-500"
: status === "running"
? "bg-yellow-500"
: "bg-red-500"
}`}
/>
<div className="p-4">
<div className="flex items-start justify-between mb-3">
<div className="flex items-start gap-3">
<GitPullRequest className="size-5 text-muted-foreground mt-1 flex-shrink-0" />
<div className="flex justify-between gap-2 max-sm:flex-wrap">
<div className="flex flex-col gap-2">
{deployments?.length === 0 ? (
<div>
<div className="font-medium text-sm">
{deployment.pullRequestTitle}
<span className="text-sm text-muted-foreground">
No deployments found
</span>
</div>
) : (
<div className="flex items-center gap-2">
<span className="flex items-center gap-4 font-medium capitalize text-foreground">
{previewDeployment?.pullRequestTitle}
</span>
<StatusTooltip
status={previewDeployment.previewStatus}
className="size-2.5"
/>
</div>
)}
<div className="flex flex-col gap-1">
{previewDeployment?.pullRequestTitle && (
<div className="flex items-center gap-2">
<span className="break-all text-sm text-muted-foreground w-fit">
Title: {previewDeployment?.pullRequestTitle}
</span>
</div>
<div className="text-sm text-muted-foreground mt-1">
{deployment.branch}
)}
{previewDeployment?.pullRequestURL && (
<div className="flex items-center gap-2">
<GithubIcon />
<Link
target="_blank"
href={previewDeployment?.pullRequestURL}
className="break-all text-sm text-muted-foreground w-fit hover:underline hover:text-foreground"
>
Pull Request URL
</Link>
</div>
)}
</div>
<div className="flex flex-col ">
<span>Domain </span>
<div className="flex flex-row items-center gap-4">
<Link
target="_blank"
href={`http://${domain?.host}`}
className="text-sm text-muted-foreground w-fit hover:underline hover:text-foreground"
>
{domain?.host}
</Link>
<AddPreviewDomain
previewDeploymentId={
previewDeployment.previewDeploymentId
}
domainId={domain?.domainId}
>
<Button variant="outline" size="sm">
<Pencil className="size-4 text-muted-foreground" />
</Button>
</AddPreviewDomain>
</div>
</div>
<Badge variant="outline" className="gap-2">
<StatusTooltip
status={deployment.previewStatus}
className="size-2"
/>
<DateTooltip date={deployment.createdAt} />
</Badge>
</div>
<div className="pl-8 space-y-3">
<div className="relative flex-grow">
<Input
value={deploymentUrl}
readOnly
className="pr-8 text-sm text-blue-500 hover:text-blue-600 cursor-pointer"
onClick={() =>
window.open(deploymentUrl, "_blank")
}
/>
<ExternalLink className="absolute right-3 top-1/2 -translate-y-1/2 size-4 text-gray-400" />
</div>
<div className="flex flex-col sm:items-end gap-2 max-sm:w-full">
{previewDeployment?.createdAt && (
<div className="text-sm capitalize text-muted-foreground">
<DateTooltip
date={previewDeployment?.createdAt}
/>
</div>
)}
<ShowPreviewBuilds
deployments={previewDeployment?.deployments || []}
serverId={data?.serverId || ""}
/>
<div className="flex gap-2 opacity-80 group-hover:opacity-100 transition-opacity">
<Button
variant="outline"
size="sm"
className="gap-2"
onClick={() =>
window.open(deployment.pullRequestURL, "_blank")
}
>
<GithubIcon className="size-4" />
Pull Request
<ShowModalLogs
appName={previewDeployment.appName}
serverId={data?.serverId || ""}
>
<Button variant="outline">View Logs</Button>
</ShowModalLogs>
<DialogAction
title="Delete Preview"
description="Are you sure you want to delete this preview?"
onClick={() => {
deletePreviewDeployment({
previewDeploymentId:
previewDeployment.previewDeploymentId,
})
.then(() => {
refetchPreviewDeployments();
toast.success("Preview deployment deleted");
})
.catch((error) => {
toast.error(error.message);
});
}}
>
<Button variant="destructive" isLoading={isLoading}>
Delete Preview
</Button>
<ShowModalLogs
appName={deployment.appName}
serverId={data?.serverId || ""}
>
<Button
variant="outline"
size="sm"
className="gap-2"
>
<FileText className="size-4" />
Logs
</Button>
</ShowModalLogs>
<ShowDeploymentsModal
id={deployment.previewDeploymentId}
type="previewDeployment"
serverId={data?.serverId || ""}
/>
<AddPreviewDomain
previewDeploymentId={`${deployment.previewDeploymentId}`}
domainId={deployment.domain?.domainId}
>
<Button
variant="ghost"
size="sm"
className="gap-2"
>
<PenSquare className="size-4" />
</Button>
</AddPreviewDomain>
<DialogAction
title="Delete Preview"
description="Are you sure you want to delete this preview?"
onClick={() =>
handleDeletePreviewDeployment(
deployment.previewDeploymentId,
)
}
>
<Button
variant="ghost"
size="sm"
isLoading={isLoading}
className="text-red-600 hover:text-red-700 hover:bg-red-50"
>
<Trash2 className="size-4" />
</Button>
</DialogAction>
</div>
</DialogAction>
</div>
</div>
</div>

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