diff --git a/.gitignore b/.gitignore index d39d787d4..602556df8 100644 --- a/.gitignore +++ b/.gitignore @@ -45,4 +45,5 @@ yarn-error.log* .db -.playwright-* \ No newline at end of file +.playwright-* +.credentials \ No newline at end of file diff --git a/apps/dokploy/__test__/drop/drop.test.ts b/apps/dokploy/__test__/drop/drop.test.ts index a524e8da0..eda3b9f0d 100644 --- a/apps/dokploy/__test__/drop/drop.test.ts +++ b/apps/dokploy/__test__/drop/drop.test.ts @@ -32,6 +32,8 @@ const baseApp: ApplicationNested = { railpackVersion: "0.15.4", applicationId: "", previewLabels: [], + networkIds: [], + detachDokployNetwork: false, createEnvFile: true, bitbucketRepositorySlug: "", herokuVersion: "", diff --git a/apps/dokploy/__test__/traefik/traefik.test.ts b/apps/dokploy/__test__/traefik/traefik.test.ts index 68758ca2d..b7b0f5645 100644 --- a/apps/dokploy/__test__/traefik/traefik.test.ts +++ b/apps/dokploy/__test__/traefik/traefik.test.ts @@ -7,6 +7,8 @@ const baseApp: ApplicationNested = { rollbackActive: false, applicationId: "", previewLabels: [], + networkIds: [], + detachDokployNetwork: false, createEnvFile: true, bitbucketRepositorySlug: "", herokuVersion: "", diff --git a/apps/dokploy/components/dashboard/application/advanced/import/show-import.tsx b/apps/dokploy/components/dashboard/application/advanced/import/show-import.tsx index f06b173cf..db39db2e8 100644 --- a/apps/dokploy/components/dashboard/application/advanced/import/show-import.tsx +++ b/apps/dokploy/components/dashboard/application/advanced/import/show-import.tsx @@ -185,7 +185,7 @@ export const ShowImport = ({ composeId }: Props) => { - + Template Information @@ -199,7 +199,7 @@ export const ShowImport = ({ composeId }: Props) => { -
+
@@ -207,12 +207,14 @@ export const ShowImport = ({ composeId }: Props) => { Docker Compose
- +
+ +
diff --git a/apps/dokploy/components/dashboard/compose/advanced/add-isolation.tsx b/apps/dokploy/components/dashboard/compose/advanced/add-isolation.tsx index 352571909..730fe7a7b 100644 --- a/apps/dokploy/components/dashboard/compose/advanced/add-isolation.tsx +++ b/apps/dokploy/components/dashboard/compose/advanced/add-isolation.tsx @@ -6,6 +6,7 @@ import { toast } from "sonner"; import { z } from "zod"; import { AlertBlock } from "@/components/shared/alert-block"; import { CodeEditor } from "@/components/shared/code-editor"; +import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Card, @@ -111,7 +112,10 @@ export const IsolatedDeploymentTab = ({ composeId }: Props) => { return ( - Enable Isolated Deployment + + Enable Isolated Deployment + Deprecated + Configure isolated deployment to the compose file.
@@ -138,6 +142,11 @@ export const IsolatedDeploymentTab = ({ composeId }: Props) => {
+ + Isolated deployment is deprecated. Use the Networks section above to + attach networks per service and detach them from dokploy-network — + it is declarative and does not break on restarts. + {isError && {error?.message}}
{ Preview Compose - + Converted Compose @@ -62,10 +62,6 @@ 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. - {isPending ? (
@@ -79,7 +75,7 @@ export const ShowConvertedCompose = ({ composeId }: Props) => {
) : ( <> -
+
-
+						
-
+
)}
diff --git a/apps/dokploy/components/dashboard/networks/assign-compose-networks.tsx b/apps/dokploy/components/dashboard/networks/assign-compose-networks.tsx new file mode 100644 index 000000000..dfde4907c --- /dev/null +++ b/apps/dokploy/components/dashboard/networks/assign-compose-networks.tsx @@ -0,0 +1,347 @@ +import { zodResolver } from "@hookform/resolvers/zod"; +import { Check, ChevronsUpDown, Loader2, RefreshCw } from "lucide-react"; +import { useEffect, useState } from "react"; +import { useFieldArray, useForm } from "react-hook-form"; +import { toast } from "sonner"; +import { z } from "zod"; +import { AlertBlock } from "@/components/shared/alert-block"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Checkbox } from "@/components/ui/checkbox"; +import { + Command, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@/components/ui/command"; +import { Form, FormControl, FormField, FormItem } from "@/components/ui/form"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { Switch } from "@/components/ui/switch"; +import { cn } from "@/lib/utils"; +import { api } from "@/utils/api"; + +interface Props { + composeId: string; +} + +const serviceSchema = z.object({ + serviceName: z.string(), + networkIds: z.array(z.string()), + detachDokployNetwork: z.boolean(), +}); + +const formSchema = z.object({ + services: z.array(serviceSchema), +}); + +type FormValues = z.infer; + +export const AssignComposeNetworks = ({ composeId }: Props) => { + const [cacheType, setCacheType] = useState<"cache" | "fetch">("cache"); + + const { data: compose } = api.compose.one.useQuery({ composeId }); + const { + data: services, + isLoading: isLoadingServices, + error: servicesError, + refetch: refetchServices, + isRefetching: isRefetchingServices, + } = api.compose.loadServices.useQuery( + { composeId, type: cacheType }, + { retry: false }, + ); + + const onRetry = () => { + setCacheType("fetch"); + setTimeout(() => refetchServices(), 0); + }; + + const { data: networks } = api.network.all.useQuery( + { serverId: compose?.serverId ?? undefined }, + { enabled: compose !== undefined }, + ); + const { mutateAsync, isPending } = api.compose.update.useMutation(); + const utils = api.useUtils(); + + const form = useForm({ + resolver: zodResolver(formSchema), + defaultValues: { services: [] }, + }); + const { fields } = useFieldArray({ + control: form.control, + name: "services", + }); + + useEffect(() => { + if (!services) return; + const serviceNetworks = compose?.serviceNetworks ?? []; + form.reset({ + services: services.map((serviceName) => { + const config = serviceNetworks.find( + (s) => s.serviceName === serviceName, + ); + return { + serviceName, + networkIds: config?.networkIds ?? [], + detachDokployNetwork: config?.detachDokployNetwork ?? false, + }; + }), + }); + }, [services, compose?.serviceNetworks, form]); + + const allowsBridge = compose?.composeType === "docker-compose"; + const availableNetworks = (networks ?? []).filter( + (n) => allowsBridge || n.driver === "overlay", + ); + + const onSubmit = async (values: FormValues) => { + try { + const serviceNetworks = values.services.filter( + (s) => s.networkIds.length > 0 || s.detachDokployNetwork, + ); + await mutateAsync({ + composeId, + serviceNetworks, + }); + toast.success("Networks updated. Redeploy the compose to apply them."); + await utils.compose.one.invalidate({ composeId }); + } catch (error) { + toast.error("Error updating networks", { + description: error instanceof Error ? error.message : "Unknown error", + }); + } + }; + + return ( + + +
+ Networks + + Attach Docker networks per service and detach it from + dokploy-network. Takes effect on the next deploy. + +
+ +
+ + {servicesError ? ( +
+ + Could not load the compose services. If this compose was just + created from a template, it hasn't been cloned yet — click Reload + to clone the repository and read its services. + +
+ +
+
+ ) : isLoadingServices ? ( +
+ Loading services... + +
+ ) : !services?.length ? ( + + No services found in this compose. + + ) : ( + + + {fields.map((fieldItem, index) => ( + + ))} +
+ +
+ + + )} +
+
+ ); +}; + +type NetworkOption = { networkId: string; name: string; driver: string }; + +const ServiceRow = ({ + control, + index, + service, + availableNetworks, +}: { + control: ReturnType>["control"]; + index: number; + service: string; + availableNetworks: NetworkOption[]; +}) => { + const [open, setOpen] = useState(false); + + return ( +
+ ( + + {service} +
+ + Detach dokploy-network + + + + +
+
+ )} + /> + + { + const selectedNetworks = availableNetworks.filter((n) => + field.value.includes(n.networkId), + ); + const toggle = (networkId: string) => { + field.onChange( + field.value.includes(networkId) + ? field.value.filter((id) => id !== networkId) + : [...field.value, networkId], + ); + }; + return ( + + + + + + + + + + {availableNetworks.length === 0 ? ( +
+ No networks available on this server. +
+ ) : ( + + {availableNetworks.map((n) => { + const isSelected = field.value.includes( + n.networkId, + ); + return ( + toggle(n.networkId)} + className="cursor-pointer" + > + toggle(n.networkId)} + /> + {n.name} + + {n.driver} + + + + ); + })} + + )} +
+
+
+
+ + {selectedNetworks.length > 0 && ( +
+ {selectedNetworks.map((n) => ( + + {n.name} + + ))} +
+ )} +
+ ); + }} + /> +
+ ); +}; diff --git a/apps/dokploy/components/dashboard/networks/assign-networks.tsx b/apps/dokploy/components/dashboard/networks/assign-networks.tsx new file mode 100644 index 000000000..1ae7ec79a --- /dev/null +++ b/apps/dokploy/components/dashboard/networks/assign-networks.tsx @@ -0,0 +1,355 @@ +import { Check, ChevronsUpDown, X } from "lucide-react"; +import { useEffect, useState } from "react"; +import { toast } from "sonner"; +import { AlertBlock } from "@/components/shared/alert-block"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Checkbox } from "@/components/ui/checkbox"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@/components/ui/command"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { Switch } from "@/components/ui/switch"; +import { cn } from "@/lib/utils"; +import { api } from "@/utils/api"; + +type ServiceType = + | "application" + | "postgres" + | "mysql" + | "mariadb" + | "mongo" + | "redis" + | "libsql"; + +interface Props { + id: string; + type: ServiceType; +} + +export const AssignNetworks = ({ id, type }: Props) => { + const [open, setOpen] = useState(false); + const [selected, setSelected] = useState([]); + const [detached, setDetached] = useState(false); + + const { + service, + serverId, + networkIds, + detachDokployNetwork, + updateAsync, + isUpdating, + refetch, + } = useServiceNetworks(id, type); + + const { data: networks } = api.network.all.useQuery( + { serverId: serverId ?? undefined }, + { enabled: service !== undefined }, + ); + + const { data: applicationDomains } = api.domain.byApplicationId.useQuery( + { applicationId: id }, + { enabled: type === "application" }, + ); + const hasDomains = (applicationDomains?.length ?? 0) > 0; + + useEffect(() => { + setSelected(networkIds ?? []); + setDetached(detachDokployNetwork ?? false); + }, [networkIds, detachDokployNetwork]); + + const availableNetworks = (networks ?? []).filter( + (n) => n.driver === "overlay", + ); + const selectedNetworks = availableNetworks.filter((n) => + selected.includes(n.networkId), + ); + const isDirty = + selected.length !== (networkIds?.length ?? 0) || + selected.some((networkId) => !networkIds?.includes(networkId)) || + detached !== detachDokployNetwork; + + const toggle = (networkId: string) => { + setSelected((prev) => + prev.includes(networkId) + ? prev.filter((id) => id !== networkId) + : [...prev, networkId], + ); + }; + + const onSave = async () => { + try { + await updateAsync({ + networkIds: selected, + detachDokployNetwork: detached, + }); + toast.success("Networks updated. Redeploy the service to apply them."); + await refetch(); + } catch (error) { + toast.error("Error updating networks", { + description: error instanceof Error ? error.message : "Unknown error", + }); + } + }; + + return ( + + +
+ Networks + + Attach additional Docker networks to this service so it can reach + services on those networks. Takes effect on the next deploy. + +
+
+ +
+
+
+ + Detach from dokploy-network + + dokploy-network +
+

+ By default the service joins the shared dokploy-network. Detach it + to keep it reachable only through the networks you attach below. +

+
+ +
+ + {detached && hasDomains && ( + + Warning: this service has domains. Detaching it from dokploy-network + will break Traefik routing, and its domains will stop working. + + )} + + {detached && !hasDomains && selected.length === 0 && ( + + This service is detached from dokploy-network but has no other + network attached. It would be unreachable, so dokploy-network will + be kept until you attach a network below. + + )} + + + + + + + + + + {availableNetworks.length === 0 ? ( +
+ No overlay networks on this server. +
+ ) : ( + <> + + No networks found. + + + {availableNetworks.map((n) => { + const isSelected = selected.includes(n.networkId); + return ( + toggle(n.networkId)} + className="cursor-pointer" + > + toggle(n.networkId)} + /> + {n.name} + + {n.driver} + + + + ); + })} + + + )} +
+
+
+
+ + {selectedNetworks.length > 0 && ( +
+ {selectedNetworks.map((n) => ( + + {n.name} + + + ))} +
+ )} + +
+ +
+
+
+ ); +}; + +// Maps a service type to its one-query and update-mutation, normalizing the +// per-type id field name and the networkIds field. +const useServiceNetworks = (id: string, type: ServiceType) => { + const application = api.application.one.useQuery( + { applicationId: id }, + { enabled: type === "application" }, + ); + const postgres = api.postgres.one.useQuery( + { postgresId: id }, + { enabled: type === "postgres" }, + ); + const mysql = api.mysql.one.useQuery( + { mysqlId: id }, + { enabled: type === "mysql" }, + ); + const mariadb = api.mariadb.one.useQuery( + { mariadbId: id }, + { enabled: type === "mariadb" }, + ); + const mongo = api.mongo.one.useQuery( + { mongoId: id }, + { enabled: type === "mongo" }, + ); + const redis = api.redis.one.useQuery( + { redisId: id }, + { enabled: type === "redis" }, + ); + const libsql = api.libsql.one.useQuery( + { libsqlId: id }, + { enabled: type === "libsql" }, + ); + const applicationUpdate = api.application.update.useMutation(); + const postgresUpdate = api.postgres.update.useMutation(); + const mysqlUpdate = api.mysql.update.useMutation(); + const mariadbUpdate = api.mariadb.update.useMutation(); + const mongoUpdate = api.mongo.update.useMutation(); + const redisUpdate = api.redis.update.useMutation(); + const libsqlUpdate = api.libsql.update.useMutation(); + + const map = { + application: { + query: application, + mutation: applicationUpdate, + save: (payload: SavePayload) => + applicationUpdate.mutateAsync({ applicationId: id, ...payload }), + }, + postgres: { + query: postgres, + mutation: postgresUpdate, + save: (payload: SavePayload) => + postgresUpdate.mutateAsync({ postgresId: id, ...payload }), + }, + mysql: { + query: mysql, + mutation: mysqlUpdate, + save: (payload: SavePayload) => + mysqlUpdate.mutateAsync({ mysqlId: id, ...payload }), + }, + mariadb: { + query: mariadb, + mutation: mariadbUpdate, + save: (payload: SavePayload) => + mariadbUpdate.mutateAsync({ mariadbId: id, ...payload }), + }, + mongo: { + query: mongo, + mutation: mongoUpdate, + save: (payload: SavePayload) => + mongoUpdate.mutateAsync({ mongoId: id, ...payload }), + }, + redis: { + query: redis, + mutation: redisUpdate, + save: (payload: SavePayload) => + redisUpdate.mutateAsync({ redisId: id, ...payload }), + }, + libsql: { + query: libsql, + mutation: libsqlUpdate, + save: (payload: SavePayload) => + libsqlUpdate.mutateAsync({ libsqlId: id, ...payload }), + }, + }[type]; + + const service = map.query.data; + + return { + service, + serverId: service?.serverId ?? null, + networkIds: service?.networkIds ?? [], + detachDokployNetwork: service?.detachDokployNetwork ?? false, + updateAsync: map.save, + isUpdating: map.mutation.isPending, + refetch: map.query.refetch, + }; +}; + +type SavePayload = { + networkIds: string[]; + detachDokployNetwork: boolean; +}; diff --git a/apps/dokploy/components/dashboard/requests/requests-table.tsx b/apps/dokploy/components/dashboard/requests/requests-table.tsx index d7e836a61..802b60e46 100644 --- a/apps/dokploy/components/dashboard/requests/requests-table.tsx +++ b/apps/dokploy/components/dashboard/requests/requests-table.tsx @@ -328,7 +328,7 @@ export const RequestsTable = ({ dateRange }: RequestsTableProps) => { open={!!selectedRow} onOpenChange={(_open) => setSelectedRow(undefined)} > - + Request log diff --git a/apps/dokploy/components/dashboard/shared/show-database-advanced-settings.tsx b/apps/dokploy/components/dashboard/shared/show-database-advanced-settings.tsx index bf2089498..0534c9178 100644 --- a/apps/dokploy/components/dashboard/shared/show-database-advanced-settings.tsx +++ b/apps/dokploy/components/dashboard/shared/show-database-advanced-settings.tsx @@ -1,5 +1,6 @@ import { ShowResources } from "@/components/dashboard/application/advanced/show-resources"; import { ShowVolumes } from "@/components/dashboard/application/advanced/volumes/show-volumes"; +import { AssignNetworks } from "@/components/dashboard/networks/assign-networks"; import { ShowCustomCommand } from "@/components/dashboard/postgres/advanced/show-custom-command"; import { ShowClusterSettings } from "../application/advanced/cluster/show-cluster-settings"; import { RebuildDatabase } from "./rebuild-database"; @@ -21,6 +22,7 @@ export const ShowDatabaseAdvancedSettings = ({ id, type }: Props) => { ) : null} +
diff --git a/apps/dokploy/components/shared/drawer-logs.tsx b/apps/dokploy/components/shared/drawer-logs.tsx index 38f8b5db4..301a0fe0d 100644 --- a/apps/dokploy/components/shared/drawer-logs.tsx +++ b/apps/dokploy/components/shared/drawer-logs.tsx @@ -47,7 +47,7 @@ export const DrawerLogs = ({ isOpen, onClose, filteredLogs }: Props) => { onClose(); }} > - + Deployment Logs Details of the request log entry. diff --git a/apps/dokploy/components/ui/textarea.tsx b/apps/dokploy/components/ui/textarea.tsx index 959d789e2..4a8168211 100644 --- a/apps/dokploy/components/ui/textarea.tsx +++ b/apps/dokploy/components/ui/textarea.tsx @@ -7,7 +7,7 @@ function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {