mirror of
https://github.com/Dokploy/dokploy.git
synced 2026-10-16 13:45:28 +00:00
Compare commits
143 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9bf88b90c3 | |||
| 947d2217df | |||
| 9eba4f8b84 | |||
| f0d0e4c1e2 | |||
| 24cb47bcb1 | |||
| 9cf4a5e7a3 | |||
| 7b91d67655 | |||
| ca0acf0445 | |||
| 576ff02773 | |||
| d8b28107cd | |||
| 9898cac2f5 | |||
| dd9bed4c2b | |||
| 14c8ae675a | |||
| bda0689e18 | |||
| 7e39be4ca1 | |||
| 2e3a7c6164 | |||
| f02f75e3a0 | |||
| fe51dd6b0a | |||
| 2724336cad | |||
| 12bd017d07 | |||
| a2eff67d44 | |||
| 71555a15f8 | |||
| c681aa2e9f | |||
| 1f81ebd4fe | |||
| d243470029 | |||
| 054e581023 | |||
| f866250c25 | |||
| ee58672d58 | |||
| 135894da2a | |||
| 06c8688ddd | |||
| d8474b8aa3 | |||
| 9a4b474cdc | |||
| 1519a71535 | |||
| 9e47103131 | |||
| ef689f06d6 | |||
| 54c7572447 | |||
| 4cacc6b3d1 | |||
| 8b193d4317 | |||
| e72add74c3 | |||
| 115c8641e7 | |||
| 0af532f87e | |||
| d1bd2b29fe | |||
| 734a6607c8 | |||
| d3a2b03bb7 | |||
| 9a1436d0ae | |||
| 087e2c81cc | |||
| 32f35a6ca0 | |||
| 7fd35999b1 | |||
| fa5b75e6fb | |||
| 7262e0debe | |||
| 13c686c228 | |||
| 083bb7b87d | |||
| 1b7ecd5a41 | |||
| 56b52b3f9c | |||
| 88f67b1c71 | |||
| 75bd10cbd5 | |||
| d3397cfbd0 | |||
| a4bf48fa68 | |||
| dfe3294088 | |||
| 5b1ca4eafc | |||
| 82721251cc | |||
| cd051b72fc | |||
| 411070cfcc | |||
| 8230c1ba91 | |||
| 8b7df6ce16 | |||
| 6d71eac221 | |||
| fd3e4a8bc7 | |||
| c13eb65b5a | |||
| bb13a09def | |||
| 94bcea36c6 | |||
| 028b9b3f7b | |||
| b85639f98e | |||
| 7c981b2aac | |||
| 59b072e7e0 | |||
| 6780fa9688 | |||
| ff47a157c7 | |||
| 3601abc4c1 | |||
| b1a48d4636 | |||
| a15eb3b229 | |||
| c34c4b244e | |||
| 4991e4b1f2 | |||
| b3b7439617 | |||
| b7c061dcb4 | |||
| e00cbaeb8a | |||
| 655e29d96b | |||
| 7240ff38f1 | |||
| 4a08bacba0 | |||
| 54aaa511d5 | |||
| 701319efdd | |||
| 0fdf176648 | |||
| f62d835f57 | |||
| 5bb4710952 | |||
| 9b71ce9388 | |||
| 175d1ec432 | |||
| 8454e4f781 | |||
| 2e79c7230f | |||
| 7ddd3bb8a0 | |||
| b889a9d248 | |||
| fc21c96cd1 | |||
| 0341b19c9f | |||
| 43095f2435 | |||
| ad696ea54a | |||
| f6128bdf0c | |||
| 6be6ec940a | |||
| ba9fc59805 | |||
| 63a1039439 | |||
| d52692c6a3 | |||
| b4511ca7a2 | |||
| a3c24f1f2a | |||
| ca599f27f7 | |||
| f36de7b2f5 | |||
| 0ce055c001 | |||
| fc011a5661 | |||
| 60d6d781be | |||
| 9270739eb6 | |||
| 87b87b85c0 | |||
| 496fd40fa3 | |||
| 25fe080582 | |||
| 1c41091372 | |||
| 9230178005 | |||
| fd092f1248 | |||
| 736c186a66 | |||
| 3d348ee762 | |||
| 6e78f49c2f | |||
| e77b30671b | |||
| 244e1227c4 | |||
| 9934dac203 | |||
| 44ee326057 | |||
| 18f892096b | |||
| 9954d5b209 | |||
| e0bde5cec9 | |||
| 2d4eaeb8b5 | |||
| 787506fb6b | |||
| 50c8c3a43a | |||
| 1f09c06274 | |||
| af11bc8cd2 | |||
| 6779dec1ff | |||
| 191a6112ce | |||
| 1bf518f768 | |||
| 79ad0818f5 | |||
| 5fadd73732 | |||
| 342ff4b589 | |||
| 680811357b |
@@ -0,0 +1,72 @@
|
||||
version: 2.1
|
||||
|
||||
jobs:
|
||||
build-amd64:
|
||||
machine:
|
||||
image: ubuntu-2004:current
|
||||
steps:
|
||||
- checkout
|
||||
- run:
|
||||
name: Prepare .env file
|
||||
command: |
|
||||
cp .env.production.example .env.production
|
||||
- run:
|
||||
name: Build and push AMD64 image
|
||||
command: |
|
||||
VERSION=$(node -p "require('./package.json').version")
|
||||
echo $VERSION
|
||||
docker login -u $DOCKERHUB_USERNAME -p $DOCKERHUB_TOKEN
|
||||
docker build --platform linux/amd64 -t dokploy/dokploy:canary-amd64 .
|
||||
docker push dokploy/dokploy:canary-amd64
|
||||
|
||||
build-arm64:
|
||||
machine:
|
||||
image: ubuntu-2004:current
|
||||
resource_class: arm.large
|
||||
steps:
|
||||
- checkout
|
||||
|
||||
- run:
|
||||
name: Prepare .env file
|
||||
command: |
|
||||
cp .env.production.example .env.production
|
||||
- run:
|
||||
name: Build and push ARM64 image
|
||||
command: |
|
||||
docker login -u $DOCKERHUB_USERNAME -p $DOCKERHUB_TOKEN
|
||||
docker build --platform linux/arm64 -t dokploy/dokploy:canary-arm64 .
|
||||
docker push dokploy/dokploy:canary-arm64
|
||||
|
||||
combine-manifests:
|
||||
docker:
|
||||
- image: cimg/base:stable
|
||||
steps:
|
||||
- setup_remote_docker
|
||||
- run:
|
||||
name: Create and push multi-arch manifest
|
||||
command: |
|
||||
docker login -u $DOCKERHUB_USERNAME -p $DOCKERHUB_TOKEN
|
||||
docker manifest create dokploy/dokploy:canary \
|
||||
dokploy/dokploy:canary-amd64 \
|
||||
dokploy/dokploy:canary-arm64
|
||||
docker manifest push dokploy/dokploy:canary
|
||||
|
||||
workflows:
|
||||
version: 2
|
||||
build-all:
|
||||
jobs:
|
||||
- build-amd64:
|
||||
filters:
|
||||
branches:
|
||||
only: feat/circle
|
||||
- build-arm64:
|
||||
filters:
|
||||
branches:
|
||||
only: feat/circle
|
||||
- combine-manifests:
|
||||
requires:
|
||||
- build-amd64
|
||||
- build-arm64
|
||||
filters:
|
||||
branches:
|
||||
only: feat/circle
|
||||
@@ -0,0 +1,104 @@
|
||||
version: 2.1
|
||||
|
||||
jobs:
|
||||
build-amd64:
|
||||
machine:
|
||||
image: ubuntu-2004:current
|
||||
steps:
|
||||
- checkout
|
||||
- run:
|
||||
name: Prepare .env file
|
||||
command: |
|
||||
cp .env.production.example .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"
|
||||
else
|
||||
TAG="canary"
|
||||
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 .env.production.example .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"
|
||||
else
|
||||
TAG="canary"
|
||||
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('./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}
|
||||
else
|
||||
TAG="canary"
|
||||
docker manifest create dokploy/dokploy:${TAG} \
|
||||
dokploy/dokploy:${TAG}-amd64 \
|
||||
dokploy/dokploy:${TAG}-arm64
|
||||
docker manifest push dokploy/dokploy:${TAG}
|
||||
fi
|
||||
|
||||
workflows:
|
||||
version: 2
|
||||
build-all:
|
||||
jobs:
|
||||
- build-amd64:
|
||||
filters:
|
||||
branches:
|
||||
only:
|
||||
- main
|
||||
- canary
|
||||
- build-arm64:
|
||||
filters:
|
||||
branches:
|
||||
only:
|
||||
- main
|
||||
- canary
|
||||
- combine-manifests:
|
||||
requires:
|
||||
- build-amd64
|
||||
- build-arm64
|
||||
filters:
|
||||
branches:
|
||||
only:
|
||||
- main
|
||||
- canary
|
||||
@@ -0,0 +1,76 @@
|
||||
version: 2.1
|
||||
|
||||
jobs:
|
||||
build-amd64:
|
||||
machine:
|
||||
image: ubuntu-2004:current
|
||||
steps:
|
||||
- checkout
|
||||
- run:
|
||||
name: Prepare .env file
|
||||
command: |
|
||||
cp .env.production.example .env.production
|
||||
- run:
|
||||
name: Build and push AMD64 image
|
||||
command: |
|
||||
docker login -u $DOCKERHUB_USERNAME -p $DOCKERHUB_TOKEN
|
||||
docker build --platform linux/amd64 -t dokploy/dokploy:latest-amd64 .
|
||||
docker push dokploy/dokploy:latest-amd64
|
||||
|
||||
build-arm64:
|
||||
machine:
|
||||
image: ubuntu-2004:current
|
||||
resource_class: arm.large
|
||||
steps:
|
||||
- checkout
|
||||
|
||||
- run:
|
||||
name: Prepare .env file
|
||||
command: |
|
||||
cp .env.production.example .env.production
|
||||
- run:
|
||||
name: Build and push ARM64 image
|
||||
command: |
|
||||
docker login -u $DOCKERHUB_USERNAME -p $DOCKERHUB_TOKEN
|
||||
docker build --platform linux/arm64 -t dokploy/dokploy:latest-arm64 .
|
||||
docker push dokploy/dokploy:latest-arm64
|
||||
|
||||
combine-manifests:
|
||||
docker:
|
||||
- image: cimg/base:stable
|
||||
steps:
|
||||
- setup_remote_docker
|
||||
- run:
|
||||
name: Create and push multi-arch manifest
|
||||
command: |
|
||||
VERSION=$(node -p "require('./package.json').version")
|
||||
docker login -u $DOCKERHUB_USERNAME -p $DOCKERHUB_TOKEN
|
||||
docker manifest create dokploy/dokploy:latest \
|
||||
dokploy/dokploy:latest-amd64 \
|
||||
dokploy/dokploy:latest-arm64
|
||||
docker manifest push dokploy/dokploy:latest
|
||||
|
||||
docker manifest create dokploy/dokploy:${VERSION} \
|
||||
dokploy/dokploy:latest-amd64 \
|
||||
dokploy/dokploy:latest-arm64
|
||||
docker manifest push dokploy/dokploy:${VERSION}
|
||||
|
||||
workflows:
|
||||
version: 2
|
||||
build-all:
|
||||
jobs:
|
||||
- build-amd64:
|
||||
filters:
|
||||
branches:
|
||||
only: main
|
||||
- build-arm64:
|
||||
filters:
|
||||
branches:
|
||||
only: main
|
||||
- combine-manifests:
|
||||
requires:
|
||||
- build-amd64
|
||||
- build-arm64
|
||||
filters:
|
||||
branches:
|
||||
only: main
|
||||
@@ -16,29 +16,34 @@ jobs:
|
||||
matrix:
|
||||
node-version: [18.18.0]
|
||||
steps:
|
||||
- name: Check out the code
|
||||
uses: actions/checkout@v4
|
||||
- name: Check out the code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
|
||||
- name: Use Node.js ${{ matrix.node-version }}
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}
|
||||
cache: 'pnpm'
|
||||
- name: Use Node.js ${{ matrix.node-version }}
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}
|
||||
cache: "pnpm"
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install
|
||||
- name: Install dependencies
|
||||
run: pnpm install
|
||||
|
||||
- name: Run format and lint
|
||||
run: pnpm biome ci
|
||||
# - name: Run commitlint
|
||||
# run: pnpm commitlint --from ${{ github.event.pull_request.head.sha }}~${{ github.event.pull_request.commits }} --to ${{ github.event.pull_request.head.sha }} --verbose
|
||||
|
||||
- name: Run type check
|
||||
run: pnpm typecheck
|
||||
- name: Run format and lint
|
||||
run: pnpm biome ci
|
||||
|
||||
- name: Run Build
|
||||
run: pnpm build
|
||||
- name: Run type check
|
||||
run: pnpm typecheck
|
||||
|
||||
- name: Run Tests
|
||||
run: pnpm run test
|
||||
- name: Run Build
|
||||
run: pnpm build
|
||||
|
||||
- name: Run Tests
|
||||
run: pnpm run test
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
name: Push
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- canary
|
||||
|
||||
env:
|
||||
HUSKY: 0
|
||||
|
||||
jobs:
|
||||
build-and-push-docker-on-push:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out the code
|
||||
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: Prepare .env file
|
||||
run: |
|
||||
cp .env.production.example .env.production
|
||||
|
||||
- name: Build and push Docker image using custom script
|
||||
run: |
|
||||
chmod +x ./docker/push.sh
|
||||
./docker/push.sh ${{ github.ref_name == 'canary' && 'canary' || '' }}
|
||||
@@ -52,6 +52,7 @@ yarn-error.log*
|
||||
# otros
|
||||
/.data
|
||||
/.main
|
||||
.vscode
|
||||
|
||||
*.lockb
|
||||
*.rdb
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
#!/usr/bin/env sh
|
||||
. "$(dirname -- "$0")/_/husky.sh"
|
||||
|
||||
pnpm commitlint --edit $1
|
||||
+1
-1
@@ -72,7 +72,7 @@ RUN curl -sSL https://nixpacks.com/install.sh -o install.sh \
|
||||
&& pnpm install -g tsx
|
||||
|
||||
# Install buildpacks
|
||||
RUN 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
|
||||
RUN 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
|
||||
|
||||
# Expose port
|
||||
EXPOSE 3000
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { APPLICATIONS_PATH } from "@/server/constants";
|
||||
import { unzipDrop } from "@/server/utils/builders/drop";
|
||||
import AdmZip from "adm-zip";
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
|
||||
if (typeof window === "undefined") {
|
||||
const undici = require("undici");
|
||||
globalThis.File = undici.File as any;
|
||||
globalThis.FileList = undici.FileList as any;
|
||||
}
|
||||
|
||||
vi.mock("@/server/constants", () => ({
|
||||
APPLICATIONS_PATH: "./__test__/drop/zips/output",
|
||||
}));
|
||||
|
||||
describe("unzipDrop using real zip files", () => {
|
||||
beforeAll(async () => {
|
||||
await fs.rm(APPLICATIONS_PATH, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await fs.rm(APPLICATIONS_PATH, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("should correctly extract a zip with a single root folder", async () => {
|
||||
const appName = "single-file";
|
||||
const outputPath = path.join(APPLICATIONS_PATH, appName, "code");
|
||||
const zip = new AdmZip("./__test__/drop/zips/single-file.zip");
|
||||
|
||||
const zipBuffer = zip.toBuffer();
|
||||
const file = new File([zipBuffer], "single.zip");
|
||||
await unzipDrop(file, appName);
|
||||
|
||||
const files = await fs.readdir(outputPath, { withFileTypes: 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 () => {
|
||||
const appName = "folderwithfile";
|
||||
const outputPath = path.join(APPLICATIONS_PATH, 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, appName);
|
||||
|
||||
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 () => {
|
||||
const appName = "two-folders";
|
||||
const outputPath = path.join(APPLICATIONS_PATH, 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, appName);
|
||||
|
||||
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 () => {
|
||||
const appName = "nested";
|
||||
const outputPath = path.join(APPLICATIONS_PATH, 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, appName);
|
||||
|
||||
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 () => {
|
||||
const appName = "folder-with-sibling-file";
|
||||
const outputPath = path.join(APPLICATIONS_PATH, 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, appName);
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1 @@
|
||||
Gogogogogogo
|
||||
@@ -0,0 +1 @@
|
||||
gogogogogog
|
||||
@@ -0,0 +1 @@
|
||||
gogogogogogogogogo
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1 @@
|
||||
dsafasdfasdf
|
||||
Binary file not shown.
@@ -0,0 +1,187 @@
|
||||
import type { Domain } from "@/server/api/services/domain";
|
||||
import type { Redirect } from "@/server/api/services/redirect";
|
||||
import type { ApplicationNested } from "@/server/utils/builders";
|
||||
import { createRouterConfig } from "@/server/utils/traefik/domain";
|
||||
import { expect, test } from "vitest";
|
||||
|
||||
const baseApp: ApplicationNested = {
|
||||
applicationId: "",
|
||||
applicationStatus: "done",
|
||||
appName: "",
|
||||
autoDeploy: true,
|
||||
branch: null,
|
||||
buildArgs: null,
|
||||
buildPath: "/",
|
||||
buildType: "nixpacks",
|
||||
command: null,
|
||||
cpuLimit: null,
|
||||
cpuReservation: null,
|
||||
createdAt: "",
|
||||
customGitBranch: "",
|
||||
customGitBuildPath: "",
|
||||
customGitSSHKeyId: null,
|
||||
customGitUrl: "",
|
||||
description: "",
|
||||
dockerfile: null,
|
||||
dockerImage: null,
|
||||
dropBuildPath: null,
|
||||
enabled: null,
|
||||
env: null,
|
||||
healthCheckSwarm: null,
|
||||
labelsSwarm: null,
|
||||
memoryLimit: null,
|
||||
memoryReservation: null,
|
||||
modeSwarm: null,
|
||||
mounts: [],
|
||||
name: "",
|
||||
networkSwarm: null,
|
||||
owner: null,
|
||||
password: null,
|
||||
placementSwarm: null,
|
||||
ports: [],
|
||||
projectId: "",
|
||||
redirects: [],
|
||||
refreshToken: "",
|
||||
registry: null,
|
||||
registryId: null,
|
||||
replicas: 1,
|
||||
repository: null,
|
||||
restartPolicySwarm: null,
|
||||
rollbackConfigSwarm: null,
|
||||
security: [],
|
||||
sourceType: "git",
|
||||
subtitle: null,
|
||||
title: null,
|
||||
updateConfigSwarm: null,
|
||||
username: null,
|
||||
};
|
||||
|
||||
const baseDomain: Domain = {
|
||||
applicationId: "",
|
||||
certificateType: "none",
|
||||
createdAt: "",
|
||||
domainId: "",
|
||||
host: "",
|
||||
https: false,
|
||||
path: null,
|
||||
port: null,
|
||||
uniqueConfigKey: 1,
|
||||
};
|
||||
|
||||
const baseRedirect: Redirect = {
|
||||
redirectId: "",
|
||||
regex: "",
|
||||
replacement: "",
|
||||
permanent: false,
|
||||
uniqueConfigKey: 1,
|
||||
createdAt: "",
|
||||
applicationId: "",
|
||||
};
|
||||
|
||||
/** Middlewares */
|
||||
|
||||
test("Web entrypoint on http domain", async () => {
|
||||
const router = await createRouterConfig(
|
||||
baseApp,
|
||||
{ ...baseDomain, https: false },
|
||||
"web",
|
||||
);
|
||||
|
||||
expect(router.middlewares).not.toContain("redirect-to-https");
|
||||
});
|
||||
|
||||
test("Web entrypoint on http domain with redirect", async () => {
|
||||
const router = await createRouterConfig(
|
||||
{
|
||||
...baseApp,
|
||||
appName: "test",
|
||||
redirects: [{ ...baseRedirect, uniqueConfigKey: 1 }],
|
||||
},
|
||||
{ ...baseDomain, https: false },
|
||||
"web",
|
||||
);
|
||||
|
||||
expect(router.middlewares).not.toContain("redirect-to-https");
|
||||
expect(router.middlewares).toContain("redirect-test-1");
|
||||
});
|
||||
|
||||
test("Web entrypoint on http domain with multiple redirect", async () => {
|
||||
const router = await createRouterConfig(
|
||||
{
|
||||
...baseApp,
|
||||
appName: "test",
|
||||
redirects: [
|
||||
{ ...baseRedirect, uniqueConfigKey: 1 },
|
||||
{ ...baseRedirect, uniqueConfigKey: 2 },
|
||||
],
|
||||
},
|
||||
{ ...baseDomain, https: false },
|
||||
"web",
|
||||
);
|
||||
|
||||
expect(router.middlewares).not.toContain("redirect-to-https");
|
||||
expect(router.middlewares).toContain("redirect-test-1");
|
||||
expect(router.middlewares).toContain("redirect-test-2");
|
||||
});
|
||||
|
||||
test("Web entrypoint on https domain", async () => {
|
||||
const router = await createRouterConfig(
|
||||
baseApp,
|
||||
{ ...baseDomain, https: true },
|
||||
"web",
|
||||
);
|
||||
|
||||
expect(router.middlewares).toContain("redirect-to-https");
|
||||
});
|
||||
|
||||
test("Web entrypoint on https domain with redirect", async () => {
|
||||
const router = await createRouterConfig(
|
||||
{
|
||||
...baseApp,
|
||||
appName: "test",
|
||||
redirects: [{ ...baseRedirect, uniqueConfigKey: 1 }],
|
||||
},
|
||||
{ ...baseDomain, https: true },
|
||||
"web",
|
||||
);
|
||||
|
||||
expect(router.middlewares).toContain("redirect-to-https");
|
||||
expect(router.middlewares).not.toContain("redirect-test-1");
|
||||
});
|
||||
|
||||
test("Websecure entrypoint on https domain", async () => {
|
||||
const router = await createRouterConfig(
|
||||
baseApp,
|
||||
{ ...baseDomain, https: true },
|
||||
"websecure",
|
||||
);
|
||||
|
||||
expect(router.middlewares).not.toContain("redirect-to-https");
|
||||
});
|
||||
|
||||
test("Websecure entrypoint on https domain with redirect", async () => {
|
||||
const router = await createRouterConfig(
|
||||
{
|
||||
...baseApp,
|
||||
appName: "test",
|
||||
redirects: [{ ...baseRedirect, uniqueConfigKey: 1 }],
|
||||
},
|
||||
{ ...baseDomain, https: true },
|
||||
"websecure",
|
||||
);
|
||||
|
||||
expect(router.middlewares).not.toContain("redirect-to-https");
|
||||
expect(router.middlewares).toContain("redirect-test-1");
|
||||
});
|
||||
|
||||
/** Certificates */
|
||||
|
||||
test("CertificateType on websecure entrypoint", async () => {
|
||||
const router = await createRouterConfig(
|
||||
baseApp,
|
||||
{ ...baseDomain, certificateType: "letsencrypt" },
|
||||
"websecure",
|
||||
);
|
||||
|
||||
expect(router.tls?.certResolver).toBe("letsencrypt");
|
||||
});
|
||||
@@ -63,6 +63,7 @@ const mySchema = z.discriminatedUnion("type", [
|
||||
z
|
||||
.object({
|
||||
type: z.literal("file"),
|
||||
filePath: z.string().min(1, "File path required"),
|
||||
content: z.string().optional(),
|
||||
})
|
||||
.merge(mountSchema),
|
||||
@@ -81,7 +82,7 @@ export const AddVolumes = ({
|
||||
defaultValues: {
|
||||
type: serviceType === "compose" ? "file" : "bind",
|
||||
hostPath: "",
|
||||
mountPath: "",
|
||||
mountPath: serviceType === "compose" ? "/" : "",
|
||||
},
|
||||
resolver: zodResolver(mySchema),
|
||||
});
|
||||
@@ -125,6 +126,7 @@ export const AddVolumes = ({
|
||||
serviceId,
|
||||
content: data.content,
|
||||
mountPath: data.mountPath,
|
||||
filePath: data.filePath,
|
||||
type: data.type,
|
||||
serviceType,
|
||||
})
|
||||
@@ -288,41 +290,62 @@ export const AddVolumes = ({
|
||||
)}
|
||||
|
||||
{type === "file" && (
|
||||
<>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="content"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Content</FormLabel>
|
||||
<FormControl>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder="Any content"
|
||||
className="h-64"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="filePath"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>File Path</FormLabel>
|
||||
<FormControl>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="Name of the file"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{serviceType !== "compose" && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="content"
|
||||
name="mountPath"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Content</FormLabel>
|
||||
<FormLabel>Mount Path (In the container)</FormLabel>
|
||||
<FormControl>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder="Any content"
|
||||
className="h-64"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<Input placeholder="Mount Path" {...field} />
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="mountPath"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Mount Path</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Mount Path" {...field} />
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -73,8 +73,7 @@ export const ShowVolumes = ({ applicationId }: 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">
|
||||
@@ -91,12 +90,21 @@ export const ShowVolumes = ({ applicationId }: Props) => {
|
||||
)}
|
||||
|
||||
{mount.type === "file" && (
|
||||
<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">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">
|
||||
@@ -118,6 +126,7 @@ export const ShowVolumes = ({ applicationId }: Props) => {
|
||||
mountId={mount.mountId}
|
||||
type={mount.type}
|
||||
refetch={refetch}
|
||||
serviceType="application"
|
||||
/>
|
||||
<DeleteVolume mountId={mount.mountId} refetch={refetch} />
|
||||
</div>
|
||||
|
||||
@@ -48,6 +48,7 @@ const mySchema = z.discriminatedUnion("type", [
|
||||
.object({
|
||||
type: z.literal("file"),
|
||||
content: z.string().optional(),
|
||||
filePath: z.string().min(1, "File path required"),
|
||||
})
|
||||
.merge(mountSchema),
|
||||
]);
|
||||
@@ -58,9 +59,23 @@ interface Props {
|
||||
mountId: string;
|
||||
type: "bind" | "volume" | "file";
|
||||
refetch: () => void;
|
||||
serviceType:
|
||||
| "application"
|
||||
| "postgres"
|
||||
| "redis"
|
||||
| "mongo"
|
||||
| "redis"
|
||||
| "mysql"
|
||||
| "mariadb"
|
||||
| "compose";
|
||||
}
|
||||
|
||||
export const UpdateVolume = ({ mountId, type, refetch }: Props) => {
|
||||
export const UpdateVolume = ({
|
||||
mountId,
|
||||
type,
|
||||
refetch,
|
||||
serviceType,
|
||||
}: Props) => {
|
||||
const utils = api.useUtils();
|
||||
const { data } = api.mounts.one.useQuery(
|
||||
{
|
||||
@@ -103,6 +118,7 @@ export const UpdateVolume = ({ mountId, type, refetch }: Props) => {
|
||||
form.reset({
|
||||
content: data.content || "",
|
||||
mountPath: data.mountPath,
|
||||
filePath: data.filePath || "",
|
||||
type: "file",
|
||||
});
|
||||
}
|
||||
@@ -141,6 +157,7 @@ export const UpdateVolume = ({ mountId, type, refetch }: Props) => {
|
||||
content: data.content,
|
||||
mountPath: data.mountPath,
|
||||
type: data.type,
|
||||
filePath: data.filePath,
|
||||
mountId,
|
||||
})
|
||||
.then(() => {
|
||||
@@ -166,6 +183,11 @@ export const UpdateVolume = ({ mountId, type, refetch }: Props) => {
|
||||
<DialogDescription>Update the mount</DialogDescription>
|
||||
</DialogHeader>
|
||||
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
|
||||
{type === "file" && (
|
||||
<AlertBlock type="warning">
|
||||
Updating the mount will recreate the file or directory.
|
||||
</AlertBlock>
|
||||
)}
|
||||
|
||||
<Form {...form}>
|
||||
<form
|
||||
@@ -211,40 +233,62 @@ export const UpdateVolume = ({ mountId, type, refetch }: Props) => {
|
||||
)}
|
||||
|
||||
{type === "file" && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="content"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Content</FormLabel>
|
||||
<FormControl>
|
||||
<>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="content"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Content</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder="Any content"
|
||||
className="h-64"
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder="Any content"
|
||||
className="h-64"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="filePath"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>File Path</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
disabled
|
||||
placeholder="Name of the file"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{serviceType !== "compose" && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="mountPath"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Mount Path (In the container)</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Mount Path" {...field} />
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="mountPath"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Mount Path</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Mount Path" {...field} />
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
|
||||
@@ -29,8 +29,8 @@ export const RefreshToken = ({ applicationId }: Props) => {
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This action cannot be undone. This will permanently delete the
|
||||
domain
|
||||
This action cannot be undone. This will change the refresh token and
|
||||
other tokens will be invalidated.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
|
||||
@@ -28,84 +28,103 @@ import {
|
||||
} from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { api } from "@/utils/api";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { PlusIcon } from "lucide-react";
|
||||
import { useEffect } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
|
||||
// const hostnameRegex = /^[a-zA-Z0-9][a-zA-Z0-9\.-]*\.[a-zA-Z]{2,}$/;
|
||||
// .regex(hostnameRegex
|
||||
const addDomain = z.object({
|
||||
host: z.string().min(1, "Hostname is required"),
|
||||
path: z.string().min(1),
|
||||
port: z.number(),
|
||||
https: z.boolean(),
|
||||
certificateType: z.enum(["letsencrypt", "none"]),
|
||||
});
|
||||
import { domain } from "@/server/db/validations";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import type z from "zod";
|
||||
|
||||
type AddDomain = z.infer<typeof addDomain>;
|
||||
type Domain = z.infer<typeof domain>;
|
||||
|
||||
interface Props {
|
||||
applicationId: string;
|
||||
children?: React.ReactNode;
|
||||
domainId?: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const AddDomain = ({
|
||||
applicationId,
|
||||
children = <PlusIcon className="h-4 w-4" />,
|
||||
domainId = "",
|
||||
children,
|
||||
}: Props) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const utils = api.useUtils();
|
||||
|
||||
const { mutateAsync, isError, error } = api.domain.create.useMutation();
|
||||
|
||||
const form = useForm<AddDomain>({
|
||||
defaultValues: {
|
||||
host: "",
|
||||
https: false,
|
||||
path: "/",
|
||||
port: 3000,
|
||||
certificateType: "none",
|
||||
const { data, refetch } = api.domain.one.useQuery(
|
||||
{
|
||||
domainId,
|
||||
},
|
||||
resolver: zodResolver(addDomain),
|
||||
{
|
||||
enabled: !!domainId,
|
||||
},
|
||||
);
|
||||
|
||||
const { mutateAsync, isError, error, isLoading } = domainId
|
||||
? api.domain.update.useMutation()
|
||||
: api.domain.create.useMutation();
|
||||
|
||||
const form = useForm<Domain>({
|
||||
resolver: zodResolver(domain),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
form.reset();
|
||||
}, [form, form.reset, form.formState.isSubmitSuccessful]);
|
||||
if (data) {
|
||||
form.reset({
|
||||
...data,
|
||||
/* Convert null to undefined */
|
||||
path: data?.path || undefined,
|
||||
port: data?.port || undefined,
|
||||
});
|
||||
}
|
||||
|
||||
const onSubmit = async (data: AddDomain) => {
|
||||
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,
|
||||
host: data.host,
|
||||
https: data.https,
|
||||
path: data.path,
|
||||
port: data.port,
|
||||
certificateType: data.certificateType,
|
||||
...data,
|
||||
})
|
||||
.then(async () => {
|
||||
toast.success("Domain Created");
|
||||
toast.success(dictionary.success);
|
||||
await utils.domain.byApplicationId.invalidate({
|
||||
applicationId,
|
||||
});
|
||||
await utils.application.readTraefikConfig.invalidate({ applicationId });
|
||||
|
||||
if (domainId) {
|
||||
refetch();
|
||||
}
|
||||
setIsOpen(false);
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error to create the domain");
|
||||
toast.error(dictionary.error);
|
||||
});
|
||||
};
|
||||
return (
|
||||
<Dialog>
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DialogTrigger className="" asChild>
|
||||
<Button>{children}</Button>
|
||||
{children}
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Domain</DialogTitle>
|
||||
<DialogDescription>
|
||||
In this section you can add custom domains
|
||||
</DialogDescription>
|
||||
<DialogDescription>{dictionary.dialogDescription}</DialogDescription>
|
||||
</DialogHeader>
|
||||
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
|
||||
|
||||
@@ -169,33 +188,36 @@ export const AddDomain = ({
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<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>
|
||||
{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>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<SelectContent>
|
||||
<SelectItem value="none">None</SelectItem>
|
||||
<SelectItem value={"letsencrypt"}>
|
||||
Letsencrypt (Default)
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="https"
|
||||
@@ -206,6 +228,7 @@ export const AddDomain = ({
|
||||
<FormDescription>
|
||||
Automatically provision SSL Certificate.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
@@ -226,7 +249,7 @@ export const AddDomain = ({
|
||||
form="hook-form"
|
||||
type="submit"
|
||||
>
|
||||
Create
|
||||
{dictionary.submit}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</Form>
|
||||
|
||||
@@ -8,13 +8,11 @@ import {
|
||||
} from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { api } from "@/utils/api";
|
||||
import { ExternalLink, GlobeIcon, RefreshCcw } from "lucide-react";
|
||||
import { ExternalLink, GlobeIcon, PenBoxIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import React from "react";
|
||||
import { AddDomain } from "./add-domain";
|
||||
import { DeleteDomain } from "./delete-domain";
|
||||
import { GenerateDomain } from "./generate-domain";
|
||||
import { UpdateDomain } from "./update-domain";
|
||||
|
||||
interface Props {
|
||||
applicationId: string;
|
||||
@@ -43,7 +41,9 @@ export const ShowDomains = ({ applicationId }: Props) => {
|
||||
<div className="flex flex-row gap-4 flex-wrap">
|
||||
{data && data?.length > 0 && (
|
||||
<AddDomain applicationId={applicationId}>
|
||||
<GlobeIcon className="size-4" /> Add Domain
|
||||
<Button>
|
||||
<GlobeIcon className="size-4" /> Add Domain
|
||||
</Button>
|
||||
</AddDomain>
|
||||
)}
|
||||
{data && data?.length > 0 && (
|
||||
@@ -61,7 +61,9 @@ export const ShowDomains = ({ applicationId }: Props) => {
|
||||
</span>
|
||||
<div className="flex flex-row gap-4 flex-wrap">
|
||||
<AddDomain applicationId={applicationId}>
|
||||
<GlobeIcon className="size-4" /> Add Domain
|
||||
<Button>
|
||||
<GlobeIcon className="size-4" /> Add Domain
|
||||
</Button>
|
||||
</AddDomain>
|
||||
|
||||
<GenerateDomain applicationId={applicationId} />
|
||||
@@ -90,7 +92,14 @@ export const ShowDomains = ({ applicationId }: Props) => {
|
||||
{item.https ? "HTTPS" : "HTTP"}
|
||||
</Button>
|
||||
<div className="flex flex-row gap-1">
|
||||
<UpdateDomain domainId={item.domainId} />
|
||||
<AddDomain
|
||||
applicationId={applicationId}
|
||||
domainId={item.domainId}
|
||||
>
|
||||
<Button variant="ghost">
|
||||
<PenBoxIcon className="size-4 text-muted-foreground" />
|
||||
</Button>
|
||||
</AddDomain>
|
||||
<DeleteDomain domainId={item.domainId} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,254 +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 } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { api } from "@/utils/api";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { PenBoxIcon } from "lucide-react";
|
||||
import { useEffect } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
|
||||
const hostnameRegex = /^[a-zA-Z0-9][a-zA-Z0-9\.-]*\.[a-zA-Z]{2,}$/;
|
||||
|
||||
const updateDomain = z.object({
|
||||
host: z.string().regex(hostnameRegex, { message: "Invalid hostname" }),
|
||||
path: z.string().min(1),
|
||||
port: z
|
||||
.number()
|
||||
.min(1, { message: "Port must be at least 1" })
|
||||
.max(65535, { message: "Port must be 65535 or below" }),
|
||||
https: z.boolean(),
|
||||
certificateType: z.enum(["letsencrypt", "none"]),
|
||||
});
|
||||
|
||||
type UpdateDomain = z.infer<typeof updateDomain>;
|
||||
|
||||
interface Props {
|
||||
domainId: string;
|
||||
}
|
||||
|
||||
export const UpdateDomain = ({ domainId }: Props) => {
|
||||
const utils = api.useUtils();
|
||||
const { data, refetch } = api.domain.one.useQuery(
|
||||
{
|
||||
domainId,
|
||||
},
|
||||
{
|
||||
enabled: !!domainId,
|
||||
},
|
||||
);
|
||||
const { mutateAsync, isError, error } = api.domain.update.useMutation();
|
||||
|
||||
const form = useForm<UpdateDomain>({
|
||||
defaultValues: {
|
||||
host: "",
|
||||
https: true,
|
||||
path: "/",
|
||||
port: 3000,
|
||||
certificateType: "none",
|
||||
},
|
||||
resolver: zodResolver(updateDomain),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
form.reset({
|
||||
host: data.host || "",
|
||||
port: data.port || 3000,
|
||||
path: data.path || "/",
|
||||
https: data.https,
|
||||
certificateType: data.certificateType,
|
||||
});
|
||||
}
|
||||
}, [form, form.reset, data]);
|
||||
|
||||
const onSubmit = async (data: UpdateDomain) => {
|
||||
await mutateAsync({
|
||||
domainId,
|
||||
host: data.host,
|
||||
https: data.https,
|
||||
path: data.path,
|
||||
port: data.port,
|
||||
certificateType: data.certificateType,
|
||||
})
|
||||
.then(async (data) => {
|
||||
toast.success("Domain Updated");
|
||||
await refetch();
|
||||
await utils.domain.byApplicationId.invalidate({
|
||||
applicationId: data?.applicationId,
|
||||
});
|
||||
await utils.application.readTraefikConfig.invalidate({
|
||||
applicationId: data?.applicationId,
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error to update the domain");
|
||||
});
|
||||
};
|
||||
return (
|
||||
<Dialog>
|
||||
<DialogTrigger className="" asChild>
|
||||
<Button variant="ghost">
|
||||
<PenBoxIcon className="size-4 text-muted-foreground" />
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Domain</DialogTitle>
|
||||
<DialogDescription>
|
||||
In this section you can add custom domains
|
||||
</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>
|
||||
<FormControl>
|
||||
<Input placeholder="api.dokploy.com" {...field} />
|
||||
</FormControl>
|
||||
|
||||
<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>
|
||||
<Input
|
||||
placeholder={"3000"}
|
||||
{...field}
|
||||
onChange={(e) => {
|
||||
field.onChange(Number.parseInt(e.target.value));
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="certificateType"
|
||||
render={({ field }) => (
|
||||
<FormItem className="col-span-2">
|
||||
<FormLabel>Certificate</FormLabel>
|
||||
<Select
|
||||
onValueChange={field.onChange}
|
||||
value={field.value}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a certificate" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value={"none"}>None</SelectItem>
|
||||
<SelectItem value={"letsencrypt"}>
|
||||
Letsencrypt
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="https"
|
||||
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>HTTPS</FormLabel>
|
||||
<FormDescription>
|
||||
Automatically provision SSL Certificate.
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
isLoading={form.formState.isSubmitting}
|
||||
form="hook-form"
|
||||
type="submit"
|
||||
>
|
||||
Update
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -1,30 +1,16 @@
|
||||
import { CodeEditor } from "@/components/shared/code-editor";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Toggle } from "@/components/ui/toggle";
|
||||
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 { EyeIcon, EyeOffIcon } from "lucide-react";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
|
||||
const addEnvironmentSchema = z.object({
|
||||
environment: z.string(),
|
||||
env: z.string(),
|
||||
buildArgs: z.string(),
|
||||
});
|
||||
|
||||
type EnvironmentSchema = z.infer<typeof addEnvironmentSchema>;
|
||||
@@ -34,7 +20,6 @@ interface Props {
|
||||
}
|
||||
|
||||
export const ShowEnvironment = ({ applicationId }: Props) => {
|
||||
const [isEnvVisible, setIsEnvVisible] = useState(true);
|
||||
const { mutateAsync, isLoading } =
|
||||
api.application.saveEnvironment.useMutation();
|
||||
|
||||
@@ -46,24 +31,19 @@ export const ShowEnvironment = ({ applicationId }: Props) => {
|
||||
enabled: !!applicationId,
|
||||
},
|
||||
);
|
||||
|
||||
const form = useForm<EnvironmentSchema>({
|
||||
defaultValues: {
|
||||
environment: "",
|
||||
env: data?.env || "",
|
||||
buildArgs: data?.buildArgs || "",
|
||||
},
|
||||
resolver: zodResolver(addEnvironmentSchema),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
form.reset({
|
||||
environment: data.env || "",
|
||||
});
|
||||
}
|
||||
}, [form.reset, data, form]);
|
||||
|
||||
const onSubmit = async (data: EnvironmentSchema) => {
|
||||
mutateAsync({
|
||||
env: data.environment,
|
||||
env: data.env,
|
||||
buildArgs: data.buildArgs,
|
||||
applicationId,
|
||||
})
|
||||
.then(async () => {
|
||||
@@ -74,94 +54,50 @@ export const ShowEnvironment = ({ applicationId }: Props) => {
|
||||
toast.error("Error to add environment");
|
||||
});
|
||||
};
|
||||
useEffect(() => {
|
||||
if (isEnvVisible) {
|
||||
if (data?.env) {
|
||||
const maskedLines = data.env
|
||||
.split("\n")
|
||||
.map((line) => "*".repeat(line.length))
|
||||
.join("\n");
|
||||
form.reset({
|
||||
environment: maskedLines,
|
||||
});
|
||||
} else {
|
||||
form.reset({
|
||||
environment: "",
|
||||
});
|
||||
}
|
||||
} else {
|
||||
form.reset({
|
||||
environment: data?.env || "",
|
||||
});
|
||||
}
|
||||
}, [form.reset, data, form, isEnvVisible]);
|
||||
|
||||
return (
|
||||
<div className="flex w-full flex-col gap-5 ">
|
||||
<Card className="bg-background">
|
||||
<CardHeader className="flex flex-row w-full items-center justify-between">
|
||||
<div>
|
||||
<CardTitle className="text-xl">Environment Settings</CardTitle>
|
||||
<CardDescription>
|
||||
You can add environment variables to your resource.
|
||||
</CardDescription>
|
||||
</div>
|
||||
|
||||
<Toggle
|
||||
aria-label="Toggle bold"
|
||||
pressed={isEnvVisible}
|
||||
onPressedChange={setIsEnvVisible}
|
||||
>
|
||||
{isEnvVisible ? (
|
||||
<EyeOffIcon className="h-4 w-4 text-muted-foreground" />
|
||||
) : (
|
||||
<EyeIcon className="h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
</Toggle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Form {...form}>
|
||||
<form
|
||||
id="hook-form"
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="w-full space-y-4"
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="environment"
|
||||
render={({ field }) => (
|
||||
<FormItem className="w-full">
|
||||
<FormControl>
|
||||
<CodeEditor
|
||||
language="properties"
|
||||
disabled={isEnvVisible}
|
||||
placeholder={`NODE_ENV=production
|
||||
PORT=3000
|
||||
`}
|
||||
className="h-96 font-mono"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="flex flex-row justify-end">
|
||||
<Button
|
||||
disabled={isEnvVisible}
|
||||
isLoading={isLoading}
|
||||
className="w-fit"
|
||||
type="submit"
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="flex w-full flex-col gap-5 "
|
||||
>
|
||||
<Card className="bg-background">
|
||||
<Secrets
|
||||
name="env"
|
||||
title="Environment Settings"
|
||||
description="You can add environment variables to your resource."
|
||||
placeholder={["NODE_ENV=production", "PORT=3000"].join("\n")}
|
||||
/>
|
||||
{data?.buildType === "dockerfile" && (
|
||||
<Secrets
|
||||
name="buildArgs"
|
||||
title="Build-time Variables"
|
||||
description={
|
||||
<span>
|
||||
Available only at build-time. See documentation
|
||||
<a
|
||||
className="text-primary"
|
||||
href="https://docs.docker.com/build/guide/build-args/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
here
|
||||
</a>
|
||||
.
|
||||
</span>
|
||||
}
|
||||
placeholder="NPM_TOKEN=xyz"
|
||||
/>
|
||||
)}
|
||||
<CardContent>
|
||||
<div className="flex flex-row justify-end">
|
||||
<Button isLoading={isLoading} className="w-fit" type="submit">
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dropzone } from "@/components/ui/dropzone";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { api } from "@/utils/api";
|
||||
import { type UploadFile, uploadFileSchema } from "@/utils/schema";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { TrashIcon } from "lucide-react";
|
||||
import { useEffect } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface Props {
|
||||
applicationId: string;
|
||||
}
|
||||
|
||||
export const SaveDragNDrop = ({ applicationId }: Props) => {
|
||||
const { data, refetch } = api.application.one.useQuery({ applicationId });
|
||||
|
||||
const { mutateAsync, isLoading } =
|
||||
api.application.dropDeployment.useMutation();
|
||||
|
||||
const form = useForm<UploadFile>({
|
||||
defaultValues: {},
|
||||
resolver: zodResolver(uploadFileSchema),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
form.reset({
|
||||
dropBuildPath: data.dropBuildPath || "",
|
||||
});
|
||||
}
|
||||
}, [data, form, form.reset, form.formState.isSubmitSuccessful]);
|
||||
const zip = form.watch("zip");
|
||||
|
||||
const onSubmit = async (values: UploadFile) => {
|
||||
const formData = new FormData();
|
||||
|
||||
formData.append("zip", values.zip);
|
||||
formData.append("applicationId", applicationId);
|
||||
if (values.dropBuildPath) {
|
||||
formData.append("dropBuildPath", values.dropBuildPath);
|
||||
}
|
||||
|
||||
await mutateAsync(formData)
|
||||
.then(async () => {
|
||||
toast.success("Deployment saved");
|
||||
await refetch();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error to save the deployment");
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="flex flex-col gap-4"
|
||||
>
|
||||
<div className="grid md:grid-cols-2 gap-4 ">
|
||||
<div className="md:col-span-2 space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="dropBuildPath"
|
||||
render={({ field }) => (
|
||||
<FormItem className="w-full ">
|
||||
<FormLabel>Build Path</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="Build Path" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="zip"
|
||||
render={({ field }) => (
|
||||
<FormItem className="w-full ">
|
||||
<FormLabel>Zip file</FormLabel>
|
||||
<FormControl>
|
||||
<Dropzone
|
||||
{...field}
|
||||
dropMessage="Drop files or click here"
|
||||
accept=".zip"
|
||||
onChange={(e) => {
|
||||
if (e instanceof FileList) {
|
||||
field.onChange(e[0]);
|
||||
} else {
|
||||
field.onChange(e);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
{zip instanceof File && (
|
||||
<div className="flex flex-row gap-4 items-center">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{zip.name} ({zip.size} bytes)
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
className="w-fit"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
field.onChange(null);
|
||||
}}
|
||||
>
|
||||
<TrashIcon className="w-4 h-4 text-muted-foreground" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-row justify-end">
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-fit"
|
||||
isLoading={isLoading}
|
||||
disabled={!zip}
|
||||
>
|
||||
Deploy{" "}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
@@ -1,13 +1,4 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
@@ -17,11 +8,20 @@ import {
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { api } from "@/utils/api";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import copy from "copy-to-clipboard";
|
||||
import { CopyIcon, LockIcon } from "lucide-react";
|
||||
import { KeyRoundIcon, LockIcon } from "lucide-react";
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
@@ -33,6 +33,7 @@ const GitProviderSchema = z.object({
|
||||
}),
|
||||
branch: z.string().min(1, "Branch required"),
|
||||
buildPath: z.string().min(1, "Build Path required"),
|
||||
sshKey: z.string().optional(),
|
||||
});
|
||||
|
||||
type GitProvider = z.infer<typeof GitProviderSchema>;
|
||||
@@ -43,19 +44,18 @@ interface Props {
|
||||
|
||||
export const SaveGitProvider = ({ applicationId }: Props) => {
|
||||
const { data, refetch } = api.application.one.useQuery({ applicationId });
|
||||
const { data: sshKeys } = api.sshKey.all.useQuery();
|
||||
const router = useRouter();
|
||||
|
||||
const { mutateAsync, isLoading } =
|
||||
api.application.saveGitProdiver.useMutation();
|
||||
const { mutateAsync: generateSSHKey, isLoading: isGeneratingSSHKey } =
|
||||
api.application.generateSSHKey.useMutation();
|
||||
|
||||
const { mutateAsync: removeSSHKey, isLoading: isRemovingSSHKey } =
|
||||
api.application.removeSSHKey.useMutation();
|
||||
const form = useForm<GitProvider>({
|
||||
defaultValues: {
|
||||
branch: "",
|
||||
buildPath: "/",
|
||||
repositoryURL: "",
|
||||
sshKey: undefined,
|
||||
},
|
||||
resolver: zodResolver(GitProviderSchema),
|
||||
});
|
||||
@@ -63,6 +63,7 @@ export const SaveGitProvider = ({ applicationId }: Props) => {
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
form.reset({
|
||||
sshKey: data.customGitSSHKeyId || undefined,
|
||||
branch: data.customGitBranch || "",
|
||||
buildPath: data.customGitBuildPath || "/",
|
||||
repositoryURL: data.customGitUrl || "",
|
||||
@@ -75,6 +76,7 @@ export const SaveGitProvider = ({ applicationId }: Props) => {
|
||||
customGitBranch: values.branch,
|
||||
customGitBuildPath: values.buildPath,
|
||||
customGitUrl: values.repositoryURL,
|
||||
customGitSSHKeyId: values.sshKey === "none" ? null : values.sshKey,
|
||||
applicationId,
|
||||
})
|
||||
.then(async () => {
|
||||
@@ -92,160 +94,103 @@ export const SaveGitProvider = ({ applicationId }: Props) => {
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="flex flex-col gap-4"
|
||||
>
|
||||
<div className="grid md:grid-cols-2 gap-4 ">
|
||||
<div className="md:col-span-2 space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="repositoryURL"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="flex flex-row justify-between">
|
||||
Repository URL
|
||||
<div className="flex gap-2">
|
||||
<Dialog>
|
||||
<DialogTrigger className="flex flex-row gap-2">
|
||||
<LockIcon className="size-4 text-muted-foreground" />?
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-[425px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Private Repository</DialogTitle>
|
||||
<DialogDescription>
|
||||
If your repository is private is necessary to
|
||||
generate SSH Keys to add to your git provider.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="relative">
|
||||
<Textarea
|
||||
placeholder="Please click on Generate SSH Key"
|
||||
className="no-scrollbar h-64 text-muted-foreground"
|
||||
disabled={!data?.customGitSSHKey}
|
||||
contentEditable={false}
|
||||
value={
|
||||
data?.customGitSSHKey ||
|
||||
"Please click on Generate SSH Key"
|
||||
}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute right-2 top-2"
|
||||
onClick={() => {
|
||||
copy(
|
||||
data?.customGitSSHKey ||
|
||||
"Generate a SSH Key",
|
||||
);
|
||||
toast.success("SSH Copied to clipboard");
|
||||
}}
|
||||
<div className="grid md:grid-cols-2 gap-4">
|
||||
<div className="flex items-end col-span-2 gap-4">
|
||||
<div className="grow">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="repositoryURL"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Repository URL</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="git@bitbucket.org" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
{sshKeys && sshKeys.length > 0 ? (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="sshKey"
|
||||
render={({ field }) => (
|
||||
<FormItem className="basis-40">
|
||||
<FormLabel className="w-full inline-flex justify-between">
|
||||
SSH Key
|
||||
<LockIcon className="size-4 text-muted-foreground" />
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Select
|
||||
key={field.value}
|
||||
onValueChange={field.onChange}
|
||||
defaultValue={field.value}
|
||||
value={field.value}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a key" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{sshKeys?.map((sshKey) => (
|
||||
<SelectItem
|
||||
key={sshKey.sshKeyId}
|
||||
value={sshKey.sshKeyId}
|
||||
>
|
||||
<CopyIcon className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter className="flex sm:justify-between gap-3.5 flex-col sm:flex-col w-full">
|
||||
<div className="flex flex-row gap-2 w-full justify-between flex-wrap">
|
||||
{data?.customGitSSHKey && (
|
||||
<Button
|
||||
variant="destructive"
|
||||
isLoading={
|
||||
isGeneratingSSHKey || isRemovingSSHKey
|
||||
}
|
||||
className="max-sm:w-full"
|
||||
onClick={async () => {
|
||||
await removeSSHKey({
|
||||
applicationId,
|
||||
})
|
||||
.then(async () => {
|
||||
toast.success("SSH Key Removed");
|
||||
await refetch();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error(
|
||||
"Error to remove the SSH Key",
|
||||
);
|
||||
});
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
Remove SSH Key
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button
|
||||
isLoading={
|
||||
isGeneratingSSHKey || isRemovingSSHKey
|
||||
}
|
||||
className="max-sm:w-full"
|
||||
onClick={async () => {
|
||||
await generateSSHKey({
|
||||
applicationId,
|
||||
})
|
||||
.then(async () => {
|
||||
toast.success("SSH Key Generated");
|
||||
await refetch();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error(
|
||||
"Error to generate the SSH Key",
|
||||
);
|
||||
});
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
Generate SSH Key
|
||||
</Button>
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Is recommended to remove the SSH Key if you want
|
||||
to deploy a public repository.
|
||||
</span>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="git@bitbucket.org" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</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>
|
||||
<div className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="buildPath"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Build Path</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="/" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
{sshKey.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
<SelectItem value="none">None</SelectItem>
|
||||
<SelectLabel>Keys ({sshKeys?.length})</SelectLabel>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => router.push("/dashboard/settings/ssh-keys")}
|
||||
type="button"
|
||||
>
|
||||
<KeyRoundIcon className="size-4" /> Add SSH Key
|
||||
</Button>
|
||||
)}
|
||||
</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"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Build Path</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="/" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-row justify-end">
|
||||
<Button type="submit" className="w-fit" isLoading={isLoading}>
|
||||
Save{" "}
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -7,8 +7,9 @@ import { api } from "@/utils/api";
|
||||
import { GitBranch, LockIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import { SaveDragNDrop } from "./save-drag-n-drop";
|
||||
|
||||
type TabState = "github" | "docker" | "git";
|
||||
type TabState = "github" | "docker" | "git" | "drop";
|
||||
|
||||
interface Props {
|
||||
applicationId: string;
|
||||
@@ -62,6 +63,12 @@ export const ShowProviderForm = ({ applicationId }: Props) => {
|
||||
>
|
||||
Git
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="drop"
|
||||
className="rounded-none border-b-2 border-b-transparent data-[state=active]:border-b-2 data-[state=active]:border-b-border"
|
||||
>
|
||||
Drop
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="github" className="w-full p-2">
|
||||
{haveGithubConfigured ? (
|
||||
@@ -89,6 +96,9 @@ export const ShowProviderForm = ({ applicationId }: Props) => {
|
||||
<TabsContent value="git" className="w-full p-2">
|
||||
<SaveGitProvider applicationId={applicationId} />
|
||||
</TabsContent>
|
||||
<TabsContent value="drop" className="w-full p-2">
|
||||
<SaveDragNDrop applicationId={applicationId} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -74,7 +74,7 @@ export const ShowVolumesCompose = ({ composeId }: 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"
|
||||
>
|
||||
<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">
|
||||
@@ -91,12 +91,20 @@ export const ShowVolumesCompose = ({ composeId }: Props) => {
|
||||
)}
|
||||
|
||||
{mount.type === "file" && (
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="font-medium">Content</span>
|
||||
<span className="text-sm text-muted-foreground w-40 truncate">
|
||||
{mount.content}
|
||||
</span>
|
||||
</div>
|
||||
<>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="font-medium">Content</span>
|
||||
<span className="text-sm text-muted-foreground w-40 truncate">
|
||||
{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">
|
||||
@@ -118,6 +126,7 @@ export const ShowVolumesCompose = ({ composeId }: Props) => {
|
||||
mountId={mount.mountId}
|
||||
type={mount.type}
|
||||
refetch={refetch}
|
||||
serviceType="compose"
|
||||
/>
|
||||
<DeleteVolume mountId={mount.mountId} refetch={refetch} />
|
||||
</div>
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Toggle } from "@/components/ui/toggle";
|
||||
import { api } from "@/utils/api";
|
||||
import { ExternalLink, Globe, Terminal } from "lucide-react";
|
||||
import { CheckCircle2, ExternalLink, Globe, Terminal } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { toast } from "sonner";
|
||||
import { DockerTerminalModal } from "../../settings/web-server/docker-terminal-modal";
|
||||
@@ -50,7 +50,6 @@ export const ComposeActions = ({ composeId }: Props) => {
|
||||
return (
|
||||
<div className="flex flex-row gap-4 w-full flex-wrap ">
|
||||
<DeployCompose composeId={composeId} />
|
||||
|
||||
<Toggle
|
||||
aria-label="Toggle italic"
|
||||
pressed={data?.autoDeploy || false}
|
||||
@@ -67,8 +66,9 @@ export const ComposeActions = ({ composeId }: Props) => {
|
||||
toast.error("Error to update Auto Deploy");
|
||||
});
|
||||
}}
|
||||
className="flex flex-row gap-2 items-center"
|
||||
>
|
||||
Autodeploy
|
||||
Autodeploy {data?.autoDeploy && <CheckCircle2 className="size-4" />}
|
||||
</Toggle>
|
||||
<RedbuildCompose composeId={composeId} />
|
||||
{data?.composeType === "docker-compose" && (
|
||||
|
||||
@@ -1,13 +1,4 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
@@ -17,11 +8,19 @@ import {
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { api } from "@/utils/api";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import copy from "copy-to-clipboard";
|
||||
import { CopyIcon, LockIcon } from "lucide-react";
|
||||
import { KeyRoundIcon, LockIcon } from "lucide-react";
|
||||
import { useRouter } from "next/router";
|
||||
import { useEffect } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
@@ -33,6 +32,7 @@ const GitProviderSchema = z.object({
|
||||
message: "Repository URL is required",
|
||||
}),
|
||||
branch: z.string().min(1, "Branch required"),
|
||||
sshKey: z.string().optional(),
|
||||
});
|
||||
|
||||
type GitProvider = z.infer<typeof GitProviderSchema>;
|
||||
@@ -43,19 +43,17 @@ interface Props {
|
||||
|
||||
export const SaveGitProviderCompose = ({ composeId }: Props) => {
|
||||
const { data, refetch } = api.compose.one.useQuery({ composeId });
|
||||
const { data: sshKeys } = api.sshKey.all.useQuery();
|
||||
const router = useRouter();
|
||||
|
||||
const { mutateAsync, isLoading } = api.compose.update.useMutation();
|
||||
|
||||
const { mutateAsync: generateSSHKey, isLoading: isGeneratingSSHKey } =
|
||||
api.compose.generateSSHKey.useMutation();
|
||||
|
||||
const { mutateAsync: removeSSHKey, isLoading: isRemovingSSHKey } =
|
||||
api.compose.removeSSHKey.useMutation();
|
||||
const form = useForm<GitProvider>({
|
||||
defaultValues: {
|
||||
branch: "",
|
||||
repositoryURL: "",
|
||||
composePath: "./docker-compose.yml",
|
||||
sshKey: undefined,
|
||||
},
|
||||
resolver: zodResolver(GitProviderSchema),
|
||||
});
|
||||
@@ -63,6 +61,7 @@ export const SaveGitProviderCompose = ({ composeId }: Props) => {
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
form.reset({
|
||||
sshKey: data.customGitSSHKeyId || undefined,
|
||||
branch: data.customGitBranch || "",
|
||||
repositoryURL: data.customGitUrl || "",
|
||||
composePath: data.composePath,
|
||||
@@ -74,6 +73,7 @@ export const SaveGitProviderCompose = ({ composeId }: Props) => {
|
||||
await mutateAsync({
|
||||
customGitBranch: values.branch,
|
||||
customGitUrl: values.repositoryURL,
|
||||
customGitSSHKeyId: values.sshKey === "none" ? null : values.sshKey,
|
||||
composeId,
|
||||
sourceType: "git",
|
||||
composePath: values.composePath,
|
||||
@@ -94,123 +94,72 @@ export const SaveGitProviderCompose = ({ composeId }: Props) => {
|
||||
className="flex flex-col gap-4"
|
||||
>
|
||||
<div className="grid md:grid-cols-2 gap-4 ">
|
||||
<div className="md:col-span-2 space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="repositoryURL"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="flex flex-row justify-between">
|
||||
Repository URL
|
||||
<div className="flex gap-2">
|
||||
<Dialog>
|
||||
<DialogTrigger className="flex flex-row gap-2">
|
||||
<LockIcon className="size-4 text-muted-foreground" />?
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-[425px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Private Repository</DialogTitle>
|
||||
<DialogDescription>
|
||||
If your repository is private is necessary to
|
||||
generate SSH Keys to add to your git provider.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="relative">
|
||||
<Textarea
|
||||
placeholder="Please click on Generate SSH Key"
|
||||
className="no-scrollbar h-64 text-muted-foreground"
|
||||
disabled={!data?.customGitSSHKey}
|
||||
contentEditable={false}
|
||||
value={
|
||||
data?.customGitSSHKey ||
|
||||
"Please click on Generate SSH Key"
|
||||
}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute right-2 top-2"
|
||||
onClick={() => {
|
||||
copy(
|
||||
data?.customGitSSHKey ||
|
||||
"Generate a SSH Key",
|
||||
);
|
||||
toast.success("SSH Copied to clipboard");
|
||||
}}
|
||||
<div className="flex items-end col-span-2 gap-4">
|
||||
<div className="grow">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="repositoryURL"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="flex flex-row justify-between">
|
||||
Repository URL
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="git@bitbucket.org" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
{sshKeys && sshKeys.length > 0 ? (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="sshKey"
|
||||
render={({ field }) => (
|
||||
<FormItem className="basis-40">
|
||||
<FormLabel className="w-full inline-flex justify-between">
|
||||
SSH Key
|
||||
<LockIcon className="size-4 text-muted-foreground" />
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Select
|
||||
key={field.value}
|
||||
onValueChange={field.onChange}
|
||||
defaultValue={field.value}
|
||||
value={field.value}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a key" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{sshKeys?.map((sshKey) => (
|
||||
<SelectItem
|
||||
key={sshKey.sshKeyId}
|
||||
value={sshKey.sshKeyId}
|
||||
>
|
||||
<CopyIcon className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter className="flex sm:justify-between gap-3.5 flex-col sm:flex-col w-full">
|
||||
<div className="flex flex-row gap-2 w-full justify-between flex-wrap">
|
||||
{data?.customGitSSHKey && (
|
||||
<Button
|
||||
variant="destructive"
|
||||
isLoading={
|
||||
isGeneratingSSHKey || isRemovingSSHKey
|
||||
}
|
||||
className="max-sm:w-full"
|
||||
onClick={async () => {
|
||||
await removeSSHKey({
|
||||
composeId,
|
||||
})
|
||||
.then(async () => {
|
||||
toast.success("SSH Key Removed");
|
||||
await refetch();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error(
|
||||
"Error to remove the SSH Key",
|
||||
);
|
||||
});
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
Remove SSH Key
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button
|
||||
isLoading={
|
||||
isGeneratingSSHKey || isRemovingSSHKey
|
||||
}
|
||||
className="max-sm:w-full"
|
||||
onClick={async () => {
|
||||
await generateSSHKey({
|
||||
composeId,
|
||||
})
|
||||
.then(async () => {
|
||||
toast.success("SSH Key Generated");
|
||||
await refetch();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error(
|
||||
"Error to generate the SSH Key",
|
||||
);
|
||||
});
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
Generate SSH Key
|
||||
</Button>
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Is recommended to remove the SSH Key if you want
|
||||
to deploy a public repository.
|
||||
</span>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="git@bitbucket.org" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
{sshKey.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
<SelectItem value="none">None</SelectItem>
|
||||
<SelectLabel>Keys ({sshKeys?.length})</SelectLabel>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => router.push("/dashboard/settings/ssh-keys")}
|
||||
type="button"
|
||||
>
|
||||
<KeyRoundIcon className="size-4" /> Add SSH Key
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<FormField
|
||||
|
||||
@@ -30,12 +30,14 @@ export const DockerLogs = dynamic(
|
||||
|
||||
interface Props {
|
||||
appName: string;
|
||||
appType: "stack" | "docker-compose";
|
||||
}
|
||||
|
||||
export const ShowDockerLogsCompose = ({ appName }: Props) => {
|
||||
export const ShowDockerLogsCompose = ({ appName, appType }: Props) => {
|
||||
const { data } = api.docker.getContainersByAppNameMatch.useQuery(
|
||||
{
|
||||
appName,
|
||||
appType,
|
||||
},
|
||||
{
|
||||
enabled: !!appName,
|
||||
|
||||
@@ -31,6 +31,7 @@ export const ShowMonitoringCompose = ({
|
||||
const { data } = api.docker.getContainersByAppNameMatch.useQuery(
|
||||
{
|
||||
appName: appName,
|
||||
appType,
|
||||
},
|
||||
{
|
||||
enabled: !!appName,
|
||||
|
||||
@@ -23,8 +23,11 @@ export const DockerLogsId: React.FC<Props> = ({ id, containerId }) => {
|
||||
cursorBlink: true,
|
||||
cols: 80,
|
||||
rows: 30,
|
||||
lineHeight: 1.4,
|
||||
lineHeight: 1.25,
|
||||
fontWeight: 400,
|
||||
fontSize: 14,
|
||||
fontFamily:
|
||||
'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace',
|
||||
|
||||
convertEol: true,
|
||||
theme: {
|
||||
|
||||
@@ -14,9 +14,11 @@ import {
|
||||
FormItem,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Toggle } from "@/components/ui/toggle";
|
||||
import { api } from "@/utils/api";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import React, { useEffect } from "react";
|
||||
import { EyeIcon, EyeOffIcon } from "lucide-react";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
@@ -32,6 +34,7 @@ interface Props {
|
||||
}
|
||||
|
||||
export const ShowMariadbEnvironment = ({ mariadbId }: Props) => {
|
||||
const [isEnvVisible, setIsEnvVisible] = useState(true);
|
||||
const { mutateAsync, isLoading } = api.mariadb.saveEnvironment.useMutation();
|
||||
|
||||
const { data, refetch } = api.mariadb.one.useQuery(
|
||||
@@ -74,11 +77,26 @@ export const ShowMariadbEnvironment = ({ mariadbId }: Props) => {
|
||||
return (
|
||||
<div className="flex w-full flex-col gap-5 ">
|
||||
<Card className="bg-background">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-xl">Environment Settings</CardTitle>
|
||||
<CardDescription>
|
||||
You can add environment variables to your database.
|
||||
</CardDescription>
|
||||
{" "}
|
||||
<CardHeader className="flex flex-row w-full items-center justify-between">
|
||||
<div>
|
||||
<CardTitle className="text-xl">Environment Settings</CardTitle>
|
||||
<CardDescription>
|
||||
You can add environment variables to your resource.
|
||||
</CardDescription>
|
||||
</div>
|
||||
|
||||
<Toggle
|
||||
aria-label="Toggle bold"
|
||||
pressed={isEnvVisible}
|
||||
onPressedChange={setIsEnvVisible}
|
||||
>
|
||||
{isEnvVisible ? (
|
||||
<EyeOffIcon className="h-4 w-4 text-muted-foreground" />
|
||||
) : (
|
||||
<EyeIcon className="h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
</Toggle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Form {...form}>
|
||||
@@ -95,6 +113,7 @@ export const ShowMariadbEnvironment = ({ mariadbId }: Props) => {
|
||||
<FormControl>
|
||||
<CodeEditor
|
||||
language="properties"
|
||||
disabled={isEnvVisible}
|
||||
placeholder={`NODE_ENV=production
|
||||
PORT=3000
|
||||
`}
|
||||
|
||||
@@ -44,6 +44,7 @@ interface Props {
|
||||
mariadbId: string;
|
||||
}
|
||||
export const ShowExternalMariadbCredentials = ({ mariadbId }: Props) => {
|
||||
const { data: ip } = api.settings.getIp.useQuery();
|
||||
const { data, refetch } = api.mariadb.one.useQuery({ mariadbId });
|
||||
const { mutateAsync, isLoading } = api.mariadb.saveExternalPort.useMutation();
|
||||
const [connectionUrl, setConnectionUrl] = useState("");
|
||||
@@ -76,10 +77,9 @@ export const ShowExternalMariadbCredentials = ({ mariadbId }: Props) => {
|
||||
|
||||
useEffect(() => {
|
||||
const buildConnectionUrl = () => {
|
||||
const hostname = window.location.hostname;
|
||||
const port = form.watch("externalPort") || data?.externalPort;
|
||||
|
||||
return `mariadb://${data?.databaseUser}:${data?.databasePassword}@${hostname}:${port}/${data?.databaseName}`;
|
||||
return `mariadb://${data?.databaseUser}:${data?.databasePassword}@${ip}:${port}/${data?.databaseName}`;
|
||||
};
|
||||
|
||||
setConnectionUrl(buildConnectionUrl());
|
||||
@@ -90,6 +90,7 @@ export const ShowExternalMariadbCredentials = ({ mariadbId }: Props) => {
|
||||
form,
|
||||
data?.databaseName,
|
||||
data?.databaseUser,
|
||||
ip,
|
||||
]);
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -117,6 +117,7 @@ export const ShowVolumes = ({ mariadbId }: Props) => {
|
||||
mountId={mount.mountId}
|
||||
type={mount.type}
|
||||
refetch={refetch}
|
||||
serviceType="mariadb"
|
||||
/>
|
||||
<DeleteVolume mountId={mount.mountId} refetch={refetch} />
|
||||
</div>
|
||||
|
||||
@@ -14,9 +14,11 @@ import {
|
||||
FormItem,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Toggle } from "@/components/ui/toggle";
|
||||
import { api } from "@/utils/api";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import React, { useEffect } from "react";
|
||||
import { EyeIcon, EyeOffIcon } from "lucide-react";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
@@ -32,6 +34,7 @@ interface Props {
|
||||
}
|
||||
|
||||
export const ShowMongoEnvironment = ({ mongoId }: Props) => {
|
||||
const [isEnvVisible, setIsEnvVisible] = useState(true);
|
||||
const { mutateAsync, isLoading } = api.mongo.saveEnvironment.useMutation();
|
||||
|
||||
const { data, refetch } = api.mongo.one.useQuery(
|
||||
@@ -74,11 +77,25 @@ export const ShowMongoEnvironment = ({ mongoId }: Props) => {
|
||||
return (
|
||||
<div className="flex w-full flex-col gap-5 ">
|
||||
<Card className="bg-background">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-xl">Environment Settings</CardTitle>
|
||||
<CardDescription>
|
||||
You can add environment variables to your database.
|
||||
</CardDescription>
|
||||
<CardHeader className="flex flex-row w-full items-center justify-between">
|
||||
<div>
|
||||
<CardTitle className="text-xl">Environment Settings</CardTitle>
|
||||
<CardDescription>
|
||||
You can add environment variables to your resource.
|
||||
</CardDescription>
|
||||
</div>
|
||||
|
||||
<Toggle
|
||||
aria-label="Toggle bold"
|
||||
pressed={isEnvVisible}
|
||||
onPressedChange={setIsEnvVisible}
|
||||
>
|
||||
{isEnvVisible ? (
|
||||
<EyeOffIcon className="h-4 w-4 text-muted-foreground" />
|
||||
) : (
|
||||
<EyeIcon className="h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
</Toggle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Form {...form}>
|
||||
@@ -95,6 +112,7 @@ export const ShowMongoEnvironment = ({ mongoId }: Props) => {
|
||||
<FormControl>
|
||||
<CodeEditor
|
||||
language="properties"
|
||||
disabled={isEnvVisible}
|
||||
placeholder={`NODE_ENV=production
|
||||
PORT=3000
|
||||
`}
|
||||
|
||||
@@ -44,6 +44,7 @@ interface Props {
|
||||
mongoId: string;
|
||||
}
|
||||
export const ShowExternalMongoCredentials = ({ mongoId }: Props) => {
|
||||
const { data: ip } = api.settings.getIp.useQuery();
|
||||
const { data, refetch } = api.mongo.one.useQuery({ mongoId });
|
||||
const { mutateAsync, isLoading } = api.mongo.saveExternalPort.useMutation();
|
||||
const [connectionUrl, setConnectionUrl] = useState("");
|
||||
@@ -77,10 +78,9 @@ export const ShowExternalMongoCredentials = ({ mongoId }: Props) => {
|
||||
|
||||
useEffect(() => {
|
||||
const buildConnectionUrl = () => {
|
||||
const hostname = window.location.hostname;
|
||||
const port = form.watch("externalPort") || data?.externalPort;
|
||||
|
||||
return `mongodb://${data?.databaseUser}:${data?.databasePassword}@${hostname}:${port}`;
|
||||
return `mongodb://${data?.databaseUser}:${data?.databasePassword}@${ip}:${port}`;
|
||||
};
|
||||
|
||||
setConnectionUrl(buildConnectionUrl());
|
||||
@@ -90,6 +90,7 @@ export const ShowExternalMongoCredentials = ({ mongoId }: Props) => {
|
||||
data?.databasePassword,
|
||||
form,
|
||||
data?.databaseUser,
|
||||
ip,
|
||||
]);
|
||||
|
||||
return (
|
||||
|
||||
@@ -113,6 +113,7 @@ export const ShowVolumes = ({ mongoId }: Props) => {
|
||||
mountId={mount.mountId}
|
||||
type={mount.type}
|
||||
refetch={refetch}
|
||||
serviceType="mongo"
|
||||
/>
|
||||
<DeleteVolume mountId={mount.mountId} refetch={refetch} />
|
||||
</div>
|
||||
|
||||
@@ -14,9 +14,11 @@ import {
|
||||
FormItem,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Toggle } from "@/components/ui/toggle";
|
||||
import { api } from "@/utils/api";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import React, { useEffect } from "react";
|
||||
import { EyeIcon, EyeOffIcon } from "lucide-react";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
@@ -32,6 +34,7 @@ interface Props {
|
||||
}
|
||||
|
||||
export const ShowMysqlEnvironment = ({ mysqlId }: Props) => {
|
||||
const [isEnvVisible, setIsEnvVisible] = useState(true);
|
||||
const { mutateAsync, isLoading } = api.mysql.saveEnvironment.useMutation();
|
||||
|
||||
const { data, refetch } = api.mysql.one.useQuery(
|
||||
@@ -74,11 +77,25 @@ export const ShowMysqlEnvironment = ({ mysqlId }: Props) => {
|
||||
return (
|
||||
<div className="flex w-full flex-col gap-5 ">
|
||||
<Card className="bg-background">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-xl">Environment Settings</CardTitle>
|
||||
<CardDescription>
|
||||
You can add environment variables to your database.
|
||||
</CardDescription>
|
||||
<CardHeader className="flex flex-row w-full items-center justify-between">
|
||||
<div>
|
||||
<CardTitle className="text-xl">Environment Settings</CardTitle>
|
||||
<CardDescription>
|
||||
You can add environment variables to your resource.
|
||||
</CardDescription>
|
||||
</div>
|
||||
|
||||
<Toggle
|
||||
aria-label="Toggle bold"
|
||||
pressed={isEnvVisible}
|
||||
onPressedChange={setIsEnvVisible}
|
||||
>
|
||||
{isEnvVisible ? (
|
||||
<EyeOffIcon className="h-4 w-4 text-muted-foreground" />
|
||||
) : (
|
||||
<EyeIcon className="h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
</Toggle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Form {...form}>
|
||||
@@ -95,6 +112,7 @@ export const ShowMysqlEnvironment = ({ mysqlId }: Props) => {
|
||||
<FormControl>
|
||||
<CodeEditor
|
||||
language="properties"
|
||||
disabled={isEnvVisible}
|
||||
placeholder={`NODE_ENV=production
|
||||
PORT=3000
|
||||
`}
|
||||
|
||||
@@ -44,6 +44,7 @@ interface Props {
|
||||
mysqlId: string;
|
||||
}
|
||||
export const ShowExternalMysqlCredentials = ({ mysqlId }: Props) => {
|
||||
const { data: ip } = api.settings.getIp.useQuery();
|
||||
const { data, refetch } = api.mysql.one.useQuery({ mysqlId });
|
||||
const { mutateAsync, isLoading } = api.mysql.saveExternalPort.useMutation();
|
||||
const [connectionUrl, setConnectionUrl] = useState("");
|
||||
@@ -77,10 +78,9 @@ export const ShowExternalMysqlCredentials = ({ mysqlId }: Props) => {
|
||||
|
||||
useEffect(() => {
|
||||
const buildConnectionUrl = () => {
|
||||
const hostname = window.location.hostname;
|
||||
const port = form.watch("externalPort") || data?.externalPort;
|
||||
|
||||
return `mysql://${data?.databaseUser}:${data?.databasePassword}@${hostname}:${port}/${data?.databaseName}`;
|
||||
return `mysql://${data?.databaseUser}:${data?.databasePassword}@${ip}:${port}/${data?.databaseName}`;
|
||||
};
|
||||
|
||||
setConnectionUrl(buildConnectionUrl());
|
||||
@@ -91,6 +91,7 @@ export const ShowExternalMysqlCredentials = ({ mysqlId }: Props) => {
|
||||
data?.databaseName,
|
||||
data?.databaseUser,
|
||||
form,
|
||||
ip,
|
||||
]);
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -86,12 +86,14 @@ export const ShowVolumes = ({ mysqlId }: Props) => {
|
||||
)}
|
||||
|
||||
{mount.type === "file" && (
|
||||
<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">Content</span>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{mount.content}
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{mount.type === "bind" && (
|
||||
<div className="flex flex-col gap-1">
|
||||
@@ -113,6 +115,7 @@ export const ShowVolumes = ({ mysqlId }: Props) => {
|
||||
mountId={mount.mountId}
|
||||
type={mount.type}
|
||||
refetch={refetch}
|
||||
serviceType="mysql"
|
||||
/>
|
||||
<DeleteVolume mountId={mount.mountId} refetch={refetch} />
|
||||
</div>
|
||||
|
||||
@@ -14,9 +14,11 @@ import {
|
||||
FormItem,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Toggle } from "@/components/ui/toggle";
|
||||
import { api } from "@/utils/api";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import React, { useEffect } from "react";
|
||||
import { EyeIcon, EyeOffIcon } from "lucide-react";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
@@ -32,6 +34,7 @@ interface Props {
|
||||
}
|
||||
|
||||
export const ShowPostgresEnvironment = ({ postgresId }: Props) => {
|
||||
const [isEnvVisible, setIsEnvVisible] = useState(true);
|
||||
const { mutateAsync, isLoading } = api.postgres.saveEnvironment.useMutation();
|
||||
|
||||
const { data, refetch } = api.postgres.one.useQuery(
|
||||
@@ -74,11 +77,25 @@ export const ShowPostgresEnvironment = ({ postgresId }: Props) => {
|
||||
return (
|
||||
<div className="flex w-full flex-col gap-5 ">
|
||||
<Card className="bg-background">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-xl">Environment Settings</CardTitle>
|
||||
<CardDescription>
|
||||
You can add environment variables to your database.
|
||||
</CardDescription>
|
||||
<CardHeader className="flex flex-row w-full items-center justify-between">
|
||||
<div>
|
||||
<CardTitle className="text-xl">Environment Settings</CardTitle>
|
||||
<CardDescription>
|
||||
You can add environment variables to your resource.
|
||||
</CardDescription>
|
||||
</div>
|
||||
|
||||
<Toggle
|
||||
aria-label="Toggle bold"
|
||||
pressed={isEnvVisible}
|
||||
onPressedChange={setIsEnvVisible}
|
||||
>
|
||||
{isEnvVisible ? (
|
||||
<EyeOffIcon className="h-4 w-4 text-muted-foreground" />
|
||||
) : (
|
||||
<EyeIcon className="h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
</Toggle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Form {...form}>
|
||||
@@ -95,6 +112,7 @@ export const ShowPostgresEnvironment = ({ postgresId }: Props) => {
|
||||
<FormControl>
|
||||
<CodeEditor
|
||||
language="properties"
|
||||
disabled={isEnvVisible}
|
||||
placeholder={`NODE_ENV=production
|
||||
PORT=3000
|
||||
`}
|
||||
|
||||
@@ -44,6 +44,7 @@ interface Props {
|
||||
postgresId: string;
|
||||
}
|
||||
export const ShowExternalPostgresCredentials = ({ postgresId }: Props) => {
|
||||
const { data: ip } = api.settings.getIp.useQuery();
|
||||
const { data, refetch } = api.postgres.one.useQuery({ postgresId });
|
||||
const { mutateAsync, isLoading } =
|
||||
api.postgres.saveExternalPort.useMutation();
|
||||
@@ -81,7 +82,7 @@ export const ShowExternalPostgresCredentials = ({ postgresId }: Props) => {
|
||||
const hostname = window.location.hostname;
|
||||
const port = form.watch("externalPort") || data?.externalPort;
|
||||
|
||||
return `postgresql://${data?.databaseUser}:${data?.databasePassword}@${hostname}:${port}/${data?.databaseName}`;
|
||||
return `postgresql://${data?.databaseUser}:${data?.databasePassword}@${ip}:${port}/${data?.databaseName}`;
|
||||
};
|
||||
|
||||
setConnectionUrl(buildConnectionUrl());
|
||||
@@ -91,6 +92,7 @@ export const ShowExternalPostgresCredentials = ({ postgresId }: Props) => {
|
||||
data?.databasePassword,
|
||||
form,
|
||||
data?.databaseName,
|
||||
ip,
|
||||
]);
|
||||
|
||||
return (
|
||||
|
||||
@@ -118,6 +118,7 @@ export const ShowVolumes = ({ postgresId }: Props) => {
|
||||
mountId={mount.mountId}
|
||||
type={mount.type}
|
||||
refetch={refetch}
|
||||
serviceType="postgres"
|
||||
/>
|
||||
<DeleteVolume mountId={mount.mountId} refetch={refetch} />
|
||||
</div>
|
||||
|
||||
@@ -12,6 +12,13 @@ import {
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
} from "@/components/ui/command";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -22,8 +29,23 @@ import {
|
||||
} from "@/components/ui/dialog";
|
||||
import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { api } from "@/utils/api";
|
||||
import { Code, Github, Globe, PuzzleIcon } from "lucide-react";
|
||||
import { ScrollArea } from "@radix-ui/react-scroll-area";
|
||||
import {
|
||||
CheckIcon,
|
||||
ChevronsUpDown,
|
||||
Code,
|
||||
Github,
|
||||
Globe,
|
||||
PuzzleIcon,
|
||||
SearchIcon,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
@@ -34,13 +56,23 @@ interface Props {
|
||||
export const AddTemplate = ({ projectId }: Props) => {
|
||||
const [query, setQuery] = useState("");
|
||||
const { data } = api.compose.templates.useQuery();
|
||||
const [selectedTags, setSelectedTags] = useState<string[]>([]);
|
||||
const { data: tags, isLoading: isLoadingTags } =
|
||||
api.compose.getTags.useQuery();
|
||||
const utils = api.useUtils();
|
||||
const { mutateAsync, isLoading, error, isError } =
|
||||
api.compose.deployTemplate.useMutation();
|
||||
|
||||
const templates = data?.filter((t) =>
|
||||
t.name.toLowerCase().includes(query.toLowerCase()),
|
||||
);
|
||||
const templates =
|
||||
data?.filter((template) => {
|
||||
const matchesTags =
|
||||
selectedTags.length === 0 ||
|
||||
template.tags.some((tag) => selectedTags.includes(tag));
|
||||
const matchesQuery =
|
||||
query === "" ||
|
||||
template.name.toLowerCase().includes(query.toLowerCase());
|
||||
return matchesTags && matchesQuery;
|
||||
}) || [];
|
||||
|
||||
return (
|
||||
<Dialog>
|
||||
@@ -62,146 +94,220 @@ export const AddTemplate = ({ projectId }: Props) => {
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
|
||||
<Input
|
||||
placeholder="Search Template"
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
value={query}
|
||||
/>
|
||||
</div>
|
||||
<div className="p-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-4 w-full gap-4">
|
||||
{templates?.map((template, index) => (
|
||||
<div key={`template-${index}`}>
|
||||
<div
|
||||
key={template.id}
|
||||
className="flex flex-col gap-4 border p-6 rounded-lg h-full"
|
||||
<div className="flex flex-col md:flex-row gap-2">
|
||||
<Input
|
||||
placeholder="Search Template"
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
className="w-full"
|
||||
value={query}
|
||||
/>
|
||||
<Popover modal={true}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
className={cn(
|
||||
"md:max-w-[15rem] w-full justify-between !bg-input",
|
||||
// !field.value && "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<img
|
||||
src={`/templates/${template.logo}`}
|
||||
className="size-28 object-contain"
|
||||
alt=""
|
||||
/>
|
||||
</div>
|
||||
{isLoadingTags
|
||||
? "Loading...."
|
||||
: selectedTags.length > 0
|
||||
? `Selected ${selectedTags.length} tags`
|
||||
: "Select tag"}
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex flex-col gap-2 justify-center items-center">
|
||||
<div className="flex flex-col gap-2 items-center justify-center">
|
||||
<div className="flex flex-row gap-2 flex-wrap">
|
||||
<span className="text-sm font-medium">
|
||||
{template.name}
|
||||
</span>
|
||||
<Badge>{template.version}</Badge>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-row gap-0">
|
||||
<Link
|
||||
href={template.links.github}
|
||||
target="_blank"
|
||||
className={
|
||||
"text-sm text-muted-foreground p-3 rounded-full hover:bg-border items-center flex transition-colors"
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="p-0" align="start">
|
||||
<Command>
|
||||
<CommandInput placeholder="Search tag..." className="h-9" />
|
||||
{isLoadingTags && (
|
||||
<span className="py-6 text-center text-sm">
|
||||
Loading Tags....
|
||||
</span>
|
||||
)}
|
||||
<CommandEmpty>No tags found.</CommandEmpty>
|
||||
<ScrollArea className="h-96 overflow-y-auto">
|
||||
<CommandGroup>
|
||||
{tags?.map((tag) => {
|
||||
return (
|
||||
<CommandItem
|
||||
value={tag}
|
||||
key={tag}
|
||||
onSelect={() => {
|
||||
if (selectedTags.includes(tag)) {
|
||||
setSelectedTags(
|
||||
selectedTags.filter((t) => t !== tag),
|
||||
);
|
||||
return;
|
||||
}
|
||||
>
|
||||
<Github className="size-4 text-muted-foreground" />
|
||||
</Link>
|
||||
{template.links.website && (
|
||||
<Link
|
||||
href={template.links.website}
|
||||
target="_blank"
|
||||
className={
|
||||
"text-sm text-muted-foreground p-3 rounded-full hover:bg-border items-center flex transition-colors"
|
||||
}
|
||||
>
|
||||
<Globe className="size-4 text-muted-foreground" />
|
||||
</Link>
|
||||
)}
|
||||
{template.links.docs && (
|
||||
<Link
|
||||
href={template.links.docs}
|
||||
target="_blank"
|
||||
className={
|
||||
"text-sm text-muted-foreground p-3 rounded-full hover:bg-border items-center flex transition-colors"
|
||||
}
|
||||
>
|
||||
<Globe className="size-4 text-muted-foreground" />
|
||||
</Link>
|
||||
)}
|
||||
<Link
|
||||
href={`https://github.com/dokploy/dokploy/tree/canary/templates/${template.id}`}
|
||||
target="_blank"
|
||||
className={
|
||||
"text-sm text-muted-foreground p-3 rounded-full hover:bg-border items-center flex transition-colors"
|
||||
}
|
||||
>
|
||||
<Code className="size-4 text-muted-foreground" />
|
||||
</Link>
|
||||
</div>
|
||||
<div className="flex flex-row gap-2 flex-wrap justify-center">
|
||||
{template.tags.map((tag) => (
|
||||
<Badge variant="secondary" key={tag}>
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button onSelect={(e) => e.preventDefault()}>
|
||||
Deploy
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
Are you absolutely sure?
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This will deploy {template.name} template to
|
||||
your project.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={async () => {
|
||||
await mutateAsync({
|
||||
projectId,
|
||||
id: template.id,
|
||||
})
|
||||
.then(async () => {
|
||||
toast.success(
|
||||
`${template.name} template created succesfully`,
|
||||
);
|
||||
|
||||
utils.project.one.invalidate({
|
||||
projectId,
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error(
|
||||
`Error to delete ${template.name} template`,
|
||||
);
|
||||
});
|
||||
}}
|
||||
>
|
||||
Confirm
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
setSelectedTags([...selectedTags, tag]);
|
||||
}}
|
||||
>
|
||||
{tag}
|
||||
<CheckIcon
|
||||
className={cn(
|
||||
"ml-auto h-4 w-4",
|
||||
selectedTags.includes(tag)
|
||||
? "opacity-100"
|
||||
: "opacity-0",
|
||||
)}
|
||||
/>
|
||||
</CommandItem>
|
||||
);
|
||||
})}
|
||||
</CommandGroup>
|
||||
</ScrollArea>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-6 w-full">
|
||||
{templates.length === 0 ? (
|
||||
<div className="flex justify-center items-center w-full gap-2 min-h-[50vh]">
|
||||
<SearchIcon className="text-muted-foreground size-6" />
|
||||
<div className="text-xl font-medium text-muted-foreground">
|
||||
No templates found
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-4 w-full gap-4">
|
||||
{templates?.map((template, index) => (
|
||||
<div key={`template-${index}`}>
|
||||
<div
|
||||
key={template.id}
|
||||
className="flex flex-col gap-4 border p-6 rounded-lg h-full"
|
||||
>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<img
|
||||
src={`/templates/${template.logo}`}
|
||||
className="size-28 object-contain"
|
||||
alt=""
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{template.description}
|
||||
</p>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex flex-col gap-2 justify-center items-center">
|
||||
<div className="flex flex-col gap-2 items-center justify-center">
|
||||
<div className="flex flex-row gap-2 flex-wrap">
|
||||
<span className="text-sm font-medium">
|
||||
{template.name}
|
||||
</span>
|
||||
<Badge>{template.version}</Badge>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-row gap-0">
|
||||
<Link
|
||||
href={template.links.github}
|
||||
target="_blank"
|
||||
className={
|
||||
"text-sm text-muted-foreground p-3 rounded-full hover:bg-border items-center flex transition-colors"
|
||||
}
|
||||
>
|
||||
<Github className="size-4 text-muted-foreground" />
|
||||
</Link>
|
||||
{template.links.website && (
|
||||
<Link
|
||||
href={template.links.website}
|
||||
target="_blank"
|
||||
className={
|
||||
"text-sm text-muted-foreground p-3 rounded-full hover:bg-border items-center flex transition-colors"
|
||||
}
|
||||
>
|
||||
<Globe className="size-4 text-muted-foreground" />
|
||||
</Link>
|
||||
)}
|
||||
{template.links.docs && (
|
||||
<Link
|
||||
href={template.links.docs}
|
||||
target="_blank"
|
||||
className={
|
||||
"text-sm text-muted-foreground p-3 rounded-full hover:bg-border items-center flex transition-colors"
|
||||
}
|
||||
>
|
||||
<Globe className="size-4 text-muted-foreground" />
|
||||
</Link>
|
||||
)}
|
||||
<Link
|
||||
href={`https://github.com/dokploy/dokploy/tree/canary/templates/${template.id}`}
|
||||
target="_blank"
|
||||
className={
|
||||
"text-sm text-muted-foreground p-3 rounded-full hover:bg-border items-center flex transition-colors"
|
||||
}
|
||||
>
|
||||
<Code className="size-4 text-muted-foreground" />
|
||||
</Link>
|
||||
</div>
|
||||
<div className="flex flex-row gap-2 flex-wrap justify-center">
|
||||
{template.tags.map((tag) => (
|
||||
<Badge variant="secondary" key={tag}>
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button onSelect={(e) => e.preventDefault()}>
|
||||
Deploy
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
Are you absolutely sure?
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This will deploy {template.name} template to
|
||||
your project.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={async () => {
|
||||
await mutateAsync({
|
||||
projectId,
|
||||
id: template.id,
|
||||
})
|
||||
.then(async () => {
|
||||
toast.success(
|
||||
`${template.name} template created succesfully`,
|
||||
);
|
||||
|
||||
utils.project.one.invalidate({
|
||||
projectId,
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error(
|
||||
`Error to delete ${template.name} template`,
|
||||
);
|
||||
});
|
||||
}}
|
||||
>
|
||||
Confirm
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{template.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -14,9 +14,11 @@ import {
|
||||
FormItem,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Toggle } from "@/components/ui/toggle";
|
||||
import { api } from "@/utils/api";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import React, { useEffect } from "react";
|
||||
import { EyeIcon, EyeOffIcon } from "lucide-react";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
@@ -32,6 +34,7 @@ interface Props {
|
||||
}
|
||||
|
||||
export const ShowRedisEnvironment = ({ redisId }: Props) => {
|
||||
const [isEnvVisible, setIsEnvVisible] = useState(true);
|
||||
const { mutateAsync, isLoading } = api.redis.saveEnvironment.useMutation();
|
||||
|
||||
const { data, refetch } = api.redis.one.useQuery(
|
||||
@@ -74,11 +77,25 @@ export const ShowRedisEnvironment = ({ redisId }: Props) => {
|
||||
return (
|
||||
<div className="flex w-full flex-col gap-5 ">
|
||||
<Card className="bg-background">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-xl">Environment Settings</CardTitle>
|
||||
<CardDescription>
|
||||
You can add environment variables to your database.
|
||||
</CardDescription>
|
||||
<CardHeader className="flex flex-row w-full items-center justify-between">
|
||||
<div>
|
||||
<CardTitle className="text-xl">Environment Settings</CardTitle>
|
||||
<CardDescription>
|
||||
You can add environment variables to your resource.
|
||||
</CardDescription>
|
||||
</div>
|
||||
|
||||
<Toggle
|
||||
aria-label="Toggle bold"
|
||||
pressed={isEnvVisible}
|
||||
onPressedChange={setIsEnvVisible}
|
||||
>
|
||||
{isEnvVisible ? (
|
||||
<EyeOffIcon className="h-4 w-4 text-muted-foreground" />
|
||||
) : (
|
||||
<EyeIcon className="h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
</Toggle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Form {...form}>
|
||||
@@ -95,6 +112,7 @@ export const ShowRedisEnvironment = ({ redisId }: Props) => {
|
||||
<FormControl>
|
||||
<CodeEditor
|
||||
language="properties"
|
||||
disabled={isEnvVisible}
|
||||
placeholder={`NODE_ENV=production
|
||||
PORT=3000
|
||||
`}
|
||||
|
||||
@@ -44,6 +44,7 @@ interface Props {
|
||||
redisId: string;
|
||||
}
|
||||
export const ShowExternalRedisCredentials = ({ redisId }: Props) => {
|
||||
const { data: ip } = api.settings.getIp.useQuery();
|
||||
const { data, refetch } = api.redis.one.useQuery({ redisId });
|
||||
const { mutateAsync, isLoading } = api.redis.saveExternalPort.useMutation();
|
||||
const [connectionUrl, setConnectionUrl] = useState("");
|
||||
@@ -80,11 +81,11 @@ export const ShowExternalRedisCredentials = ({ redisId }: Props) => {
|
||||
const hostname = window.location.hostname;
|
||||
const port = form.watch("externalPort") || data?.externalPort;
|
||||
|
||||
return `redis://default:${data?.databasePassword}@${hostname}:${port}`;
|
||||
return `redis://default:${data?.databasePassword}@${ip}:${port}`;
|
||||
};
|
||||
|
||||
setConnectionUrl(buildConnectionUrl());
|
||||
}, [data?.appName, data?.externalPort, data?.databasePassword, form]);
|
||||
}, [data?.appName, data?.externalPort, data?.databasePassword, form, ip]);
|
||||
return (
|
||||
<>
|
||||
<div className="flex w-full flex-col gap-5 ">
|
||||
|
||||
@@ -115,6 +115,7 @@ export const ShowVolumes = ({ redisId }: Props) => {
|
||||
mountId={mount.mountId}
|
||||
type={mount.type}
|
||||
refetch={refetch}
|
||||
serviceType="redis"
|
||||
/>
|
||||
<DeleteVolume mountId={mount.mountId} refetch={refetch} />
|
||||
</div>
|
||||
|
||||
@@ -99,7 +99,7 @@ export const AddRegistry = () => {
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button>
|
||||
<Button className="max-sm:w-full">
|
||||
<Container className="h-4 w-4" />
|
||||
Create Registry
|
||||
</Button>
|
||||
|
||||
@@ -88,7 +88,7 @@ export const AddSelfHostedRegistry = () => {
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button>
|
||||
<Button className="max-sm:w-full">
|
||||
<Container className="h-4 w-4" />
|
||||
Enable Self Hosted Registry
|
||||
</Button>
|
||||
|
||||
@@ -42,11 +42,11 @@ export const ShowRegistry = () => {
|
||||
{data?.length === 0 ? (
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<Server className="size-8 self-center text-muted-foreground" />
|
||||
<span className="text-base text-muted-foreground">
|
||||
<span className="text-base text-muted-foreground text-center">
|
||||
To create a cluster is required to set a registry.
|
||||
</span>
|
||||
|
||||
<div className="flex flex-row gap-2">
|
||||
<div className="flex flex-row md:flex-row gap-2 flex-wrap w-full justify-center">
|
||||
<AddSelfHostedRegistry />
|
||||
<AddRegistry />
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,742 @@
|
||||
import {
|
||||
DiscordIcon,
|
||||
SlackIcon,
|
||||
TelegramIcon,
|
||||
} from "@/components/icons/notification-icons";
|
||||
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 { Label } from "@/components/ui/label";
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { api } from "@/utils/api";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { AlertTriangle, Mail } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useFieldArray, useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
|
||||
const notificationBaseSchema = z.object({
|
||||
name: z.string().min(1, {
|
||||
message: "Name is required",
|
||||
}),
|
||||
appDeploy: z.boolean().default(false),
|
||||
appBuildError: z.boolean().default(false),
|
||||
databaseBackup: z.boolean().default(false),
|
||||
dokployRestart: z.boolean().default(false),
|
||||
dockerCleanup: z.boolean().default(false),
|
||||
});
|
||||
|
||||
export const notificationSchema = z.discriminatedUnion("type", [
|
||||
z
|
||||
.object({
|
||||
type: z.literal("slack"),
|
||||
webhookUrl: z.string().min(1, { message: "Webhook URL is required" }),
|
||||
channel: z.string(),
|
||||
})
|
||||
.merge(notificationBaseSchema),
|
||||
z
|
||||
.object({
|
||||
type: z.literal("telegram"),
|
||||
botToken: z.string().min(1, { message: "Bot Token is required" }),
|
||||
chatId: z.string().min(1, { message: "Chat ID is required" }),
|
||||
})
|
||||
.merge(notificationBaseSchema),
|
||||
z
|
||||
.object({
|
||||
type: z.literal("discord"),
|
||||
webhookUrl: z.string().min(1, { message: "Webhook URL is required" }),
|
||||
})
|
||||
.merge(notificationBaseSchema),
|
||||
z
|
||||
.object({
|
||||
type: z.literal("email"),
|
||||
smtpServer: z.string().min(1, { message: "SMTP Server is required" }),
|
||||
smtpPort: z.number().min(1, { message: "SMTP Port is required" }),
|
||||
username: z.string().min(1, { message: "Username is required" }),
|
||||
password: z.string().min(1, { message: "Password is required" }),
|
||||
fromAddress: z.string().min(1, { message: "From Address is required" }),
|
||||
toAddresses: z
|
||||
.array(
|
||||
z.string().min(1, { message: "Email is required" }).email({
|
||||
message: "Email is invalid",
|
||||
}),
|
||||
)
|
||||
.min(1, { message: "At least one email is required" }),
|
||||
})
|
||||
.merge(notificationBaseSchema),
|
||||
]);
|
||||
|
||||
export const notificationsMap = {
|
||||
slack: {
|
||||
icon: <SlackIcon />,
|
||||
label: "Slack",
|
||||
},
|
||||
telegram: {
|
||||
icon: <TelegramIcon />,
|
||||
label: "Telegram",
|
||||
},
|
||||
discord: {
|
||||
icon: <DiscordIcon />,
|
||||
label: "Discord",
|
||||
},
|
||||
email: {
|
||||
icon: <Mail size={29} className="text-muted-foreground" />,
|
||||
label: "Email",
|
||||
},
|
||||
};
|
||||
|
||||
export type NotificationSchema = z.infer<typeof notificationSchema>;
|
||||
|
||||
export const AddNotification = () => {
|
||||
const utils = api.useUtils();
|
||||
const [visible, setVisible] = useState(false);
|
||||
|
||||
const { mutateAsync: testSlackConnection, isLoading: isLoadingSlack } =
|
||||
api.notification.testSlackConnection.useMutation();
|
||||
|
||||
const { mutateAsync: testTelegramConnection, isLoading: isLoadingTelegram } =
|
||||
api.notification.testTelegramConnection.useMutation();
|
||||
const { mutateAsync: testDiscordConnection, isLoading: isLoadingDiscord } =
|
||||
api.notification.testDiscordConnection.useMutation();
|
||||
const { mutateAsync: testEmailConnection, isLoading: isLoadingEmail } =
|
||||
api.notification.testEmailConnection.useMutation();
|
||||
const slackMutation = api.notification.createSlack.useMutation();
|
||||
const telegramMutation = api.notification.createTelegram.useMutation();
|
||||
const discordMutation = api.notification.createDiscord.useMutation();
|
||||
const emailMutation = api.notification.createEmail.useMutation();
|
||||
|
||||
const form = useForm<NotificationSchema>({
|
||||
defaultValues: {
|
||||
type: "slack",
|
||||
webhookUrl: "",
|
||||
channel: "",
|
||||
name: "",
|
||||
},
|
||||
resolver: zodResolver(notificationSchema),
|
||||
});
|
||||
const type = form.watch("type");
|
||||
|
||||
const { fields, append, remove } = useFieldArray({
|
||||
control: form.control,
|
||||
name: "toAddresses" as never,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (type === "email") {
|
||||
append("");
|
||||
}
|
||||
}, [type, append]);
|
||||
|
||||
useEffect(() => {
|
||||
form.reset();
|
||||
}, [form, form.reset, form.formState.isSubmitSuccessful]);
|
||||
|
||||
const activeMutation = {
|
||||
slack: slackMutation,
|
||||
telegram: telegramMutation,
|
||||
discord: discordMutation,
|
||||
email: emailMutation,
|
||||
};
|
||||
|
||||
const onSubmit = async (data: NotificationSchema) => {
|
||||
const {
|
||||
appBuildError,
|
||||
appDeploy,
|
||||
dokployRestart,
|
||||
databaseBackup,
|
||||
dockerCleanup,
|
||||
} = data;
|
||||
let promise: Promise<unknown> | null = null;
|
||||
if (data.type === "slack") {
|
||||
promise = slackMutation.mutateAsync({
|
||||
appBuildError: appBuildError,
|
||||
appDeploy: appDeploy,
|
||||
dokployRestart: dokployRestart,
|
||||
databaseBackup: databaseBackup,
|
||||
webhookUrl: data.webhookUrl,
|
||||
channel: data.channel,
|
||||
name: data.name,
|
||||
dockerCleanup: dockerCleanup,
|
||||
});
|
||||
} else if (data.type === "telegram") {
|
||||
promise = telegramMutation.mutateAsync({
|
||||
appBuildError: appBuildError,
|
||||
appDeploy: appDeploy,
|
||||
dokployRestart: dokployRestart,
|
||||
databaseBackup: databaseBackup,
|
||||
botToken: data.botToken,
|
||||
chatId: data.chatId,
|
||||
name: data.name,
|
||||
dockerCleanup: dockerCleanup,
|
||||
});
|
||||
} else if (data.type === "discord") {
|
||||
promise = discordMutation.mutateAsync({
|
||||
appBuildError: appBuildError,
|
||||
appDeploy: appDeploy,
|
||||
dokployRestart: dokployRestart,
|
||||
databaseBackup: databaseBackup,
|
||||
webhookUrl: data.webhookUrl,
|
||||
name: data.name,
|
||||
dockerCleanup: dockerCleanup,
|
||||
});
|
||||
} else if (data.type === "email") {
|
||||
promise = emailMutation.mutateAsync({
|
||||
appBuildError: appBuildError,
|
||||
appDeploy: appDeploy,
|
||||
dokployRestart: dokployRestart,
|
||||
databaseBackup: databaseBackup,
|
||||
smtpServer: data.smtpServer,
|
||||
smtpPort: data.smtpPort,
|
||||
username: data.username,
|
||||
password: data.password,
|
||||
fromAddress: data.fromAddress,
|
||||
toAddresses: data.toAddresses,
|
||||
name: data.name,
|
||||
dockerCleanup: dockerCleanup,
|
||||
});
|
||||
}
|
||||
|
||||
if (promise) {
|
||||
await promise
|
||||
.then(async () => {
|
||||
toast.success("Notification Created");
|
||||
form.reset({
|
||||
type: "slack",
|
||||
webhookUrl: "",
|
||||
});
|
||||
setVisible(false);
|
||||
await utils.notification.all.invalidate();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error to create a notification");
|
||||
});
|
||||
}
|
||||
};
|
||||
return (
|
||||
<Dialog open={visible} onOpenChange={setVisible}>
|
||||
<DialogTrigger className="" asChild>
|
||||
<Button>Add Notification</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-3xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add Notification</DialogTitle>
|
||||
<DialogDescription>
|
||||
Create new notifications providers for multiple
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Form {...form}>
|
||||
<form
|
||||
id="hook-form"
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="grid w-full gap-8 "
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
defaultValue={form.control._defaultValues.type}
|
||||
name="type"
|
||||
render={({ field }) => (
|
||||
<FormItem className="space-y-3">
|
||||
<FormLabel className="text-muted-foreground">
|
||||
Select a provider
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<RadioGroup
|
||||
onValueChange={field.onChange}
|
||||
defaultValue={field.value}
|
||||
className="grid w-full grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4"
|
||||
>
|
||||
{Object.entries(notificationsMap).map(([key, value]) => (
|
||||
<FormItem
|
||||
key={key}
|
||||
className="flex w-full items-center space-x-3 space-y-0"
|
||||
>
|
||||
<FormControl className="w-full">
|
||||
<div>
|
||||
<RadioGroupItem
|
||||
value={key}
|
||||
id={key}
|
||||
className="peer sr-only"
|
||||
/>
|
||||
<Label
|
||||
htmlFor={key}
|
||||
className="flex flex-col gap-2 items-center justify-between rounded-md border-2 border-muted bg-popover p-4 hover:bg-accent hover:text-accent-foreground peer-data-[state=checked]:border-primary [&:has([data-state=checked])]:border-primary cursor-pointer"
|
||||
>
|
||||
{value.icon}
|
||||
{value.label}
|
||||
</Label>
|
||||
</div>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
))}
|
||||
</RadioGroup>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
{activeMutation[field.value].isError && (
|
||||
<div className="flex flex-row gap-4 rounded-lg 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">
|
||||
{activeMutation[field.value].error?.message}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
<FormLabel className="text-lg font-semibold leading-none tracking-tight">
|
||||
Fill the next fields.
|
||||
</FormLabel>
|
||||
<div className="flex flex-col gap-2">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Name" {...field} />
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{type === "slack" && (
|
||||
<>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="webhookUrl"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Webhook URL</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="https://hooks.slack.com/services/T00000000/B00000000/XXXXXXXXXXXXXXXXXXXXXXXX"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="channel"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Channel</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Channel" {...field} />
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{type === "telegram" && (
|
||||
<>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="botToken"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Bot Token</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="6660491268:AAFMGmajZOVewpMNZCgJr5H7cpXpoZPgvXw"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="chatId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Chat ID</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="431231869" {...field} />
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{type === "discord" && (
|
||||
<>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="webhookUrl"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Webhook URL</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="https://discord.com/api/webhooks/123456789/ABCDEFGHIJKLMNOPQRSTUVWXYZ"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{type === "email" && (
|
||||
<>
|
||||
<div className="flex md:flex-row flex-col gap-2 w-full">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="smtpServer"
|
||||
render={({ field }) => (
|
||||
<FormItem className="w-full">
|
||||
<FormLabel>SMTP Server</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="smtp.gmail.com" {...field} />
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="smtpPort"
|
||||
render={({ field }) => (
|
||||
<FormItem className="w-full">
|
||||
<FormLabel>SMTP Port</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="587"
|
||||
{...field}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
if (value) {
|
||||
const port = Number.parseInt(value);
|
||||
if (port > 0 && port < 65536) {
|
||||
field.onChange(port);
|
||||
}
|
||||
}
|
||||
}}
|
||||
type="number"
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex md:flex-row flex-col gap-2 w-full">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="username"
|
||||
render={({ field }) => (
|
||||
<FormItem className="w-full">
|
||||
<FormLabel>Username</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="username" {...field} />
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormItem className="w-full">
|
||||
<FormLabel>Password</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="******************"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="fromAddress"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>From Address</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="from@example.com" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<div className="flex flex-col gap-2 pt-2">
|
||||
<FormLabel>To Addresses</FormLabel>
|
||||
|
||||
{fields.map((field, index) => (
|
||||
<div
|
||||
key={field.id}
|
||||
className="flex flex-row gap-2 w-full"
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`toAddresses.${index}`}
|
||||
render={({ field }) => (
|
||||
<FormItem className="w-full">
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="email@example.com"
|
||||
className="w-full"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
remove(index);
|
||||
}}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
{type === "email" &&
|
||||
"toAddresses" in form.formState.errors && (
|
||||
<div className="text-sm font-medium text-destructive">
|
||||
{form.formState?.errors?.toAddresses?.root?.message}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
append("");
|
||||
}}
|
||||
>
|
||||
Add
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4">
|
||||
<FormLabel className="text-lg font-semibold leading-none tracking-tight">
|
||||
Select the actions.
|
||||
</FormLabel>
|
||||
|
||||
<div className="grid md:grid-cols-2 gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="appDeploy"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3 shadow-sm gap-2">
|
||||
<div className="">
|
||||
<FormLabel>App Deploy</FormLabel>
|
||||
<FormDescription>
|
||||
Trigger the action when a app is deployed.
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="appBuildError"
|
||||
render={({ field }) => (
|
||||
<FormItem className=" flex flex-row items-center justify-between rounded-lg border p-3 shadow-sm gap-2">
|
||||
<div className="space-y-0.5">
|
||||
<FormLabel>App Build Error</FormLabel>
|
||||
<FormDescription>
|
||||
Trigger the action when the build fails.
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="databaseBackup"
|
||||
render={({ field }) => (
|
||||
<FormItem className=" flex flex-row items-center justify-between rounded-lg border p-3 shadow-sm gap-2">
|
||||
<div className="space-y-0.5">
|
||||
<FormLabel>Database Backup</FormLabel>
|
||||
<FormDescription>
|
||||
Trigger the action when a database backup is created.
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="dockerCleanup"
|
||||
render={({ field }) => (
|
||||
<FormItem className=" flex flex-row items-center justify-between rounded-lg border p-3 shadow-sm gap-2">
|
||||
<div className="space-y-0.5">
|
||||
<FormLabel>Docker Cleanup</FormLabel>
|
||||
<FormDescription>
|
||||
Trigger the action when the docker cleanup is
|
||||
performed.
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="dokployRestart"
|
||||
render={({ field }) => (
|
||||
<FormItem className=" flex flex-row items-center justify-between rounded-lg border p-3 shadow-sm gap-2">
|
||||
<div className="space-y-0.5">
|
||||
<FormLabel>Dokploy Restart</FormLabel>
|
||||
<FormDescription>
|
||||
Trigger the action when a dokploy is restarted.
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<DialogFooter className="flex flex-row gap-2 !justify-between w-full">
|
||||
<Button
|
||||
isLoading={
|
||||
isLoadingSlack ||
|
||||
isLoadingTelegram ||
|
||||
isLoadingDiscord ||
|
||||
isLoadingEmail
|
||||
}
|
||||
variant="secondary"
|
||||
onClick={async () => {
|
||||
try {
|
||||
if (type === "slack") {
|
||||
await testSlackConnection({
|
||||
webhookUrl: form.getValues("webhookUrl"),
|
||||
channel: form.getValues("channel"),
|
||||
});
|
||||
} else if (type === "telegram") {
|
||||
await testTelegramConnection({
|
||||
botToken: form.getValues("botToken"),
|
||||
chatId: form.getValues("chatId"),
|
||||
});
|
||||
} else if (type === "discord") {
|
||||
await testDiscordConnection({
|
||||
webhookUrl: form.getValues("webhookUrl"),
|
||||
});
|
||||
} else if (type === "email") {
|
||||
await testEmailConnection({
|
||||
smtpServer: form.getValues("smtpServer"),
|
||||
smtpPort: form.getValues("smtpPort"),
|
||||
username: form.getValues("username"),
|
||||
password: form.getValues("password"),
|
||||
toAddresses: form.getValues("toAddresses"),
|
||||
fromAddress: form.getValues("fromAddress"),
|
||||
});
|
||||
}
|
||||
toast.success("Connection Success");
|
||||
} catch (err) {
|
||||
toast.error("Error to test the provider");
|
||||
}
|
||||
}}
|
||||
>
|
||||
Test Notification
|
||||
</Button>
|
||||
<Button
|
||||
isLoading={form.formState.isSubmitting}
|
||||
form="hook-form"
|
||||
type="submit"
|
||||
>
|
||||
Create
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -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 {
|
||||
notificationId: string;
|
||||
}
|
||||
export const DeleteNotification = ({ notificationId }: Props) => {
|
||||
const { mutateAsync, isLoading } = api.notification.remove.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
|
||||
notification
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={async () => {
|
||||
await mutateAsync({
|
||||
notificationId,
|
||||
})
|
||||
.then(() => {
|
||||
utils.notification.all.invalidate();
|
||||
toast.success("Notification delete succesfully");
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error to delete notification");
|
||||
});
|
||||
}}
|
||||
>
|
||||
Confirm
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,90 @@
|
||||
import {
|
||||
DiscordIcon,
|
||||
SlackIcon,
|
||||
TelegramIcon,
|
||||
} from "@/components/icons/notification-icons";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { api } from "@/utils/api";
|
||||
import { BellRing, Mail } from "lucide-react";
|
||||
import { AddNotification } from "./add-notification";
|
||||
import { DeleteNotification } from "./delete-notification";
|
||||
import { UpdateNotification } from "./update-notification";
|
||||
|
||||
export const ShowNotifications = () => {
|
||||
const { data } = api.notification.all.useQuery();
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<Card className="h-full bg-transparent">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-xl">Notifications</CardTitle>
|
||||
<CardDescription>
|
||||
Add your providers to receive notifications, like Discord, Slack,
|
||||
Telegram, Email.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2 pt-4">
|
||||
{data?.length === 0 ? (
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<BellRing className="size-8 self-center text-muted-foreground" />
|
||||
<span className="text-base text-muted-foreground">
|
||||
To send notifications is required to set at least 1 provider.
|
||||
</span>
|
||||
<AddNotification />
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="grid lg:grid-cols-2 xl:grid-cols-3 gap-4">
|
||||
{data?.map((notification, index) => (
|
||||
<div
|
||||
key={notification.notificationId}
|
||||
className="flex items-center justify-between border gap-2 p-3.5 rounded-lg"
|
||||
>
|
||||
<div className="flex flex-row gap-2 items-center w-full ">
|
||||
{notification.notificationType === "slack" && (
|
||||
<SlackIcon className="text-muted-foreground size-6 flex-shrink-0" />
|
||||
)}
|
||||
{notification.notificationType === "telegram" && (
|
||||
<TelegramIcon className="text-muted-foreground size-8 flex-shrink-0" />
|
||||
)}
|
||||
{notification.notificationType === "discord" && (
|
||||
<DiscordIcon className="text-muted-foreground size-7 flex-shrink-0" />
|
||||
)}
|
||||
{notification.notificationType === "email" && (
|
||||
<Mail
|
||||
size={29}
|
||||
className="text-muted-foreground size-6 flex-shrink-0"
|
||||
/>
|
||||
)}
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{notification.name}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-row gap-1 w-fit">
|
||||
<UpdateNotification
|
||||
notificationId={notification.notificationId}
|
||||
/>
|
||||
<DeleteNotification
|
||||
notificationId={notification.notificationId}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex flex-col gap-4 justify-end w-full items-end">
|
||||
<AddNotification />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,699 @@
|
||||
import {
|
||||
DiscordIcon,
|
||||
SlackIcon,
|
||||
TelegramIcon,
|
||||
} from "@/components/icons/notification-icons";
|
||||
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 { Mail, PenBoxIcon } from "lucide-react";
|
||||
import { useEffect } from "react";
|
||||
import { FieldErrors, useFieldArray, useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
type NotificationSchema,
|
||||
notificationSchema,
|
||||
} from "./add-notification";
|
||||
|
||||
interface Props {
|
||||
notificationId: string;
|
||||
}
|
||||
|
||||
export const UpdateNotification = ({ notificationId }: Props) => {
|
||||
const utils = api.useUtils();
|
||||
const { data, refetch } = api.notification.one.useQuery(
|
||||
{
|
||||
notificationId,
|
||||
},
|
||||
{
|
||||
enabled: !!notificationId,
|
||||
},
|
||||
);
|
||||
const { mutateAsync: testSlackConnection, isLoading: isLoadingSlack } =
|
||||
api.notification.testSlackConnection.useMutation();
|
||||
|
||||
const { mutateAsync: testTelegramConnection, isLoading: isLoadingTelegram } =
|
||||
api.notification.testTelegramConnection.useMutation();
|
||||
const { mutateAsync: testDiscordConnection, isLoading: isLoadingDiscord } =
|
||||
api.notification.testDiscordConnection.useMutation();
|
||||
const { mutateAsync: testEmailConnection, isLoading: isLoadingEmail } =
|
||||
api.notification.testEmailConnection.useMutation();
|
||||
const slackMutation = api.notification.updateSlack.useMutation();
|
||||
const telegramMutation = api.notification.updateTelegram.useMutation();
|
||||
const discordMutation = api.notification.updateDiscord.useMutation();
|
||||
const emailMutation = api.notification.updateEmail.useMutation();
|
||||
|
||||
const form = useForm<NotificationSchema>({
|
||||
defaultValues: {
|
||||
type: "slack",
|
||||
webhookUrl: "",
|
||||
channel: "",
|
||||
},
|
||||
resolver: zodResolver(notificationSchema),
|
||||
});
|
||||
const type = form.watch("type");
|
||||
|
||||
const { fields, append, remove } = useFieldArray({
|
||||
control: form.control,
|
||||
name: "toAddresses" as never,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
if (data.notificationType === "slack") {
|
||||
form.reset({
|
||||
appBuildError: data.appBuildError,
|
||||
appDeploy: data.appDeploy,
|
||||
dokployRestart: data.dokployRestart,
|
||||
databaseBackup: data.databaseBackup,
|
||||
dockerCleanup: data.dockerCleanup,
|
||||
webhookUrl: data.slack?.webhookUrl,
|
||||
channel: data.slack?.channel || "",
|
||||
name: data.name,
|
||||
type: data.notificationType,
|
||||
});
|
||||
} else if (data.notificationType === "telegram") {
|
||||
form.reset({
|
||||
appBuildError: data.appBuildError,
|
||||
appDeploy: data.appDeploy,
|
||||
dokployRestart: data.dokployRestart,
|
||||
databaseBackup: data.databaseBackup,
|
||||
botToken: data.telegram?.botToken,
|
||||
chatId: data.telegram?.chatId,
|
||||
type: data.notificationType,
|
||||
name: data.name,
|
||||
dockerCleanup: data.dockerCleanup,
|
||||
});
|
||||
} else if (data.notificationType === "discord") {
|
||||
form.reset({
|
||||
appBuildError: data.appBuildError,
|
||||
appDeploy: data.appDeploy,
|
||||
dokployRestart: data.dokployRestart,
|
||||
databaseBackup: data.databaseBackup,
|
||||
type: data.notificationType,
|
||||
webhookUrl: data.discord?.webhookUrl,
|
||||
name: data.name,
|
||||
dockerCleanup: data.dockerCleanup,
|
||||
});
|
||||
} else if (data.notificationType === "email") {
|
||||
form.reset({
|
||||
appBuildError: data.appBuildError,
|
||||
appDeploy: data.appDeploy,
|
||||
dokployRestart: data.dokployRestart,
|
||||
databaseBackup: data.databaseBackup,
|
||||
type: data.notificationType,
|
||||
smtpServer: data.email?.smtpServer,
|
||||
smtpPort: data.email?.smtpPort,
|
||||
username: data.email?.username,
|
||||
password: data.email?.password,
|
||||
toAddresses: data.email?.toAddresses,
|
||||
fromAddress: data.email?.fromAddress,
|
||||
name: data.name,
|
||||
dockerCleanup: data.dockerCleanup,
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [form, form.reset, data]);
|
||||
|
||||
const onSubmit = async (formData: NotificationSchema) => {
|
||||
const {
|
||||
appBuildError,
|
||||
appDeploy,
|
||||
dokployRestart,
|
||||
databaseBackup,
|
||||
dockerCleanup,
|
||||
} = formData;
|
||||
let promise: Promise<unknown> | null = null;
|
||||
if (formData?.type === "slack" && data?.slackId) {
|
||||
promise = slackMutation.mutateAsync({
|
||||
appBuildError: appBuildError,
|
||||
appDeploy: appDeploy,
|
||||
dokployRestart: dokployRestart,
|
||||
databaseBackup: databaseBackup,
|
||||
webhookUrl: formData.webhookUrl,
|
||||
channel: formData.channel,
|
||||
name: formData.name,
|
||||
notificationId: notificationId,
|
||||
slackId: data?.slackId,
|
||||
dockerCleanup: dockerCleanup,
|
||||
});
|
||||
} else if (formData.type === "telegram" && data?.telegramId) {
|
||||
promise = telegramMutation.mutateAsync({
|
||||
appBuildError: appBuildError,
|
||||
appDeploy: appDeploy,
|
||||
dokployRestart: dokployRestart,
|
||||
databaseBackup: databaseBackup,
|
||||
botToken: formData.botToken,
|
||||
chatId: formData.chatId,
|
||||
name: formData.name,
|
||||
notificationId: notificationId,
|
||||
telegramId: data?.telegramId,
|
||||
dockerCleanup: dockerCleanup,
|
||||
});
|
||||
} else if (formData.type === "discord" && data?.discordId) {
|
||||
promise = discordMutation.mutateAsync({
|
||||
appBuildError: appBuildError,
|
||||
appDeploy: appDeploy,
|
||||
dokployRestart: dokployRestart,
|
||||
databaseBackup: databaseBackup,
|
||||
webhookUrl: formData.webhookUrl,
|
||||
name: formData.name,
|
||||
notificationId: notificationId,
|
||||
discordId: data?.discordId,
|
||||
dockerCleanup: dockerCleanup,
|
||||
});
|
||||
} else if (formData.type === "email" && data?.emailId) {
|
||||
promise = emailMutation.mutateAsync({
|
||||
appBuildError: appBuildError,
|
||||
appDeploy: appDeploy,
|
||||
dokployRestart: dokployRestart,
|
||||
databaseBackup: databaseBackup,
|
||||
smtpServer: formData.smtpServer,
|
||||
smtpPort: formData.smtpPort,
|
||||
username: formData.username,
|
||||
password: formData.password,
|
||||
fromAddress: formData.fromAddress,
|
||||
toAddresses: formData.toAddresses,
|
||||
name: formData.name,
|
||||
notificationId: notificationId,
|
||||
emailId: data?.emailId,
|
||||
dockerCleanup: dockerCleanup,
|
||||
});
|
||||
}
|
||||
|
||||
if (promise) {
|
||||
await promise
|
||||
.then(async () => {
|
||||
toast.success("Notification Updated");
|
||||
await utils.notification.all.invalidate();
|
||||
refetch();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error to update a notification");
|
||||
});
|
||||
}
|
||||
};
|
||||
return (
|
||||
<Dialog>
|
||||
<DialogTrigger className="" asChild>
|
||||
<Button variant="ghost">
|
||||
<PenBoxIcon className="size-4 text-muted-foreground" />
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Update Notification</DialogTitle>
|
||||
<DialogDescription>
|
||||
Update the current notification config
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<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-row gap-2 w-full items-center">
|
||||
<div className="flex flex-row gap-2 items-center w-full ">
|
||||
<FormLabel className="text-lg font-semibold leading-none tracking-tight flex">
|
||||
{data?.notificationType === "slack"
|
||||
? "Slack"
|
||||
: data?.notificationType === "telegram"
|
||||
? "Telegram"
|
||||
: data?.notificationType === "discord"
|
||||
? "Discord"
|
||||
: "Email"}
|
||||
</FormLabel>
|
||||
</div>
|
||||
{data?.notificationType === "slack" && (
|
||||
<SlackIcon className="text-muted-foreground size-6 flex-shrink-0" />
|
||||
)}
|
||||
{data?.notificationType === "telegram" && (
|
||||
<TelegramIcon className="text-muted-foreground size-8 flex-shrink-0" />
|
||||
)}
|
||||
{data?.notificationType === "discord" && (
|
||||
<DiscordIcon className="text-muted-foreground size-7 flex-shrink-0" />
|
||||
)}
|
||||
{data?.notificationType === "email" && (
|
||||
<Mail
|
||||
size={29}
|
||||
className="text-muted-foreground size-6 flex-shrink-0"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Name" {...field} />
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{type === "slack" && (
|
||||
<>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="webhookUrl"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Webhook URL</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="https://hooks.slack.com/services/T00000000/B00000000/XXXXXXXXXXXXXXXXXXXXXXXX"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="channel"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Channel</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Channel" {...field} />
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{type === "telegram" && (
|
||||
<>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="botToken"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Bot Token</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="6660491268:AAFMGmajZOVewpMNZCgJr5H7cpXpoZPgvXw"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="chatId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Chat ID</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="431231869" {...field} />
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{type === "discord" && (
|
||||
<>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="webhookUrl"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Webhook URL</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="https://discord.com/api/webhooks/123456789/ABCDEFGHIJKLMNOPQRSTUVWXYZ"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{type === "email" && (
|
||||
<>
|
||||
<div className="flex md:flex-row flex-col gap-2 w-full">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="smtpServer"
|
||||
render={({ field }) => (
|
||||
<FormItem className="w-full">
|
||||
<FormLabel>SMTP Server</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="smtp.gmail.com" {...field} />
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="smtpPort"
|
||||
render={({ field }) => (
|
||||
<FormItem className="w-full">
|
||||
<FormLabel>SMTP Port</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="587"
|
||||
{...field}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
if (value) {
|
||||
const port = Number.parseInt(value);
|
||||
if (port > 0 && port < 65536) {
|
||||
field.onChange(port);
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex md:flex-row flex-col gap-2 w-full">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="username"
|
||||
render={({ field }) => (
|
||||
<FormItem className="w-full">
|
||||
<FormLabel>Username</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="username" {...field} />
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormItem className="w-full">
|
||||
<FormLabel>Password</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="******************"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="fromAddress"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>From Address</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="from@example.com" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<div className="flex flex-col gap-2 pt-2">
|
||||
<FormLabel>To Addresses</FormLabel>
|
||||
|
||||
{fields.map((field, index) => (
|
||||
<div
|
||||
key={field.id}
|
||||
className="flex flex-row gap-2 w-full"
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`toAddresses.${index}`}
|
||||
render={({ field }) => (
|
||||
<FormItem className="w-full">
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="email@example.com"
|
||||
className="w-full"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
remove(index);
|
||||
}}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
{type === "email" &&
|
||||
"toAddresses" in form.formState.errors && (
|
||||
<div className="text-sm font-medium text-destructive">
|
||||
{form.formState?.errors?.toAddresses?.root?.message}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
append("");
|
||||
}}
|
||||
>
|
||||
Add
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4">
|
||||
<FormLabel className="text-lg font-semibold leading-none tracking-tight">
|
||||
Select the actions.
|
||||
</FormLabel>
|
||||
|
||||
<div className="grid md:grid-cols-2 gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
defaultValue={form.control._defaultValues.appDeploy}
|
||||
name="appDeploy"
|
||||
render={({ field }) => (
|
||||
<FormItem className=" flex flex-row items-center justify-between rounded-lg border p-3 shadow-sm gap-2">
|
||||
<div className="space-y-0.5">
|
||||
<FormLabel>App Deploy</FormLabel>
|
||||
<FormDescription>
|
||||
Trigger the action when a app is deployed.
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
defaultValue={form.control._defaultValues.appBuildError}
|
||||
name="appBuildError"
|
||||
render={({ field }) => (
|
||||
<FormItem className=" flex flex-row items-center justify-between rounded-lg border p-3 shadow-sm gap-2">
|
||||
<div className="space-y-0.5">
|
||||
<FormLabel>App Builder Error</FormLabel>
|
||||
<FormDescription>
|
||||
Trigger the action when the build fails.
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="databaseBackup"
|
||||
defaultValue={form.control._defaultValues.databaseBackup}
|
||||
render={({ field }) => (
|
||||
<FormItem className=" flex flex-row items-center justify-between rounded-lg border p-3 shadow-sm gap-2">
|
||||
<div className="space-y-0.5">
|
||||
<FormLabel>Database Backup</FormLabel>
|
||||
<FormDescription>
|
||||
Trigger the action when a database backup is created.
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="dockerCleanup"
|
||||
render={({ field }) => (
|
||||
<FormItem className=" flex flex-row items-center justify-between rounded-lg border p-3 shadow-sm gap-2">
|
||||
<div className="space-y-0.5">
|
||||
<FormLabel>Docker Cleanup</FormLabel>
|
||||
<FormDescription>
|
||||
Trigger the action when the docker cleanup is
|
||||
performed.
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
defaultValue={form.control._defaultValues.dokployRestart}
|
||||
name="dokployRestart"
|
||||
render={({ field }) => (
|
||||
<FormItem className=" flex flex-row items-center justify-between rounded-lg border p-3 shadow-sm gap-2">
|
||||
<div className="space-y-0.5">
|
||||
<FormLabel>Dokploy Restart</FormLabel>
|
||||
<FormDescription>
|
||||
Trigger the action when a dokploy is restarted.
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<DialogFooter className="flex flex-row gap-2 !justify-between w-full">
|
||||
<Button
|
||||
isLoading={
|
||||
isLoadingSlack ||
|
||||
isLoadingTelegram ||
|
||||
isLoadingDiscord ||
|
||||
isLoadingEmail
|
||||
}
|
||||
variant="secondary"
|
||||
onClick={async () => {
|
||||
try {
|
||||
if (type === "slack") {
|
||||
await testSlackConnection({
|
||||
webhookUrl: form.getValues("webhookUrl"),
|
||||
channel: form.getValues("channel"),
|
||||
});
|
||||
} else if (type === "telegram") {
|
||||
await testTelegramConnection({
|
||||
botToken: form.getValues("botToken"),
|
||||
chatId: form.getValues("chatId"),
|
||||
});
|
||||
} else if (type === "discord") {
|
||||
await testDiscordConnection({
|
||||
webhookUrl: form.getValues("webhookUrl"),
|
||||
});
|
||||
} else if (type === "email") {
|
||||
await testEmailConnection({
|
||||
smtpServer: form.getValues("smtpServer"),
|
||||
smtpPort: form.getValues("smtpPort"),
|
||||
username: form.getValues("username"),
|
||||
password: form.getValues("password"),
|
||||
toAddresses: form.getValues("toAddresses"),
|
||||
fromAddress: form.getValues("fromAddress"),
|
||||
});
|
||||
}
|
||||
toast.success("Connection Success");
|
||||
} catch (err) {
|
||||
toast.error("Error to test the provider");
|
||||
}
|
||||
}}
|
||||
>
|
||||
Test Notification
|
||||
</Button>
|
||||
<Button
|
||||
isLoading={form.formState.isSubmitting}
|
||||
form="hook-form"
|
||||
type="submit"
|
||||
>
|
||||
Update
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,200 @@
|
||||
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 { Textarea } from "@/components/ui/textarea";
|
||||
import { sshKeyCreate, type sshKeyType } from "@/server/db/validations";
|
||||
import { api } from "@/utils/api";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { type ReactNode, useState } from "react";
|
||||
import { flushSync } from "react-dom";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import type { z } from "zod";
|
||||
|
||||
type SSHKey = z.infer<typeof sshKeyCreate>;
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export const AddSSHKey = ({ children }: Props) => {
|
||||
const utils = api.useUtils();
|
||||
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
const { mutateAsync, isError, error, isLoading } =
|
||||
api.sshKey.create.useMutation();
|
||||
|
||||
const generateMutation = api.sshKey.generate.useMutation();
|
||||
|
||||
const form = useForm<SSHKey>({
|
||||
resolver: zodResolver(sshKeyCreate),
|
||||
});
|
||||
|
||||
const onSubmit = async (data: SSHKey) => {
|
||||
await mutateAsync(data)
|
||||
.then(async () => {
|
||||
toast.success("SSH key created successfully");
|
||||
await utils.sshKey.all.invalidate();
|
||||
/*
|
||||
Flushsync is needed for a bug witht he react-hook-form reset method
|
||||
https://github.com/orgs/react-hook-form/discussions/7589#discussioncomment-10060621
|
||||
*/
|
||||
flushSync(() => form.reset());
|
||||
setIsOpen(false);
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error to create the SSH key");
|
||||
});
|
||||
};
|
||||
|
||||
const onGenerateSSHKey = (type: z.infer<typeof sshKeyType>) =>
|
||||
generateMutation
|
||||
.mutateAsync(type)
|
||||
.then(async (data) => {
|
||||
toast.success("SSH Key Generated");
|
||||
form.setValue("privateKey", data.privateKey);
|
||||
form.setValue("publicKey", data.publicKey);
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error to generate the SSH Key");
|
||||
});
|
||||
|
||||
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>SSH Key</DialogTitle>
|
||||
<DialogDescription className="space-y-4">
|
||||
<div>
|
||||
In this section you can add one of your keys or generate a new
|
||||
one.
|
||||
</div>
|
||||
<div className="flex gap-4">
|
||||
<Button
|
||||
variant={"secondary"}
|
||||
disabled={generateMutation.isLoading}
|
||||
className="max-sm:w-full"
|
||||
onClick={() => onGenerateSSHKey("rsa")}
|
||||
type="button"
|
||||
>
|
||||
Generate RSA SSH Key
|
||||
</Button>
|
||||
<Button
|
||||
variant={"secondary"}
|
||||
disabled={generateMutation.isLoading}
|
||||
className="max-sm:w-full"
|
||||
onClick={() => onGenerateSSHKey("ed25519")}
|
||||
type="button"
|
||||
>
|
||||
Generate ED25519 SSH Key
|
||||
</Button>
|
||||
</div>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
|
||||
|
||||
<Form {...form}>
|
||||
<form
|
||||
className="grid w-full gap-4 "
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<FormItem>
|
||||
<FormLabel>Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder={"Personal projects"} {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="description"
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<FormItem>
|
||||
<FormLabel>Description</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder={"Used on my personal Hetzner VPS"}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="privateKey"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<div className="space-y-0.5">
|
||||
<FormLabel>Private Key</FormLabel>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder={"-----BEGIN RSA PRIVATE KEY-----"}
|
||||
rows={5}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="publicKey"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<div className="space-y-0.5">
|
||||
<FormLabel>Public Key</FormLabel>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Input placeholder={"ssh-rsa AAAAB3NzaC1yc2E"} {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<DialogFooter>
|
||||
<Button isLoading={isLoading} type="submit">
|
||||
Create
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -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 {
|
||||
sshKeyId: string;
|
||||
}
|
||||
export const DeleteSSHKey = ({ sshKeyId }: Props) => {
|
||||
const { mutateAsync, isLoading } = api.sshKey.remove.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 SSH
|
||||
key
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={async () => {
|
||||
await mutateAsync({
|
||||
sshKeyId,
|
||||
})
|
||||
.then(() => {
|
||||
utils.sshKey.all.invalidate();
|
||||
toast.success("SSH Key delete successfully");
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error to delete SSH key");
|
||||
});
|
||||
}}
|
||||
>
|
||||
Confirm
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,96 @@
|
||||
import { UpdateSSHKey } from "@/components/dashboard/settings/ssh-keys/update-ssh-key";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { api } from "@/utils/api";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import { KeyRound, KeyRoundIcon, PenBoxIcon } from "lucide-react";
|
||||
import { AddSSHKey } from "./add-ssh-key";
|
||||
import { DeleteSSHKey } from "./delete-ssh-key";
|
||||
|
||||
export const ShowDestinations = () => {
|
||||
const { data } = api.sshKey.all.useQuery();
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<Card className="h-full bg-transparent">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-xl">SSH Keys</CardTitle>
|
||||
<CardDescription>
|
||||
Use SSH to beeing able cloning from private repositories.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2 pt-4">
|
||||
{data?.length === 0 ? (
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<KeyRound className="size-8 self-center text-muted-foreground" />
|
||||
<span className="text-base text-muted-foreground">
|
||||
Add your first SSH Key
|
||||
</span>
|
||||
<AddSSHKey>
|
||||
<Button>
|
||||
<KeyRoundIcon className="size-4" /> Add SSH Key
|
||||
</Button>
|
||||
</AddSSHKey>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-8">
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex gap-4 text-xs px-3.5">
|
||||
<div className="col-span-2 basis-4/12">Key</div>
|
||||
<div className="basis-3/12">Added</div>
|
||||
<div>Last Used</div>
|
||||
</div>
|
||||
{data?.map((sshKey) => (
|
||||
<div
|
||||
key={sshKey.sshKeyId}
|
||||
className="flex gap-4 items-center border p-3.5 rounded-lg text-sm"
|
||||
>
|
||||
<div className="flex flex-col basis-4/12">
|
||||
<span>{sshKey.name}</span>
|
||||
{sshKey.description && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{sshKey.description}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="basis-3/12">
|
||||
{formatDistanceToNow(new Date(sshKey.createdAt), {
|
||||
addSuffix: true,
|
||||
})}
|
||||
</div>
|
||||
<div className="grow">
|
||||
{sshKey.lastUsedAt
|
||||
? formatDistanceToNow(new Date(sshKey.lastUsedAt), {
|
||||
addSuffix: true,
|
||||
})
|
||||
: "Never"}
|
||||
</div>
|
||||
<div className="flex flex-row gap-1">
|
||||
<UpdateSSHKey sshKeyId={sshKey.sshKeyId}>
|
||||
<Button variant="ghost">
|
||||
<PenBoxIcon className="size-4 text-muted-foreground" />
|
||||
</Button>
|
||||
</UpdateSSHKey>
|
||||
<DeleteSSHKey sshKeyId={sshKey.sshKeyId} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<AddSSHKey>
|
||||
<Button>
|
||||
<KeyRoundIcon className="size-4" /> Add SSH Key
|
||||
</Button>
|
||||
</AddSSHKey>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,168 @@
|
||||
import { DeleteSSHKey } from "@/components/dashboard/settings/ssh-keys/delete-ssh-key";
|
||||
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 { Textarea } from "@/components/ui/textarea";
|
||||
import { sshKeyUpdate } from "@/server/db/validations";
|
||||
import { api } from "@/utils/api";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import copy from "copy-to-clipboard";
|
||||
import { CopyIcon } from "lucide-react";
|
||||
import { type ReactNode, useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import type { z } from "zod";
|
||||
|
||||
type SSHKey = z.infer<typeof sshKeyUpdate>;
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
sshKeyId?: string;
|
||||
}
|
||||
|
||||
export const UpdateSSHKey = ({ children, sshKeyId = "" }: Props) => {
|
||||
const utils = api.useUtils();
|
||||
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const { data } = api.sshKey.one.useQuery({
|
||||
sshKeyId,
|
||||
});
|
||||
|
||||
const { mutateAsync, isError, error, isLoading } =
|
||||
api.sshKey.update.useMutation();
|
||||
|
||||
const form = useForm<SSHKey>({
|
||||
resolver: zodResolver(sshKeyUpdate),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
form.reset({
|
||||
...data,
|
||||
/* Convert null to undefined */
|
||||
description: data.description || undefined,
|
||||
});
|
||||
}
|
||||
}, [data]);
|
||||
|
||||
const onSubmit = async (data: SSHKey) => {
|
||||
await mutateAsync({
|
||||
sshKeyId,
|
||||
...data,
|
||||
})
|
||||
.then(async () => {
|
||||
toast.success("SSH Key Updated");
|
||||
await utils.sshKey.all.invalidate();
|
||||
setIsOpen(false);
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error to update the SSH key");
|
||||
});
|
||||
};
|
||||
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>SSH Key</DialogTitle>
|
||||
<DialogDescription>
|
||||
In this section you can edit an SSH key
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
|
||||
|
||||
<Form {...form}>
|
||||
<form
|
||||
className="grid w-full gap-4 "
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<FormItem>
|
||||
<FormLabel>Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder={"Personal projects"} {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="description"
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<FormItem>
|
||||
<FormLabel>Description</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder={"Used on my personal Hetzner VPS"}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
|
||||
<FormItem>
|
||||
<FormLabel>Public Key</FormLabel>
|
||||
<FormControl>
|
||||
<div className="relative">
|
||||
<Textarea
|
||||
rows={7}
|
||||
readOnly
|
||||
disabled
|
||||
value={data?.publicKey}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute right-2 top-2"
|
||||
onClick={() => {
|
||||
copy(data?.publicKey || "Generate a SSH Key");
|
||||
toast.success("SSH Copied to clipboard");
|
||||
}}
|
||||
>
|
||||
<CopyIcon className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
|
||||
<DialogFooter>
|
||||
<Button isLoading={isLoading} type="submit">
|
||||
Update
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
} from "@/components/ui/card";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import React from "react";
|
||||
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -37,6 +37,9 @@ export const WebServer = () => {
|
||||
api.settings.reloadTraefik.useMutation();
|
||||
const { mutateAsync: cleanAll, isLoading: cleanAllIsLoading } =
|
||||
api.settings.cleanAll.useMutation();
|
||||
const { mutateAsync: toggleDashboard, isLoading: toggleDashboardIsLoading } =
|
||||
api.settings.toggleDashboard.useMutation();
|
||||
|
||||
const {
|
||||
mutateAsync: cleanDockerBuilder,
|
||||
isLoading: cleanDockerBuilderIsLoading,
|
||||
@@ -107,6 +110,7 @@ export const WebServer = () => {
|
||||
<span>View Traefik config</span>
|
||||
</DropdownMenuItem>
|
||||
</ShowServerTraefikConfig>
|
||||
|
||||
<ShowServerMiddlewareConfig>
|
||||
<DropdownMenuItem
|
||||
onSelect={(e) => e.preventDefault()}
|
||||
@@ -124,8 +128,14 @@ export const WebServer = () => {
|
||||
</DropdownMenu>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild disabled={reloadTraefikIsLoading}>
|
||||
<Button isLoading={reloadTraefikIsLoading} variant="outline">
|
||||
<DropdownMenuTrigger
|
||||
asChild
|
||||
disabled={reloadTraefikIsLoading || toggleDashboardIsLoading}
|
||||
>
|
||||
<Button
|
||||
isLoading={reloadTraefikIsLoading || toggleDashboardIsLoading}
|
||||
variant="outline"
|
||||
>
|
||||
Traefik
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
@@ -157,6 +167,38 @@ export const WebServer = () => {
|
||||
<span>View Traefik config</span>
|
||||
</DropdownMenuItem>
|
||||
</ShowMainTraefikConfig>
|
||||
<DropdownMenuItem
|
||||
onClick={async () => {
|
||||
await toggleDashboard({
|
||||
enableDashboard: true,
|
||||
})
|
||||
.then(async () => {
|
||||
toast.success("Dashboard Enabled");
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error to enable Dashboard");
|
||||
});
|
||||
}}
|
||||
className="w-full cursor-pointer space-x-3"
|
||||
>
|
||||
<span>Enable Dashboard</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={async () => {
|
||||
await toggleDashboard({
|
||||
enableDashboard: false,
|
||||
})
|
||||
.then(async () => {
|
||||
toast.success("Dashboard Disabled");
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error to disable Dashboard");
|
||||
});
|
||||
}}
|
||||
className="w-full cursor-pointer space-x-3"
|
||||
>
|
||||
<span>Disable Dashboard</span>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DockerTerminalModal appName="dokploy-traefik">
|
||||
<DropdownMenuItem
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
}
|
||||
export const SlackIcon = ({ className }: Props) => {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 2447.6 2452.5"
|
||||
className={cn("size-8", className)}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<g clipRule="evenodd" fillRule="evenodd">
|
||||
<path
|
||||
d="m897.4 0c-135.3.1-244.8 109.9-244.7 245.2-.1 135.3 109.5 245.1 244.8 245.2h244.8v-245.1c.1-135.3-109.5-245.1-244.9-245.3.1 0 .1 0 0 0m0 654h-652.6c-135.3.1-244.9 109.9-244.8 245.2-.2 135.3 109.4 245.1 244.7 245.3h652.7c135.3-.1 244.9-109.9 244.8-245.2.1-135.4-109.5-245.2-244.8-245.3z"
|
||||
fill="#36c5f0"
|
||||
/>
|
||||
<path
|
||||
d="m2447.6 899.2c.1-135.3-109.5-245.1-244.8-245.2-135.3.1-244.9 109.9-244.8 245.2v245.3h244.8c135.3-.1 244.9-109.9 244.8-245.3zm-652.7 0v-654c.1-135.2-109.4-245-244.7-245.2-135.3.1-244.9 109.9-244.8 245.2v654c-.2 135.3 109.4 245.1 244.7 245.3 135.3-.1 244.9-109.9 244.8-245.3z"
|
||||
fill="#2eb67d"
|
||||
/>
|
||||
<path
|
||||
d="m1550.1 2452.5c135.3-.1 244.9-109.9 244.8-245.2.1-135.3-109.5-245.1-244.8-245.2h-244.8v245.2c-.1 135.2 109.5 245 244.8 245.2zm0-654.1h652.7c135.3-.1 244.9-109.9 244.8-245.2.2-135.3-109.4-245.1-244.7-245.3h-652.7c-135.3.1-244.9 109.9-244.8 245.2-.1 135.4 109.4 245.2 244.7 245.3z"
|
||||
fill="#ecb22e"
|
||||
/>
|
||||
<path
|
||||
d="m0 1553.2c-.1 135.3 109.5 245.1 244.8 245.2 135.3-.1 244.9-109.9 244.8-245.2v-245.2h-244.8c-135.3.1-244.9 109.9-244.8 245.2zm652.7 0v654c-.2 135.3 109.4 245.1 244.7 245.3 135.3-.1 244.9-109.9 244.8-245.2v-653.9c.2-135.3-109.4-245.1-244.7-245.3-135.4 0-244.9 109.8-244.8 245.1 0 0 0 .1 0 0"
|
||||
fill="#e01e5a"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
export const TelegramIcon = ({ className }: Props) => {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 48 48"
|
||||
width="48px"
|
||||
height="48px"
|
||||
className={cn("size-9", className)}
|
||||
>
|
||||
<linearGradient
|
||||
id="BiF7D16UlC0RZ_VqXJHnXa"
|
||||
x1="9.858"
|
||||
x2="38.142"
|
||||
y1="9.858"
|
||||
y2="38.142"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop offset="0" stopColor="#33bef0" />
|
||||
<stop offset="1" stopColor="#0a85d9" />
|
||||
</linearGradient>
|
||||
<path
|
||||
fill="url(#BiF7D16UlC0RZ_VqXJHnXa)"
|
||||
d="M44,24c0,11.045-8.955,20-20,20S4,35.045,4,24S12.955,4,24,4S44,12.955,44,24z"
|
||||
/>
|
||||
<path
|
||||
d="M10.119,23.466c8.155-3.695,17.733-7.704,19.208-8.284c3.252-1.279,4.67,0.028,4.448,2.113 c-0.273,2.555-1.567,9.99-2.363,15.317c-0.466,3.117-2.154,4.072-4.059,2.863c-1.445-0.917-6.413-4.17-7.72-5.282 c-0.891-0.758-1.512-1.608-0.88-2.474c0.185-0.253,0.658-0.763,0.921-1.017c1.319-1.278,1.141-1.553-0.454-0.412 c-0.19,0.136-1.292,0.935-1.745,1.237c-1.11,0.74-2.131,0.78-3.862,0.192c-1.416-0.481-2.776-0.852-3.634-1.223 C8.794,25.983,8.34,24.272,10.119,23.466z"
|
||||
opacity=".05"
|
||||
/>
|
||||
<path
|
||||
d="M10.836,23.591c7.572-3.385,16.884-7.264,18.246-7.813c3.264-1.318,4.465-0.536,4.114,2.011 c-0.326,2.358-1.483,9.654-2.294,14.545c-0.478,2.879-1.874,3.513-3.692,2.337c-1.139-0.734-5.723-3.754-6.835-4.633 c-0.86-0.679-1.751-1.463-0.71-2.598c0.348-0.379,2.27-2.234,3.707-3.614c0.833-0.801,0.536-1.196-0.469-0.508 c-1.843,1.263-4.858,3.262-5.396,3.625c-1.025,0.69-1.988,0.856-3.664,0.329c-1.321-0.416-2.597-0.819-3.262-1.078 C9.095,25.618,9.075,24.378,10.836,23.591z"
|
||||
opacity=".07"
|
||||
/>
|
||||
<path
|
||||
fill="#fff"
|
||||
d="M11.553,23.717c6.99-3.075,16.035-6.824,17.284-7.343c3.275-1.358,4.28-1.098,3.779,1.91 c-0.36,2.162-1.398,9.319-2.226,13.774c-0.491,2.642-1.593,2.955-3.325,1.812c-0.833-0.55-5.038-3.331-5.951-3.984 c-0.833-0.595-1.982-1.311-0.541-2.721c0.513-0.502,3.874-3.712,6.493-6.21c0.343-0.328-0.088-0.867-0.484-0.604 c-3.53,2.341-8.424,5.59-9.047,6.013c-0.941,0.639-1.845,0.932-3.467,0.466c-1.226-0.352-2.423-0.772-2.889-0.932 C9.384,25.282,9.81,24.484,11.553,23.717z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
export const DiscordIcon = ({ className }: Props) => {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 48 48"
|
||||
width="48px"
|
||||
height="48px"
|
||||
className={cn("size-9", className)}
|
||||
>
|
||||
<path
|
||||
fill="#536dfe"
|
||||
d="M39.248,10.177c-2.804-1.287-5.812-2.235-8.956-2.778c-0.057-0.01-0.114,0.016-0.144,0.068 c-0.387,0.688-0.815,1.585-1.115,2.291c-3.382-0.506-6.747-0.506-10.059,0c-0.3-0.721-0.744-1.603-1.133-2.291 c-0.03-0.051-0.087-0.077-0.144-0.068c-3.143,0.541-6.15,1.489-8.956,2.778c-0.024,0.01-0.045,0.028-0.059,0.051 c-5.704,8.522-7.267,16.835-6.5,25.044c0.003,0.04,0.026,0.079,0.057,0.103c3.763,2.764,7.409,4.442,10.987,5.554 c0.057,0.017,0.118-0.003,0.154-0.051c0.846-1.156,1.601-2.374,2.248-3.656c0.038-0.075,0.002-0.164-0.076-0.194 c-1.197-0.454-2.336-1.007-3.432-1.636c-0.087-0.051-0.094-0.175-0.014-0.234c0.231-0.173,0.461-0.353,0.682-0.534 c0.04-0.033,0.095-0.04,0.142-0.019c7.201,3.288,14.997,3.288,22.113,0c0.047-0.023,0.102-0.016,0.144,0.017 c0.22,0.182,0.451,0.363,0.683,0.536c0.08,0.059,0.075,0.183-0.012,0.234c-1.096,0.641-2.236,1.182-3.434,1.634 c-0.078,0.03-0.113,0.12-0.075,0.196c0.661,1.28,1.415,2.498,2.246,3.654c0.035,0.049,0.097,0.07,0.154,0.052 c3.595-1.112,7.241-2.79,11.004-5.554c0.033-0.024,0.054-0.061,0.057-0.101c0.917-9.491-1.537-17.735-6.505-25.044 C39.293,10.205,39.272,10.187,39.248,10.177z M16.703,30.273c-2.168,0-3.954-1.99-3.954-4.435s1.752-4.435,3.954-4.435 c2.22,0,3.989,2.008,3.954,4.435C20.658,28.282,18.906,30.273,16.703,30.273z M31.324,30.273c-2.168,0-3.954-1.99-3.954-4.435 s1.752-4.435,3.954-4.435c2.22,0,3.989,2.008,3.954,4.435C35.278,28.282,33.544,30.273,31.324,30.273z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
@@ -53,6 +53,12 @@ export const SettingsLayout = ({ children }: Props) => {
|
||||
icon: ShieldCheck,
|
||||
href: "/dashboard/settings/certificates",
|
||||
},
|
||||
{
|
||||
title: "SSH Keys",
|
||||
label: "",
|
||||
icon: KeyRound,
|
||||
href: "/dashboard/settings/ssh-keys",
|
||||
},
|
||||
{
|
||||
title: "Users",
|
||||
label: "",
|
||||
@@ -65,6 +71,12 @@ export const SettingsLayout = ({ children }: Props) => {
|
||||
icon: Server,
|
||||
href: "/dashboard/settings/cluster",
|
||||
},
|
||||
{
|
||||
title: "Notifications",
|
||||
label: "",
|
||||
icon: Bell,
|
||||
href: "/dashboard/settings/notifications",
|
||||
},
|
||||
]
|
||||
: []),
|
||||
]}
|
||||
@@ -78,7 +90,9 @@ export const SettingsLayout = ({ children }: Props) => {
|
||||
|
||||
import {
|
||||
Activity,
|
||||
Bell,
|
||||
Database,
|
||||
KeyRound,
|
||||
type LucideIcon,
|
||||
Route,
|
||||
Server,
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { FolderIcon } from "lucide-react";
|
||||
import React, { type ChangeEvent, useRef } from "react";
|
||||
|
||||
interface DropzoneProps
|
||||
extends Omit<
|
||||
React.InputHTMLAttributes<HTMLInputElement>,
|
||||
"value" | "onChange"
|
||||
> {
|
||||
classNameWrapper?: string;
|
||||
className?: string;
|
||||
dropMessage: string;
|
||||
onChange: (acceptedFiles: FileList | null) => void;
|
||||
}
|
||||
|
||||
export const Dropzone = React.forwardRef<HTMLDivElement, DropzoneProps>(
|
||||
({ className, classNameWrapper, dropMessage, onChange, ...props }, ref) => {
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
// Function to handle drag over event
|
||||
const handleDragOver = (e: React.DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onChange(null);
|
||||
};
|
||||
|
||||
// Function to handle drop event
|
||||
const handleDrop = (e: React.DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const { files } = e.dataTransfer;
|
||||
if (inputRef.current) {
|
||||
inputRef.current.files = files;
|
||||
onChange(files);
|
||||
}
|
||||
};
|
||||
|
||||
// Function to simulate a click on the file input element
|
||||
const handleButtonClick = () => {
|
||||
if (inputRef.current) {
|
||||
inputRef.current.click();
|
||||
}
|
||||
};
|
||||
return (
|
||||
<Card
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"border-2 border-dashed bg-muted/20 hover:cursor-pointer hover:border-muted-foreground/50 ",
|
||||
classNameWrapper,
|
||||
)}
|
||||
>
|
||||
<CardContent
|
||||
className="flex flex-col items-center justify-center space-y-2 px-2 py-4 text-xs h-96"
|
||||
onDragOver={handleDragOver}
|
||||
onDrop={handleDrop}
|
||||
onClick={handleButtonClick}
|
||||
>
|
||||
<div className="flex items-center justify-center text-muted-foreground">
|
||||
<span className="font-medium text-xl flex items-center gap-2">
|
||||
<FolderIcon className="size-6 text-muted-foreground" />
|
||||
{dropMessage}
|
||||
</span>
|
||||
<Input
|
||||
{...props}
|
||||
value={undefined}
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
className={cn("hidden", className)}
|
||||
onChange={(e: ChangeEvent<HTMLInputElement>) =>
|
||||
onChange(e.target.files)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,78 @@
|
||||
import { CodeEditor } from "@/components/shared/code-editor";
|
||||
import {
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import {
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Toggle } from "@/components/ui/toggle";
|
||||
import { EyeIcon, EyeOffIcon } from "lucide-react";
|
||||
import { type CSSProperties, type ReactNode, useState } from "react";
|
||||
import { useFormContext } from "react-hook-form";
|
||||
|
||||
interface Props {
|
||||
name: string;
|
||||
title: string;
|
||||
description: ReactNode;
|
||||
placeholder: string;
|
||||
}
|
||||
|
||||
export const Secrets = (props: Props) => {
|
||||
const [isVisible, setIsVisible] = useState(true);
|
||||
const form = useFormContext<Record<string, string>>();
|
||||
|
||||
return (
|
||||
<>
|
||||
<CardHeader className="flex flex-row w-full items-center justify-between">
|
||||
<div>
|
||||
<CardTitle className="text-xl">{props.title}</CardTitle>
|
||||
<CardDescription>{props.description}</CardDescription>
|
||||
</div>
|
||||
|
||||
<Toggle
|
||||
aria-label="Toggle bold"
|
||||
pressed={isVisible}
|
||||
onPressedChange={setIsVisible}
|
||||
>
|
||||
{isVisible ? (
|
||||
<EyeOffIcon className="h-4 w-4 text-muted-foreground" />
|
||||
) : (
|
||||
<EyeIcon className="h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
</Toggle>
|
||||
</CardHeader>
|
||||
<CardContent className="w-full space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={props.name}
|
||||
render={({ field }) => (
|
||||
<FormItem className="w-full">
|
||||
<FormControl>
|
||||
<CodeEditor
|
||||
style={
|
||||
{
|
||||
WebkitTextSecurity: isVisible ? "disc" : null,
|
||||
} as CSSProperties
|
||||
}
|
||||
language="properties"
|
||||
disabled={isVisible}
|
||||
placeholder={props.placeholder}
|
||||
className="h-96 font-mono"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</CardContent>
|
||||
</>
|
||||
);
|
||||
};
|
||||
+1
-1
@@ -57,7 +57,7 @@ echo "Network created"
|
||||
|
||||
mkdir -p /etc/dokploy
|
||||
|
||||
chmod -R 777 /etc/dokploy
|
||||
chmod 777 /etc/dokploy
|
||||
|
||||
docker pull dokploy/dokploy:canary
|
||||
|
||||
|
||||
+33
-11
@@ -30,11 +30,6 @@ if ss -tulnp | grep ':443 ' >/dev/null; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
command_exists() {
|
||||
command -v "$@" > /dev/null 2>&1
|
||||
}
|
||||
@@ -46,7 +41,25 @@ else
|
||||
fi
|
||||
|
||||
docker swarm leave --force 2>/dev/null
|
||||
docker swarm init;
|
||||
|
||||
get_ip() {
|
||||
# Try to get IPv4
|
||||
local ipv4=$(curl -4s https://ifconfig.io 2>/dev/null)
|
||||
|
||||
if [ -n "$ipv4" ]; then
|
||||
echo "$ipv4"
|
||||
else
|
||||
# Try to get IPv6
|
||||
local ipv6=$(curl -6s https://ifconfig.io 2>/dev/null)
|
||||
if [ -n "$ipv6" ]; then
|
||||
echo "$ipv6"
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
advertise_addr=$(get_ip)
|
||||
|
||||
docker swarm init --advertise-addr $advertise_addr
|
||||
|
||||
echo "Swarm initialized"
|
||||
|
||||
@@ -57,7 +70,7 @@ echo "Network created"
|
||||
|
||||
mkdir -p /etc/dokploy
|
||||
|
||||
chmod -R 777 /etc/dokploy
|
||||
chmod 777 /etc/dokploy
|
||||
|
||||
docker pull dokploy/dokploy:latest
|
||||
|
||||
@@ -71,19 +84,28 @@ docker service create \
|
||||
--publish published=3000,target=3000,mode=host \
|
||||
--update-parallelism 1 \
|
||||
--update-order stop-first \
|
||||
--constraint 'node.role == manager' \
|
||||
dokploy/dokploy:latest
|
||||
|
||||
|
||||
public_ip=$(hostname -I | awk '{print $1}')
|
||||
|
||||
GREEN="\033[0;32m"
|
||||
YELLOW="\033[1;33m"
|
||||
BLUE="\033[0;34m"
|
||||
NC="\033[0m" # No Color
|
||||
|
||||
format_ip_for_url() {
|
||||
local ip="$1"
|
||||
if echo "$ip" | grep -q ':'; then
|
||||
# IPv6
|
||||
echo "[${ip}]"
|
||||
else
|
||||
# IPv4
|
||||
echo "${ip}"
|
||||
fi
|
||||
}
|
||||
|
||||
formatted_addr=$(format_ip_for_url "$advertise_addr")
|
||||
echo ""
|
||||
printf "${GREEN}Congratulations, Dokploy is installed!${NC}\n"
|
||||
printf "${BLUE}Wait 15 seconds for the server to start${NC}\n"
|
||||
printf "${YELLOW}Please go to http://${public_ip}:3000${NC}\n\n"
|
||||
printf "${YELLOW}Please go to http://${formatted_addr}:3000${NC}\n\n"
|
||||
echo ""
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
DO $$ BEGIN
|
||||
CREATE TYPE "public"."notificationType" AS ENUM('slack', 'telegram', 'discord', 'email');
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE IF NOT EXISTS "discord" (
|
||||
"discordId" text PRIMARY KEY NOT NULL,
|
||||
"webhookUrl" text NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE IF NOT EXISTS "email" (
|
||||
"emailId" text PRIMARY KEY NOT NULL,
|
||||
"smtpServer" text NOT NULL,
|
||||
"smtpPort" integer NOT NULL,
|
||||
"username" text NOT NULL,
|
||||
"password" text NOT NULL,
|
||||
"fromAddress" text NOT NULL,
|
||||
"toAddress" text[] NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE IF NOT EXISTS "notification" (
|
||||
"notificationId" text PRIMARY KEY NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"appDeploy" boolean DEFAULT false NOT NULL,
|
||||
"userJoin" boolean DEFAULT false NOT NULL,
|
||||
"appBuildError" boolean DEFAULT false NOT NULL,
|
||||
"databaseBackup" boolean DEFAULT false NOT NULL,
|
||||
"dokployRestart" boolean DEFAULT false NOT NULL,
|
||||
"notificationType" "notificationType" NOT NULL,
|
||||
"createdAt" text NOT NULL,
|
||||
"slackId" text,
|
||||
"telegramId" text,
|
||||
"discordId" text,
|
||||
"emailId" text
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE IF NOT EXISTS "slack" (
|
||||
"slackId" text PRIMARY KEY NOT NULL,
|
||||
"webhookUrl" text NOT NULL,
|
||||
"channel" text
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE IF NOT EXISTS "telegram" (
|
||||
"telegramId" text PRIMARY KEY NOT NULL,
|
||||
"botToken" text NOT NULL,
|
||||
"chatId" text NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "notification" ADD CONSTRAINT "notification_slackId_slack_slackId_fk" FOREIGN KEY ("slackId") REFERENCES "public"."slack"("slackId") ON DELETE cascade ON UPDATE no action;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "notification" ADD CONSTRAINT "notification_telegramId_telegram_telegramId_fk" FOREIGN KEY ("telegramId") REFERENCES "public"."telegram"("telegramId") ON DELETE cascade ON UPDATE no action;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "notification" ADD CONSTRAINT "notification_discordId_discord_discordId_fk" FOREIGN KEY ("discordId") REFERENCES "public"."discord"("discordId") ON DELETE cascade ON UPDATE no action;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "notification" ADD CONSTRAINT "notification_emailId_email_emailId_fk" FOREIGN KEY ("emailId") REFERENCES "public"."email"("emailId") ON DELETE cascade ON UPDATE no action;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE "notification" ADD COLUMN "dockerCleanup" boolean DEFAULT false NOT NULL;
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE "notification" DROP COLUMN IF EXISTS "userJoin";
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TYPE "sourceType" ADD VALUE 'drop';
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE "application" ADD COLUMN "dropBuildPath" text;
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE "mount" ADD COLUMN "filePath" text;
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE "application" ADD COLUMN "buildArgs" text;
|
||||
@@ -0,0 +1,25 @@
|
||||
CREATE TABLE IF NOT EXISTS "ssh-key" (
|
||||
"sshKeyId" text PRIMARY KEY NOT NULL,
|
||||
"publicKey" text NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"description" text,
|
||||
"createdAt" text NOT NULL,
|
||||
"lastUsedAt" text
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "application" ADD COLUMN "customGitSSHKeyId" text;--> statement-breakpoint
|
||||
ALTER TABLE "compose" ADD COLUMN "customGitSSHKeyId" text;--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "application" ADD CONSTRAINT "application_customGitSSHKeyId_ssh-key_sshKeyId_fk" FOREIGN KEY ("customGitSSHKeyId") REFERENCES "public"."ssh-key"("sshKeyId") ON DELETE set null ON UPDATE no action;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "compose" ADD CONSTRAINT "compose_customGitSSHKeyId_ssh-key_sshKeyId_fk" FOREIGN KEY ("customGitSSHKeyId") REFERENCES "public"."ssh-key"("sshKeyId") ON DELETE set null ON UPDATE no action;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "application" DROP COLUMN IF EXISTS "customGitSSHKey";--> statement-breakpoint
|
||||
ALTER TABLE "compose" DROP COLUMN IF EXISTS "customGitSSHKey";
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -134,6 +134,62 @@
|
||||
"when": 1719928377858,
|
||||
"tag": "0018_careful_killmonger",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 19,
|
||||
"version": "6",
|
||||
"when": 1721110706912,
|
||||
"tag": "0019_heavy_freak",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 20,
|
||||
"version": "6",
|
||||
"when": 1721363861686,
|
||||
"tag": "0020_fantastic_slapstick",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 21,
|
||||
"version": "6",
|
||||
"when": 1721370423752,
|
||||
"tag": "0021_premium_sebastian_shaw",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 22,
|
||||
"version": "6",
|
||||
"when": 1721531163852,
|
||||
"tag": "0022_warm_colonel_america",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 23,
|
||||
"version": "6",
|
||||
"when": 1721542782659,
|
||||
"tag": "0023_icy_maverick",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 24,
|
||||
"version": "6",
|
||||
"when": 1721603595092,
|
||||
"tag": "0024_dapper_supernaut",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 25,
|
||||
"version": "6",
|
||||
"when": 1721633853118,
|
||||
"tag": "0025_lying_mephisto",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 26,
|
||||
"version": "6",
|
||||
"when": 1721979220929,
|
||||
"tag": "0026_known_dormammu",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
/node_modules
|
||||
/dist
|
||||
@@ -0,0 +1,113 @@
|
||||
import {
|
||||
Body,
|
||||
Button,
|
||||
Container,
|
||||
Head,
|
||||
Heading,
|
||||
Html,
|
||||
Img,
|
||||
Link,
|
||||
Preview,
|
||||
Section,
|
||||
Tailwind,
|
||||
Text,
|
||||
} from "@react-email/components";
|
||||
import * as React from "react";
|
||||
|
||||
export type TemplateProps = {
|
||||
projectName: string;
|
||||
applicationName: string;
|
||||
applicationType: string;
|
||||
errorMessage: string;
|
||||
buildLink: string;
|
||||
date: string;
|
||||
};
|
||||
|
||||
export const BuildFailedEmail = ({
|
||||
projectName = "dokploy",
|
||||
applicationName = "frontend",
|
||||
applicationType = "application",
|
||||
errorMessage = "Error array.length is not a function",
|
||||
buildLink = "https://dokploy.com/projects/dokploy-test/applications/dokploy-test",
|
||||
date = "2023-05-01T00:00:00.000Z",
|
||||
}: TemplateProps) => {
|
||||
const previewText = `Build failed for ${applicationName}`;
|
||||
return (
|
||||
<Html>
|
||||
<Head />
|
||||
<Preview>{previewText}</Preview>
|
||||
<Tailwind
|
||||
config={{
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
brand: "#007291",
|
||||
},
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Body className="bg-white my-auto mx-auto font-sans px-2">
|
||||
<Container className="border border-solid border-[#eaeaea] rounded-lg my-[40px] mx-auto p-[20px] max-w-[465px]">
|
||||
<Section className="mt-[32px]">
|
||||
<Img
|
||||
src={
|
||||
"https://raw.githubusercontent.com/Dokploy/dokploy/canary/logo.png"
|
||||
}
|
||||
width="100"
|
||||
height="50"
|
||||
alt="Dokploy"
|
||||
className="my-0 mx-auto"
|
||||
/>
|
||||
</Section>
|
||||
<Heading className="text-black text-[24px] font-normal text-center p-0 my-[30px] mx-0">
|
||||
Build failed for <strong>{applicationName}</strong>
|
||||
</Heading>
|
||||
<Text className="text-black text-[14px] leading-[24px]">
|
||||
Hello,
|
||||
</Text>
|
||||
<Text className="text-black text-[14px] leading-[24px]">
|
||||
Your build for <strong>{applicationName}</strong> failed. Please
|
||||
check the error message below.
|
||||
</Text>
|
||||
<Section className="flex text-black text-[14px] leading-[24px] bg-[#F4F4F5] rounded-lg p-2">
|
||||
<Text className="!leading-3 font-bold">Details: </Text>
|
||||
<Text className="!leading-3">
|
||||
Project Name: <strong>{projectName}</strong>
|
||||
</Text>
|
||||
<Text className="!leading-3">
|
||||
Application Name: <strong>{applicationName}</strong>
|
||||
</Text>
|
||||
<Text className="!leading-3">
|
||||
Application Type: <strong>{applicationType}</strong>
|
||||
</Text>
|
||||
<Text className="!leading-3">
|
||||
Date: <strong>{date}</strong>
|
||||
</Text>
|
||||
</Section>
|
||||
<Section className="flex text-black text-[14px] mt-4 leading-[24px] bg-[#F4F4F5] rounded-lg p-2">
|
||||
<Text className="!leading-3 font-bold">Reason: </Text>
|
||||
<Text className="text-[12px] leading-[24px]">{errorMessage}</Text>
|
||||
</Section>
|
||||
<Section className="text-center mt-[32px] mb-[32px]">
|
||||
<Button
|
||||
href={buildLink}
|
||||
className="bg-[#000000] rounded text-white text-[12px] font-semibold no-underline text-center px-5 py-3"
|
||||
>
|
||||
View build
|
||||
</Button>
|
||||
</Section>
|
||||
<Text className="text-black text-[14px] leading-[24px]">
|
||||
or copy and paste this URL into your browser:{" "}
|
||||
<Link href={buildLink} className="text-blue-600 no-underline">
|
||||
{buildLink}
|
||||
</Link>
|
||||
</Text>
|
||||
</Container>
|
||||
</Body>
|
||||
</Tailwind>
|
||||
</Html>
|
||||
);
|
||||
};
|
||||
|
||||
export default BuildFailedEmail;
|
||||
@@ -0,0 +1,106 @@
|
||||
import {
|
||||
Body,
|
||||
Button,
|
||||
Container,
|
||||
Head,
|
||||
Heading,
|
||||
Html,
|
||||
Img,
|
||||
Link,
|
||||
Preview,
|
||||
Section,
|
||||
Tailwind,
|
||||
Text,
|
||||
} from "@react-email/components";
|
||||
import * as React from "react";
|
||||
|
||||
export type TemplateProps = {
|
||||
projectName: string;
|
||||
applicationName: string;
|
||||
applicationType: string;
|
||||
buildLink: string;
|
||||
date: string;
|
||||
};
|
||||
|
||||
export const BuildSuccessEmail = ({
|
||||
projectName = "dokploy",
|
||||
applicationName = "frontend",
|
||||
applicationType = "application",
|
||||
buildLink = "https://dokploy.com/projects/dokploy-test/applications/dokploy-test",
|
||||
date = "2023-05-01T00:00:00.000Z",
|
||||
}: TemplateProps) => {
|
||||
const previewText = `Build success for ${applicationName}`;
|
||||
return (
|
||||
<Html>
|
||||
<Head />
|
||||
<Preview>{previewText}</Preview>
|
||||
<Tailwind
|
||||
config={{
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
brand: "#007291",
|
||||
},
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Body className="bg-white my-auto mx-auto font-sans px-2">
|
||||
<Container className="border border-solid border-[#eaeaea] rounded-lg my-[40px] mx-auto p-[20px] max-w-[465px]">
|
||||
<Section className="mt-[32px]">
|
||||
<Img
|
||||
src={
|
||||
"https://raw.githubusercontent.com/Dokploy/dokploy/canary/logo.png"
|
||||
}
|
||||
width="100"
|
||||
height="50"
|
||||
alt="Dokploy"
|
||||
className="my-0 mx-auto"
|
||||
/>
|
||||
</Section>
|
||||
<Heading className="text-black text-[24px] font-normal text-center p-0 my-[30px] mx-0">
|
||||
Build success for <strong>{applicationName}</strong>
|
||||
</Heading>
|
||||
<Text className="text-black text-[14px] leading-[24px]">
|
||||
Hello,
|
||||
</Text>
|
||||
<Text className="text-black text-[14px] leading-[24px]">
|
||||
Your build for <strong>{applicationName}</strong> was successful
|
||||
</Text>
|
||||
<Section className="flex text-black text-[14px] leading-[24px] bg-[#F4F4F5] rounded-lg p-2">
|
||||
<Text className="!leading-3 font-bold">Details: </Text>
|
||||
<Text className="!leading-3">
|
||||
Project Name: <strong>{projectName}</strong>
|
||||
</Text>
|
||||
<Text className="!leading-3">
|
||||
Application Name: <strong>{applicationName}</strong>
|
||||
</Text>
|
||||
<Text className="!leading-3">
|
||||
Application Type: <strong>{applicationType}</strong>
|
||||
</Text>
|
||||
<Text className="!leading-3">
|
||||
Date: <strong>{date}</strong>
|
||||
</Text>
|
||||
</Section>
|
||||
<Section className="text-center mt-[32px] mb-[32px]">
|
||||
<Button
|
||||
href={buildLink}
|
||||
className="bg-[#000000] rounded text-white text-[12px] font-semibold no-underline text-center px-5 py-3"
|
||||
>
|
||||
View build
|
||||
</Button>
|
||||
</Section>
|
||||
<Text className="text-black text-[14px] leading-[24px]">
|
||||
or copy and paste this URL into your browser:{" "}
|
||||
<Link href={buildLink} className="text-blue-600 no-underline">
|
||||
{buildLink}
|
||||
</Link>
|
||||
</Text>
|
||||
</Container>
|
||||
</Body>
|
||||
</Tailwind>
|
||||
</Html>
|
||||
);
|
||||
};
|
||||
|
||||
export default BuildSuccessEmail;
|
||||
@@ -0,0 +1,105 @@
|
||||
import {
|
||||
Body,
|
||||
Container,
|
||||
Head,
|
||||
Heading,
|
||||
Html,
|
||||
Img,
|
||||
Preview,
|
||||
Section,
|
||||
Tailwind,
|
||||
Text,
|
||||
} from "@react-email/components";
|
||||
import * as React from "react";
|
||||
|
||||
export type TemplateProps = {
|
||||
projectName: string;
|
||||
applicationName: string;
|
||||
databaseType: "postgres" | "mysql" | "mongodb" | "mariadb";
|
||||
type: "error" | "success";
|
||||
errorMessage?: string;
|
||||
date: string;
|
||||
};
|
||||
|
||||
export const DatabaseBackupEmail = ({
|
||||
projectName = "dokploy",
|
||||
applicationName = "frontend",
|
||||
databaseType = "postgres",
|
||||
type = "success",
|
||||
errorMessage,
|
||||
date = "2023-05-01T00:00:00.000Z",
|
||||
}: TemplateProps) => {
|
||||
const previewText = `Database backup for ${applicationName} was ${type === "success" ? "successful ✅" : "failed ❌"}`;
|
||||
return (
|
||||
<Html>
|
||||
<Preview>{previewText}</Preview>
|
||||
<Tailwind
|
||||
config={{
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
brand: "#007291",
|
||||
},
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Head />
|
||||
|
||||
<Body className="bg-white my-auto mx-auto font-sans px-2">
|
||||
<Container className="border border-solid border-[#eaeaea] rounded-lg my-[40px] mx-auto p-[20px] max-w-[465px]">
|
||||
<Section className="mt-[32px]">
|
||||
<Img
|
||||
src={
|
||||
"https://raw.githubusercontent.com/Dokploy/dokploy/canary/logo.png"
|
||||
}
|
||||
width="100"
|
||||
height="50"
|
||||
alt="Dokploy"
|
||||
className="my-0 mx-auto"
|
||||
/>
|
||||
</Section>
|
||||
<Heading className="text-black text-[24px] font-normal text-center p-0 my-[30px] mx-0">
|
||||
Database backup for <strong>{applicationName}</strong>
|
||||
</Heading>
|
||||
<Text className="text-black text-[14px] leading-[24px]">
|
||||
Hello,
|
||||
</Text>
|
||||
<Text className="text-black text-[14px] leading-[24px]">
|
||||
Your database backup for <strong>{applicationName}</strong> was{" "}
|
||||
{type === "success"
|
||||
? "successful ✅"
|
||||
: "failed Please check the error message below. ❌"}
|
||||
.
|
||||
</Text>
|
||||
<Section className="flex text-black text-[14px] leading-[24px] bg-[#F4F4F5] rounded-lg p-2">
|
||||
<Text className="!leading-3 font-bold">Details: </Text>
|
||||
<Text className="!leading-3">
|
||||
Project Name: <strong>{projectName}</strong>
|
||||
</Text>
|
||||
<Text className="!leading-3">
|
||||
Application Name: <strong>{applicationName}</strong>
|
||||
</Text>
|
||||
<Text className="!leading-3">
|
||||
Database Type: <strong>{databaseType}</strong>
|
||||
</Text>
|
||||
<Text className="!leading-3">
|
||||
Date: <strong>{date}</strong>
|
||||
</Text>
|
||||
</Section>
|
||||
{type === "error" && errorMessage ? (
|
||||
<Section className="flex text-black text-[14px] mt-4 leading-[24px] bg-[#F4F4F5] rounded-lg p-2">
|
||||
<Text className="!leading-3 font-bold">Reason: </Text>
|
||||
<Text className="text-[12px] leading-[24px]">
|
||||
{errorMessage || "Error message not provided"}
|
||||
</Text>
|
||||
</Section>
|
||||
) : null}
|
||||
</Container>
|
||||
</Body>
|
||||
</Tailwind>
|
||||
</Html>
|
||||
);
|
||||
};
|
||||
|
||||
export default DatabaseBackupEmail;
|
||||
@@ -0,0 +1,81 @@
|
||||
import {
|
||||
Body,
|
||||
Button,
|
||||
Container,
|
||||
Head,
|
||||
Heading,
|
||||
Html,
|
||||
Img,
|
||||
Preview,
|
||||
Section,
|
||||
Tailwind,
|
||||
Text,
|
||||
} from "@react-email/components";
|
||||
import * as React from "react";
|
||||
|
||||
export type TemplateProps = {
|
||||
message: string;
|
||||
date: string;
|
||||
};
|
||||
|
||||
export const DockerCleanupEmail = ({
|
||||
message = "Docker cleanup for dokploy",
|
||||
date = "2023-05-01T00:00:00.000Z",
|
||||
}: TemplateProps) => {
|
||||
const previewText = "Docker cleanup for dokploy";
|
||||
return (
|
||||
<Html>
|
||||
<Preview>{previewText}</Preview>
|
||||
<Tailwind
|
||||
config={{
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
brand: "#007291",
|
||||
},
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Head />
|
||||
|
||||
<Body className="bg-white my-auto mx-auto font-sans px-2">
|
||||
<Container className="border border-solid border-[#eaeaea] rounded-lg my-[40px] mx-auto p-[20px] max-w-[465px]">
|
||||
<Section className="mt-[32px]">
|
||||
<Img
|
||||
src={
|
||||
"https://raw.githubusercontent.com/Dokploy/dokploy/canary/logo.png"
|
||||
}
|
||||
width="100"
|
||||
height="50"
|
||||
alt="Dokploy"
|
||||
className="my-0 mx-auto"
|
||||
/>
|
||||
</Section>
|
||||
<Heading className="text-black text-[24px] font-normal text-center p-0 my-[30px] mx-0">
|
||||
Docker cleanup for <strong>dokploy</strong>
|
||||
</Heading>
|
||||
<Text className="text-black text-[14px] leading-[24px]">
|
||||
Hello,
|
||||
</Text>
|
||||
<Text className="text-black text-[14px] leading-[24px]">
|
||||
The docker cleanup for <strong>dokploy</strong> was successful ✅
|
||||
</Text>
|
||||
|
||||
<Section className="flex text-black text-[14px] leading-[24px] bg-[#F4F4F5] rounded-lg p-2">
|
||||
<Text className="!leading-3 font-bold">Details: </Text>
|
||||
<Text className="!leading-3">
|
||||
Message: <strong>{message}</strong>
|
||||
</Text>
|
||||
<Text className="!leading-3">
|
||||
Date: <strong>{date}</strong>
|
||||
</Text>
|
||||
</Section>
|
||||
</Container>
|
||||
</Body>
|
||||
</Tailwind>
|
||||
</Html>
|
||||
);
|
||||
};
|
||||
|
||||
export default DockerCleanupEmail;
|
||||
@@ -0,0 +1,75 @@
|
||||
import {
|
||||
Body,
|
||||
Container,
|
||||
Head,
|
||||
Heading,
|
||||
Html,
|
||||
Img,
|
||||
Preview,
|
||||
Section,
|
||||
Tailwind,
|
||||
Text,
|
||||
} from "@react-email/components";
|
||||
import * as React from "react";
|
||||
|
||||
export type TemplateProps = {
|
||||
date: string;
|
||||
};
|
||||
|
||||
export const DokployRestartEmail = ({
|
||||
date = "2023-05-01T00:00:00.000Z",
|
||||
}: TemplateProps) => {
|
||||
const previewText = "Your dokploy server was restarted";
|
||||
return (
|
||||
<Html>
|
||||
<Preview>{previewText}</Preview>
|
||||
<Tailwind
|
||||
config={{
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
brand: "#007291",
|
||||
},
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Head />
|
||||
|
||||
<Body className="bg-white my-auto mx-auto font-sans px-2">
|
||||
<Container className="border border-solid border-[#eaeaea] rounded-lg my-[40px] mx-auto p-[20px] max-w-[465px]">
|
||||
<Section className="mt-[32px]">
|
||||
<Img
|
||||
src={
|
||||
"https://raw.githubusercontent.com/Dokploy/dokploy/canary/logo.png"
|
||||
}
|
||||
width="100"
|
||||
height="50"
|
||||
alt="Dokploy"
|
||||
className="my-0 mx-auto"
|
||||
/>
|
||||
</Section>
|
||||
<Heading className="text-black text-[24px] font-normal text-center p-0 my-[30px] mx-0">
|
||||
Dokploy Server Restart
|
||||
</Heading>
|
||||
<Text className="text-black text-[14px] leading-[24px]">
|
||||
Hello,
|
||||
</Text>
|
||||
<Text className="text-black text-[14px] leading-[24px]">
|
||||
Your dokploy server was restarted ✅
|
||||
</Text>
|
||||
|
||||
<Section className="flex text-black text-[14px] leading-[24px] bg-[#F4F4F5] rounded-lg p-2">
|
||||
<Text className="!leading-3 font-bold">Details: </Text>
|
||||
<Text className="!leading-3">
|
||||
Date: <strong>{date}</strong>
|
||||
</Text>
|
||||
</Section>
|
||||
</Container>
|
||||
</Body>
|
||||
</Tailwind>
|
||||
</Html>
|
||||
);
|
||||
};
|
||||
|
||||
export default DokployRestartEmail;
|
||||
@@ -0,0 +1,98 @@
|
||||
import {
|
||||
Body,
|
||||
Button,
|
||||
Container,
|
||||
Head,
|
||||
Heading,
|
||||
Hr,
|
||||
Html,
|
||||
Img,
|
||||
Link,
|
||||
Preview,
|
||||
Section,
|
||||
Tailwind,
|
||||
Text,
|
||||
} from "@react-email/components";
|
||||
|
||||
export type TemplateProps = {
|
||||
email: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
interface VercelInviteUserEmailProps {
|
||||
inviteLink: string;
|
||||
toEmail: string;
|
||||
}
|
||||
|
||||
export const InvitationEmail = ({
|
||||
inviteLink,
|
||||
toEmail,
|
||||
}: VercelInviteUserEmailProps) => {
|
||||
const previewText = "Join to Dokploy";
|
||||
return (
|
||||
<Html>
|
||||
<Head />
|
||||
<Preview>{previewText}</Preview>
|
||||
<Tailwind
|
||||
config={{
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
brand: "#007291",
|
||||
},
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Body className="bg-white my-auto mx-auto font-sans px-2">
|
||||
<Container className="border border-solid border-[#eaeaea] rounded-lg my-[40px] mx-auto p-[20px] max-w-[465px]">
|
||||
<Section className="mt-[32px]">
|
||||
<Img
|
||||
src={
|
||||
"https://raw.githubusercontent.com/Dokploy/dokploy/canary/logo.png"
|
||||
}
|
||||
width="100"
|
||||
height="50"
|
||||
alt="Dokploy"
|
||||
className="my-0 mx-auto"
|
||||
/>
|
||||
</Section>
|
||||
<Heading className="text-black text-[24px] font-normal text-center p-0 my-[30px] mx-0">
|
||||
Join to <strong>Dokploy</strong>
|
||||
</Heading>
|
||||
<Text className="text-black text-[14px] leading-[24px]">
|
||||
Hello,
|
||||
</Text>
|
||||
<Text className="text-black text-[14px] leading-[24px]">
|
||||
You have been invited to join <strong>Dokploy</strong>, a platform
|
||||
that helps for deploying your apps to the cloud.
|
||||
</Text>
|
||||
<Section className="text-center mt-[32px] mb-[32px]">
|
||||
<Button
|
||||
href={inviteLink}
|
||||
className="bg-[#000000] rounded text-white text-[12px] font-semibold no-underline text-center px-5 py-3"
|
||||
>
|
||||
Join the team 🚀
|
||||
</Button>
|
||||
</Section>
|
||||
<Text className="text-black text-[14px] leading-[24px]">
|
||||
or copy and paste this URL into your browser:{" "}
|
||||
<Link href={inviteLink} className="text-blue-600 no-underline">
|
||||
https://dokploy.com
|
||||
</Link>
|
||||
</Text>
|
||||
<Hr className="border border-solid border-[#eaeaea] my-[26px] mx-0 w-full" />
|
||||
<Text className="text-[#666666] text-[12px] leading-[24px]">
|
||||
This invitation was intended for {toEmail}. This invite was sent
|
||||
from <strong className="text-black">dokploy.com</strong>. If you
|
||||
were not expecting this invitation, you can ignore this email. If
|
||||
you are concerned about your account's safety, please reply to
|
||||
</Text>
|
||||
</Container>
|
||||
</Body>
|
||||
</Tailwind>
|
||||
</Html>
|
||||
);
|
||||
};
|
||||
|
||||
export default InvitationEmail;
|
||||
@@ -0,0 +1,150 @@
|
||||
import {
|
||||
Body,
|
||||
Container,
|
||||
Head,
|
||||
Heading,
|
||||
Html,
|
||||
Img,
|
||||
Link,
|
||||
Preview,
|
||||
Text,
|
||||
} from "@react-email/components";
|
||||
import * as React from "react";
|
||||
|
||||
interface NotionMagicLinkEmailProps {
|
||||
loginCode?: string;
|
||||
}
|
||||
|
||||
const baseUrl = process.env.VERCEL_URL
|
||||
? `https://${process.env.VERCEL_URL}`
|
||||
: "";
|
||||
|
||||
export const NotionMagicLinkEmail = ({
|
||||
loginCode,
|
||||
}: NotionMagicLinkEmailProps) => (
|
||||
<Html>
|
||||
<Head />
|
||||
<Preview>Log in with this magic link</Preview>
|
||||
<Body style={main}>
|
||||
<Container style={container}>
|
||||
<Heading style={h1}>Login</Heading>
|
||||
<Link
|
||||
href="https://notion.so"
|
||||
target="_blank"
|
||||
style={{
|
||||
...link,
|
||||
display: "block",
|
||||
marginBottom: "16px",
|
||||
}}
|
||||
>
|
||||
Click here to log in with this magic link
|
||||
</Link>
|
||||
<Text style={{ ...text, marginBottom: "14px" }}>
|
||||
Or, copy and paste this temporary login code:
|
||||
</Text>
|
||||
<code style={code}>{loginCode}</code>
|
||||
<Text
|
||||
style={{
|
||||
...text,
|
||||
color: "#ababab",
|
||||
marginTop: "14px",
|
||||
marginBottom: "16px",
|
||||
}}
|
||||
>
|
||||
If you didn't try to login, you can safely ignore this email.
|
||||
</Text>
|
||||
<Text
|
||||
style={{
|
||||
...text,
|
||||
color: "#ababab",
|
||||
marginTop: "12px",
|
||||
marginBottom: "38px",
|
||||
}}
|
||||
>
|
||||
Hint: You can set a permanent password in Settings & members → My
|
||||
account.
|
||||
</Text>
|
||||
<Img
|
||||
src={`${baseUrl}/static/notion-logo.png`}
|
||||
width="32"
|
||||
height="32"
|
||||
alt="Notion's Logo"
|
||||
/>
|
||||
<Text style={footer}>
|
||||
<Link
|
||||
href="https://notion.so"
|
||||
target="_blank"
|
||||
style={{ ...link, color: "#898989" }}
|
||||
>
|
||||
Notion.so
|
||||
</Link>
|
||||
, the all-in-one-workspace
|
||||
<br />
|
||||
for your notes, tasks, wikis, and databases.
|
||||
</Text>
|
||||
</Container>
|
||||
</Body>
|
||||
</Html>
|
||||
);
|
||||
|
||||
NotionMagicLinkEmail.PreviewProps = {
|
||||
loginCode: "sparo-ndigo-amurt-secan",
|
||||
} as NotionMagicLinkEmailProps;
|
||||
|
||||
export default NotionMagicLinkEmail;
|
||||
|
||||
const main = {
|
||||
backgroundColor: "#ffffff",
|
||||
};
|
||||
|
||||
const container = {
|
||||
paddingLeft: "12px",
|
||||
paddingRight: "12px",
|
||||
margin: "0 auto",
|
||||
};
|
||||
|
||||
const h1 = {
|
||||
color: "#333",
|
||||
fontFamily:
|
||||
"-apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif",
|
||||
fontSize: "24px",
|
||||
fontWeight: "bold",
|
||||
margin: "40px 0",
|
||||
padding: "0",
|
||||
};
|
||||
|
||||
const link = {
|
||||
color: "#2754C5",
|
||||
fontFamily:
|
||||
"-apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif",
|
||||
fontSize: "14px",
|
||||
textDecoration: "underline",
|
||||
};
|
||||
|
||||
const text = {
|
||||
color: "#333",
|
||||
fontFamily:
|
||||
"-apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif",
|
||||
fontSize: "14px",
|
||||
margin: "24px 0",
|
||||
};
|
||||
|
||||
const footer = {
|
||||
color: "#898989",
|
||||
fontFamily:
|
||||
"-apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif",
|
||||
fontSize: "12px",
|
||||
lineHeight: "22px",
|
||||
marginTop: "12px",
|
||||
marginBottom: "24px",
|
||||
};
|
||||
|
||||
const code = {
|
||||
display: "inline-block",
|
||||
padding: "16px 4.5%",
|
||||
width: "90.5%",
|
||||
backgroundColor: "#f4f4f4",
|
||||
borderRadius: "5px",
|
||||
border: "1px solid #eee",
|
||||
color: "#333",
|
||||
};
|
||||
@@ -0,0 +1,158 @@
|
||||
import {
|
||||
Body,
|
||||
Container,
|
||||
Head,
|
||||
Heading,
|
||||
Html,
|
||||
Img,
|
||||
Link,
|
||||
Section,
|
||||
Text,
|
||||
} from "@react-email/components";
|
||||
import * as React from "react";
|
||||
|
||||
interface PlaidVerifyIdentityEmailProps {
|
||||
validationCode?: string;
|
||||
}
|
||||
|
||||
const baseUrl = process.env.VERCEL_URL
|
||||
? `https://${process.env.VERCEL_URL}`
|
||||
: "";
|
||||
|
||||
export const PlaidVerifyIdentityEmail = ({
|
||||
validationCode,
|
||||
}: PlaidVerifyIdentityEmailProps) => (
|
||||
<Html>
|
||||
<Head />
|
||||
<Body style={main}>
|
||||
<Container style={container}>
|
||||
<Img
|
||||
src={`${baseUrl}/static/plaid-logo.png`}
|
||||
width="212"
|
||||
height="88"
|
||||
alt="Plaid"
|
||||
style={logo}
|
||||
/>
|
||||
<Text style={tertiary}>Verify Your Identity</Text>
|
||||
<Heading style={secondary}>
|
||||
Enter the following code to finish linking Venmo.
|
||||
</Heading>
|
||||
<Section style={codeContainer}>
|
||||
<Text style={code}>{validationCode}</Text>
|
||||
</Section>
|
||||
<Text style={paragraph}>Not expecting this email?</Text>
|
||||
<Text style={paragraph}>
|
||||
Contact{" "}
|
||||
<Link href="mailto:login@plaid.com" style={link}>
|
||||
login@plaid.com
|
||||
</Link>{" "}
|
||||
if you did not request this code.
|
||||
</Text>
|
||||
</Container>
|
||||
<Text style={footer}>Securely powered by Plaid.</Text>
|
||||
</Body>
|
||||
</Html>
|
||||
);
|
||||
|
||||
PlaidVerifyIdentityEmail.PreviewProps = {
|
||||
validationCode: "144833",
|
||||
} as PlaidVerifyIdentityEmailProps;
|
||||
|
||||
export default PlaidVerifyIdentityEmail;
|
||||
|
||||
const main = {
|
||||
backgroundColor: "#ffffff",
|
||||
fontFamily: "HelveticaNeue,Helvetica,Arial,sans-serif",
|
||||
};
|
||||
|
||||
const container = {
|
||||
backgroundColor: "#ffffff",
|
||||
border: "1px solid #eee",
|
||||
borderRadius: "5px",
|
||||
boxShadow: "0 5px 10px rgba(20,50,70,.2)",
|
||||
marginTop: "20px",
|
||||
maxWidth: "360px",
|
||||
margin: "0 auto",
|
||||
padding: "68px 0 130px",
|
||||
};
|
||||
|
||||
const logo = {
|
||||
margin: "0 auto",
|
||||
};
|
||||
|
||||
const tertiary = {
|
||||
color: "#0a85ea",
|
||||
fontSize: "11px",
|
||||
fontWeight: 700,
|
||||
fontFamily: "HelveticaNeue,Helvetica,Arial,sans-serif",
|
||||
height: "16px",
|
||||
letterSpacing: "0",
|
||||
lineHeight: "16px",
|
||||
margin: "16px 8px 8px 8px",
|
||||
textTransform: "uppercase" as const,
|
||||
textAlign: "center" as const,
|
||||
};
|
||||
|
||||
const secondary = {
|
||||
color: "#000",
|
||||
display: "inline-block",
|
||||
fontFamily: "HelveticaNeue-Medium,Helvetica,Arial,sans-serif",
|
||||
fontSize: "20px",
|
||||
fontWeight: 500,
|
||||
lineHeight: "24px",
|
||||
marginBottom: "0",
|
||||
marginTop: "0",
|
||||
textAlign: "center" as const,
|
||||
};
|
||||
|
||||
const codeContainer = {
|
||||
background: "rgba(0,0,0,.05)",
|
||||
borderRadius: "4px",
|
||||
margin: "16px auto 14px",
|
||||
verticalAlign: "middle",
|
||||
width: "280px",
|
||||
};
|
||||
|
||||
const code = {
|
||||
color: "#000",
|
||||
display: "inline-block",
|
||||
fontFamily: "HelveticaNeue-Bold",
|
||||
fontSize: "32px",
|
||||
fontWeight: 700,
|
||||
letterSpacing: "6px",
|
||||
lineHeight: "40px",
|
||||
paddingBottom: "8px",
|
||||
paddingTop: "8px",
|
||||
margin: "0 auto",
|
||||
width: "100%",
|
||||
textAlign: "center" as const,
|
||||
};
|
||||
|
||||
const paragraph = {
|
||||
color: "#444",
|
||||
fontSize: "15px",
|
||||
fontFamily: "HelveticaNeue,Helvetica,Arial,sans-serif",
|
||||
letterSpacing: "0",
|
||||
lineHeight: "23px",
|
||||
padding: "0 40px",
|
||||
margin: "0",
|
||||
textAlign: "center" as const,
|
||||
};
|
||||
|
||||
const link = {
|
||||
color: "#444",
|
||||
textDecoration: "underline",
|
||||
};
|
||||
|
||||
const footer = {
|
||||
color: "#000",
|
||||
fontSize: "12px",
|
||||
fontWeight: 800,
|
||||
letterSpacing: "0",
|
||||
lineHeight: "23px",
|
||||
margin: "0",
|
||||
marginTop: "20px",
|
||||
fontFamily: "HelveticaNeue,Helvetica,Arial,sans-serif",
|
||||
textAlign: "center" as const,
|
||||
textTransform: "uppercase" as const,
|
||||
};
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.9 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 3.9 KiB |
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user