diff --git a/.gitignore b/.gitignore index d531bab01..602556df8 100644 --- a/.gitignore +++ b/.gitignore @@ -43,4 +43,7 @@ yarn-error.log* *.pem -.db \ No newline at end of file +.db + +.playwright-* +.credentials \ No newline at end of file diff --git a/apps/dokploy/__test__/compose/network/service-networks.test.ts b/apps/dokploy/__test__/compose/network/service-networks.test.ts new file mode 100644 index 000000000..85d912971 --- /dev/null +++ b/apps/dokploy/__test__/compose/network/service-networks.test.ts @@ -0,0 +1,199 @@ +import type { Compose, ComposeSpecification } from "@dokploy/server"; +import { + applyServiceNetworks, + declareUsedNetworksInRoot, + resolveServiceNetworks, +} from "@dokploy/server"; +import { db } from "@dokploy/server/db"; +import { beforeEach, expect, test, type vi } from "vitest"; +import { parse } from "yaml"; + +const findManyMock = db.query.network.findMany as ReturnType; + +beforeEach(() => { + findManyMock.mockReset(); + findManyMock.mockResolvedValue([]); +}); + +const baseCompose = { + serverId: null, + isolatedDeployment: false, +} as unknown as Compose; + +const withServiceNetworks = ( + serviceNetworks: Compose["serviceNetworks"], +): Compose => ({ ...baseCompose, serviceNetworks }); + +test("applyServiceNetworks: no-op when serviceNetworks is empty", async () => { + const result = parse(` +services: + web: + image: nginx +`) as ComposeSpecification; + + const injected = await applyServiceNetworks(result, withServiceNetworks([])); + + expect(injected.size).toBe(0); + expect(result.services?.web?.networks).toBeUndefined(); + expect(findManyMock).not.toHaveBeenCalled(); +}); + +test("applyServiceNetworks: injects assigned network by networkId", async () => { + findManyMock.mockResolvedValue([{ networkId: "net-1", name: "shared-net" }]); + + const result = parse(` +services: + web: + image: nginx +`) as ComposeSpecification; + + const injected = await applyServiceNetworks( + result, + withServiceNetworks([ + { + serviceName: "web", + networkIds: ["net-1"], + detachDokployNetwork: false, + }, + ]), + ); + + expect(injected.has("shared-net")).toBe(true); + expect(result.services?.web?.networks).toContain("shared-net"); +}); + +test("applyServiceNetworks: detach removes dokploy-network and default", async () => { + const result = parse(` +services: + db: + image: postgres + networks: + - dokploy-network + - default +`) as ComposeSpecification; + + const injected = await applyServiceNetworks( + result, + withServiceNetworks([ + { serviceName: "db", networkIds: [], detachDokployNetwork: true }, + ]), + ); + + expect(injected.size).toBe(0); + expect(result.services?.db?.networks).not.toContain("dokploy-network"); + expect(result.services?.db?.networks).not.toContain("default"); +}); + +test("applyServiceNetworks: unknown networkId is skipped", async () => { + findManyMock.mockResolvedValue([]); + + const result = parse(` +services: + web: + image: nginx +`) as ComposeSpecification; + + const injected = await applyServiceNetworks( + result, + withServiceNetworks([ + { + serviceName: "web", + networkIds: ["missing"], + detachDokployNetwork: false, + }, + ]), + ); + + expect(injected.size).toBe(0); +}); + +test("applyServiceNetworks: skips services that don't exist in the compose", async () => { + findManyMock.mockResolvedValue([{ networkId: "net-1", name: "shared-net" }]); + + const result = parse(` +services: + web: + image: nginx +`) as ComposeSpecification; + + const injected = await applyServiceNetworks( + result, + withServiceNetworks([ + { + serviceName: "ghost", + networkIds: ["net-1"], + detachDokployNetwork: false, + }, + ]), + ); + + expect(injected.size).toBe(0); + expect(result.services?.web?.networks).toBeUndefined(); +}); + +test("declareUsedNetworksInRoot: declares dokploy-network only when used", () => { + const used = parse(` +services: + web: + image: nginx + networks: + - dokploy-network +`) as ComposeSpecification; + declareUsedNetworksInRoot(used, new Set()); + expect(used.networks).toHaveProperty("dokploy-network"); + + const unused = parse(` +services: + web: + image: nginx + networks: + - default +`) as ComposeSpecification; + declareUsedNetworksInRoot(unused, new Set()); + expect(unused.networks ?? {}).not.toHaveProperty("dokploy-network"); +}); + +test("declareUsedNetworksInRoot: declares injected networks that are used", () => { + const result = parse(` +services: + web: + image: nginx + networks: + - shared-net +`) as ComposeSpecification; + + declareUsedNetworksInRoot(result, new Set(["shared-net", "unused-net"])); + + expect(result.networks).toHaveProperty("shared-net"); + expect(result.networks ?? {}).not.toHaveProperty("unused-net"); +}); + +test("resolveServiceNetworks: returns dokploy-network by default", async () => { + const resolved = await resolveServiceNetworks({}); + expect(resolved).toEqual([{ Target: "dokploy-network" }]); + expect(findManyMock).not.toHaveBeenCalled(); +}); + +test("resolveServiceNetworks: omits dokploy-network when detached", async () => { + const resolved = await resolveServiceNetworks({ detachDokployNetwork: true }); + expect(resolved).toEqual([]); +}); + +test("resolveServiceNetworks: appends overlay networks by networkId", async () => { + findManyMock.mockResolvedValue([{ name: "overlay-a" }]); + + const resolved = await resolveServiceNetworks({ networkIds: ["net-a"] }); + + expect(resolved).toEqual([ + { Target: "dokploy-network" }, + { Target: "overlay-a" }, + ]); +}); + +test("resolveServiceNetworks: networkSwarm override takes precedence", async () => { + const override = [{ Target: "custom-net" }]; + const resolved = await resolveServiceNetworks({ networkSwarm: override }); + + expect(resolved).toBe(override); + expect(findManyMock).not.toHaveBeenCalled(); +}); 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/networks/handle-network.tsx b/apps/dokploy/components/dashboard/networks/handle-network.tsx new file mode 100644 index 000000000..8f522c40d --- /dev/null +++ b/apps/dokploy/components/dashboard/networks/handle-network.tsx @@ -0,0 +1,373 @@ +"use client"; + +import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema"; +import { Network, Plus, Trash2 } from "lucide-react"; +import { useState } from "react"; +import { useFieldArray, useForm } from "react-hook-form"; +import { toast } from "sonner"; +import { z } from "zod"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Switch } from "@/components/ui/switch"; +import { api } from "@/utils/api"; + +// Only bridge and overlay can be created: "host"/"none" are Docker +// singletons and macvlan/ipvlan need driver options we don't expose. +const networkDriverEnum = ["bridge", "overlay"] as const; + +const ipamConfigEntrySchema = z.object({ + subnet: z.string().optional(), + ipRange: z.string().optional(), + gateway: z.string().optional(), +}); + +const networkFormSchema = z + .object({ + name: z.string().min(1, "Name is required"), + driver: z.enum(networkDriverEnum), + internal: z.boolean(), + attachable: z.boolean(), + enableIPv4: z.boolean(), + enableIPv6: z.boolean(), + ipamDriver: z.string().optional(), + ipamConfig: z.array(ipamConfigEntrySchema), + }) + .superRefine((input, ctx) => { + if (!input.enableIPv4 && !input.enableIPv6) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["enableIPv4"], + message: "IPv4 or IPv6 must be enabled", + }); + } + for (const [index, entry] of input.ipamConfig.entries()) { + if (!entry.subnet && (entry.gateway || entry.ipRange)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["ipamConfig", index, "subnet"], + message: "Gateway and IP range require a subnet", + }); + } + } + }); + +type NetworkFormValues = z.infer; + +const defaultValues: NetworkFormValues = { + name: "", + driver: "bridge", + internal: false, + attachable: false, + enableIPv4: true, + enableIPv6: false, + ipamDriver: "", + ipamConfig: [], +}; + +const toggleOptions = [ + { + name: "internal", + label: "Internal", + description: "Containers on this network cannot reach external networks.", + }, + { + name: "attachable", + label: "Attachable", + description: + "Allow standalone containers to attach (overlay networks only).", + }, + { + name: "enableIPv4", + label: "Enable IPv4", + description: "Enable IPv4 addressing on the network.", + }, + { + name: "enableIPv6", + label: "Enable IPv6", + description: "Enable IPv6 addressing on the network.", + }, +] as const; + +interface HandleNetworkProps { + /** Target server; undefined creates on the local Dokploy server */ + serverId?: string; + children?: React.ReactNode; +} + +// Docker networks are immutable, so this dialog only creates them; +// changing a network means deleting and recreating it. +export const HandleNetwork = ({ serverId, children }: HandleNetworkProps) => { + const [isOpen, setIsOpen] = useState(false); + const utils = api.useUtils(); + + const { mutateAsync, isPending } = api.network.create.useMutation(); + + const form = useForm({ + resolver: zodResolver(networkFormSchema), + defaultValues, + }); + + const ipamConfigFieldArray = useFieldArray({ + control: form.control, + name: "ipamConfig", + }); + + const onSubmit = async (data: NetworkFormValues) => { + try { + await mutateAsync({ + name: data.name, + driver: data.driver, + serverId, + internal: data.internal, + attachable: data.attachable, + enableIPv4: data.enableIPv4, + enableIPv6: data.enableIPv6, + ipam: { + driver: data.ipamDriver || undefined, + config: data.ipamConfig, + }, + }); + + toast.success("Network created"); + await utils.network.all.invalidate(); + setIsOpen(false); + form.reset(defaultValues); + } catch (error) { + toast.error("Error creating network", { + description: error instanceof Error ? error.message : "Unknown error", + }); + } + }; + + const trigger = children ?? ( + + ); + + return ( + + {trigger} + + + + + Add network + + + Create a new Docker network for your organization. Networks are + immutable: to change one, delete it and create it again. + + +
+ +
+ ( + + Name + + + + + + )} + /> + ( + + Driver + + + bridge for single-server containers; overlay for Swarm + services. + + + + )} + /> +
+
+ {toggleOptions.map((option) => ( + ( + +
+ {option.label} + + {option.description} + +
+ + + +
+ )} + /> + ))} +
+
+
+ IPAM +

