diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml index e9591f3cc..6c74dbc02 100644 --- a/.github/workflows/pull-request.yml +++ b/.github/workflows/pull-request.yml @@ -4,9 +4,15 @@ on: pull_request: branches: [main, canary] +permissions: + contents: read + jobs: - lint-and-typecheck: + pr-check: runs-on: ubuntu-latest + strategy: + matrix: + job: [build, test, typecheck] steps: - uses: actions/checkout@v4 - uses: pnpm/action-setup@v4 @@ -15,32 +21,5 @@ jobs: node-version: 20.16.0 cache: "pnpm" - run: pnpm install --frozen-lockfile - - run: pnpm run server:build - - run: pnpm typecheck - - build-and-test: - needs: lint-and-typecheck - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: pnpm/action-setup@v4 - - uses: actions/setup-node@v4 - with: - node-version: 20.16.0 - cache: "pnpm" - - run: pnpm install --frozen-lockfile - - run: pnpm run server:build - - run: pnpm build - - parallel-tests: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: pnpm/action-setup@v4 - - uses: actions/setup-node@v4 - with: - node-version: 20.16.0 - cache: "pnpm" - - run: pnpm install --frozen-lockfile - - run: pnpm run server:build - - run: pnpm test + - run: pnpm server:build + - run: pnpm ${{ matrix.job }} diff --git a/apps/dokploy/components/dashboard/compose/advanced/add-isolation.tsx b/apps/dokploy/components/dashboard/compose/advanced/add-isolation.tsx new file mode 100644 index 000000000..5b6e04154 --- /dev/null +++ b/apps/dokploy/components/dashboard/compose/advanced/add-isolation.tsx @@ -0,0 +1,241 @@ +import { zodResolver } from "@hookform/resolvers/zod"; +import { AlertTriangle, Loader2 } from "lucide-react"; +import { useEffect, useState } from "react"; +import { useForm } from "react-hook-form"; +import { toast } from "sonner"; +import { z } from "zod"; +import { AlertBlock } from "@/components/shared/alert-block"; +import { CodeEditor } from "@/components/shared/code-editor"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, +} from "@/components/ui/form"; + +import { Switch } from "@/components/ui/switch"; +import { api } from "@/utils/api"; + +interface Props { + composeId: string; +} + +// Schema for Isolated Deployment +const isolatedSchema = z.object({ + isolatedDeployment: z.boolean().optional(), +}); + +type IsolatedSchema = z.infer; + +export const IsolatedDeploymentTab = ({ composeId }: Props) => { + const utils = api.useUtils(); + const [compose, setCompose] = useState(""); + const [isPreviewLoading, setIsPreviewLoading] = useState(false); + const { mutateAsync, error, isError } = + api.compose.isolatedDeployment.useMutation(); + + const [isOpenPreview, setIsOpenPreview] = useState(false); + + const { mutateAsync: updateCompose } = api.compose.update.useMutation(); + + const { data, refetch } = api.compose.one.useQuery( + { composeId }, + { enabled: !!composeId }, + ); + + const form = useForm({ + defaultValues: { + isolatedDeployment: false, + }, + resolver: zodResolver(isolatedSchema), + }); + + useEffect(() => { + if (data) { + form.reset({ + isolatedDeployment: data?.isolatedDeployment || false, + }); + } + }, [form, form.reset, form.formState.isSubmitSuccessful, data]); + + const onSubmit = async (formData: IsolatedSchema) => { + await updateCompose({ + composeId, + isolatedDeployment: formData?.isolatedDeployment || false, + }) + .then(async (_data) => { + await refetch(); + toast.success("Compose updated"); + }) + .catch(() => { + toast.error("Error updating the compose"); + }); + }; + + const generatePreview = async () => { + setIsOpenPreview(true); + setIsPreviewLoading(true); + try { + await mutateAsync({ + composeId, + suffix: data?.appName || "", + }).then(async (data) => { + await utils.project.all.invalidate(); + setCompose(data); + }); + } catch { + toast.error("Error generating preview"); + setIsOpenPreview(false); + } finally { + setIsPreviewLoading(false); + } + }; + + return ( + + + Enable Isolated Deployment + + Configure isolated deployment to the compose file. +
+ + This feature creates an isolated environment for your deployment + by adding unique prefixes to all resources. It establishes a + dedicated network based on your compose file's name, ensuring your + services run in isolation. This prevents conflicts when running + multiple instances of the same template or services with identical + names. + +
+
+

