diff --git a/.circleci/config.yml b/.circleci/config.yml deleted file mode 100644 index ba405c22e..000000000 --- a/.circleci/config.yml +++ /dev/null @@ -1,119 +0,0 @@ -version: 2.1 - -jobs: - build-amd64: - machine: - image: ubuntu-2004:current - steps: - - checkout - - run: - name: Prepare .env file - command: | - cp apps/dokploy/.env.production.example .env.production - cp apps/dokploy/.env.production.example apps/dokploy/.env.production - - - run: - name: Build and push AMD64 image - command: | - docker login -u $DOCKERHUB_USERNAME -p $DOCKERHUB_TOKEN - if [ "${CIRCLE_BRANCH}" == "main" ]; then - TAG="latest" - elif [ "${CIRCLE_BRANCH}" == "canary" ]; then - TAG="canary" - else - TAG="feature" - fi - docker build --platform linux/amd64 -t dokploy/dokploy:${TAG}-amd64 . - docker push dokploy/dokploy:${TAG}-amd64 - - build-arm64: - machine: - image: ubuntu-2004:current - resource_class: arm.large - steps: - - checkout - - run: - name: Prepare .env file - command: | - cp apps/dokploy/.env.production.example .env.production - cp apps/dokploy/.env.production.example apps/dokploy/.env.production - - run: - name: Build and push ARM64 image - command: | - docker login -u $DOCKERHUB_USERNAME -p $DOCKERHUB_TOKEN - if [ "${CIRCLE_BRANCH}" == "main" ]; then - TAG="latest" - elif [ "${CIRCLE_BRANCH}" == "canary" ]; then - TAG="canary" - else - TAG="feature" - fi - docker build --platform linux/arm64 -t dokploy/dokploy:${TAG}-arm64 . - docker push dokploy/dokploy:${TAG}-arm64 - - combine-manifests: - docker: - - image: cimg/node:20.9.0 - steps: - - checkout - - setup_remote_docker - - run: - name: Create and push multi-arch manifest - command: | - docker login -u $DOCKERHUB_USERNAME -p $DOCKERHUB_TOKEN - - if [ "${CIRCLE_BRANCH}" == "main" ]; then - VERSION=$(node -p "require('./apps/dokploy/package.json').version") - echo $VERSION - TAG="latest" - - docker manifest create dokploy/dokploy:${TAG} \ - dokploy/dokploy:${TAG}-amd64 \ - dokploy/dokploy:${TAG}-arm64 - docker manifest push dokploy/dokploy:${TAG} - - docker manifest create dokploy/dokploy:${VERSION} \ - dokploy/dokploy:${TAG}-amd64 \ - dokploy/dokploy:${TAG}-arm64 - docker manifest push dokploy/dokploy:${VERSION} - elif [ "${CIRCLE_BRANCH}" == "canary" ]; then - TAG="canary" - docker manifest create dokploy/dokploy:${TAG} \ - dokploy/dokploy:${TAG}-amd64 \ - dokploy/dokploy:${TAG}-arm64 - docker manifest push dokploy/dokploy:${TAG} - else - TAG="feature" - docker manifest create dokploy/dokploy:${TAG} \ - dokploy/dokploy:${TAG}-amd64 \ - dokploy/dokploy:${TAG}-arm64 - docker manifest push dokploy/dokploy:${TAG} - fi - -workflows: - build-all: - jobs: - - build-amd64: - filters: - branches: - only: - - main - - canary - - feat/add-sidebar - - build-arm64: - filters: - branches: - only: - - main - - canary - - feat/add-sidebar - - combine-manifests: - requires: - - build-amd64 - - build-arm64 - filters: - branches: - only: - - main - - canary - - feat/add-sidebar diff --git a/.github/workflows/create-pr.yml b/.github/workflows/create-pr.yml new file mode 100644 index 000000000..994231bb5 --- /dev/null +++ b/.github/workflows/create-pr.yml @@ -0,0 +1,84 @@ +name: Auto PR to main when version changes + +on: + push: + branches: + - canary + +permissions: + contents: write + pull-requests: write + +jobs: + create-pr: + runs-on: ubuntu-latest + steps: + - name: Checkout Repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Get version from package.json + id: package_version + run: echo "VERSION=$(jq -r .version ./apps/dokploy/package.json)" >> $GITHUB_ENV + + - name: Get latest GitHub tag + id: latest_tag + run: | + LATEST_TAG=$(git ls-remote --tags origin | awk -F'/' '{print $3}' | sort -V | tail -n1) + echo "LATEST_TAG=$LATEST_TAG" >> $GITHUB_ENV + echo $LATEST_TAG + - name: Compare versions + id: compare_versions + run: | + if [ "${{ env.VERSION }}" != "${{ env.LATEST_TAG }}" ]; then + VERSION_CHANGED="true" + else + VERSION_CHANGED="false" + fi + echo "VERSION_CHANGED=$VERSION_CHANGED" >> $GITHUB_ENV + echo "Comparing versions:" + echo "Current version: ${{ env.VERSION }}" + echo "Latest tag: ${{ env.LATEST_TAG }}" + echo "Version changed: $VERSION_CHANGED" + - name: Check if a PR already exists + id: check_pr + run: | + PR_EXISTS=$(gh pr list --state open --base main --head canary --json number --jq '. | length') + echo "PR_EXISTS=$PR_EXISTS" >> $GITHUB_ENV + env: + GH_TOKEN: ${{ secrets.GH_PAT }} + + - name: Create Pull Request + if: env.VERSION_CHANGED == 'true' && env.PR_EXISTS == '0' + run: | + git config --global user.name "github-actions[bot]" + git config --global user.email "41898282+github-actions[bot]@users.noreply.github.com" + + git fetch origin main + git checkout canary + git push origin canary + + gh pr create \ + --title "🚀 Release ${{ env.VERSION }}" \ + --body ' + This PR promotes changes from `canary` to `main` for version ${{ env.VERSION }}. + + ### 🔍 Changes Include: + - Version bump to ${{ env.VERSION }} + - All changes from canary branch + + ### ✅ Pre-merge Checklist: + - [ ] All tests passing + - [ ] Documentation updated + - [ ] Docker images built and tested + + > 🤖 This PR was automatically generated by [GitHub Actions](https://github.com/actions)' \ + --base main \ + --head canary \ + --draft \ + --label "release" --label "automated pr" || true \ + --reviewer siumauricio \ + --assignee siumauricio + env: + GH_TOKEN: ${{ github.token }} diff --git a/.github/workflows/dokploy.yml b/.github/workflows/dokploy.yml new file mode 100644 index 000000000..0f52ae36b --- /dev/null +++ b/.github/workflows/dokploy.yml @@ -0,0 +1,134 @@ +name: Dokploy Docker Build + +on: + push: + branches: [main, canary, feat/github-runners] + +env: + IMAGE_NAME: dokploy/dokploy + +jobs: + docker-amd: + runs-on: ubuntu-22.04 + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Login to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Set tag and version + id: meta + run: | + if [ "${{ github.ref }}" = "refs/heads/main" ]; then + TAG="latest" + VERSION=$(node -p "require('./apps/dokploy/package.json').version") + elif [ "${{ github.ref }}" = "refs/heads/canary" ]; then + TAG="canary" + else + TAG="feature" + fi + echo "tags=${IMAGE_NAME}:${TAG}-amd64" >> $GITHUB_OUTPUT + + - name: Prepare env file + run: | + cp apps/dokploy/.env.production.example .env.production + cp apps/dokploy/.env.production.example apps/dokploy/.env.production + + - name: Build and push + uses: docker/build-push-action@v5 + with: + context: . + platforms: linux/amd64 + push: true + tags: ${{ steps.meta.outputs.tags }} + docker-arm: + runs-on: ubuntu-24.04-arm + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Login to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Set tag and version + id: meta + run: | + VERSION=$(node -p "require('./apps/dokploy/package.json').version") + if [ "${{ github.ref }}" = "refs/heads/main" ]; then + TAG="latest" + VERSION=$(node -p "require('./apps/dokploy/package.json').version") + elif [ "${{ github.ref }}" = "refs/heads/canary" ]; then + TAG="canary" + else + TAG="feature" + fi + echo "tags=${IMAGE_NAME}:${TAG}-arm64" >> $GITHUB_OUTPUT + + - name: Prepare env file + run: | + cp apps/dokploy/.env.production.example .env.production + cp apps/dokploy/.env.production.example apps/dokploy/.env.production + + - name: Build and push + uses: docker/build-push-action@v5 + with: + context: . + platforms: linux/arm64 + push: true + tags: ${{ steps.meta.outputs.tags }} + + combine-manifests: + needs: [docker-amd, docker-arm] + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Login to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Create and push manifests + run: | + if [ "${{ github.ref }}" = "refs/heads/main" ]; then + VERSION=$(node -p "require('./apps/dokploy/package.json').version") + TAG="latest" + + docker buildx imagetools create -t ${IMAGE_NAME}:${TAG} \ + ${IMAGE_NAME}:${TAG}-amd64 \ + ${IMAGE_NAME}:${TAG}-arm64 + + docker buildx imagetools create -t ${IMAGE_NAME}:${VERSION} \ + ${IMAGE_NAME}:${TAG}-amd64 \ + ${IMAGE_NAME}:${TAG}-arm64 + + elif [ "${{ github.ref }}" = "refs/heads/canary" ]; then + TAG="canary" + docker buildx imagetools create -t ${IMAGE_NAME}:${TAG} \ + ${IMAGE_NAME}:${TAG}-amd64 \ + ${IMAGE_NAME}:${TAG}-arm64 + + else + TAG="feature" + docker buildx imagetools create -t ${IMAGE_NAME}:${TAG} \ + ${IMAGE_NAME}:${TAG}-amd64 \ + ${IMAGE_NAME}:${TAG}-arm64 + fi diff --git a/apps/dokploy/__test__/deploy/github.test.ts b/apps/dokploy/__test__/deploy/github.test.ts new file mode 100644 index 000000000..18d7619ab --- /dev/null +++ b/apps/dokploy/__test__/deploy/github.test.ts @@ -0,0 +1,98 @@ +import { extractCommitMessage } from "@/pages/api/deploy/[refreshToken]"; +import { describe, expect, it } from "vitest"; + +describe("GitHub Webhook Skip CI", () => { + const mockGithubHeaders = { + "x-github-event": "push", + }; + + const createMockBody = (message: string) => ({ + head_commit: { + message, + }, + }); + + const skipKeywords = [ + "[skip ci]", + "[ci skip]", + "[no ci]", + "[skip actions]", + "[actions skip]", + ]; + + it("should detect skip keywords in commit message", () => { + for (const keyword of skipKeywords) { + const message = `feat: add new feature ${keyword}`; + const commitMessage = extractCommitMessage( + mockGithubHeaders, + createMockBody(message), + ); + expect(commitMessage.includes(keyword)).toBe(true); + } + }); + + it("should not detect skip keywords in normal commit message", () => { + const message = "feat: add new feature"; + const commitMessage = extractCommitMessage( + mockGithubHeaders, + createMockBody(message), + ); + for (const keyword of skipKeywords) { + expect(commitMessage.includes(keyword)).toBe(false); + } + }); + + it("should handle different webhook sources", () => { + // GitHub + expect( + extractCommitMessage( + { "x-github-event": "push" }, + { head_commit: { message: "[skip ci] test" } }, + ), + ).toBe("[skip ci] test"); + + // GitLab + expect( + extractCommitMessage( + { "x-gitlab-event": "push" }, + { commits: [{ message: "[skip ci] test" }] }, + ), + ).toBe("[skip ci] test"); + + // Bitbucket + expect( + extractCommitMessage( + { "x-event-key": "repo:push" }, + { + push: { + changes: [{ new: { target: { message: "[skip ci] test" } } }], + }, + }, + ), + ).toBe("[skip ci] test"); + + // Gitea + expect( + extractCommitMessage( + { "x-gitea-event": "push" }, + { commits: [{ message: "[skip ci] test" }] }, + ), + ).toBe("[skip ci] test"); + }); + + it("should handle missing commit message", () => { + expect(extractCommitMessage(mockGithubHeaders, {})).toBe("NEW COMMIT"); + expect(extractCommitMessage({ "x-gitlab-event": "push" }, {})).toBe( + "NEW COMMIT", + ); + expect( + extractCommitMessage( + { "x-event-key": "repo:push" }, + { push: { changes: [] } }, + ), + ).toBe("NEW COMMIT"); + expect(extractCommitMessage({ "x-gitea-event": "push" }, {})).toBe( + "NEW COMMIT", + ); + }); +}); diff --git a/apps/dokploy/__test__/traefik/server/update-server-config.test.ts b/apps/dokploy/__test__/traefik/server/update-server-config.test.ts index c966748a7..fac90cc72 100644 --- a/apps/dokploy/__test__/traefik/server/update-server-config.test.ts +++ b/apps/dokploy/__test__/traefik/server/update-server-config.test.ts @@ -14,6 +14,9 @@ import { import { beforeEach, expect, test, vi } from "vitest"; const baseAdmin: Admin = { + cleanupCacheApplications: false, + cleanupCacheOnCompose: false, + cleanupCacheOnPreviews: false, createdAt: "", authId: "", adminId: "string", diff --git a/apps/dokploy/__test__/vitest.config.ts b/apps/dokploy/__test__/vitest.config.ts index 14eabf695..ddc84d6ac 100644 --- a/apps/dokploy/__test__/vitest.config.ts +++ b/apps/dokploy/__test__/vitest.config.ts @@ -13,6 +13,7 @@ export default defineConfig({ NODE: "test", }, }, + plugins: [tsconfigPaths()], resolve: { alias: { "@dokploy/server": path.resolve( diff --git a/apps/dokploy/components/dashboard/database/backups/show-backups.tsx b/apps/dokploy/components/dashboard/database/backups/show-backups.tsx index 6ecdbe021..21fe28d4a 100644 --- a/apps/dokploy/components/dashboard/database/backups/show-backups.tsx +++ b/apps/dokploy/components/dashboard/database/backups/show-backups.tsx @@ -75,14 +75,14 @@ export const ShowBackups = ({ id, type }: Props) => { {data?.length === 0 ? (
- + To create a backup it is required to set at least 1 provider. Please, go to{" "} - Settings + S3 Destinations {" "} to do so. diff --git a/apps/dokploy/components/dashboard/docker/show/show-containers.tsx b/apps/dokploy/components/dashboard/docker/show/show-containers.tsx index 8c1acfe01..c66c9b9ba 100644 --- a/apps/dokploy/components/dashboard/docker/show/show-containers.tsx +++ b/apps/dokploy/components/dashboard/docker/show/show-containers.tsx @@ -80,7 +80,7 @@ export const ShowContainers = ({ serverId }: Props) => { return (
- +
diff --git a/apps/dokploy/components/dashboard/file-system/show-traefik-system.tsx b/apps/dokploy/components/dashboard/file-system/show-traefik-system.tsx index d2263f747..ed2ed1974 100644 --- a/apps/dokploy/components/dashboard/file-system/show-traefik-system.tsx +++ b/apps/dokploy/components/dashboard/file-system/show-traefik-system.tsx @@ -35,7 +35,7 @@ export const ShowTraefikSystem = ({ serverId }: Props) => { return (
- +
diff --git a/apps/dokploy/components/dashboard/monitoring/docker/show.tsx b/apps/dokploy/components/dashboard/monitoring/docker/show.tsx index 2e5eecef0..a457f35e6 100644 --- a/apps/dokploy/components/dashboard/monitoring/docker/show.tsx +++ b/apps/dokploy/components/dashboard/monitoring/docker/show.tsx @@ -200,7 +200,7 @@ export const DockerMonitoring = ({
-
+
CPU Usage diff --git a/apps/dokploy/components/dashboard/monitoring/web-server/show.tsx b/apps/dokploy/components/dashboard/monitoring/web-server/show.tsx index f6bd8cc29..d6f15057e 100644 --- a/apps/dokploy/components/dashboard/monitoring/web-server/show.tsx +++ b/apps/dokploy/components/dashboard/monitoring/web-server/show.tsx @@ -3,7 +3,7 @@ import { DockerMonitoring } from "../docker/show"; export const ShowMonitoring = () => { return ( -
+
); diff --git a/apps/dokploy/components/dashboard/project/add-template.tsx b/apps/dokploy/components/dashboard/project/add-template.tsx index 068fc984d..90d173640 100644 --- a/apps/dokploy/components/dashboard/project/add-template.tsx +++ b/apps/dokploy/components/dashboard/project/add-template.tsx @@ -114,26 +114,28 @@ export const AddTemplate = ({ projectId }: Props) => { -
-
+
+
Create from Template Create an open source application from a template
-
+
setQuery(e.target.value)} - className="w-[200px]" + className="w-full sm:w-[200px]" value={query} /> )} + + {type === "gotify" && ( + <> + ( + + Server URL + + + + + + )} + /> + ( + + App Token + + + + + + )} + /> + ( + + Priority + + { + const value = e.target.value; + if (value) { + const port = Number.parseInt(value); + if (port > 0 && port < 10) { + field.onChange(port); + } + } + }} + type="number" + /> + + + Message priority (1-10, default: 5) + + + + )} + /> + ( + +
+ Decoration + + Decorate the notification with emojis. + +
+ + + +
+ )} + /> + + )}
@@ -824,7 +965,8 @@ export const HandleNotifications = ({ notificationId }: Props) => { isLoadingSlack || isLoadingTelegram || isLoadingDiscord || - isLoadingEmail + isLoadingEmail || + isLoadingGotify } variant="secondary" onClick={async () => { @@ -853,6 +995,13 @@ export const HandleNotifications = ({ notificationId }: Props) => { toAddresses: form.getValues("toAddresses"), fromAddress: form.getValues("fromAddress"), }); + } else if (type === "gotify") { + await testGotifyConnection({ + serverUrl: form.getValues("serverUrl"), + appToken: form.getValues("appToken"), + priority: form.getValues("priority"), + decoration: form.getValues("decoration"), + }); } toast.success("Connection Success"); } catch (err) { diff --git a/apps/dokploy/components/dashboard/settings/notifications/show-notifications.tsx b/apps/dokploy/components/dashboard/settings/notifications/show-notifications.tsx index 75b66622c..d65069d4b 100644 --- a/apps/dokploy/components/dashboard/settings/notifications/show-notifications.tsx +++ b/apps/dokploy/components/dashboard/settings/notifications/show-notifications.tsx @@ -13,7 +13,7 @@ import { CardTitle, } from "@/components/ui/card"; import { api } from "@/utils/api"; -import { Bell, Loader2, Mail, Trash2 } from "lucide-react"; +import { Bell, Loader2, Mail, MessageCircleMore, Trash2 } from "lucide-react"; import { toast } from "sonner"; import { HandleNotifications } from "./handle-notifications"; @@ -47,7 +47,7 @@ export const ShowNotifications = () => { {data?.length === 0 ? (
- + To send notifications it is required to set at least 1 provider. @@ -83,6 +83,11 @@ export const ShowNotifications = () => {
)} + {notification.notificationType === "gotify" && ( +
+ +
+ )} {notification.name} diff --git a/apps/dokploy/components/dashboard/settings/users/add-permissions.tsx b/apps/dokploy/components/dashboard/settings/users/add-permissions.tsx index bb82c9829..7c1f50375 100644 --- a/apps/dokploy/components/dashboard/settings/users/add-permissions.tsx +++ b/apps/dokploy/components/dashboard/settings/users/add-permissions.tsx @@ -30,8 +30,8 @@ import { toast } from "sonner"; import { z } from "zod"; const addPermissions = z.object({ - accesedProjects: z.array(z.string()).optional(), - accesedServices: z.array(z.string()).optional(), + accessedProjects: z.array(z.string()).optional(), + accessedServices: z.array(z.string()).optional(), canCreateProjects: z.boolean().optional().default(false), canCreateServices: z.boolean().optional().default(false), canDeleteProjects: z.boolean().optional().default(false), @@ -66,8 +66,8 @@ export const AddUserPermissions = ({ userId }: Props) => { const form = useForm({ defaultValues: { - accesedProjects: [], - accesedServices: [], + accessedProjects: [], + accessedServices: [], }, resolver: zodResolver(addPermissions), }); @@ -75,8 +75,8 @@ export const AddUserPermissions = ({ userId }: Props) => { useEffect(() => { if (data) { form.reset({ - accesedProjects: data.accesedProjects || [], - accesedServices: data.accesedServices || [], + accessedProjects: data.accessedProjects || [], + accessedServices: data.accessedServices || [], canCreateProjects: data.canCreateProjects, canCreateServices: data.canCreateServices, canDeleteProjects: data.canDeleteProjects, @@ -98,8 +98,8 @@ export const AddUserPermissions = ({ userId }: Props) => { canDeleteServices: data.canDeleteServices, canDeleteProjects: data.canDeleteProjects, canAccessToTraefikFiles: data.canAccessToTraefikFiles, - accesedProjects: data.accesedProjects || [], - accesedServices: data.accesedServices || [], + accessedProjects: data.accessedProjects || [], + accessedServices: data.accessedServices || [], canAccessToDocker: data.canAccessToDocker, canAccessToAPI: data.canAccessToAPI, canAccessToSSHKeys: data.canAccessToSSHKeys, @@ -318,7 +318,7 @@ export const AddUserPermissions = ({ userId }: Props) => { /> (
@@ -339,7 +339,7 @@ export const AddUserPermissions = ({ userId }: Props) => { { return ( { { return ( {node.Hostname}
{node.ManagerStatus || "Worker"}
-
+
TLS Status: {node.TLSStatus} Availability: {node.Availability}
diff --git a/apps/dokploy/components/dashboard/swarm/monitoring-card.tsx b/apps/dokploy/components/dashboard/swarm/monitoring-card.tsx index 3418060dd..0c38b509f 100644 --- a/apps/dokploy/components/dashboard/swarm/monitoring-card.tsx +++ b/apps/dokploy/components/dashboard/swarm/monitoring-card.tsx @@ -70,9 +70,9 @@ export default function SwarmMonitorCard({ serverId }: Props) { ); return ( - +
-
+
@@ -94,7 +94,7 @@ export default function SwarmMonitorCard({ serverId }: Props) { )}
-
+
Total Nodes diff --git a/apps/dokploy/components/layouts/dashboard-layout.tsx b/apps/dokploy/components/layouts/dashboard-layout.tsx index 13e9061d3..00697e7c4 100644 --- a/apps/dokploy/components/layouts/dashboard-layout.tsx +++ b/apps/dokploy/components/layouts/dashboard-layout.tsx @@ -5,9 +5,5 @@ interface Props { } export const DashboardLayout = ({ children }: Props) => { - return ( - -
{children}
-
- ); + return {children}; }; diff --git a/apps/dokploy/components/layouts/onboarding-layout.tsx b/apps/dokploy/components/layouts/onboarding-layout.tsx index 093c14921..9d4068cf1 100644 --- a/apps/dokploy/components/layouts/onboarding-layout.tsx +++ b/apps/dokploy/components/layouts/onboarding-layout.tsx @@ -11,7 +11,7 @@ interface Props { export const OnboardingLayout = ({ children }: Props) => { return (
-
+
{
-

+

“The Open Source alternative to Netlify, Vercel, Heroku.”

- {/*
Sofia Davis
*/}
{children} - - {/*

- By clicking continue, you agree to our{" "} - - Terms of Service - {" "} - and{" "} - - Privacy Policy - - . -

*/}
diff --git a/apps/dokploy/pages/dashboard/project/[projectId]/services/mongo/[mongoId].tsx b/apps/dokploy/pages/dashboard/project/[projectId]/services/mongo/[mongoId].tsx index dfc003667..49dcfa656 100644 --- a/apps/dokploy/pages/dashboard/project/[projectId]/services/mongo/[mongoId].tsx +++ b/apps/dokploy/pages/dashboard/project/[projectId]/services/mongo/[mongoId].tsx @@ -165,10 +165,10 @@ const Mongo = ( router.push( `/dashboard/project/${data?.projectId}`, ); - toast.success("Postgres deleted successfully"); + toast.success("Mongo deleted successfully"); }) .catch(() => { - toast.error("Error deleting the postgres"); + toast.error("Error deleting the mongo"); }); }} > diff --git a/apps/dokploy/pages/dashboard/settings/index.tsx b/apps/dokploy/pages/dashboard/settings/index.tsx new file mode 100644 index 000000000..bf76607b4 --- /dev/null +++ b/apps/dokploy/pages/dashboard/settings/index.tsx @@ -0,0 +1,220 @@ +import { DashboardLayout } from "@/components/layouts/dashboard-layout"; + +import { AlertBlock } from "@/components/shared/alert-block"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { DialogFooter } from "@/components/ui/dialog"; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { Switch } from "@/components/ui/switch"; +import { appRouter } from "@/server/api/root"; +import { api } from "@/utils/api"; +import { validateRequest } from "@dokploy/server"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { createServerSideHelpers } from "@trpc/react-query/server"; +import { Settings } from "lucide-react"; +import type { GetServerSidePropsContext } from "next"; +import React, { useEffect, type ReactElement } from "react"; +import { useForm } from "react-hook-form"; +import { toast } from "sonner"; +import superjson from "superjson"; +import { z } from "zod"; + +const settings = z.object({ + cleanCacheOnApplications: z.boolean(), + cleanCacheOnCompose: z.boolean(), + cleanCacheOnPreviews: z.boolean(), +}); + +type SettingsType = z.infer; + +const Page = () => { + const { data, refetch } = api.admin.one.useQuery(); + const { mutateAsync, isLoading, isError, error } = + api.admin.update.useMutation(); + const form = useForm({ + defaultValues: { + cleanCacheOnApplications: false, + cleanCacheOnCompose: false, + cleanCacheOnPreviews: false, + }, + resolver: zodResolver(settings), + }); + useEffect(() => { + form.reset({ + cleanCacheOnApplications: data?.cleanupCacheApplications || false, + cleanCacheOnCompose: data?.cleanupCacheOnCompose || false, + cleanCacheOnPreviews: data?.cleanupCacheOnPreviews || false, + }); + }, [form, form.reset, form.formState.isSubmitSuccessful, data]); + + const onSubmit = async (values: SettingsType) => { + await mutateAsync({ + cleanupCacheApplications: values.cleanCacheOnApplications, + cleanupCacheOnCompose: values.cleanCacheOnCompose, + cleanupCacheOnPreviews: values.cleanCacheOnPreviews, + }) + .then(() => { + toast.success("Settings updated"); + refetch(); + }) + .catch(() => { + toast.error("Something went wrong"); + }); + }; + return ( +
+ +
+ + + + Settings + + Manage your Dokploy settings + {isError && {error?.message}} + + +
+ + ( + +
+ Clean Cache on Applications + + Clean the cache after every application deployment + +
+ + + +
+ )} + /> + ( + +
+ Clean Cache on Previews + + Clean the cache after every preview deployment + +
+ + + +
+ )} + /> + ( + +
+ Clean Cache on Compose + + Clean the cache after every compose deployment + +
+ + + +
+ )} + /> + + + + + +
+
+
+
+ ); +}; + +export default Page; + +Page.getLayout = (page: ReactElement) => { + return {page}; +}; +export async function getServerSideProps( + ctx: GetServerSidePropsContext<{ serviceId: string }>, +) { + const { req, res } = ctx; + const { user, session } = await validateRequest(ctx.req, ctx.res); + if (!user) { + return { + redirect: { + permanent: true, + destination: "/", + }, + }; + } + if (user.rol === "user") { + return { + redirect: { + permanent: true, + destination: "/dashboard/settings/profile", + }, + }; + } + + const helpers = createServerSideHelpers({ + router: appRouter, + ctx: { + req: req as any, + res: res as any, + db: null as any, + session: session, + user: user, + }, + transformer: superjson, + }); + await helpers.auth.get.prefetch(); + + return { + props: { + trpcState: helpers.dehydrate(), + }, + }; +} diff --git a/apps/dokploy/pages/dashboard/settings/profile.tsx b/apps/dokploy/pages/dashboard/settings/profile.tsx index 62ab6150c..44e007f11 100644 --- a/apps/dokploy/pages/dashboard/settings/profile.tsx +++ b/apps/dokploy/pages/dashboard/settings/profile.tsx @@ -26,7 +26,7 @@ const Page = () => { const { data: isCloud } = api.settings.isCloud.useQuery(); return (
-
+
{(user?.canAccessToAPI || data?.rol === "admin") && } diff --git a/apps/dokploy/pages/dashboard/settings/server.tsx b/apps/dokploy/pages/dashboard/settings/server.tsx index 82b0359f9..d501f1fa6 100644 --- a/apps/dokploy/pages/dashboard/settings/server.tsx +++ b/apps/dokploy/pages/dashboard/settings/server.tsx @@ -13,7 +13,7 @@ import superjson from "superjson"; const Page = () => { return (
-
+
diff --git a/apps/dokploy/pages/dashboard/swarm.tsx b/apps/dokploy/pages/dashboard/swarm.tsx index f40a0a832..3a8a60b28 100644 --- a/apps/dokploy/pages/dashboard/swarm.tsx +++ b/apps/dokploy/pages/dashboard/swarm.tsx @@ -8,11 +8,7 @@ import type { ReactElement } from "react"; import superjson from "superjson"; const Dashboard = () => { - return ( - <> - - - ); + return ; }; export default Dashboard; diff --git a/apps/dokploy/public/templates/cloudflared.svg b/apps/dokploy/public/templates/cloudflared.svg new file mode 100644 index 000000000..5be105f51 --- /dev/null +++ b/apps/dokploy/public/templates/cloudflared.svg @@ -0,0 +1,33 @@ + + + \ No newline at end of file diff --git a/apps/dokploy/public/templates/conduwuit.svg b/apps/dokploy/public/templates/conduwuit.svg new file mode 100644 index 000000000..162a3d9e3 --- /dev/null +++ b/apps/dokploy/public/templates/conduwuit.svg @@ -0,0 +1,37 @@ + + + + + + + diff --git a/apps/dokploy/public/templates/couchdb.png b/apps/dokploy/public/templates/couchdb.png new file mode 100644 index 000000000..7dc4cc4af Binary files /dev/null and b/apps/dokploy/public/templates/couchdb.png differ diff --git a/apps/dokploy/public/templates/it-tools.svg b/apps/dokploy/public/templates/it-tools.svg new file mode 100644 index 000000000..1e3b614da --- /dev/null +++ b/apps/dokploy/public/templates/it-tools.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/apps/dokploy/server/api/routers/notification.ts b/apps/dokploy/server/api/routers/notification.ts index f88695039..2eafc66d2 100644 --- a/apps/dokploy/server/api/routers/notification.ts +++ b/apps/dokploy/server/api/routers/notification.ts @@ -2,20 +2,24 @@ import { adminProcedure, createTRPCRouter, protectedProcedure, + publicProcedure, } from "@/server/api/trpc"; import { db } from "@/server/db"; import { apiCreateDiscord, apiCreateEmail, + apiCreateGotify, apiCreateSlack, apiCreateTelegram, apiFindOneNotification, apiTestDiscordConnection, apiTestEmailConnection, + apiTestGotifyConnection, apiTestSlackConnection, apiTestTelegramConnection, apiUpdateDiscord, apiUpdateEmail, + apiUpdateGotify, apiUpdateSlack, apiUpdateTelegram, notifications, @@ -24,16 +28,19 @@ import { IS_CLOUD, createDiscordNotification, createEmailNotification, + createGotifyNotification, createSlackNotification, createTelegramNotification, findNotificationById, removeNotificationById, sendDiscordNotification, sendEmailNotification, + sendGotifyNotification, sendSlackNotification, sendTelegramNotification, updateDiscordNotification, updateEmailNotification, + updateGotifyNotification, updateSlackNotification, updateTelegramNotification, } from "@dokploy/server"; @@ -300,10 +307,61 @@ export const notificationRouter = createTRPCRouter({ telegram: true, discord: true, email: true, + gotify: true, }, orderBy: desc(notifications.createdAt), ...(IS_CLOUD && { where: eq(notifications.adminId, ctx.user.adminId) }), // TODO: Remove this line when the cloud version is ready }); }), + createGotify: adminProcedure + .input(apiCreateGotify) + .mutation(async ({ input, ctx }) => { + try { + return await createGotifyNotification(input, ctx.user.adminId); + } catch (error) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Error creating the notification", + cause: error, + }); + } + }), + updateGotify: adminProcedure + .input(apiUpdateGotify) + .mutation(async ({ input, ctx }) => { + try { + const notification = await findNotificationById(input.notificationId); + if (IS_CLOUD && notification.adminId !== ctx.user.adminId) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not authorized to update this notification", + }); + } + return await updateGotifyNotification({ + ...input, + adminId: ctx.user.adminId, + }); + } catch (error) { + throw error; + } + }), + testGotifyConnection: adminProcedure + .input(apiTestGotifyConnection) + .mutation(async ({ input }) => { + try { + await sendGotifyNotification( + input, + "Test Notification", + "Hi, From Dokploy 👋", + ); + return true; + } catch (error) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Error testing the notification", + cause: error, + }); + } + }), }); diff --git a/apps/dokploy/server/api/routers/project.ts b/apps/dokploy/server/api/routers/project.ts index 967b39e37..9c2608cca 100644 --- a/apps/dokploy/server/api/routers/project.ts +++ b/apps/dokploy/server/api/routers/project.ts @@ -68,7 +68,7 @@ export const projectRouter = createTRPCRouter({ .input(apiFindOneProject) .query(async ({ input, ctx }) => { if (ctx.user.rol === "user") { - const { accesedServices } = await findUserByAuthId(ctx.user.authId); + const { accessedServices } = await findUserByAuthId(ctx.user.authId); await checkProjectAccess(ctx.user.authId, "access", input.projectId); @@ -79,28 +79,28 @@ export const projectRouter = createTRPCRouter({ ), with: { compose: { - where: buildServiceFilter(compose.composeId, accesedServices), + where: buildServiceFilter(compose.composeId, accessedServices), }, applications: { where: buildServiceFilter( applications.applicationId, - accesedServices, + accessedServices, ), }, mariadb: { - where: buildServiceFilter(mariadb.mariadbId, accesedServices), + where: buildServiceFilter(mariadb.mariadbId, accessedServices), }, mongo: { - where: buildServiceFilter(mongo.mongoId, accesedServices), + where: buildServiceFilter(mongo.mongoId, accessedServices), }, mysql: { - where: buildServiceFilter(mysql.mysqlId, accesedServices), + where: buildServiceFilter(mysql.mysqlId, accessedServices), }, postgres: { - where: buildServiceFilter(postgres.postgresId, accesedServices), + where: buildServiceFilter(postgres.postgresId, accessedServices), }, redis: { - where: buildServiceFilter(redis.redisId, accesedServices), + where: buildServiceFilter(redis.redisId, accessedServices), }, }, }); @@ -125,18 +125,18 @@ export const projectRouter = createTRPCRouter({ }), all: protectedProcedure.query(async ({ ctx }) => { if (ctx.user.rol === "user") { - const { accesedProjects, accesedServices } = await findUserByAuthId( + const { accessedProjects, accessedServices } = await findUserByAuthId( ctx.user.authId, ); - if (accesedProjects.length === 0) { + if (accessedProjects.length === 0) { return []; } const query = await db.query.projects.findMany({ where: and( sql`${projects.projectId} IN (${sql.join( - accesedProjects.map((projectId) => sql`${projectId}`), + accessedProjects.map((projectId) => sql`${projectId}`), sql`, `, )})`, eq(projects.adminId, ctx.user.adminId), @@ -145,27 +145,27 @@ export const projectRouter = createTRPCRouter({ applications: { where: buildServiceFilter( applications.applicationId, - accesedServices, + accessedServices, ), with: { domains: true }, }, mariadb: { - where: buildServiceFilter(mariadb.mariadbId, accesedServices), + where: buildServiceFilter(mariadb.mariadbId, accessedServices), }, mongo: { - where: buildServiceFilter(mongo.mongoId, accesedServices), + where: buildServiceFilter(mongo.mongoId, accessedServices), }, mysql: { - where: buildServiceFilter(mysql.mysqlId, accesedServices), + where: buildServiceFilter(mysql.mysqlId, accessedServices), }, postgres: { - where: buildServiceFilter(postgres.postgresId, accesedServices), + where: buildServiceFilter(postgres.postgresId, accessedServices), }, redis: { - where: buildServiceFilter(redis.redisId, accesedServices), + where: buildServiceFilter(redis.redisId, accessedServices), }, compose: { - where: buildServiceFilter(compose.composeId, accesedServices), + where: buildServiceFilter(compose.composeId, accessedServices), with: { domains: true }, }, }, @@ -239,10 +239,13 @@ export const projectRouter = createTRPCRouter({ } }), }); -function buildServiceFilter(fieldName: AnyPgColumn, accesedServices: string[]) { - return accesedServices.length > 0 +function buildServiceFilter( + fieldName: AnyPgColumn, + accessedServices: string[], +) { + return accessedServices.length > 0 ? sql`${fieldName} IN (${sql.join( - accesedServices.map((serviceId) => sql`${serviceId}`), + accessedServices.map((serviceId) => sql`${serviceId}`), sql`, `, )})` : sql`1 = 0`; diff --git a/apps/dokploy/server/utils/docker.ts b/apps/dokploy/server/utils/docker.ts index 7577c844f..92008678f 100644 --- a/apps/dokploy/server/utils/docker.ts +++ b/apps/dokploy/server/utils/docker.ts @@ -14,7 +14,7 @@ export const isWSL = async () => { /** Returns the Docker host IP address. */ export const getDockerHost = async (): Promise => { if (process.env.NODE_ENV === "production") { - if (process.platform === "linux" && !isWSL()) { + if (process.platform === "linux" && !(await isWSL())) { try { // Try to get the Docker bridge IP first const { stdout } = await execAsync( diff --git a/apps/dokploy/styles/globals.css b/apps/dokploy/styles/globals.css index 4cf9bef4b..7b7977b9c 100644 --- a/apps/dokploy/styles/globals.css +++ b/apps/dokploy/styles/globals.css @@ -101,9 +101,29 @@ * { @apply border-border; } + body { @apply bg-background text-foreground; } + + /* Custom scrollbar styling */ + ::-webkit-scrollbar { + width: 0.3125rem; + } + + ::-webkit-scrollbar-track { + background: transparent; + } + + ::-webkit-scrollbar-thumb { + background: hsl(var(--border)); + border-radius: 0.3125rem; + } + + * { + scrollbar-width: thin; + scrollbar-color: hsl(var(--border)) transparent; + } } .xterm-viewport { diff --git a/apps/dokploy/templates/cloudflared/docker-compose.yml b/apps/dokploy/templates/cloudflared/docker-compose.yml new file mode 100644 index 000000000..d9fc27419 --- /dev/null +++ b/apps/dokploy/templates/cloudflared/docker-compose.yml @@ -0,0 +1,18 @@ +services: + cloudflared: + image: 'cloudflare/cloudflared:latest' + environment: + # Don't forget to set this in your Dokploy Environment + - 'TUNNEL_TOKEN=${CLOUDFLARE_TUNNEL_TOKEN}' + network_mode: host + restart: unless-stopped + command: [ + "tunnel", + + # More tunnel run parameters here: + # https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/configure-tunnels/tunnel-run-parameters/ + "--no-autoupdate", + #"--protocol", "http2", + + "run" + ] diff --git a/apps/dokploy/templates/cloudflared/index.ts b/apps/dokploy/templates/cloudflared/index.ts new file mode 100644 index 000000000..661fa31d0 --- /dev/null +++ b/apps/dokploy/templates/cloudflared/index.ts @@ -0,0 +1,9 @@ +import type { Schema, Template } from "../utils"; + +export function generate(schema: Schema): Template { + const envs = [`CLOUDFLARE_TUNNEL_TOKEN=""`]; + + return { + envs, + }; +} diff --git a/apps/dokploy/templates/conduwuit/docker-compose.yml b/apps/dokploy/templates/conduwuit/docker-compose.yml new file mode 100644 index 000000000..7945d6c97 --- /dev/null +++ b/apps/dokploy/templates/conduwuit/docker-compose.yml @@ -0,0 +1,48 @@ +# conduwuit +# https://conduwuit.puppyirl.gay/deploying/docker-compose.yml + +services: + homeserver: + image: girlbossceo/conduwuit:latest + restart: unless-stopped + ports: + - 8448:6167 + volumes: + - db:/var/lib/conduwuit + #- ./conduwuit.toml:/etc/conduwuit.toml + environment: + # Edit this in your Dokploy Environment + CONDUWUIT_SERVER_NAME: ${CONDUWUIT_SERVER_NAME} + + CONDUWUIT_DATABASE_PATH: /var/lib/conduwuit + CONDUWUIT_PORT: 6167 + CONDUWUIT_MAX_REQUEST_SIZE: 20000000 # in bytes, ~20 MB + + CONDUWUIT_ALLOW_REGISTRATION: 'true' + CONDUWUIT_REGISTRATION_TOKEN: ${CONDUWUIT_REGISTRATION_TOKEN} + + CONDUWUIT_ALLOW_FEDERATION: 'true' + CONDUWUIT_ALLOW_CHECK_FOR_UPDATES: 'true' + CONDUWUIT_TRUSTED_SERVERS: '["matrix.org"]' + #CONDUWUIT_LOG: warn,state_res=warn + CONDUWUIT_ADDRESS: 0.0.0.0 + + # Uncomment if you mapped config toml in volumes + #CONDUWUIT_CONFIG: '/etc/conduwuit.toml' + + ### Uncomment if you want to use your own Element-Web App. + ### Note: You need to provide a config.json for Element and you also need a second + ### Domain or Subdomain for the communication between Element and conduwuit + ### Config-Docs: https://github.com/vector-im/element-web/blob/develop/docs/config.md + # element-web: + # image: vectorim/element-web:latest + # restart: unless-stopped + # ports: + # - 8009:80 + # volumes: + # - ./element_config.json:/app/config.json + # depends_on: + # - homeserver + +volumes: + db: diff --git a/apps/dokploy/templates/conduwuit/index.ts b/apps/dokploy/templates/conduwuit/index.ts new file mode 100644 index 000000000..9d9e98569 --- /dev/null +++ b/apps/dokploy/templates/conduwuit/index.ts @@ -0,0 +1,30 @@ +import { + type DomainSchema, + type Schema, + type Template, + generatePassword, + generateRandomDomain, +} from "../utils"; + +export function generate(schema: Schema): Template { + const matrixSubdomain = generateRandomDomain(schema); + const registrationToken = generatePassword(20); + + const domains: DomainSchema[] = [ + { + host: matrixSubdomain, + port: 6167, + serviceName: "homeserver", + }, + ]; + + const envs = [ + `CONDUWUIT_SERVER_NAME=${matrixSubdomain}`, + `CONDUWUIT_REGISTRATION_TOKEN=${registrationToken}`, + ]; + + return { + envs, + domains, + }; +} diff --git a/apps/dokploy/templates/couchdb/docker-compose.yml b/apps/dokploy/templates/couchdb/docker-compose.yml new file mode 100644 index 000000000..cb00bf69d --- /dev/null +++ b/apps/dokploy/templates/couchdb/docker-compose.yml @@ -0,0 +1,17 @@ +version: '3.8' + +services: + couchdb: + image: couchdb:latest + ports: + - '5984' + volumes: + - couchdb-data:/opt/couchdb/data + environment: + - COUCHDB_USER=${COUCHDB_USER} + - COUCHDB_PASSWORD=${COUCHDB_PASSWORD} + restart: unless-stopped + +volumes: + couchdb-data: + driver: local diff --git a/apps/dokploy/templates/couchdb/index.ts b/apps/dokploy/templates/couchdb/index.ts new file mode 100644 index 000000000..70d716695 --- /dev/null +++ b/apps/dokploy/templates/couchdb/index.ts @@ -0,0 +1,28 @@ +import { + type DomainSchema, + type Schema, + type Template, + generatePassword, + generateRandomDomain, +} from "../utils"; + +export function generate(schema: Schema): Template { + const mainDomain = generateRandomDomain(schema); + const username = generatePassword(16); + const password = generatePassword(32); + + const domains: DomainSchema[] = [ + { + serviceName: "couchdb", + host: mainDomain, + port: 5984, + }, + ]; + + const envs = [`COUCHDB_USER=${username}`, `COUCHDB_PASSWORD=${password}`]; + + return { + envs, + domains, + }; +} diff --git a/apps/dokploy/templates/it-tools/docker-compose.yml b/apps/dokploy/templates/it-tools/docker-compose.yml new file mode 100644 index 000000000..b26665f8a --- /dev/null +++ b/apps/dokploy/templates/it-tools/docker-compose.yml @@ -0,0 +1,8 @@ +services: + it-tools: + image: corentinth/it-tools:latest + healthcheck: + test: ["CMD", "curl", "-f", "http://127.0.0.1:80"] + interval: 30s + timeout: 10s + retries: 3 diff --git a/apps/dokploy/templates/it-tools/index.ts b/apps/dokploy/templates/it-tools/index.ts new file mode 100644 index 000000000..9912c4ba1 --- /dev/null +++ b/apps/dokploy/templates/it-tools/index.ts @@ -0,0 +1,20 @@ +import { + type DomainSchema, + type Schema, + type Template, + generateRandomDomain, +} from "../utils"; + +export function generate(schema: Schema): Template { + const domains: DomainSchema[] = [ + { + host: generateRandomDomain(schema), + port: 80, + serviceName: "it-tools", + }, + ]; + + return { + domains, + }; +} diff --git a/apps/dokploy/templates/templates.ts b/apps/dokploy/templates/templates.ts index a0fbdb1be..9531eb7ae 100644 --- a/apps/dokploy/templates/templates.ts +++ b/apps/dokploy/templates/templates.ts @@ -1239,4 +1239,63 @@ export const templates: TemplateData[] = [ tags: ["matrix", "communication"], load: () => import("./conduit/index").then((m) => m.generate), }, + { + id: "conduwuit", + name: "Conduwuit", + version: "latest", + description: + "Well-maintained, featureful Matrix chat homeserver (fork of Conduit)", + logo: "conduwuit.svg", + links: { + github: "https://github.com/girlbossceo/conduwuit", + website: "https://conduwuit.puppyirl.gay", + docs: "https://conduwuit.puppyirl.gay/configuration.html", + }, + tags: ["backend", "chat", "communication", "matrix", "server"], + load: () => import("./conduwuit/index").then((m) => m.generate), + }, + { + id: "cloudflared", + name: "Cloudflared", + version: "latest", + description: + "A lightweight daemon that securely connects local services to the internet through Cloudflare Tunnel.", + logo: "cloudflared.svg", + links: { + github: "https://github.com/cloudflare/cloudflared", + website: + "https://developers.cloudflare.com/cloudflare-one/connections/connect-apps/", + docs: "https://developers.cloudflare.com/cloudflare-one/connections/connect-apps/install-and-setup/", + }, + tags: ["cloud", "networking", "security", "tunnel"], + load: () => import("./cloudflared/index").then((m) => m.generate), + }, + { + id: "couchdb", + name: "CouchDB", + version: "latest", + description: + "CouchDB is a document-oriented NoSQL database that excels at replication and horizontal scaling.", + logo: "couchdb.png", // we defined the name and the extension of the logo + links: { + github: "lorem", + website: "lorem", + docs: "lorem", + }, + tags: ["database", "storage"], + load: () => import("./couchdb/index").then((m) => m.generate), + }, + { + id: "it-tools", + name: "IT Tools", + version: "latest", + description: "A collection of handy online it-tools for developers.", + logo: "it-tools.svg", + links: { + github: "https://github.com/CorentinTh/it-tools", + website: "https://it-tools.tech", + }, + tags: ["developer", "tools"], + load: () => import("./it-tools/index").then((m) => m.generate), + }, ]; diff --git a/packages/server/src/db/schema/admin.ts b/packages/server/src/db/schema/admin.ts index 222fb16c8..e9c73bcc1 100644 --- a/packages/server/src/db/schema/admin.ts +++ b/packages/server/src/db/schema/admin.ts @@ -31,6 +31,15 @@ export const admins = pgTable("admin", { stripeCustomerId: text("stripeCustomerId"), stripeSubscriptionId: text("stripeSubscriptionId"), serversQuantity: integer("serversQuantity").notNull().default(0), + cleanupCacheApplications: boolean("cleanupCacheApplications") + .notNull() + .default(true), + cleanupCacheOnPreviews: boolean("cleanupCacheOnPreviews") + .notNull() + .default(false), + cleanupCacheOnCompose: boolean("cleanupCacheOnCompose") + .notNull() + .default(false), }); export const adminsRelations = relations(admins, ({ one, many }) => ({ diff --git a/packages/server/src/db/schema/notification.ts b/packages/server/src/db/schema/notification.ts index 5501621dc..12c7698e2 100644 --- a/packages/server/src/db/schema/notification.ts +++ b/packages/server/src/db/schema/notification.ts @@ -10,6 +10,7 @@ export const notificationType = pgEnum("notificationType", [ "telegram", "discord", "email", + "gotify", ]); export const notifications = pgTable("notification", { @@ -39,6 +40,9 @@ export const notifications = pgTable("notification", { emailId: text("emailId").references(() => email.emailId, { onDelete: "cascade", }), + gotifyId: text("gotifyId").references(() => gotify.gotifyId, { + onDelete: "cascade", + }), adminId: text("adminId").references(() => admins.adminId, { onDelete: "cascade", }), @@ -84,6 +88,17 @@ export const email = pgTable("email", { toAddresses: text("toAddress").array().notNull(), }); +export const gotify = pgTable("gotify", { + gotifyId: text("gotifyId") + .notNull() + .primaryKey() + .$defaultFn(() => nanoid()), + serverUrl: text("serverUrl").notNull(), + appToken: text("appToken").notNull(), + priority: integer("priority").notNull().default(5), + decoration: boolean("decoration"), +}); + export const notificationsRelations = relations(notifications, ({ one }) => ({ slack: one(slack, { fields: [notifications.slackId], @@ -101,6 +116,10 @@ export const notificationsRelations = relations(notifications, ({ one }) => ({ fields: [notifications.emailId], references: [email.emailId], }), + gotify: one(gotify, { + fields: [notifications.gotifyId], + references: [gotify.gotifyId], + }), admin: one(admins, { fields: [notifications.adminId], references: [admins.adminId], @@ -224,6 +243,39 @@ export const apiTestEmailConnection = apiCreateEmail.pick({ fromAddress: true, }); +export const apiCreateGotify = notificationsSchema + .pick({ + appBuildError: true, + databaseBackup: true, + dokployRestart: true, + name: true, + appDeploy: true, + dockerCleanup: true, + }) + .extend({ + serverUrl: z.string().min(1), + appToken: z.string().min(1), + priority: z.number().min(1), + decoration: z.boolean(), + }) + .required(); + +export const apiUpdateGotify = apiCreateGotify.partial().extend({ + notificationId: z.string().min(1), + gotifyId: z.string().min(1), + adminId: z.string().optional(), +}); + +export const apiTestGotifyConnection = apiCreateGotify + .pick({ + serverUrl: true, + appToken: true, + priority: true, + }) + .extend({ + decoration: z.boolean().optional(), + }); + export const apiFindOneNotification = notificationsSchema .pick({ notificationId: true, @@ -242,5 +294,8 @@ export const apiSendTest = notificationsSchema username: z.string(), password: z.string(), toAddresses: z.array(z.string()), + serverUrl: z.string(), + appToken: z.string(), + priority: z.number(), }) .partial(); diff --git a/packages/server/src/db/schema/user.ts b/packages/server/src/db/schema/user.ts index fec3d1277..735898f9a 100644 --- a/packages/server/src/db/schema/user.ts +++ b/packages/server/src/db/schema/user.ts @@ -40,11 +40,11 @@ export const users = pgTable("user", { canAccessToTraefikFiles: boolean("canAccessToTraefikFiles") .notNull() .default(false), - accesedProjects: text("accesedProjects") + accessedProjects: text("accesedProjects") .array() .notNull() .default(sql`ARRAY[]::text[]`), - accesedServices: text("accesedServices") + accessedServices: text("accesedServices") .array() .notNull() .default(sql`ARRAY[]::text[]`), @@ -73,8 +73,8 @@ const createSchema = createInsertSchema(users, { token: z.string().min(1), isRegistered: z.boolean().optional(), adminId: z.string(), - accesedProjects: z.array(z.string()).optional(), - accesedServices: z.array(z.string()).optional(), + accessedProjects: z.array(z.string()).optional(), + accessedServices: z.array(z.string()).optional(), canCreateProjects: z.boolean().optional(), canCreateServices: z.boolean().optional(), canDeleteProjects: z.boolean().optional(), @@ -106,8 +106,8 @@ export const apiAssignPermissions = createSchema canCreateServices: true, canDeleteProjects: true, canDeleteServices: true, - accesedProjects: true, - accesedServices: true, + accessedProjects: true, + accessedServices: true, canAccessToTraefikFiles: true, canAccessToDocker: true, canAccessToAPI: true, diff --git a/packages/server/src/services/application.ts b/packages/server/src/services/application.ts index e2ed407fc..ccadebf79 100644 --- a/packages/server/src/services/application.ts +++ b/packages/server/src/services/application.ts @@ -40,7 +40,7 @@ import { createTraefikConfig } from "@dokploy/server/utils/traefik/application"; import { TRPCError } from "@trpc/server"; import { eq } from "drizzle-orm"; import { encodeBase64 } from "../utils/docker/utils"; -import { getDokployUrl } from "./admin"; +import { findAdminById, getDokployUrl } from "./admin"; import { createDeployment, createDeploymentPreview, @@ -58,6 +58,7 @@ import { updatePreviewDeployment, } from "./preview-deployment"; import { validUniqueServerAppName } from "./project"; +import { cleanupFullDocker } from "./settings"; export type Application = typeof applications.$inferSelect; export const createApplication = async ( @@ -213,6 +214,7 @@ export const deployApplication = async ({ applicationType: "application", buildLink, adminId: application.project.adminId, + domains: application.domains, }); } catch (error) { await updateDeploymentStatus(deployment.deploymentId, "error"); @@ -228,6 +230,12 @@ export const deployApplication = async ({ }); throw error; + } finally { + const admin = await findAdminById(application.project.adminId); + + if (admin.cleanupCacheApplications) { + await cleanupFullDocker(application?.serverId); + } } return true; @@ -269,6 +277,12 @@ export const rebuildApplication = async ({ await updateDeploymentStatus(deployment.deploymentId, "error"); await updateApplicationStatus(applicationId, "error"); throw error; + } finally { + const admin = await findAdminById(application.project.adminId); + + if (admin.cleanupCacheApplications) { + await cleanupFullDocker(application?.serverId); + } } return true; @@ -332,6 +346,7 @@ export const deployRemoteApplication = async ({ applicationType: "application", buildLink, adminId: application.project.adminId, + domains: application.domains, }); } catch (error) { // @ts-ignore @@ -357,15 +372,13 @@ export const deployRemoteApplication = async ({ adminId: application.project.adminId, }); - console.log( - "Error on ", - application.buildType, - "/", - application.sourceType, - error, - ); - throw error; + } finally { + const admin = await findAdminById(application.project.adminId); + + if (admin.cleanupCacheApplications) { + await cleanupFullDocker(application?.serverId); + } } return true; @@ -473,6 +486,12 @@ export const deployPreviewApplication = async ({ previewStatus: "error", }); throw error; + } finally { + const admin = await findAdminById(application.project.adminId); + + if (admin.cleanupCacheOnPreviews) { + await cleanupFullDocker(application?.serverId); + } } return true; @@ -585,6 +604,12 @@ export const deployRemotePreviewApplication = async ({ previewStatus: "error", }); throw error; + } finally { + const admin = await findAdminById(application.project.adminId); + + if (admin.cleanupCacheOnPreviews) { + await cleanupFullDocker(application?.serverId); + } } return true; @@ -632,6 +657,12 @@ export const rebuildRemoteApplication = async ({ await updateDeploymentStatus(deployment.deploymentId, "error"); await updateApplicationStatus(applicationId, "error"); throw error; + } finally { + const admin = await findAdminById(application.project.adminId); + + if (admin.cleanupCacheApplications) { + await cleanupFullDocker(application?.serverId); + } } return true; diff --git a/packages/server/src/services/compose.ts b/packages/server/src/services/compose.ts index 50459c450..7f6a59547 100644 --- a/packages/server/src/services/compose.ts +++ b/packages/server/src/services/compose.ts @@ -3,7 +3,6 @@ import { paths } from "@dokploy/server/constants"; import { db } from "@dokploy/server/db"; import { type apiCreateCompose, compose } from "@dokploy/server/db/schema"; import { buildAppName, cleanAppName } from "@dokploy/server/db/schema"; -import { generatePassword } from "@dokploy/server/templates/utils"; import { buildCompose, getBuildComposeCommand, @@ -45,9 +44,10 @@ import { import { TRPCError } from "@trpc/server"; import { eq } from "drizzle-orm"; import { encodeBase64 } from "../utils/docker/utils"; -import { getDokployUrl } from "./admin"; +import { findAdminById, getDokployUrl } from "./admin"; import { createDeploymentCompose, updateDeploymentStatus } from "./deployment"; import { validUniqueServerAppName } from "./project"; +import { cleanupFullDocker } from "./settings"; export type Compose = typeof compose.$inferSelect; @@ -243,6 +243,7 @@ export const deployCompose = async ({ applicationType: "compose", buildLink, adminId: compose.project.adminId, + domains: compose.domains, }); } catch (error) { await updateDeploymentStatus(deployment.deploymentId, "error"); @@ -259,6 +260,11 @@ export const deployCompose = async ({ adminId: compose.project.adminId, }); throw error; + } finally { + const admin = await findAdminById(compose.project.adminId); + if (admin.cleanupCacheOnCompose) { + await cleanupFullDocker(compose?.serverId); + } } }; @@ -295,6 +301,11 @@ export const rebuildCompose = async ({ composeStatus: "error", }); throw error; + } finally { + const admin = await findAdminById(compose.project.adminId); + if (admin.cleanupCacheOnCompose) { + await cleanupFullDocker(compose?.serverId); + } } return true; @@ -366,6 +377,7 @@ export const deployRemoteCompose = async ({ applicationType: "compose", buildLink, adminId: compose.project.adminId, + domains: compose.domains, }); } catch (error) { // @ts-ignore @@ -392,6 +404,11 @@ export const deployRemoteCompose = async ({ adminId: compose.project.adminId, }); throw error; + } finally { + const admin = await findAdminById(compose.project.adminId); + if (admin.cleanupCacheOnCompose) { + await cleanupFullDocker(compose?.serverId); + } } }; @@ -436,6 +453,11 @@ export const rebuildRemoteCompose = async ({ composeStatus: "error", }); throw error; + } finally { + const admin = await findAdminById(compose.project.adminId); + if (admin.cleanupCacheOnCompose) { + await cleanupFullDocker(compose?.serverId); + } } return true; diff --git a/packages/server/src/services/notification.ts b/packages/server/src/services/notification.ts index e75154dfc..2b62b4574 100644 --- a/packages/server/src/services/notification.ts +++ b/packages/server/src/services/notification.ts @@ -2,14 +2,17 @@ import { db } from "@dokploy/server/db"; import { type apiCreateDiscord, type apiCreateEmail, + type apiCreateGotify, type apiCreateSlack, type apiCreateTelegram, type apiUpdateDiscord, type apiUpdateEmail, + type apiUpdateGotify, type apiUpdateSlack, type apiUpdateTelegram, discord, email, + gotify, notifications, slack, telegram, @@ -379,6 +382,96 @@ export const updateEmailNotification = async ( }); }; +export const createGotifyNotification = async ( + input: typeof apiCreateGotify._type, + adminId: string, +) => { + await db.transaction(async (tx) => { + const newGotify = await tx + .insert(gotify) + .values({ + serverUrl: input.serverUrl, + appToken: input.appToken, + priority: input.priority, + decoration: input.decoration, + }) + .returning() + .then((value) => value[0]); + + if (!newGotify) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Error input: Inserting gotify", + }); + } + + const newDestination = await tx + .insert(notifications) + .values({ + gotifyId: newGotify.gotifyId, + name: input.name, + appDeploy: input.appDeploy, + appBuildError: input.appBuildError, + databaseBackup: input.databaseBackup, + dokployRestart: input.dokployRestart, + dockerCleanup: input.dockerCleanup, + notificationType: "gotify", + adminId: adminId, + }) + .returning() + .then((value) => value[0]); + + if (!newDestination) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Error input: Inserting notification", + }); + } + + return newDestination; + }); +}; + +export const updateGotifyNotification = async ( + input: typeof apiUpdateGotify._type, +) => { + await db.transaction(async (tx) => { + const newDestination = await tx + .update(notifications) + .set({ + name: input.name, + appDeploy: input.appDeploy, + appBuildError: input.appBuildError, + databaseBackup: input.databaseBackup, + dokployRestart: input.dokployRestart, + dockerCleanup: input.dockerCleanup, + adminId: input.adminId, + }) + .where(eq(notifications.notificationId, input.notificationId)) + .returning() + .then((value) => value[0]); + + if (!newDestination) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Error Updating notification", + }); + } + + await tx + .update(gotify) + .set({ + serverUrl: input.serverUrl, + appToken: input.appToken, + priority: input.priority, + decoration: input.decoration, + }) + .where(eq(gotify.gotifyId, input.gotifyId)); + + return newDestination; + }); +}; + export const findNotificationById = async (notificationId: string) => { const notification = await db.query.notifications.findFirst({ where: eq(notifications.notificationId, notificationId), @@ -387,6 +480,7 @@ export const findNotificationById = async (notificationId: string) => { telegram: true, discord: true, email: true, + gotify: true, }, }); if (!notification) { diff --git a/packages/server/src/services/settings.ts b/packages/server/src/services/settings.ts index 37f7b2ee8..d22780c94 100644 --- a/packages/server/src/services/settings.ts +++ b/packages/server/src/services/settings.ts @@ -5,6 +5,7 @@ import { execAsync, execAsyncRemote, } from "@dokploy/server/utils/process/execAsync"; +import { findAdminById } from "./admin"; // import packageInfo from "../../../package.json"; export interface IUpdateData { @@ -213,3 +214,35 @@ echo "$json_output" } return result; }; + +export const cleanupFullDocker = async (serverId?: string | null) => { + const cleanupImages = "docker image prune --all --force"; + const cleanupVolumes = "docker volume prune --all --force"; + const cleanupContainers = "docker container prune --force"; + const cleanupSystem = "docker system prune --all --force --volumes"; + const cleanupBuilder = "docker builder prune --all --force"; + + try { + if (serverId) { + await execAsyncRemote( + serverId, + ` + ${cleanupImages} + ${cleanupVolumes} + ${cleanupContainers} + ${cleanupSystem} + ${cleanupBuilder} + `, + ); + } + await execAsync(` + ${cleanupImages} + ${cleanupVolumes} + ${cleanupContainers} + ${cleanupSystem} + ${cleanupBuilder} + `); + } catch (error) { + console.log(error); + } +}; diff --git a/packages/server/src/services/user.ts b/packages/server/src/services/user.ts index 1cfe1260d..d8d9862c4 100644 --- a/packages/server/src/services/user.ts +++ b/packages/server/src/services/user.ts @@ -54,7 +54,7 @@ export const addNewProject = async (authId: string, projectId: string) => { await db .update(users) .set({ - accesedProjects: [...user.accesedProjects, projectId], + accessedProjects: [...user.accessedProjects, projectId], }) .where(eq(users.authId, authId)); }; @@ -64,7 +64,7 @@ export const addNewService = async (authId: string, serviceId: string) => { await db .update(users) .set({ - accesedServices: [...user.accesedServices, serviceId], + accessedServices: [...user.accessedServices, serviceId], }) .where(eq(users.authId, authId)); }; @@ -73,8 +73,9 @@ export const canPerformCreationService = async ( userId: string, projectId: string, ) => { - const { accesedProjects, canCreateServices } = await findUserByAuthId(userId); - const haveAccessToProject = accesedProjects.includes(projectId); + const { accessedProjects, canCreateServices } = + await findUserByAuthId(userId); + const haveAccessToProject = accessedProjects.includes(projectId); if (canCreateServices && haveAccessToProject) { return true; @@ -87,8 +88,8 @@ export const canPerformAccessService = async ( userId: string, serviceId: string, ) => { - const { accesedServices } = await findUserByAuthId(userId); - const haveAccessToService = accesedServices.includes(serviceId); + const { accessedServices } = await findUserByAuthId(userId); + const haveAccessToService = accessedServices.includes(serviceId); if (haveAccessToService) { return true; @@ -101,8 +102,9 @@ export const canPeformDeleteService = async ( authId: string, serviceId: string, ) => { - const { accesedServices, canDeleteServices } = await findUserByAuthId(authId); - const haveAccessToService = accesedServices.includes(serviceId); + const { accessedServices, canDeleteServices } = + await findUserByAuthId(authId); + const haveAccessToService = accessedServices.includes(serviceId); if (canDeleteServices && haveAccessToService) { return true; @@ -135,9 +137,9 @@ export const canPerformAccessProject = async ( authId: string, projectId: string, ) => { - const { accesedProjects } = await findUserByAuthId(authId); + const { accessedProjects } = await findUserByAuthId(authId); - const haveAccessToProject = accesedProjects.includes(projectId); + const haveAccessToProject = accessedProjects.includes(projectId); if (haveAccessToProject) { return true; diff --git a/packages/server/src/utils/notifications/build-error.ts b/packages/server/src/utils/notifications/build-error.ts index 695b37863..95936652c 100644 --- a/packages/server/src/utils/notifications/build-error.ts +++ b/packages/server/src/utils/notifications/build-error.ts @@ -2,10 +2,12 @@ import { db } from "@dokploy/server/db"; import { notifications } from "@dokploy/server/db/schema"; import BuildFailedEmail from "@dokploy/server/emails/emails/build-failed"; import { renderAsync } from "@react-email/components"; +import { format } from "date-fns"; import { and, eq } from "drizzle-orm"; import { sendDiscordNotification, sendEmailNotification, + sendGotifyNotification, sendSlackNotification, sendTelegramNotification, } from "./utils"; @@ -39,11 +41,12 @@ export const sendBuildErrorNotifications = async ({ discord: true, telegram: true, slack: true, + gotify: true, }, }); for (const notification of notificationList) { - const { email, discord, telegram, slack } = notification; + const { email, discord, telegram, slack, gotify } = notification; if (email) { const template = await renderAsync( BuildFailedEmail({ @@ -112,22 +115,35 @@ export const sendBuildErrorNotifications = async ({ }); } + if (gotify) { + const decorate = (decoration: string, text: string) => + `${gotify.decoration ? decoration : ""} ${text}\n`; + await sendGotifyNotification( + gotify, + decorate("⚠️", "Build Failed"), + `${decorate("🛠️", `Project: ${projectName}`)}` + + `${decorate("⚙️", `Application: ${applicationName}`)}` + + `${decorate("❔", `Type: ${applicationType}`)}` + + `${decorate("🕒", `Date: ${date.toLocaleString()}`)}` + + `${decorate("⚠️", `Error:\n${errorMessage}`)}` + + `${decorate("🔗", `Build details:\n${buildLink}`)}`, + ); + } + if (telegram) { + const inlineButton = [ + [ + { + text: "Deployment Logs", + url: buildLink, + }, + ], + ]; + await sendTelegramNotification( telegram, - ` - ⚠️ Build Failed - - Project: ${projectName} - Application: ${applicationName} - Type: ${applicationType} - Time: ${date.toLocaleString()} - - Error: -
${errorMessage}
- - Build Details: ${buildLink} - `, + `⚠️ Build Failed\n\nProject: ${projectName}\nApplication: ${applicationName}\nType: ${applicationType}\nDate: ${format(date, "PP")}\nTime: ${format(date, "pp")}\n\nError:\n
${errorMessage}
`, + inlineButton, ); } diff --git a/packages/server/src/utils/notifications/build-success.ts b/packages/server/src/utils/notifications/build-success.ts index 16aa4a588..960f7a6a4 100644 --- a/packages/server/src/utils/notifications/build-success.ts +++ b/packages/server/src/utils/notifications/build-success.ts @@ -1,11 +1,14 @@ import { db } from "@dokploy/server/db"; import { notifications } from "@dokploy/server/db/schema"; import BuildSuccessEmail from "@dokploy/server/emails/emails/build-success"; +import type { Domain } from "@dokploy/server/services/domain"; import { renderAsync } from "@react-email/components"; +import { format } from "date-fns"; import { and, eq } from "drizzle-orm"; import { sendDiscordNotification, sendEmailNotification, + sendGotifyNotification, sendSlackNotification, sendTelegramNotification, } from "./utils"; @@ -16,6 +19,7 @@ interface Props { applicationType: string; buildLink: string; adminId: string; + domains: Domain[]; } export const sendBuildSuccessNotifications = async ({ @@ -24,6 +28,7 @@ export const sendBuildSuccessNotifications = async ({ applicationType, buildLink, adminId, + domains, }: Props) => { const date = new Date(); const unixDate = ~~(Number(date) / 1000); @@ -37,11 +42,12 @@ export const sendBuildSuccessNotifications = async ({ discord: true, telegram: true, slack: true, + gotify: true, }, }); for (const notification of notificationList) { - const { email, discord, telegram, slack } = notification; + const { email, discord, telegram, slack, gotify } = notification; if (email) { const template = await renderAsync( @@ -106,19 +112,45 @@ export const sendBuildSuccessNotifications = async ({ }); } + if (gotify) { + const decorate = (decoration: string, text: string) => + `${gotify.decoration ? decoration : ""} ${text}\n`; + await sendGotifyNotification( + gotify, + decorate("✅", "Build Success"), + `${decorate("🛠️", `Project: ${projectName}`)}` + + `${decorate("⚙️", `Application: ${applicationName}`)}` + + `${decorate("❔", `Type: ${applicationType}`)}` + + `${decorate("🕒", `Date: ${date.toLocaleString()}`)}` + + `${decorate("🔗", `Build details:\n${buildLink}`)}`, + ); + } + if (telegram) { - await sendTelegramNotification( - telegram, - ` - ✅ Build Success + const chunkArray = (array: T[], chunkSize: number): T[][] => + Array.from({ length: Math.ceil(array.length / chunkSize) }, (_, i) => + array.slice(i * chunkSize, i * chunkSize + chunkSize), + ); - Project: ${projectName} - Application: ${applicationName} - Type: ${applicationType} - Time: ${date.toLocaleString()} + const inlineButton = [ + [ + { + text: "Deployment Logs", + url: buildLink, + }, + ], + ...chunkArray(domains, 2).map((chunk) => + chunk.map((data) => ({ + text: data.host, + url: `${data.https ? "https" : "http"}://${data.host}`, + })), + ), + ]; - Build Details: ${buildLink} - `, + await sendTelegramNotification( + telegram, + `✅ Build Success\n\nProject: ${projectName}\nApplication: ${applicationName}\nType: ${applicationType}\nDate: ${format(date, "PP")}\nTime: ${format(date, "pp")}`, + inlineButton, ); } diff --git a/packages/server/src/utils/notifications/database-backup.ts b/packages/server/src/utils/notifications/database-backup.ts index 3aec6f3d3..0b1d61f7e 100644 --- a/packages/server/src/utils/notifications/database-backup.ts +++ b/packages/server/src/utils/notifications/database-backup.ts @@ -1,11 +1,14 @@ +import { error } from "node:console"; import { db } from "@dokploy/server/db"; import { notifications } from "@dokploy/server/db/schema"; import DatabaseBackupEmail from "@dokploy/server/emails/emails/database-backup"; import { renderAsync } from "@react-email/components"; +import { format } from "date-fns"; import { and, eq } from "drizzle-orm"; import { sendDiscordNotification, sendEmailNotification, + sendGotifyNotification, sendSlackNotification, sendTelegramNotification, } from "./utils"; @@ -37,11 +40,12 @@ export const sendDatabaseBackupNotifications = async ({ discord: true, telegram: true, slack: true, + gotify: true, }, }); for (const notification of notificationList) { - const { email, discord, telegram, slack } = notification; + const { email, discord, telegram, slack, gotify } = notification; if (email) { const template = await renderAsync( @@ -120,19 +124,35 @@ export const sendDatabaseBackupNotifications = async ({ }); } + if (gotify) { + const decorate = (decoration: string, text: string) => + `${gotify.decoration ? decoration : ""} ${text}\n`; + + await sendGotifyNotification( + gotify, + decorate( + type === "success" ? "✅" : "❌", + `Database Backup ${type === "success" ? "Successful" : "Failed"}`, + ), + `${decorate("🛠️", `Project: ${projectName}`)}` + + `${decorate("⚙️", `Application: ${applicationName}`)}` + + `${decorate("❔", `Type: ${databaseType}`)}` + + `${decorate("🕒", `Date: ${date.toLocaleString()}`)}` + + `${type === "error" && errorMessage ? decorate("❌", `Error:\n${errorMessage}`) : ""}`, + ); + } + if (telegram) { + const isError = type === "error" && errorMessage; + const statusEmoji = type === "success" ? "✅" : "❌"; - const messageText = ` - ${statusEmoji} Database Backup ${type === "success" ? "Successful" : "Failed"} + const typeStatus = type === "success" ? "Successful" : "Failed"; + const errorMsg = isError + ? `\n\nError:\n
${errorMessage}
` + : ""; - Project: ${projectName} - Application: ${applicationName} - Type: ${databaseType} - Time: ${date.toLocaleString()} + const messageText = `${statusEmoji} Database Backup ${typeStatus}\n\nProject: ${projectName}\nApplication: ${applicationName}\nType: ${databaseType}\nDate: ${format(date, "PP")}\nTime: ${format(date, "pp")}${isError ? errorMsg : ""}`; - Status: ${type === "success" ? "Successful" : "Failed"} - ${type === "error" && errorMessage ? `Error: ${errorMessage}` : ""} - `; await sendTelegramNotification(telegram, messageText); } diff --git a/packages/server/src/utils/notifications/docker-cleanup.ts b/packages/server/src/utils/notifications/docker-cleanup.ts index c95c79067..b60e3b0ac 100644 --- a/packages/server/src/utils/notifications/docker-cleanup.ts +++ b/packages/server/src/utils/notifications/docker-cleanup.ts @@ -2,10 +2,12 @@ import { db } from "@dokploy/server/db"; import { notifications } from "@dokploy/server/db/schema"; import DockerCleanupEmail from "@dokploy/server/emails/emails/docker-cleanup"; import { renderAsync } from "@react-email/components"; +import { format } from "date-fns"; import { and, eq } from "drizzle-orm"; import { sendDiscordNotification, sendEmailNotification, + sendGotifyNotification, sendSlackNotification, sendTelegramNotification, } from "./utils"; @@ -26,11 +28,12 @@ export const sendDockerCleanupNotifications = async ( discord: true, telegram: true, slack: true, + gotify: true, }, }); for (const notification of notificationList) { - const { email, discord, telegram, slack } = notification; + const { email, discord, telegram, slack, gotify } = notification; if (email) { const template = await renderAsync( @@ -79,14 +82,21 @@ export const sendDockerCleanupNotifications = async ( }); } + if (gotify) { + const decorate = (decoration: string, text: string) => + `${gotify.decoration ? decoration : ""} ${text}\n`; + await sendGotifyNotification( + gotify, + decorate("✅", "Docker Cleanup"), + `${decorate("🕒", `Date: ${date.toLocaleString()}`)}` + + `${decorate("📜", `Message:\n${message}`)}`, + ); + } + if (telegram) { await sendTelegramNotification( telegram, - ` - ✅ Docker Cleanup - Message: ${message} - Time: ${date.toLocaleString()} - `, + `✅ Docker Cleanup\n\nMessage: ${message}\nDate: ${format(date, "PP")}\nTime: ${format(date, "pp")}`, ); } diff --git a/packages/server/src/utils/notifications/dokploy-restart.ts b/packages/server/src/utils/notifications/dokploy-restart.ts index 16170349b..5a156affe 100644 --- a/packages/server/src/utils/notifications/dokploy-restart.ts +++ b/packages/server/src/utils/notifications/dokploy-restart.ts @@ -2,10 +2,12 @@ import { db } from "@dokploy/server/db"; import { notifications } from "@dokploy/server/db/schema"; import DokployRestartEmail from "@dokploy/server/emails/emails/dokploy-restart"; import { renderAsync } from "@react-email/components"; +import { format } from "date-fns"; import { eq } from "drizzle-orm"; import { sendDiscordNotification, sendEmailNotification, + sendGotifyNotification, sendSlackNotification, sendTelegramNotification, } from "./utils"; @@ -20,11 +22,12 @@ export const sendDokployRestartNotifications = async () => { discord: true, telegram: true, slack: true, + gotify: true, }, }); for (const notification of notificationList) { - const { email, discord, telegram, slack } = notification; + const { email, discord, telegram, slack, gotify } = notification; if (email) { const template = await renderAsync( @@ -64,13 +67,20 @@ export const sendDokployRestartNotifications = async () => { }); } + if (gotify) { + const decorate = (decoration: string, text: string) => + `${gotify.decoration ? decoration : ""} ${text}\n`; + await sendGotifyNotification( + gotify, + decorate("✅", "Dokploy Server Restarted"), + `${decorate("🕒", `Date: ${date.toLocaleString()}`)}`, + ); + } + if (telegram) { await sendTelegramNotification( telegram, - ` - ✅ Dokploy Serverd Restarted - Time: ${date.toLocaleString()} - `, + `✅ Dokploy Server Restarted\n\nDate: ${format(date, "PP")}\nTime: ${format(date, "pp")}`, ); } diff --git a/packages/server/src/utils/notifications/utils.ts b/packages/server/src/utils/notifications/utils.ts index 2f8324bb1..4f8bb1a5e 100644 --- a/packages/server/src/utils/notifications/utils.ts +++ b/packages/server/src/utils/notifications/utils.ts @@ -1,6 +1,7 @@ import type { discord, email, + gotify, slack, telegram, } from "@dokploy/server/db/schema"; @@ -55,6 +56,10 @@ export const sendDiscordNotification = async ( export const sendTelegramNotification = async ( connection: typeof telegram.$inferInsert, messageText: string, + inlineButton?: { + text: string; + url: string; + }[][], ) => { try { const url = `https://api.telegram.org/bot${connection.botToken}/sendMessage`; @@ -66,6 +71,9 @@ export const sendTelegramNotification = async ( text: messageText, parse_mode: "HTML", disable_web_page_preview: true, + reply_markup: { + inline_keyboard: inlineButton, + }, }), }); } catch (err) { @@ -87,3 +95,33 @@ export const sendSlackNotification = async ( console.log(err); } }; + +export const sendGotifyNotification = async ( + connection: typeof gotify.$inferInsert, + title: string, + message: string, +) => { + const response = await fetch(`${connection.serverUrl}/message`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-Gotify-Key": connection.appToken, + }, + body: JSON.stringify({ + title: title, + message: message, + priority: connection.priority, + extras: { + "client::display": { + contentType: "text/plain", + }, + }, + }), + }); + + if (!response.ok) { + throw new Error( + `Failed to send Gotify notification: ${response.statusText}`, + ); + } +};