Merge pull request #251 from Dokploy/canary

v0.4.0
This commit is contained in:
Mauricio Siu
2024-07-22 03:38:47 -06:00
committed by GitHub
137 changed files with 36960 additions and 6925 deletions

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

View File

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

View File

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

@@ -0,0 +1,4 @@
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"
pnpm commitlint --edit $1

1
.nvmrc Normal file
View File

@@ -0,0 +1 @@
18.18.0

View File

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

View 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);
});
});

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1 @@
Gogogogogogo

View File

@@ -0,0 +1 @@
gogogogogog

View File

@@ -0,0 +1 @@
gogogogogogogogogo

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1 @@
dsafasdfasdf

Binary file not shown.

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

View File

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

View File

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

View File

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

View File

@@ -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>
);
};

View File

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

View File

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

View File

@@ -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" && (

View File

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

View File

@@ -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());

View File

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

View File

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

View File

@@ -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());

View File

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

View File

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

View File

@@ -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());

View File

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

View File

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

View File

@@ -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());

View File

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

View File

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

View File

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

View File

@@ -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());

View File

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

View File

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

View File

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

View File

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

View 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>
);
};

View File

@@ -0,0 +1,61 @@
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import { api } from "@/utils/api";
import { TrashIcon } from "lucide-react";
import React from "react";
import { toast } from "sonner";
interface Props {
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>
);
};

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View 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>
);
};

View File

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

View 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>
);
},
);

View 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 $$;

View File

@@ -0,0 +1 @@
ALTER TABLE "notification" ADD COLUMN "dockerCleanup" boolean DEFAULT false NOT NULL;

View File

@@ -0,0 +1 @@
ALTER TABLE "notification" DROP COLUMN IF EXISTS "userJoin";

View File

@@ -0,0 +1 @@
ALTER TYPE "sourceType" ADD VALUE 'drop';

View File

@@ -0,0 +1 @@
ALTER TABLE "application" ADD COLUMN "dropBuildPath" text;

View File

@@ -0,0 +1 @@
ALTER TABLE "mount" ADD COLUMN "filePath" text;

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View 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
View File

@@ -0,0 +1,2 @@
/node_modules
/dist

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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&apos;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",
};

View 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,
};

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 426 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

View 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",
};

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

File diff suppressed because it is too large Load Diff

27
emails/readme.md Normal file
View 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

View File

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

View File

@@ -18,7 +18,6 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
res.status(401).json({ message: "Unauthorized" });
return;
}
// @ts-ignore
return createOpenApiNextHandler({
router: appRouter,

View File

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

View File

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

View File

@@ -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",
},
};

View 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

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -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 }) => {

View File

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

View File

@@ -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;
}),
});

View 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),
});
}),
});

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