+ Resources that will be isolated: +

+
    +
  • Docker networks
  • +
+
+
+
+
+
+ +
+ {isError && {error?.message}} +
+ + {isError && ( +
+ + + {error?.message} + +
+ )} + +
+
+ ( + +
+ + Enable Isolated Deployment ({data?.appName}) + + + Enable isolated deployment to the compose file. + +
+ + + +
+ )} + /> +
+ +
+ +
+
+ +
+ + + + + Isolated Deployment Preview + + Preview of the compose file with isolated deployment + configuration + + +
+ {isPreviewLoading ? ( +
+ +

+ Generating compose preview... +

+
+ ) : ( +
+													
+												
+ )} +
+
+
+
+
+ +
+
+
+ ); +}; diff --git a/apps/dokploy/components/dashboard/compose/general/compose-file-editor.tsx b/apps/dokploy/components/dashboard/compose/general/compose-file-editor.tsx index 41e40efbe..c2db472d2 100644 --- a/apps/dokploy/components/dashboard/compose/general/compose-file-editor.tsx +++ b/apps/dokploy/components/dashboard/compose/general/compose-file-editor.tsx @@ -1,3 +1,8 @@ +import { zodResolver } from "@hookform/resolvers/zod"; +import { useEffect } from "react"; +import { useForm } from "react-hook-form"; +import { toast } from "sonner"; +import { z } from "zod"; import { CodeEditor } from "@/components/shared/code-editor"; import { Button } from "@/components/ui/button"; import { @@ -8,13 +13,7 @@ import { FormMessage, } from "@/components/ui/form"; import { api } from "@/utils/api"; -import { zodResolver } from "@hookform/resolvers/zod"; -import { useEffect } from "react"; -import { useForm } from "react-hook-form"; -import { toast } from "sonner"; -import { z } from "zod"; import { validateAndFormatYAML } from "../../application/advanced/traefik/update-traefik-config"; -import { ShowUtilities } from "./show-utilities"; interface Props { composeId: string; @@ -142,9 +141,7 @@ services:
-
- -
+
-
- -
- -
-							
-						
-
- - - - ); -}; diff --git a/apps/dokploy/components/dashboard/compose/general/show-converted-compose.tsx b/apps/dokploy/components/dashboard/compose/general/show-converted-compose.tsx index 253a5fde3..fac6c2a34 100644 --- a/apps/dokploy/components/dashboard/compose/general/show-converted-compose.tsx +++ b/apps/dokploy/components/dashboard/compose/general/show-converted-compose.tsx @@ -62,7 +62,7 @@ export const ShowConvertedCompose = ({ composeId }: Props) => { {isError && {error?.message}} - + Preview your docker-compose file with added domains. Note: At least one domain must be specified for this conversion to take effect. diff --git a/apps/dokploy/components/dashboard/compose/general/show-utilities.tsx b/apps/dokploy/components/dashboard/compose/general/show-utilities.tsx deleted file mode 100644 index 6df800494..000000000 --- a/apps/dokploy/components/dashboard/compose/general/show-utilities.tsx +++ /dev/null @@ -1,46 +0,0 @@ -import { Button } from "@/components/ui/button"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogHeader, - DialogTitle, - DialogTrigger, -} from "@/components/ui/dialog"; -import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; -import { useState } from "react"; -import { IsolatedDeployment } from "./isolated-deployment"; -import { RandomizeCompose } from "./randomize-compose"; - -interface Props { - composeId: string; -} - -export const ShowUtilities = ({ composeId }: Props) => { - const [isOpen, setIsOpen] = useState(false); - return ( - - - - - - - Utilities - Modify the application data - - - - Isolated Deployment - Randomize Compose - - - - - - - - - - - ); -}; diff --git a/apps/dokploy/hooks/use-keyboard-nav.tsx b/apps/dokploy/hooks/use-keyboard-nav.tsx new file mode 100644 index 000000000..d24064c31 --- /dev/null +++ b/apps/dokploy/hooks/use-keyboard-nav.tsx @@ -0,0 +1,77 @@ +"use client"; + +import { usePathname, useRouter, useSearchParams } from "next/navigation"; +import { useCallback, useEffect, useState } from "react"; + +const SHORTCUTS = { + g: "general", + e: "environment", + u: "domains", + p: "preview-deployments", + s: "schedules", + v: "volume-backups", + d: "deployments", + l: "logs", + m: "monitoring", + a: "advanced", +}; + +/** + * Use this to register keyboard shortcuts for the application page. Each + * shortcut must be prefixed with `g` (like GitHub). + * + * - `g g` "General", + * - `g e` "Environment", + * - `g u` "Domains", + * - `g p` "Preview Deployments", + * - `g s` "Schedules", + * - `g v` "Volume Backups", + * - `g d` "Deployments", + * - `g l` "Logs", + * - `g m` "Monitoring", + * - `g a` "Advanced" + */ +export function UseKeyboardNavForApplications() { + const [isModPressed, setModPressed] = useState(false); + const [timer, setTimer] = useState(null); + + const sp = useSearchParams(); + const router = useRouter(); + const pathname = usePathname(); + + const updateSearchParam = useCallback( + (name: string, value: string) => { + const params = new URLSearchParams(sp.toString()); + params.set(name, value); + + return params.toString(); + }, + [sp], + ); + + useEffect(() => { + const handleKeyDown = ({ key }: KeyboardEvent) => { + if (isModPressed) { + if (timer) clearTimeout(timer); + setModPressed(false); + + if (key in SHORTCUTS) { + const tab = SHORTCUTS[key as keyof typeof SHORTCUTS]; + router.push( + `${pathname}?${updateSearchParam("tab", tab.toLowerCase())}`, + ); + } + } else { + if (key === "g") { + setModPressed(true); + setTimer(setTimeout(() => setModPressed(false), 5000)); + } + } + }; + + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [isModPressed, timer, updateSearchParam, router, pathname]); + + return null; +} diff --git a/apps/dokploy/package.json b/apps/dokploy/package.json index 481c4956c..2758bbced 100644 --- a/apps/dokploy/package.json +++ b/apps/dokploy/package.json @@ -1,6 +1,6 @@ { "name": "dokploy", - "version": "v0.24.9", + "version": "v0.24.10", "private": true, "license": "Apache-2.0", "type": "module", diff --git a/apps/dokploy/pages/dashboard/project/[projectId]/services/application/[applicationId].tsx b/apps/dokploy/pages/dashboard/project/[projectId]/services/application/[applicationId].tsx index 209f6f65f..104b1ff7b 100644 --- a/apps/dokploy/pages/dashboard/project/[projectId]/services/application/[applicationId].tsx +++ b/apps/dokploy/pages/dashboard/project/[projectId]/services/application/[applicationId].tsx @@ -51,6 +51,7 @@ import { TooltipProvider, TooltipTrigger, } from "@/components/ui/tooltip"; +import { UseKeyboardNavForApplications } from "@/hooks/use-keyboard-nav"; import { appRouter } from "@/server/api/root"; import { api } from "@/utils/api"; @@ -91,6 +92,7 @@ const Service = ( return (
+ +
diff --git a/apps/dokploy/server/api/routers/swarm.ts b/apps/dokploy/server/api/routers/swarm.ts index 91409a75c..cd3b042e9 100644 --- a/apps/dokploy/server/api/routers/swarm.ts +++ b/apps/dokploy/server/api/routers/swarm.ts @@ -67,8 +67,7 @@ export const swarmRouter = createTRPCRouter({ .string() .min(1) .regex(containerIdRegex, "Invalid app name.") - .array() - .min(1), + .array(), serverId: z.string().optional(), }), ) diff --git a/packages/server/src/services/settings.ts b/packages/server/src/services/settings.ts index 613a97b0d..e4402892f 100644 --- a/packages/server/src/services/settings.ts +++ b/packages/server/src/services/settings.ts @@ -391,22 +391,22 @@ export const readPorts = async ( ); }; -export const writeTraefikSetup = async ( - input: TraefikOptions, - serverId?: string, -) => { - const resourceType = await getDockerResourceType("dokploy-traefik", serverId); +export const writeTraefikSetup = async (input: TraefikOptions) => { + const resourceType = await getDockerResourceType( + "dokploy-traefik", + input.serverId, + ); if (resourceType === "service") { await initializeTraefikService({ env: input.env, additionalPorts: input.additionalPorts, - serverId: serverId, + serverId: input.serverId, }); } else { await initializeStandaloneTraefik({ env: input.env, additionalPorts: input.additionalPorts, - serverId: serverId, + serverId: input.serverId, }); } }; diff --git a/packages/server/src/setup/traefik-setup.ts b/packages/server/src/setup/traefik-setup.ts index cf10d7fa1..ccdfa30f8 100644 --- a/packages/server/src/setup/traefik-setup.ts +++ b/packages/server/src/setup/traefik-setup.ts @@ -89,21 +89,14 @@ export const initializeStandaloneTraefik = async ({ const docker = await getRemoteDocker(serverId); try { const container = docker.getContainer(containerName); - try { - await container.remove({ force: true }); - await new Promise((resolve) => setTimeout(resolve, 5000)); - await docker.createContainer(settings); - const newContainer = docker.getContainer(containerName); - await newContainer.start(); - console.log("Traefik Started ✅"); - } catch (error) { - console.error("Error in initializeStandaloneTraefik", error); - } - } catch (error) { - await docker.createContainer(settings); - console.error("Error in initializeStandaloneTraefik", error); - throw error; - } + await container.remove({ force: true }); + await new Promise((resolve) => setTimeout(resolve, 5000)); + } catch {} + + await docker.createContainer(settings); + const newContainer = docker.getContainer(containerName); + await newContainer.start(); + console.log("Traefik Started ✅"); }; export const initializeTraefikService = async ({ diff --git a/packages/server/src/utils/docker/collision.ts b/packages/server/src/utils/docker/collision.ts index 9d399dc0d..de6d9bbb3 100644 --- a/packages/server/src/utils/docker/collision.ts +++ b/packages/server/src/utils/docker/collision.ts @@ -1,8 +1,14 @@ import { findComposeById } from "@dokploy/server/services/compose"; -import { dump, load } from "js-yaml"; +import { dump } from "js-yaml"; import { addAppNameToAllServiceNames } from "./collision/root-network"; import { generateRandomHash } from "./compose"; import { addSuffixToAllVolumes } from "./compose/volume"; +import { + cloneCompose, + cloneComposeRemote, + loadDockerCompose, + loadDockerComposeRemote, +} from "./domain"; import type { ComposeSpecification } from "./types"; export const addAppNameToPreventCollision = ( @@ -24,16 +30,34 @@ export const randomizeIsolatedDeploymentComposeFile = async ( suffix?: string, ) => { const compose = await findComposeById(composeId); - const composeFile = compose.composeFile; - const composeData = load(composeFile) as ComposeSpecification; + + if (compose.serverId) { + await cloneComposeRemote(compose); + } else { + await cloneCompose(compose); + } + + let composeData: ComposeSpecification | null; + + if (compose.serverId) { + composeData = await loadDockerComposeRemote(compose); + } else { + composeData = await loadDockerCompose(compose); + } + + if (!composeData) { + throw new Error("Compose data not found"); + } const randomSuffix = suffix || compose.appName || generateRandomHash(); - const newComposeFile = addAppNameToPreventCollision( - composeData, - randomSuffix, - compose.isolatedDeploymentsVolume, - ); + const newComposeFile = compose.isolatedDeployment + ? addAppNameToPreventCollision( + composeData, + randomSuffix, + compose.isolatedDeploymentsVolume, + ) + : composeData; return dump(newComposeFile); };