mirror of
https://github.com/Dokploy/dokploy
synced 2025-06-26 18:27:59 +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 |
72
.circleci/canary-config.yml
Normal file
72
.circleci/canary-config.yml
Normal file
@@ -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
|
||||
104
.circleci/config.yml
Normal file
104
.circleci/config.yml
Normal file
@@ -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
|
||||
76
.circleci/main-config.yml
Normal file
76
.circleci/main-config.yml
Normal file
@@ -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
|
||||
43
.github/workflows/pull-request.yml
vendored
43
.github/workflows/pull-request.yml
vendored
@@ -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
|
||||
|
||||
35
.github/workflows/push.yml
vendored
35
.github/workflows/push.yml
vendored
@@ -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' || '' }}
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -52,6 +52,7 @@ yarn-error.log*
|
||||
# otros
|
||||
/.data
|
||||
/.main
|
||||
.vscode
|
||||
|
||||
*.lockb
|
||||
*.rdb
|
||||
|
||||
4
.husky/commit-msg
Normal file
4
.husky/commit-msg
Normal file
@@ -0,0 +1,4 @@
|
||||
#!/usr/bin/env sh
|
||||
. "$(dirname -- "$0")/_/husky.sh"
|
||||
|
||||
pnpm commitlint --edit $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
|
||||
|
||||
98
__test__/drop/drop.test.test.ts
Normal file
98
__test__/drop/drop.test.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
BIN
__test__/drop/zips/folder-with-file.zip
Normal file
BIN
__test__/drop/zips/folder-with-file.zip
Normal file
Binary file not shown.
BIN
__test__/drop/zips/folder-with-sibling-file.zip
Normal file
BIN
__test__/drop/zips/folder-with-sibling-file.zip
Normal file
Binary file not shown.
1
__test__/drop/zips/folder1/folder1.txt
Normal file
1
__test__/drop/zips/folder1/folder1.txt
Normal file
@@ -0,0 +1 @@
|
||||
Gogogogogogo
|
||||
1
__test__/drop/zips/folder2/folder2.txt
Normal file
1
__test__/drop/zips/folder2/folder2.txt
Normal file
@@ -0,0 +1 @@
|
||||
gogogogogog
|
||||
1
__test__/drop/zips/folder3/file3.txt
Normal file
1
__test__/drop/zips/folder3/file3.txt
Normal file
@@ -0,0 +1 @@
|
||||
gogogogogogogogogo
|
||||
BIN
__test__/drop/zips/nested.zip
Normal file
BIN
__test__/drop/zips/nested.zip
Normal file
Binary file not shown.
BIN
__test__/drop/zips/single-file.zip
Normal file
BIN
__test__/drop/zips/single-file.zip
Normal file
Binary file not shown.
1
__test__/drop/zips/test.txt
Normal file
1
__test__/drop/zips/test.txt
Normal file
@@ -0,0 +1 @@
|
||||
dsafasdfasdf
|
||||
BIN
__test__/drop/zips/two-folders.zip
Normal file
BIN
__test__/drop/zips/two-folders.zip
Normal file
Binary file not shown.
187
__test__/traefik/traefik.test.ts
Normal file
187
__test__/traefik/traefik.test.ts
Normal file
@@ -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>
|
||||
|
||||
742
components/dashboard/settings/notifications/add-notification.tsx
Normal file
742
components/dashboard/settings/notifications/add-notification.tsx
Normal file
@@ -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>
|
||||
);
|
||||
};
|
||||
200
components/dashboard/settings/ssh-keys/add-ssh-key.tsx
Normal file
200
components/dashboard/settings/ssh-keys/add-ssh-key.tsx
Normal file
@@ -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>
|
||||
);
|
||||
};
|
||||
61
components/dashboard/settings/ssh-keys/delete-ssh-key.tsx
Normal file
61
components/dashboard/settings/ssh-keys/delete-ssh-key.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { api } from "@/utils/api";
|
||||
import { TrashIcon } from "lucide-react";
|
||||
import React from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface Props {
|
||||
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>
|
||||
);
|
||||
};
|
||||
96
components/dashboard/settings/ssh-keys/show-ssh-keys.tsx
Normal file
96
components/dashboard/settings/ssh-keys/show-ssh-keys.tsx
Normal file
@@ -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>
|
||||
);
|
||||
};
|
||||
168
components/dashboard/settings/ssh-keys/update-ssh-key.tsx
Normal file
168
components/dashboard/settings/ssh-keys/update-ssh-key.tsx
Normal file
@@ -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
|
||||
|
||||
90
components/icons/notification-icons.tsx
Normal file
90
components/icons/notification-icons.tsx
Normal file
@@ -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,
|
||||
|
||||
79
components/ui/dropzone.tsx
Normal file
79
components/ui/dropzone.tsx
Normal file
@@ -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>
|
||||
);
|
||||
},
|
||||
);
|
||||
78
components/ui/secrets.tsx
Normal file
78
components/ui/secrets.tsx
Normal file
@@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 ""
|
||||
|
||||
72
drizzle/0019_heavy_freak.sql
Normal file
72
drizzle/0019_heavy_freak.sql
Normal file
@@ -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 $$;
|
||||
1
drizzle/0020_fantastic_slapstick.sql
Normal file
1
drizzle/0020_fantastic_slapstick.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE "notification" ADD COLUMN "dockerCleanup" boolean DEFAULT false NOT NULL;
|
||||
1
drizzle/0021_premium_sebastian_shaw.sql
Normal file
1
drizzle/0021_premium_sebastian_shaw.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE "notification" DROP COLUMN IF EXISTS "userJoin";
|
||||
1
drizzle/0022_warm_colonel_america.sql
Normal file
1
drizzle/0022_warm_colonel_america.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TYPE "sourceType" ADD VALUE 'drop';
|
||||
1
drizzle/0023_icy_maverick.sql
Normal file
1
drizzle/0023_icy_maverick.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE "application" ADD COLUMN "dropBuildPath" text;
|
||||
1
drizzle/0024_dapper_supernaut.sql
Normal file
1
drizzle/0024_dapper_supernaut.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE "mount" ADD COLUMN "filePath" text;
|
||||
1
drizzle/0025_lying_mephisto.sql
Normal file
1
drizzle/0025_lying_mephisto.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE "application" ADD COLUMN "buildArgs" text;
|
||||
25
drizzle/0026_known_dormammu.sql
Normal file
25
drizzle/0026_known_dormammu.sql
Normal file
@@ -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";
|
||||
2919
drizzle/meta/0019_snapshot.json
Normal file
2919
drizzle/meta/0019_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
2926
drizzle/meta/0020_snapshot.json
Normal file
2926
drizzle/meta/0020_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
2919
drizzle/meta/0021_snapshot.json
Normal file
2919
drizzle/meta/0021_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
2920
drizzle/meta/0022_snapshot.json
Normal file
2920
drizzle/meta/0022_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
2926
drizzle/meta/0023_snapshot.json
Normal file
2926
drizzle/meta/0023_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
2932
drizzle/meta/0024_snapshot.json
Normal file
2932
drizzle/meta/0024_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
2938
drizzle/meta/0025_snapshot.json
Normal file
2938
drizzle/meta/0025_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
3010
drizzle/meta/0026_snapshot.json
Normal file
3010
drizzle/meta/0026_snapshot.json
Normal file
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
|
||||
}
|
||||
]
|
||||
}
|
||||
2
emails/.gitignore
vendored
Normal file
2
emails/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
/node_modules
|
||||
/dist
|
||||
113
emails/emails/build-failed.tsx
Normal file
113
emails/emails/build-failed.tsx
Normal file
@@ -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;
|
||||
106
emails/emails/build-success.tsx
Normal file
106
emails/emails/build-success.tsx
Normal file
@@ -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;
|
||||
105
emails/emails/database-backup.tsx
Normal file
105
emails/emails/database-backup.tsx
Normal file
@@ -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;
|
||||
81
emails/emails/docker-cleanup.tsx
Normal file
81
emails/emails/docker-cleanup.tsx
Normal file
@@ -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;
|
||||
75
emails/emails/dokploy-restart.tsx
Normal file
75
emails/emails/dokploy-restart.tsx
Normal file
@@ -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;
|
||||
98
emails/emails/invitation.tsx
Normal file
98
emails/emails/invitation.tsx
Normal file
@@ -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;
|
||||
150
emails/emails/notion-magic-link.tsx
Normal file
150
emails/emails/notion-magic-link.tsx
Normal file
@@ -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",
|
||||
};
|
||||
158
emails/emails/plaid-verify-identity.tsx
Normal file
158
emails/emails/plaid-verify-identity.tsx
Normal file
@@ -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,
|
||||
};
|
||||
BIN
emails/emails/static/logo.png
Normal file
BIN
emails/emails/static/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
BIN
emails/emails/static/notion-logo.png
Normal file
BIN
emails/emails/static/notion-logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.9 KiB |
BIN
emails/emails/static/plaid-logo.png
Normal file
BIN
emails/emails/static/plaid-logo.png
Normal file
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