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
@@ -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/base:stable
|
||||
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
@@ -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
@@ -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
@@ -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' || '' }}
|
||||
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
@@ -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-sibling-file.zip
Normal file
1
__test__/drop/zips/folder1/folder1.txt
Normal file
@@ -0,0 +1 @@
|
||||
Gogogogogogo
|
||||
1
__test__/drop/zips/folder2/folder2.txt
Normal file
@@ -0,0 +1 @@
|
||||
gogogogogog
|
||||
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/single-file.zip
Normal file
1
__test__/drop/zips/test.txt
Normal file
@@ -0,0 +1 @@
|
||||
dsafasdfasdf
|
||||
BIN
__test__/drop/zips/two-folders.zip
Normal file
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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" && (
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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,7 +81,7 @@ 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());
|
||||
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
};
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -65,6 +65,12 @@ export const SettingsLayout = ({ children }: Props) => {
|
||||
icon: Server,
|
||||
href: "/dashboard/settings/cluster",
|
||||
},
|
||||
{
|
||||
title: "Notifications",
|
||||
label: "",
|
||||
icon: Bell,
|
||||
href: "/dashboard/settings/notifications",
|
||||
},
|
||||
]
|
||||
: []),
|
||||
]}
|
||||
@@ -78,6 +84,7 @@ export const SettingsLayout = ({ children }: Props) => {
|
||||
|
||||
import {
|
||||
Activity,
|
||||
Bell,
|
||||
Database,
|
||||
type LucideIcon,
|
||||
Route,
|
||||
|
||||
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>
|
||||
);
|
||||
},
|
||||
);
|
||||
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
@@ -0,0 +1 @@
|
||||
ALTER TABLE "notification" ADD COLUMN "dockerCleanup" boolean DEFAULT false NOT NULL;
|
||||
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
@@ -0,0 +1 @@
|
||||
ALTER TYPE "sourceType" ADD VALUE 'drop';
|
||||
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
@@ -0,0 +1 @@
|
||||
ALTER TABLE "mount" ADD COLUMN "filePath" text;
|
||||
2919
drizzle/meta/0019_snapshot.json
Normal file
2926
drizzle/meta/0020_snapshot.json
Normal file
2919
drizzle/meta/0021_snapshot.json
Normal file
2920
drizzle/meta/0022_snapshot.json
Normal file
2926
drizzle/meta/0023_snapshot.json
Normal file
2932
drizzle/meta/0024_snapshot.json
Normal file
@@ -134,6 +134,48 @@
|
||||
"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
|
||||
}
|
||||
]
|
||||
}
|
||||
2
emails/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
/node_modules
|
||||
/dist
|
||||
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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
After Width: | Height: | Size: 17 KiB |
BIN
emails/emails/static/notion-logo.png
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
BIN
emails/emails/static/plaid-logo.png
Normal file
|
After Width: | Height: | Size: 3.9 KiB |
BIN
emails/emails/static/plaid.png
Normal file
|
After Width: | Height: | Size: 3.9 KiB |
BIN
emails/emails/static/stripe-logo.png
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
emails/emails/static/vercel-arrow.png
Normal file
|
After Width: | Height: | Size: 426 B |
BIN
emails/emails/static/vercel-logo.png
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
BIN
emails/emails/static/vercel-team.png
Normal file
|
After Width: | Height: | Size: 3.0 KiB |
BIN
emails/emails/static/vercel-user.png
Normal file
|
After Width: | Height: | Size: 54 KiB |
152
emails/emails/stripe-welcome.tsx
Normal file
@@ -0,0 +1,152 @@
|
||||
import {
|
||||
Body,
|
||||
Button,
|
||||
Container,
|
||||
Head,
|
||||
Hr,
|
||||
Html,
|
||||
Img,
|
||||
Link,
|
||||
Preview,
|
||||
Section,
|
||||
Text,
|
||||
} from "@react-email/components";
|
||||
import * as React from "react";
|
||||
|
||||
const baseUrl = process.env.VERCEL_URL
|
||||
? `https://${process.env.VERCEL_URL}`
|
||||
: "";
|
||||
|
||||
export const StripeWelcomeEmail = () => (
|
||||
<Html>
|
||||
<Head />
|
||||
<Preview>You're now ready to make live transactions with Stripe!</Preview>
|
||||
<Body style={main}>
|
||||
<Container style={container}>
|
||||
<Section style={box}>
|
||||
<Img
|
||||
src={`${baseUrl}/static/stripe-logo.png`}
|
||||
width="49"
|
||||
height="21"
|
||||
alt="Stripe"
|
||||
/>
|
||||
<Hr style={hr} />
|
||||
<Text style={paragraph}>
|
||||
Thanks for submitting your account information. You're now ready to
|
||||
make live transactions with Stripe!
|
||||
</Text>
|
||||
<Text style={paragraph}>
|
||||
You can view your payments and a variety of other information about
|
||||
your account right from your dashboard.
|
||||
</Text>
|
||||
<Button style={button} href="https://dashboard.stripe.com/login">
|
||||
View your Stripe Dashboard
|
||||
</Button>
|
||||
<Hr style={hr} />
|
||||
<Text style={paragraph}>
|
||||
If you haven't finished your integration, you might find our{" "}
|
||||
<Link style={anchor} href="https://stripe.com/docs">
|
||||
docs
|
||||
</Link>{" "}
|
||||
handy.
|
||||
</Text>
|
||||
<Text style={paragraph}>
|
||||
Once you're ready to start accepting payments, you'll just need to
|
||||
use your live{" "}
|
||||
<Link
|
||||
style={anchor}
|
||||
href="https://dashboard.stripe.com/login?redirect=%2Fapikeys"
|
||||
>
|
||||
API keys
|
||||
</Link>{" "}
|
||||
instead of your test API keys. Your account can simultaneously be
|
||||
used for both test and live requests, so you can continue testing
|
||||
while accepting live payments. Check out our{" "}
|
||||
<Link style={anchor} href="https://stripe.com/docs/dashboard">
|
||||
tutorial about account basics
|
||||
</Link>
|
||||
.
|
||||
</Text>
|
||||
<Text style={paragraph}>
|
||||
Finally, we've put together a{" "}
|
||||
<Link
|
||||
style={anchor}
|
||||
href="https://stripe.com/docs/checklist/website"
|
||||
>
|
||||
quick checklist
|
||||
</Link>{" "}
|
||||
to ensure your website conforms to card network standards.
|
||||
</Text>
|
||||
<Text style={paragraph}>
|
||||
We'll be here to help you with any step along the way. You can find
|
||||
answers to most questions and get in touch with us on our{" "}
|
||||
<Link style={anchor} href="https://support.stripe.com/">
|
||||
support site
|
||||
</Link>
|
||||
.
|
||||
</Text>
|
||||
<Text style={paragraph}>— The Stripe team</Text>
|
||||
<Hr style={hr} />
|
||||
<Text style={footer}>
|
||||
Stripe, 354 Oyster Point Blvd, South San Francisco, CA 94080
|
||||
</Text>
|
||||
</Section>
|
||||
</Container>
|
||||
</Body>
|
||||
</Html>
|
||||
);
|
||||
|
||||
export default StripeWelcomeEmail;
|
||||
|
||||
const main = {
|
||||
backgroundColor: "#f6f9fc",
|
||||
fontFamily:
|
||||
'-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Ubuntu,sans-serif',
|
||||
};
|
||||
|
||||
const container = {
|
||||
backgroundColor: "#ffffff",
|
||||
margin: "0 auto",
|
||||
padding: "20px 0 48px",
|
||||
marginBottom: "64px",
|
||||
};
|
||||
|
||||
const box = {
|
||||
padding: "0 48px",
|
||||
};
|
||||
|
||||
const hr = {
|
||||
borderColor: "#e6ebf1",
|
||||
margin: "20px 0",
|
||||
};
|
||||
|
||||
const paragraph = {
|
||||
color: "#525f7f",
|
||||
|
||||
fontSize: "16px",
|
||||
lineHeight: "24px",
|
||||
textAlign: "left" as const,
|
||||
};
|
||||
|
||||
const anchor = {
|
||||
color: "#556cd6",
|
||||
};
|
||||
|
||||
const button = {
|
||||
backgroundColor: "#656ee8",
|
||||
borderRadius: "5px",
|
||||
color: "#fff",
|
||||
fontSize: "16px",
|
||||
fontWeight: "bold",
|
||||
textDecoration: "none",
|
||||
textAlign: "center" as const,
|
||||
display: "block",
|
||||
width: "100%",
|
||||
padding: "10px",
|
||||
};
|
||||
|
||||
const footer = {
|
||||
color: "#8898aa",
|
||||
fontSize: "12px",
|
||||
lineHeight: "16px",
|
||||
};
|
||||
154
emails/emails/vercel-invite-user.tsx
Normal file
@@ -0,0 +1,154 @@
|
||||
import {
|
||||
Body,
|
||||
Button,
|
||||
Column,
|
||||
Container,
|
||||
Head,
|
||||
Heading,
|
||||
Hr,
|
||||
Html,
|
||||
Img,
|
||||
Link,
|
||||
Preview,
|
||||
Row,
|
||||
Section,
|
||||
Tailwind,
|
||||
Text,
|
||||
} from "@react-email/components";
|
||||
import * as React from "react";
|
||||
|
||||
interface VercelInviteUserEmailProps {
|
||||
username?: string;
|
||||
userImage?: string;
|
||||
invitedByUsername?: string;
|
||||
invitedByEmail?: string;
|
||||
teamName?: string;
|
||||
teamImage?: string;
|
||||
inviteLink?: string;
|
||||
inviteFromIp?: string;
|
||||
inviteFromLocation?: string;
|
||||
}
|
||||
|
||||
const baseUrl = process.env.VERCEL_URL
|
||||
? `https://${process.env.VERCEL_URL}`
|
||||
: "";
|
||||
|
||||
export const VercelInviteUserEmail = ({
|
||||
username,
|
||||
userImage,
|
||||
invitedByUsername,
|
||||
invitedByEmail,
|
||||
teamName,
|
||||
teamImage,
|
||||
inviteLink,
|
||||
inviteFromIp,
|
||||
inviteFromLocation,
|
||||
}: VercelInviteUserEmailProps) => {
|
||||
const previewText = `Join ${invitedByUsername} on Vercel`;
|
||||
|
||||
return (
|
||||
<Html>
|
||||
<Head />
|
||||
<Preview>{previewText}</Preview>
|
||||
<Tailwind>
|
||||
<Body className="bg-white my-auto mx-auto font-sans px-2">
|
||||
<Container className="border border-solid border-[#eaeaea] rounded my-[40px] mx-auto p-[20px] max-w-[465px]">
|
||||
<Section className="mt-[32px]">
|
||||
<Img
|
||||
src={`${baseUrl}/static/vercel-logo.png`}
|
||||
width="40"
|
||||
height="37"
|
||||
alt="Vercel"
|
||||
className="my-0 mx-auto"
|
||||
/>
|
||||
</Section>
|
||||
<Heading className="text-black text-[24px] font-normal text-center p-0 my-[30px] mx-0">
|
||||
Join <strong>{teamName}</strong> on <strong>Vercel</strong>
|
||||
</Heading>
|
||||
<Text className="text-black text-[14px] leading-[24px]">
|
||||
Hello {username},
|
||||
</Text>
|
||||
<Text className="text-black text-[14px] leading-[24px]">
|
||||
<strong>{invitedByUsername}</strong> (
|
||||
<Link
|
||||
href={`mailto:${invitedByEmail}`}
|
||||
className="text-blue-600 no-underline"
|
||||
>
|
||||
{invitedByEmail}
|
||||
</Link>
|
||||
) has invited you to the <strong>{teamName}</strong> team on{" "}
|
||||
<strong>Vercel</strong>.
|
||||
</Text>
|
||||
<Section>
|
||||
<Row>
|
||||
<Column align="right">
|
||||
<Img
|
||||
className="rounded-full"
|
||||
src={userImage}
|
||||
width="64"
|
||||
height="64"
|
||||
/>
|
||||
</Column>
|
||||
<Column align="center">
|
||||
<Img
|
||||
src={`${baseUrl}/static/vercel-arrow.png`}
|
||||
width="12"
|
||||
height="9"
|
||||
alt="invited you to"
|
||||
/>
|
||||
</Column>
|
||||
<Column align="left">
|
||||
<Img
|
||||
className="rounded-full"
|
||||
src={teamImage}
|
||||
width="64"
|
||||
height="64"
|
||||
/>
|
||||
</Column>
|
||||
</Row>
|
||||
</Section>
|
||||
<Section className="text-center mt-[32px] mb-[32px]">
|
||||
<Button
|
||||
className="bg-[#000000] rounded text-white text-[12px] font-semibold no-underline text-center px-5 py-3"
|
||||
href={inviteLink}
|
||||
>
|
||||
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">
|
||||
{inviteLink}
|
||||
</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{" "}
|
||||
<span className="text-black">{username}</span>. This invite was
|
||||
sent from <span className="text-black">{inviteFromIp}</span>{" "}
|
||||
located in{" "}
|
||||
<span className="text-black">{inviteFromLocation}</span>. If you
|
||||
were not expecting this invitation, you can ignore this email. If
|
||||
you are concerned about your account's safety, please reply to
|
||||
this email to get in touch with us.
|
||||
</Text>
|
||||
</Container>
|
||||
</Body>
|
||||
</Tailwind>
|
||||
</Html>
|
||||
);
|
||||
};
|
||||
|
||||
VercelInviteUserEmail.PreviewProps = {
|
||||
username: "alanturing",
|
||||
userImage: `${baseUrl}/static/vercel-user.png`,
|
||||
invitedByUsername: "Alan",
|
||||
invitedByEmail: "alan.turing@example.com",
|
||||
teamName: "Enigma",
|
||||
teamImage: `${baseUrl}/static/vercel-team.png`,
|
||||
inviteLink: "https://vercel.com/teams/invite/foo",
|
||||
inviteFromIp: "204.13.186.218",
|
||||
inviteFromLocation: "São Paulo, Brazil",
|
||||
} as VercelInviteUserEmailProps;
|
||||
|
||||
export default VercelInviteUserEmail;
|
||||
20
emails/package.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "emails",
|
||||
"version": "0.0.19",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "email build",
|
||||
"dev": "email dev",
|
||||
"export": "email export"
|
||||
},
|
||||
"dependencies": {
|
||||
"@react-email/components": "0.0.21",
|
||||
"react-email": "2.1.5",
|
||||
"react": "^18.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "18.2.33",
|
||||
"@types/react-dom": "18.2.14"
|
||||
}
|
||||
}
|
||||
4209
emails/pnpm-lock.yaml
generated
Normal file
27
emails/readme.md
Normal file
@@ -0,0 +1,27 @@
|
||||
# React Email Starter
|
||||
|
||||
A live preview right in your browser so you don't need to keep sending real emails during development.
|
||||
|
||||
## Getting Started
|
||||
|
||||
First, install the dependencies:
|
||||
|
||||
```sh
|
||||
npm install
|
||||
# or
|
||||
yarn
|
||||
```
|
||||
|
||||
Then, run the development server:
|
||||
|
||||
```sh
|
||||
npm run dev
|
||||
# or
|
||||
yarn dev
|
||||
```
|
||||
|
||||
Open [localhost:3000](http://localhost:3000) with your browser to see the result.
|
||||
|
||||
## License
|
||||
|
||||
MIT License
|
||||
24
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "dokploy",
|
||||
"version": "v0.3.3",
|
||||
"version": "v0.4.0",
|
||||
"private": true,
|
||||
"license": "Apache-2.0",
|
||||
"type": "module",
|
||||
@@ -20,8 +20,8 @@
|
||||
"db:push": "drizzle-kit --config ./server/db/drizzle.config.ts",
|
||||
"db:truncate": "tsx -r dotenv/config ./server/db/reset.ts",
|
||||
"db:studio": "drizzle-kit studio --config ./server/db/drizzle.config.ts",
|
||||
"check": "biome check",
|
||||
"format": "biome format",
|
||||
"check": "biome check --write --no-errors-on-unmatched --files-ignore-unknown=true",
|
||||
"format": "biome format --write",
|
||||
"lint": "biome lint",
|
||||
"typecheck": "tsc",
|
||||
"db:seed": "tsx -r dotenv/config ./server/db/seed.ts",
|
||||
@@ -64,6 +64,7 @@
|
||||
"@radix-ui/react-tabs": "^1.0.4",
|
||||
"@radix-ui/react-toggle": "^1.0.3",
|
||||
"@radix-ui/react-tooltip": "^1.0.7",
|
||||
"@react-email/components": "^0.0.21",
|
||||
"@tanstack/react-query": "^4.36.1",
|
||||
"@tanstack/react-table": "^8.16.0",
|
||||
"@trpc/client": "^10.43.6",
|
||||
@@ -74,6 +75,7 @@
|
||||
"@uiw/react-codemirror": "^4.22.1",
|
||||
"@xterm/addon-attach": "0.10.0",
|
||||
"@xterm/xterm": "^5.4.0",
|
||||
"adm-zip": "^0.5.14",
|
||||
"bcrypt": "5.1.1",
|
||||
"bl": "6.0.11",
|
||||
"boxen": "^7.1.1",
|
||||
@@ -103,6 +105,7 @@
|
||||
"node-os-utils": "1.3.7",
|
||||
"node-pty": "1.0.0",
|
||||
"node-schedule": "2.1.1",
|
||||
"nodemailer": "6.9.14",
|
||||
"octokit": "3.1.2",
|
||||
"otpauth": "^9.2.3",
|
||||
"postgres": "3.4.4",
|
||||
@@ -119,13 +122,18 @@
|
||||
"tailwind-merge": "^2.2.0",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"tar-fs": "3.0.5",
|
||||
"undici": "^6.19.2",
|
||||
"use-resize-observer": "9.1.0",
|
||||
"ws": "8.16.0",
|
||||
"xterm-addon-fit": "^0.8.0",
|
||||
"zod": "^3.23.4"
|
||||
"zod": "^3.23.4",
|
||||
"zod-form-data": "^2.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "1.8.3",
|
||||
"@commitlint/cli": "^19.3.0",
|
||||
"@commitlint/config-conventional": "^19.2.2",
|
||||
"@types/adm-zip": "^0.5.5",
|
||||
"@types/bcrypt": "5.0.2",
|
||||
"@types/dockerode": "3.3.23",
|
||||
"@types/js-yaml": "4.0.9",
|
||||
@@ -133,6 +141,7 @@
|
||||
"@types/node": "^18.17.0",
|
||||
"@types/node-os-utils": "1.3.4",
|
||||
"@types/node-schedule": "2.1.6",
|
||||
"@types/nodemailer": "^6.4.15",
|
||||
"@types/qrcode": "^1.5.5",
|
||||
"@types/react": "^18.2.37",
|
||||
"@types/react-dom": "^18.2.15",
|
||||
@@ -158,14 +167,17 @@
|
||||
"ct3aMetadata": {
|
||||
"initVersion": "7.25.2"
|
||||
},
|
||||
"packageManager": "pnpm@8.15.4",
|
||||
"packageManager": "pnpm@9.5.0",
|
||||
"engines": {
|
||||
"node": "^18.18.0",
|
||||
"pnpm": ">=8.15.4"
|
||||
"pnpm": ">=9.5.0"
|
||||
},
|
||||
"lint-staged": {
|
||||
"*": [
|
||||
"biome check --write --no-errors-on-unmatched --files-ignore-unknown=true"
|
||||
]
|
||||
},
|
||||
"commitlint": {
|
||||
"extends": ["@commitlint/config-conventional"]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,7 +18,6 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
res.status(401).json({ message: "Unauthorized" });
|
||||
return;
|
||||
}
|
||||
|
||||
// @ts-ignore
|
||||
return createOpenApiNextHandler({
|
||||
router: appRouter,
|
||||
|
||||
@@ -27,7 +27,9 @@ export default async function handler(
|
||||
return;
|
||||
}
|
||||
if (!application?.autoDeploy) {
|
||||
res.status(400).json({ message: "Application Not Deployable" });
|
||||
res.status(400).json({
|
||||
message: "Automatic deployments are disabled for this application",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -32,7 +32,9 @@ export default async function handler(
|
||||
return;
|
||||
}
|
||||
if (!composeResult?.autoDeploy) {
|
||||
res.status(400).json({ message: "Compose Not Deployable" });
|
||||
res.status(400).json({
|
||||
message: "Automatic deployments are disabled for this compose",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { createNextApiHandler } from "@trpc/server/adapters/next";
|
||||
|
||||
import { appRouter } from "@/server/api/root";
|
||||
import { createTRPCContext } from "@/server/api/trpc";
|
||||
import { createNextApiHandler } from "@trpc/server/adapters/next";
|
||||
import { nodeHTTPFormDataContentTypeHandler } from "@trpc/server/adapters/node-http/content-type/form-data";
|
||||
import { nodeHTTPJSONContentTypeHandler } from "@trpc/server/adapters/node-http/content-type/json";
|
||||
|
||||
// export API handler
|
||||
export default createNextApiHandler({
|
||||
@@ -15,4 +16,15 @@ export default createNextApiHandler({
|
||||
);
|
||||
}
|
||||
: undefined,
|
||||
experimental_contentTypeHandlers: [
|
||||
nodeHTTPFormDataContentTypeHandler(),
|
||||
nodeHTTPJSONContentTypeHandler(),
|
||||
],
|
||||
});
|
||||
|
||||
export const config = {
|
||||
api: {
|
||||
bodyParser: false,
|
||||
sizeLimit: "1gb",
|
||||
},
|
||||
};
|
||||
|
||||
42
pages/dashboard/settings/notifications.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import { ShowDestinations } from "@/components/dashboard/settings/destination/show-destinations";
|
||||
import { ShowNotifications } from "@/components/dashboard/settings/notifications/show-notifications";
|
||||
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
|
||||
import { SettingsLayout } from "@/components/layouts/settings-layout";
|
||||
import { validateRequest } from "@/server/auth/auth";
|
||||
import type { GetServerSidePropsContext } from "next";
|
||||
import React, { type ReactElement } from "react";
|
||||
|
||||
const Page = () => {
|
||||
return (
|
||||
<div className="flex flex-col gap-4 w-full">
|
||||
<ShowNotifications />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Page;
|
||||
|
||||
Page.getLayout = (page: ReactElement) => {
|
||||
return (
|
||||
<DashboardLayout tab={"settings"}>
|
||||
<SettingsLayout>{page}</SettingsLayout>
|
||||
</DashboardLayout>
|
||||
);
|
||||
};
|
||||
export async function getServerSideProps(
|
||||
ctx: GetServerSidePropsContext<{ serviceId: string }>,
|
||||
) {
|
||||
const { user, session } = await validateRequest(ctx.req, ctx.res);
|
||||
if (!user || user.rol === "user") {
|
||||
return {
|
||||
redirect: {
|
||||
permanent: true,
|
||||
destination: "/",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
props: {},
|
||||
};
|
||||
}
|
||||
15291
pnpm-lock.yaml
generated
@@ -14,6 +14,7 @@ import { mariadbRouter } from "./routers/mariadb";
|
||||
import { mongoRouter } from "./routers/mongo";
|
||||
import { mountRouter } from "./routers/mount";
|
||||
import { mysqlRouter } from "./routers/mysql";
|
||||
import { notificationRouter } from "./routers/notification";
|
||||
import { portRouter } from "./routers/port";
|
||||
import { postgresRouter } from "./routers/postgres";
|
||||
import { projectRouter } from "./routers/project";
|
||||
@@ -54,6 +55,7 @@ export const appRouter = createTRPCRouter({
|
||||
port: portRouter,
|
||||
registry: registryRouter,
|
||||
cluster: clusterRouter,
|
||||
notification: notificationRouter,
|
||||
});
|
||||
|
||||
// export type definition of API
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc";
|
||||
import {
|
||||
createTRPCRouter,
|
||||
protectedProcedure,
|
||||
uploadProcedure,
|
||||
} from "@/server/api/trpc";
|
||||
import { db } from "@/server/db";
|
||||
import {
|
||||
apiCreateApplication,
|
||||
@@ -52,6 +56,9 @@ import {
|
||||
import { removeDeployments } from "../services/deployment";
|
||||
import { addNewService, checkServiceAccess } from "../services/user";
|
||||
|
||||
import { unzipDrop } from "@/server/utils/builders/drop";
|
||||
import { uploadFileSchema } from "@/utils/schema";
|
||||
|
||||
export const applicationRouter = createTRPCRouter({
|
||||
create: protectedProcedure
|
||||
.input(apiCreateApplication)
|
||||
@@ -324,6 +331,45 @@ export const applicationRouter = createTRPCRouter({
|
||||
return traefikConfig;
|
||||
}),
|
||||
|
||||
dropDeployment: protectedProcedure
|
||||
.meta({
|
||||
openapi: {
|
||||
path: "/drop-deployment",
|
||||
method: "POST",
|
||||
override: true,
|
||||
enabled: false,
|
||||
},
|
||||
})
|
||||
.use(uploadProcedure)
|
||||
.input(uploadFileSchema)
|
||||
.mutation(async ({ input }) => {
|
||||
const zipFile = input.zip;
|
||||
|
||||
updateApplication(input.applicationId as string, {
|
||||
sourceType: "drop",
|
||||
dropBuildPath: input.dropBuildPath,
|
||||
});
|
||||
|
||||
const app = await findApplicationById(input.applicationId as string);
|
||||
await unzipDrop(zipFile, app.appName);
|
||||
|
||||
const jobData: DeploymentJob = {
|
||||
applicationId: app.applicationId,
|
||||
titleLog: "Manual deployment",
|
||||
descriptionLog: "",
|
||||
type: "deploy",
|
||||
applicationType: "application",
|
||||
};
|
||||
await myQueue.add(
|
||||
"deployments",
|
||||
{ ...jobData },
|
||||
{
|
||||
removeOnComplete: true,
|
||||
removeOnFail: true,
|
||||
},
|
||||
);
|
||||
return true;
|
||||
}),
|
||||
updateTraefikConfig: protectedProcedure
|
||||
.input(z.object({ applicationId: z.string(), traefikConfig: z.string() }))
|
||||
.mutation(async ({ input }) => {
|
||||
|
||||
@@ -90,6 +90,7 @@ export const backupRouter = createTRPCRouter({
|
||||
const backup = await findBackupById(input.backupId);
|
||||
const postgres = await findPostgresByBackupId(backup.backupId);
|
||||
await runPostgresBackup(postgres, backup);
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
} from "@/templates/utils";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { eq } from "drizzle-orm";
|
||||
import _ from "lodash";
|
||||
import { nanoid } from "nanoid";
|
||||
import { findAdmin } from "../services/admin";
|
||||
import {
|
||||
@@ -283,4 +284,10 @@ export const composeRouter = createTRPCRouter({
|
||||
|
||||
return templatesData;
|
||||
}),
|
||||
|
||||
getTags: protectedProcedure.query(async ({ input }) => {
|
||||
const allTags = templates.flatMap((template) => template.tags);
|
||||
const uniqueTags = _.uniq(allTags);
|
||||
return uniqueTags;
|
||||
}),
|
||||
});
|
||||
|
||||
255
server/api/routers/notification.ts
Normal file
@@ -0,0 +1,255 @@
|
||||
import {
|
||||
adminProcedure,
|
||||
createTRPCRouter,
|
||||
protectedProcedure,
|
||||
} from "@/server/api/trpc";
|
||||
import { db } from "@/server/db";
|
||||
import {
|
||||
apiCreateDiscord,
|
||||
apiCreateEmail,
|
||||
apiCreateSlack,
|
||||
apiCreateTelegram,
|
||||
apiFindOneNotification,
|
||||
apiTestDiscordConnection,
|
||||
apiTestEmailConnection,
|
||||
apiTestSlackConnection,
|
||||
apiTestTelegramConnection,
|
||||
apiUpdateDiscord,
|
||||
apiUpdateEmail,
|
||||
apiUpdateSlack,
|
||||
apiUpdateTelegram,
|
||||
notifications,
|
||||
} from "@/server/db/schema";
|
||||
import {
|
||||
sendDiscordNotification,
|
||||
sendEmailNotification,
|
||||
sendSlackNotification,
|
||||
sendTelegramNotification,
|
||||
} from "@/server/utils/notifications/utils";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { desc } from "drizzle-orm";
|
||||
import {
|
||||
createDiscordNotification,
|
||||
createEmailNotification,
|
||||
createSlackNotification,
|
||||
createTelegramNotification,
|
||||
findNotificationById,
|
||||
removeNotificationById,
|
||||
updateDiscordNotification,
|
||||
updateEmailNotification,
|
||||
updateSlackNotification,
|
||||
updateTelegramNotification,
|
||||
} from "../services/notification";
|
||||
|
||||
export const notificationRouter = createTRPCRouter({
|
||||
createSlack: adminProcedure
|
||||
.input(apiCreateSlack)
|
||||
.mutation(async ({ input }) => {
|
||||
try {
|
||||
return await createSlackNotification(input);
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Error to create the notification",
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
}),
|
||||
updateSlack: adminProcedure
|
||||
.input(apiUpdateSlack)
|
||||
.mutation(async ({ input }) => {
|
||||
try {
|
||||
return await updateSlackNotification(input);
|
||||
} catch (error) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Error to update the notification",
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
}),
|
||||
testSlackConnection: adminProcedure
|
||||
.input(apiTestSlackConnection)
|
||||
.mutation(async ({ input }) => {
|
||||
try {
|
||||
await sendSlackNotification(input, {
|
||||
channel: input.channel,
|
||||
text: "Hi, From Dokploy 👋",
|
||||
});
|
||||
return true;
|
||||
} catch (error) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Error to test the notification",
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
}),
|
||||
createTelegram: adminProcedure
|
||||
.input(apiCreateTelegram)
|
||||
.mutation(async ({ input }) => {
|
||||
try {
|
||||
return await createTelegramNotification(input);
|
||||
} catch (error) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Error to create the notification",
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
}),
|
||||
|
||||
updateTelegram: adminProcedure
|
||||
.input(apiUpdateTelegram)
|
||||
.mutation(async ({ input }) => {
|
||||
try {
|
||||
return await updateTelegramNotification(input);
|
||||
} catch (error) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Error to update the notification",
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
}),
|
||||
testTelegramConnection: adminProcedure
|
||||
.input(apiTestTelegramConnection)
|
||||
.mutation(async ({ input }) => {
|
||||
try {
|
||||
await sendTelegramNotification(input, "Hi, From Dokploy 👋");
|
||||
return true;
|
||||
} catch (error) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Error to test the notification",
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
}),
|
||||
createDiscord: adminProcedure
|
||||
.input(apiCreateDiscord)
|
||||
.mutation(async ({ input }) => {
|
||||
try {
|
||||
// go to your discord server
|
||||
// go to settings
|
||||
// go to integrations
|
||||
// add a new integration
|
||||
// select webhook
|
||||
// copy the webhook url
|
||||
return await createDiscordNotification(input);
|
||||
} catch (error) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Error to create the notification",
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
}),
|
||||
|
||||
updateDiscord: adminProcedure
|
||||
.input(apiUpdateDiscord)
|
||||
.mutation(async ({ input }) => {
|
||||
try {
|
||||
return await updateDiscordNotification(input);
|
||||
} catch (error) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Error to update the notification",
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
}),
|
||||
|
||||
testDiscordConnection: adminProcedure
|
||||
.input(apiTestDiscordConnection)
|
||||
.mutation(async ({ input }) => {
|
||||
try {
|
||||
await sendDiscordNotification(input, {
|
||||
title: "Test Notification",
|
||||
description: "Hi, From Dokploy 👋",
|
||||
});
|
||||
return true;
|
||||
} catch (error) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Error to test the notification",
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
}),
|
||||
createEmail: adminProcedure
|
||||
.input(apiCreateEmail)
|
||||
.mutation(async ({ input }) => {
|
||||
try {
|
||||
return await createEmailNotification(input);
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Error to create the notification",
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
}),
|
||||
updateEmail: adminProcedure
|
||||
.input(apiUpdateEmail)
|
||||
.mutation(async ({ input }) => {
|
||||
try {
|
||||
return await updateEmailNotification(input);
|
||||
} catch (error) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Error to update the notification",
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
}),
|
||||
testEmailConnection: adminProcedure
|
||||
.input(apiTestEmailConnection)
|
||||
.mutation(async ({ input }) => {
|
||||
try {
|
||||
await sendEmailNotification(
|
||||
input,
|
||||
"Test Email",
|
||||
"<p>Hi, From Dokploy 👋</p>",
|
||||
);
|
||||
return true;
|
||||
} catch (error) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Error to test the notification",
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
}),
|
||||
remove: adminProcedure
|
||||
.input(apiFindOneNotification)
|
||||
.mutation(async ({ input }) => {
|
||||
try {
|
||||
return await removeNotificationById(input.notificationId);
|
||||
} catch (error) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Error to delete this notification",
|
||||
});
|
||||
}
|
||||
}),
|
||||
one: protectedProcedure
|
||||
.input(apiFindOneNotification)
|
||||
.query(async ({ input }) => {
|
||||
const notification = await findNotificationById(input.notificationId);
|
||||
return notification;
|
||||
}),
|
||||
all: adminProcedure.query(async () => {
|
||||
return await db.query.notifications.findMany({
|
||||
with: {
|
||||
slack: true,
|
||||
telegram: true,
|
||||
discord: true,
|
||||
email: true,
|
||||
},
|
||||
orderBy: desc(notifications.createdAt),
|
||||
});
|
||||
}),
|
||||
});
|
||||