diff --git a/apps/dokploy/components/dashboard/networks/handle-network.tsx b/apps/dokploy/components/dashboard/networks/handle-network.tsx index c30591088..8c006cfdb 100644 --- a/apps/dokploy/components/dashboard/networks/handle-network.tsx +++ b/apps/dokploy/components/dashboard/networks/handle-network.tsx @@ -1,8 +1,8 @@ "use client"; import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema"; -import { Network, Pencil, Plus, Trash2 } from "lucide-react"; -import { useEffect, useState } from "react"; +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"; @@ -36,17 +36,10 @@ import { import { Switch } from "@/components/ui/switch"; import { api } from "@/utils/api"; -const networkDriverEnum = [ - "bridge", - "host", - "overlay", - "macvlan", - "none", - "ipvlan", -] as const; +// 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; -/** Sentinel for "no scope" */ -const SCOPE_EMPTY = "__scope_none__"; /** Sentinel for the local Dokploy server (no serverId) */ const SERVER_LOCAL = "__server_local__"; @@ -56,32 +49,45 @@ const ipamConfigEntrySchema = z.object({ gateway: z.string().optional(), }); -const networkFormSchema = z.object({ - name: z.string().min(1, "Name is required"), - driver: z.enum(networkDriverEnum), - scope: z.string().optional(), - serverId: z.string().optional(), - internal: z.boolean(), - attachable: z.boolean(), - ingress: z.boolean(), - configOnly: z.boolean(), - enableIPv4: z.boolean(), - enableIPv6: z.boolean(), - ipamDriver: z.string().optional(), - ipamConfig: z.array(ipamConfigEntrySchema), -}); +const networkFormSchema = z + .object({ + name: z.string().min(1, "Name is required"), + driver: z.enum(networkDriverEnum), + serverId: z.string().optional(), + 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", - scope: SCOPE_EMPTY, serverId: undefined, internal: false, attachable: false, - ingress: false, - configOnly: false, enableIPv4: true, enableIPv6: false, ipamDriver: "", @@ -97,7 +103,8 @@ const toggleOptions = [ { name: "attachable", label: "Attachable", - description: "Allow standalone containers to attach to this network.", + description: + "Allow standalone containers to attach (overlay networks only).", }, { name: "enableIPv4", @@ -109,41 +116,21 @@ const toggleOptions = [ label: "Enable IPv6", description: "Enable IPv6 addressing on the network.", }, - { - name: "ingress", - label: "Ingress", - description: "Use as the routing-mesh network in Swarm mode.", - }, - { - name: "configOnly", - label: "Config only", - description: "Placeholder network whose config is reused by others.", - }, ] as const; interface HandleNetworkProps { - networkId?: string; children?: React.ReactNode; } -export const HandleNetwork = ({ networkId, children }: HandleNetworkProps) => { +// Docker networks are immutable, so this dialog only creates them; +// changing a network means deleting and recreating it. +export const HandleNetwork = ({ children }: HandleNetworkProps) => { const [isOpen, setIsOpen] = useState(false); const { data: isCloud } = api.settings.isCloud.useQuery(); const utils = api.useUtils(); - const isEdit = !!networkId; const { data: servers } = api.server.all.useQuery(); - const { data: network, isLoading: isLoadingNetwork } = - api.network.one.useQuery( - { networkId: networkId! }, - { enabled: isEdit && !!networkId }, - ); - - const createMutation = api.network.create.useMutation(); - const updateMutation = api.network.update.useMutation(); - const isPending = isEdit - ? updateMutation.isPending - : createMutation.isPending; + const { mutateAsync, isPending } = api.network.create.useMutation(); const form = useForm({ resolver: zodResolver(networkFormSchema), @@ -155,83 +142,39 @@ export const HandleNetwork = ({ networkId, children }: HandleNetworkProps) => { name: "ipamConfig", }); - useEffect(() => { - if (isEdit && network && isOpen) { - const ipam = network.ipam ?? {}; - const ipamConfigArr = (ipam.config ?? []).map((c) => ({ - subnet: c.subnet ?? "", - ipRange: c.ipRange ?? "", - gateway: c.gateway ?? "", - })); - form.reset({ - ...defaultValues, - name: network.name, - driver: network.driver, - scope: network.scope ?? SCOPE_EMPTY, - serverId: network.serverId || undefined, - internal: network.internal, - attachable: network.attachable, - enableIPv4: network.enableIPv4, - enableIPv6: network.enableIPv6, - ipamDriver: ipam.driver ?? "", - ipamConfig: ipamConfigArr, - ingress: network.ingress, - configOnly: network.configOnly, - }); - } - }, [isEdit, isOpen, network, form]); - const onSubmit = async (data: NetworkFormValues) => { - const scope = - data.scope && data.scope !== SCOPE_EMPTY ? data.scope : undefined; - - const payload = { - name: data.name, - driver: data.driver, - scope, - serverId: data.serverId || undefined, - internal: data.internal, - attachable: data.attachable, - ingress: data.ingress, - configOnly: data.configOnly, - enableIPv4: data.enableIPv4, - enableIPv6: data.enableIPv6, - ipam: { - driver: data.ipamDriver, - config: data.ipamConfig, - }, - }; - try { - if (isEdit && networkId) { - await updateMutation.mutateAsync({ networkId, ...payload }); - } else { - await createMutation.mutateAsync(payload); - } + await mutateAsync({ + name: data.name, + driver: data.driver, + serverId: data.serverId || undefined, + internal: data.internal, + attachable: data.attachable, + enableIPv4: data.enableIPv4, + enableIPv6: data.enableIPv6, + ipam: { + driver: data.ipamDriver || undefined, + config: data.ipamConfig, + }, + }); - toast.success(isEdit ? "Network updated" : "Network created"); + toast.success("Network created"); await utils.network.all.invalidate(); - if (networkId) await utils.network.one.invalidate({ networkId }); setIsOpen(false); form.reset(defaultValues); - } catch { - toast.error(isEdit ? "Error updating network" : "Error creating network"); + } catch (error) { + toast.error("Error creating network", { + description: error instanceof Error ? error.message : "Unknown error", + }); } }; - const trigger = - children ?? - (isEdit ? ( - - ) : ( - - )); + const trigger = children ?? ( + + ); return ( @@ -240,274 +183,237 @@ export const HandleNetwork = ({ networkId, children }: HandleNetworkProps) => { - {isEdit ? "Edit network" : "Add network"} + Add network - {isEdit - ? "Update this Docker network. Changes apply to name, driver, and server assignment." - : "Create a new Docker network for your organization. You can optionally assign it to a server."} + Create a new Docker network for your organization. Networks are + immutable: to change one, delete it and create it again. - {isEdit && isLoadingNetwork ? ( -
- Loading network… -
- ) : ( -
- -
- ( - - Name + + +
+ ( + + Name + + + + + + )} + /> + ( + + Driver + + + + - - - )} - /> - ( - - Driver - - - - )} - /> -
-
- ( - - Server - - - {isCloud - ? "Server where this network will be created." - : "Dokploy server is the default local server."} - - - - )} - /> - ( - - Scope (optional) - - - - )} - /> -
-
- {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) => ( -
- ( - - - - - - - )} - /> - ( - - - - - - - )} - /> - ( - - - - - - - )} - /> - -
- ))} -
+ ( + + Server + + + {isCloud + ? "Server where this network will be created." + : "Dokploy server is the default local server."} + + + + )} + /> +
+ {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-networks.tsx b/apps/dokploy/components/dashboard/networks/show-networks.tsx index 0d8f642b5..e26e60846 100644 --- a/apps/dokploy/components/dashboard/networks/show-networks.tsx +++ b/apps/dokploy/components/dashboard/networks/show-networks.tsx @@ -1,7 +1,9 @@ "use client"; -import { Loader2, Network, Pencil } from "lucide-react"; +import { Loader2, Network, Trash2 } from "lucide-react"; +import { toast } from "sonner"; import { HandleNetwork } from "@/components/dashboard/networks/handle-network"; +import { DialogAction } from "@/components/shared/dialog-action"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { @@ -23,7 +25,9 @@ import { import { api } from "@/utils/api"; export const ShowNetworks = () => { + const utils = api.useUtils(); const { data: networks, isLoading } = api.network.all.useQuery(); + const { mutateAsync: removeNetwork } = api.network.remove.useMutation(); return (
@@ -91,7 +95,7 @@ export const ShowNetworks = () => { {n.driver} - {n.scope ?? "—"} + {n.driver === "overlay" ? "swarm" : "local"} {n.internal ? "Yes" : "No"} @@ -106,15 +110,34 @@ export const ShowNetworks = () => { {new Date(n.createdAt).toLocaleDateString()} - + { + try { + await removeNetwork({ + networkId: n.networkId, + }); + toast.success("Network deleted"); + await utils.network.all.invalidate(); + } catch (error) { + toast.error("Error deleting network", { + description: + error instanceof Error + ? error.message + : "Unknown error", + }); + } + }} + > - + ))} diff --git a/apps/dokploy/drizzle/0175_wet_grey_gargoyle.sql b/apps/dokploy/drizzle/0175_gray_dreaming_celestial.sql similarity index 86% rename from apps/dokploy/drizzle/0175_wet_grey_gargoyle.sql rename to apps/dokploy/drizzle/0175_gray_dreaming_celestial.sql index 314ea2052..9ace63e8e 100644 --- a/apps/dokploy/drizzle/0175_wet_grey_gargoyle.sql +++ b/apps/dokploy/drizzle/0175_gray_dreaming_celestial.sql @@ -1,13 +1,10 @@ -CREATE TYPE "public"."networkDriver" AS ENUM('bridge', 'host', 'overlay', 'macvlan', 'none', 'ipvlan');--> statement-breakpoint +CREATE TYPE "public"."networkDriver" AS ENUM('bridge', 'overlay');--> statement-breakpoint CREATE TABLE "network" ( "networkId" text PRIMARY KEY NOT NULL, "name" text NOT NULL, "driver" "networkDriver" DEFAULT 'bridge' NOT NULL, - "scope" text, "internal" boolean DEFAULT false NOT NULL, "attachable" boolean DEFAULT false NOT NULL, - "ingress" boolean DEFAULT false NOT NULL, - "configOnly" boolean DEFAULT false NOT NULL, "enableIPv4" boolean DEFAULT true NOT NULL, "enableIPv6" boolean DEFAULT false NOT NULL, "ipam" jsonb DEFAULT '{}'::jsonb, diff --git a/apps/dokploy/drizzle/meta/0175_snapshot.json b/apps/dokploy/drizzle/meta/0175_snapshot.json index e698338e3..0b4c609c9 100644 --- a/apps/dokploy/drizzle/meta/0175_snapshot.json +++ b/apps/dokploy/drizzle/meta/0175_snapshot.json @@ -1,5 +1,5 @@ { - "id": "36b4b7bf-31bb-4d60-8ef8-0a33b328d623", + "id": "957b942e-9aeb-4551-8d5d-1174eec70fd6", "prevId": "daaa9837-09f3-452c-b5ba-232c3d6eeca2", "version": "7", "dialect": "postgresql", @@ -4788,12 +4788,6 @@ "notNull": true, "default": "'bridge'" }, - "scope": { - "name": "scope", - "type": "text", - "primaryKey": false, - "notNull": false - }, "internal": { "name": "internal", "type": "boolean", @@ -4808,20 +4802,6 @@ "notNull": true, "default": false }, - "ingress": { - "name": "ingress", - "type": "boolean", - "primaryKey": false, - "notNull": true, - "default": false - }, - "configOnly": { - "name": "configOnly", - "type": "boolean", - "primaryKey": false, - "notNull": true, - "default": false - }, "enableIPv4": { "name": "enableIPv4", "type": "boolean", @@ -8604,11 +8584,7 @@ "schema": "public", "values": [ "bridge", - "host", - "overlay", - "macvlan", - "none", - "ipvlan" + "overlay" ] }, "public.notificationType": { diff --git a/apps/dokploy/drizzle/meta/_journal.json b/apps/dokploy/drizzle/meta/_journal.json index 26ba1d3c6..4c7100b60 100644 --- a/apps/dokploy/drizzle/meta/_journal.json +++ b/apps/dokploy/drizzle/meta/_journal.json @@ -1230,8 +1230,8 @@ { "idx": 175, "version": "7", - "when": 1784682163836, - "tag": "0175_wet_grey_gargoyle", + "when": 1784692797270, + "tag": "0175_gray_dreaming_celestial", "breakpoints": true } ] diff --git a/apps/dokploy/server/api/routers/network.ts b/apps/dokploy/server/api/routers/network.ts index cf6503375..a58a68b92 100644 --- a/apps/dokploy/server/api/routers/network.ts +++ b/apps/dokploy/server/api/routers/network.ts @@ -1,9 +1,4 @@ -import { - createNetwork, - findNetworkById, - removeNetwork, - updateNetwork, -} from "@dokploy/server"; +import { createNetwork, findNetworkById, removeNetwork } from "@dokploy/server"; import { TRPCError } from "@trpc/server"; import { desc, eq } from "drizzle-orm"; import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc"; @@ -12,7 +7,6 @@ import { apiCreateNetwork, apiFindOneNetwork, apiRemoveNetwork, - apiUpdateNetwork, network as networkTable, } from "@/server/db/schema"; @@ -50,19 +44,6 @@ export const networkRouter = createTRPCRouter({ .mutation(async ({ ctx, input }) => { return createNetwork(input, ctx.session.activeOrganizationId); }), - update: protectedProcedure - .input(apiUpdateNetwork) - .mutation(async ({ ctx, input }) => { - const network = await findNetworkById(input.networkId); - if (network.organizationId !== ctx.session.activeOrganizationId) { - throw new TRPCError({ - code: "UNAUTHORIZED", - message: "Not authorized to update this network", - }); - } - return updateNetwork(input); - }), - remove: protectedProcedure .input(apiRemoveNetwork) .mutation(async ({ ctx, input }) => { diff --git a/packages/server/src/db/schema/network.ts b/packages/server/src/db/schema/network.ts index 2341f3cdb..add0ef249 100644 --- a/packages/server/src/db/schema/network.ts +++ b/packages/server/src/db/schema/network.ts @@ -6,15 +6,14 @@ import { z } from "zod"; import { organization } from "./account"; import { server } from "./server"; -/** Docker network driver types */ -export const networkDriver = pgEnum("networkDriver", [ - "bridge", - "host", - "overlay", - "macvlan", - "none", - "ipvlan", -]); +/** + * Docker network driver types. Only bridge and overlay are supported: + * "host"/"none" are Docker singletons that cannot be created, and + * macvlan/ipvlan require driver options (parent interface) we don't expose. + * Scope is derived from the driver (bridge = local, overlay = swarm), and + * ingress/config-only networks are not manageable from Dokploy. + */ +export const networkDriver = pgEnum("networkDriver", ["bridge", "overlay"]); export const network = pgTable("network", { networkId: text("networkId") @@ -23,11 +22,8 @@ export const network = pgTable("network", { .$defaultFn(() => nanoid()), name: text("name").notNull(), driver: networkDriver("driver").notNull().default("bridge"), - scope: text("scope"), // e.g. "local", "swarm" internal: boolean("internal").notNull().default(false), attachable: boolean("attachable").notNull().default(false), - ingress: boolean("ingress").notNull().default(false), - configOnly: boolean("configOnly").notNull().default(false), enableIPv4: boolean("enableIPv4").notNull().default(true), enableIPv6: boolean("enableIPv6").notNull().default(false), ipam: jsonb("ipam") @@ -61,14 +57,9 @@ export const networkRelations = relations(network, ({ one }) => ({ const createSchema = createInsertSchema(network, { networkId: z.string().min(1), name: z.string().min(1), - driver: z - .enum(["bridge", "host", "overlay", "macvlan", "none", "ipvlan"]) - .optional(), - scope: z.string().optional(), + driver: z.enum(["bridge", "overlay"]).optional(), internal: z.boolean().optional(), attachable: z.boolean().optional(), - ingress: z.boolean().optional(), - configOnly: z.boolean().optional(), enableIPv4: z.boolean().optional(), enableIPv6: z.boolean().optional(), ipam: z @@ -89,22 +80,48 @@ const createSchema = createInsertSchema(network, { serverId: z.string().optional().nullable(), }); +const validateNetworkInput = ( + input: { + enableIPv4?: boolean; + enableIPv6?: boolean; + ipam?: { + config?: Array<{ subnet?: string; gateway?: string; ipRange?: string }>; + } | null; + }, + ctx: z.RefinementCtx, +) => { + if (input.enableIPv4 === false && input.enableIPv6 !== true) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["enableIPv4"], + message: "IPv4 or IPv6 must be enabled", + }); + } + for (const [index, entry] of (input.ipam?.config ?? []).entries()) { + if (!entry.subnet && (entry.gateway || entry.ipRange)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["ipam", "config", index, "subnet"], + message: "Gateway and IP range require a subnet", + }); + } + } +}; + export const apiCreateNetwork = createSchema .pick({ name: true, driver: true, - scope: true, internal: true, attachable: true, - ingress: true, - configOnly: true, enableIPv4: true, enableIPv6: true, ipam: true, serverId: true, }) .partial() - .required({ name: true }); + .required({ name: true }) + .superRefine(validateNetworkInput); export const apiFindOneNetwork = createSchema .pick({ @@ -117,21 +134,3 @@ export const apiRemoveNetwork = createSchema networkId: true, }) .required(); - -export const apiUpdateNetwork = createSchema - .pick({ - networkId: true, - name: true, - driver: true, - scope: true, - internal: true, - attachable: true, - ingress: true, - configOnly: true, - enableIPv4: true, - enableIPv6: true, - ipam: true, - serverId: true, - }) - .partial() - .required({ networkId: true }); diff --git a/packages/server/src/services/network.ts b/packages/server/src/services/network.ts index 7934e2ebc..fbca5c776 100644 --- a/packages/server/src/services/network.ts +++ b/packages/server/src/services/network.ts @@ -1,9 +1,5 @@ import { db } from "@dokploy/server/db"; -import { - type apiCreateNetwork, - type apiUpdateNetwork, - network, -} from "@dokploy/server/db/schema"; +import { type apiCreateNetwork, network } from "@dokploy/server/db/schema"; import { TRPCError } from "@trpc/server"; import { eq } from "drizzle-orm"; import type { z } from "zod"; @@ -68,18 +64,32 @@ export const createNetwork = async ( .filter((e) => Object.keys(e).length > 0); const docker = await getRemoteDocker(input.serverId ?? null); - await docker.createNetwork({ - Name: row.name, - Driver: row.driver, - Internal: row.internal, - Attachable: row.attachable, - Ingress: row.ingress, - EnableIPv6: row.enableIPv6, - IPAM: { - Driver: ipam.driver ?? "default", - Config: ipamConfig.length > 0 ? ipamConfig : undefined, - }, - }); + try { + await docker.createNetwork({ + Name: row.name, + Driver: row.driver, + CheckDuplicate: true, + Internal: row.internal, + Attachable: row.attachable, + // EnableIPv4 is missing from dockerode's types but supported by + // the daemon (API >= 1.47); the body is sent as-is + EnableIPv4: row.enableIPv4, + EnableIPv6: row.enableIPv6, + IPAM: { + Driver: ipam.driver || "default", + Config: ipamConfig.length > 0 ? ipamConfig : undefined, + }, + } as Parameters[0]); + } catch (error) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: + error instanceof Error + ? error.message + : "Failed to create Docker network", + cause: error, + }); + } return row; }); @@ -87,27 +97,28 @@ export const createNetwork = async ( return created; }; -export const updateNetwork = async ( - input: z.infer, -) => { - const { networkId, ...rest } = input; - const [updated] = await db - .update(network) - .set(rest) - .where(eq(network.networkId, networkId)) - .returning(); +// Docker networks are immutable: there is no update, only create and remove. +export const removeNetwork = async (networkId: string) => { + const row = await findNetworkById(networkId); - if (!updated) { - throw new TRPCError({ - code: "NOT_FOUND", - message: "Network not found", - }); + const docker = await getRemoteDocker(row.serverId ?? null); + try { + await docker.getNetwork(row.name).remove(); + } catch (error) { + // If the network is already gone from Docker, still clean up the DB row + const statusCode = (error as { statusCode?: number })?.statusCode; + if (statusCode !== 404) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: + error instanceof Error + ? error.message + : "Failed to remove Docker network", + cause: error, + }); + } } - return updated; -}; - -export const removeNetwork = async (networkId: string) => { const [deleted] = await db .delete(network) .where(eq(network.networkId, networkId))