+ IP address management settings for this network. +

+
+ ( + + + Driver (optional) + + + + + + + )} + /> +
+ + Config (subnet / gateway / IP range) + + {ipamConfigFieldArray.fields.map((field, index) => ( +
+ ( + + + + + + + )} + /> + ( + + + + + + + )} + /> + ( + + + + + + + )} + /> + +
+ ))} + +
+
+ + + + +
+ +
+
+ ); +}; diff --git a/apps/dokploy/components/dashboard/networks/show-network-config.tsx b/apps/dokploy/components/dashboard/networks/show-network-config.tsx new file mode 100644 index 000000000..c7f9a158c --- /dev/null +++ b/apps/dokploy/components/dashboard/networks/show-network-config.tsx @@ -0,0 +1,69 @@ +"use client"; + +import { Eye, Loader2 } from "lucide-react"; +import { useState } from "react"; +import { AlertBlock } from "@/components/shared/alert-block"; +import { CodeEditor } from "@/components/shared/code-editor"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { api } from "@/utils/api"; + +interface Props { + networkId: string; + networkName: string; +} + +export const ShowNetworkConfig = ({ networkId, networkName }: Props) => { + const [open, setOpen] = useState(false); + const { data, isLoading, error } = api.network.inspect.useQuery( + { networkId }, + { enabled: open }, + ); + + return ( + + + + + + + Network Config + + docker network inspect output for "{networkName}" + + + {error ? ( + {error.message} + ) : isLoading ? ( +
+ Loading... + +
+ ) : ( +
+ +
+								
+							
+
+
+ )} +
+
+ ); +}; diff --git a/apps/dokploy/components/dashboard/networks/show-networks.tsx b/apps/dokploy/components/dashboard/networks/show-networks.tsx new file mode 100644 index 000000000..d87278b1e --- /dev/null +++ b/apps/dokploy/components/dashboard/networks/show-networks.tsx @@ -0,0 +1,494 @@ +"use client"; + +import { + type ColumnDef, + flexRender, + getCoreRowModel, + getPaginationRowModel, + getSortedRowModel, + type PaginationState, + type SortingState, + useReactTable, +} from "@tanstack/react-table"; +import type { inferRouterOutputs } from "@trpc/server"; +import { + ArrowUpDown, + Loader2, + Network, + RotateCcw, + ShieldCheck, + Trash2, +} from "lucide-react"; +import { useMemo, useState } from "react"; +import { toast } from "sonner"; +import { HandleNetwork } from "@/components/dashboard/networks/handle-network"; +import { ShowNetworkConfig } from "@/components/dashboard/networks/show-network-config"; +import { SyncNetworks } from "@/components/dashboard/networks/sync-networks"; +import { DialogAction } from "@/components/shared/dialog-action"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardAction, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import type { AppRouter } from "@/server/api/root"; +import { api } from "@/utils/api"; + +type NetworkRow = inferRouterOutputs["network"]["all"][number]; + +interface Props { + /** Selected server; undefined shows the local Dokploy server */ + serverId?: string; +} + +const getIpamEntries = (row: NetworkRow) => + (row.ipam?.config ?? []).filter((c) => c.subnet || c.gateway || c.ipRange); + +const SortableHeader = ({ + column, + title, +}: { + column: { + getIsSorted: () => false | "asc" | "desc"; + toggleSorting: (asc: boolean) => void; + }; + title: string; +}) => ( + +); + +export const ShowNetworks = ({ serverId }: Props) => { + const utils = api.useUtils(); + const [verified, setVerified] = useState(false); + const [sorting, setSorting] = useState([ + { id: "createdAt", desc: true }, + ]); + const [globalFilter, setGlobalFilter] = useState(""); + const [driverFilter, setDriverFilter] = useState("all"); + const [pagination, setPagination] = useState({ + pageIndex: 0, + pageSize: 10, + }); + + const { data: networks, isLoading } = api.network.all.useQuery({ serverId }); + const { mutateAsync: removeNetwork } = api.network.remove.useMutation(); + const recreateMutation = api.network.recreate.useMutation(); + + // Same query the Sync dialog uses; "missing" tells us which records + // no longer have a real network in Docker + const { + data: syncStatus, + isFetching: isVerifying, + refetch: refetchVerify, + } = api.network.networksToSync.useQuery({ serverId }, { enabled: verified }); + + const missingIds = useMemo( + () => new Set(syncStatus?.missing.map((m) => m.networkId) ?? []), + [syncStatus], + ); + + const onVerify = async () => { + setVerified(true); + const { data: result, error } = await refetchVerify(); + if (error) { + toast.error("Error verifying networks", { + description: error.message, + }); + return; + } + if (!result) return; + if (result.missing.length === 0) { + toast.success("All networks exist in Docker"); + } else { + toast.warning( + `${result.missing.length} network(s) no longer exist in Docker`, + ); + } + }; + + const filteredData = useMemo(() => { + let list = networks ?? []; + if (driverFilter !== "all") { + list = list.filter((n) => n.driver === driverFilter); + } + if (globalFilter.trim()) { + const query = globalFilter.toLowerCase(); + list = list.filter( + (n) => + n.name.toLowerCase().includes(query) || + (n.ipam?.config ?? []).some( + (c) => + c.subnet?.toLowerCase().includes(query) || + c.gateway?.toLowerCase().includes(query) || + c.ipRange?.toLowerCase().includes(query), + ), + ); + } + return list; + }, [networks, driverFilter, globalFilter]); + + const columns = useMemo[]>( + () => [ + { + accessorKey: "name", + header: ({ column }) => , + cell: ({ row }) => ( +
+ {row.original.name} + {verified && + syncStatus && + (missingIds.has(row.original.networkId) ? ( + <> + Missing in Docker + + + ) : ( + In sync + ))} +
+ ), + }, + { + accessorKey: "driver", + header: ({ column }) => ( + + ), + cell: ({ row }) => ( +
+ {row.original.driver} + + {row.original.driver === "overlay" ? "swarm" : "local"} + +
+ ), + }, + { + id: "subnet", + accessorFn: (row) => getIpamEntries(row)[0]?.subnet ?? "", + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const ipamEntries = getIpamEntries(row.original); + if (ipamEntries.length === 0) { + return Auto; + } + return ( +
+ {ipamEntries.map((c, index) => ( +
+ {c.subnet ?? "โ€”"} + {(c.gateway || c.ipRange) && ( + + {[ + c.gateway && `gw ${c.gateway}`, + c.ipRange && `range ${c.ipRange}`, + ] + .filter(Boolean) + .join(" ยท ")} + + )} +
+ ))} +
+ ); + }, + }, + { + accessorKey: "internal", + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + + {row.original.internal ? "Yes" : "No"} + + ), + }, + { + accessorKey: "attachable", + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + + {row.original.attachable ? "Yes" : "No"} + + ), + }, + { + accessorKey: "createdAt", + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + + {new Date(row.original.createdAt).toLocaleDateString()} + + ), + }, + { + id: "actions", + enableSorting: false, + header: () =>
Actions
, + cell: ({ row }) => ( +
+ + { + try { + await removeNetwork({ + networkId: row.original.networkId, + }); + toast.success("Network deleted"); + await utils.network.all.invalidate(); + await utils.network.networksToSync.invalidate(); + } catch (error) { + toast.error("Error deleting network", { + description: + error instanceof Error ? error.message : "Unknown error", + }); + } + }} + > + + +
+ ), + }, + ], + [verified, syncStatus, missingIds, removeNetwork, recreateMutation, utils], + ); + + const table = useReactTable({ + data: filteredData, + columns, + state: { + sorting, + pagination, + }, + onSortingChange: setSorting, + onPaginationChange: setPagination, + getCoreRowModel: getCoreRowModel(), + getSortedRowModel: getSortedRowModel(), + getPaginationRowModel: getPaginationRowModel(), + }); + + return ( +
+ +
+ + + + Networks + + + Manage the Docker networks of the selected server. + + +
+ {networks && networks.length > 0 && ( + + )} + + {networks && networks.length > 0 && ( + + )} +
+
+
+ + + {isLoading ? ( +
+ Loading... + +
+ ) : !networks?.length ? ( +
+
+ +
+
+

No networks yet

+

+ Create Docker networks for your organization and optionally + attach them to a server. Add your first network to get + started. +

+
+ +
+ ) : ( + <> +
+ setGlobalFilter(e.target.value)} + className="max-w-xs" + /> + +
+
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => ( + + {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef.header, + header.getContext(), + )} + + ))} + + ))} + + + {table.getRowModel().rows?.length ? ( + table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender( + cell.column.columnDef.cell, + cell.getContext(), + )} + + ))} + + )) + ) : ( + + + No networks match your filters. + + + )} + +
+
+ {table.getPageCount() > 1 && ( +
+ + Page {table.getState().pagination.pageIndex + 1} of{" "} + {table.getPageCount()} + +
+ + +
+
+ )} + + )} +
+
+
+
+ ); +}; diff --git a/apps/dokploy/components/dashboard/networks/sync-networks.tsx b/apps/dokploy/components/dashboard/networks/sync-networks.tsx new file mode 100644 index 000000000..2a0403048 --- /dev/null +++ b/apps/dokploy/components/dashboard/networks/sync-networks.tsx @@ -0,0 +1,249 @@ +"use client"; + +import { Loader2, RefreshCw, RotateCcw, Trash2 } from "lucide-react"; +import { 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 { Checkbox } from "@/components/ui/checkbox"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { Separator } from "@/components/ui/separator"; +import { api } from "@/utils/api"; + +interface Props { + serverId?: string; +} + +export const SyncNetworks = ({ serverId }: Props) => { + const [open, setOpen] = useState(false); + const [selected, setSelected] = useState>(new Set()); + const utils = api.useUtils(); + + const { data, isLoading, error, refetch } = + api.network.networksToSync.useQuery({ serverId }, { enabled: open }); + + const importMutation = api.network.import.useMutation(); + const removeMutation = api.network.remove.useMutation(); + const recreateMutation = api.network.recreate.useMutation(); + + const toggleSelected = (name: string) => { + setSelected((prev) => { + const next = new Set(prev); + if (next.has(name)) { + next.delete(name); + } else { + next.add(name); + } + return next; + }); + }; + + const onImport = async () => { + try { + const result = await importMutation.mutateAsync({ + serverId, + names: Array.from(selected), + }); + + if (result.imported.length > 0) { + toast.success(`Imported ${result.imported.length} network(s)`); + } + for (const failure of result.errors) { + toast.error(`Could not import "${failure.name}"`, { + description: failure.error, + }); + } + + setSelected(new Set()); + await utils.network.all.invalidate(); + + setOpen(false); + await refetch(); + } catch (error) { + toast.error("Error importing networks", { + description: error instanceof Error ? error.message : "Unknown error", + }); + } + }; + + const onRemoveStale = async (networkId: string, name: string) => { + try { + await removeMutation.mutateAsync({ networkId }); + toast.success(`Removed stale record "${name}"`); + await utils.network.all.invalidate(); + await refetch(); + } catch (error) { + toast.error("Error removing record", { + description: error instanceof Error ? error.message : "Unknown error", + }); + } + }; + + const onRecreate = async (networkId: string, name: string) => { + try { + await recreateMutation.mutateAsync({ networkId }); + toast.success(`Network "${name}" recreated in Docker`); + await utils.network.all.invalidate(); + await utils.network.networksToSync.invalidate(); + await refetch(); + } catch (error) { + toast.error("Error recreating network", { + description: error instanceof Error ? error.message : "Unknown error", + }); + } + }; + + return ( + { + setOpen(value); + if (!value) setSelected(new Set()); + }} + > + + + + + + + + Sync networks + + + Import networks that exist in Docker but not in Dokploy, and clean + up records whose network no longer exists. + + + + {error ? ( + {error.message} + ) : isLoading ? ( +
+ Scanning Docker networks... + +
+ ) : ( +
+
+ + Found in Docker ({data?.importable.length ?? 0}) + + {data?.importable.length ? ( + data.importable.map((dockerNetwork) => ( + + )) + ) : ( + + Nothing to import โ€” everything is in sync. + + )} +
+ + {!!data?.missing.length && ( + <> + +
+ + Missing in Docker ({data.missing.length}) + + + These records exist in Dokploy but their network is gone + from Docker. + + {data.missing.map((stale) => ( +
+ {stale.name} +
+ + +
+
+ ))} +
+ + )} +
+ )} + + + + + +
+
+ ); +}; 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/layouts/side.tsx b/apps/dokploy/components/layouts/side.tsx index c12c8a657..e58256c63 100644 --- a/apps/dokploy/components/layouts/side.tsx +++ b/apps/dokploy/components/layouts/side.tsx @@ -25,6 +25,7 @@ import { Loader2, LogIn, type LucideIcon, + Network, Package, Palette, PieChart, @@ -209,6 +210,20 @@ const MENU: Menu = { // Only enabled for users with access to Docker isEnabled: ({ permissions }) => !!permissions?.docker.read, }, + { + isSingle: true, + title: "Networks", + url: "/dashboard/networks", + icon: Network, + // Only enabled for admins and users with access to Docker in non-cloud environments + isEnabled: ({ auth, isCloud }) => + !!( + (auth?.role === "owner" || + auth?.role === "admin" || + auth?.canAccessToDocker) && + !isCloud + ), + }, { isSingle: true, title: "Requests", diff --git a/apps/dokploy/components/proprietary/audit-logs/columns.tsx b/apps/dokploy/components/proprietary/audit-logs/columns.tsx index dacb0284c..517335e29 100644 --- a/apps/dokploy/components/proprietary/audit-logs/columns.tsx +++ b/apps/dokploy/components/proprietary/audit-logs/columns.tsx @@ -95,6 +95,7 @@ const RESOURCE_LABELS: Record = { notification: "Notification", settings: "Settings", session: "Session", + network: "Network", }; function MetadataCell({ metadata }: { metadata: string | 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/button.tsx b/apps/dokploy/components/ui/button.tsx index 67f4405b0..f31410ab4 100644 --- a/apps/dokploy/components/ui/button.tsx +++ b/apps/dokploy/components/ui/button.tsx @@ -6,7 +6,7 @@ import type * as React from "react"; import { cn } from "@/lib/utils"; const buttonVariants = cva( - "group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", + "group/button inline-flex shrink-0 cursor-pointer items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", { variants: { variant: { 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">) {