refactor(networks): streamline network management and enhance validation

- Simplified the network form schema by removing unnecessary fields and enforcing validation rules for IP settings.
- Updated the HandleNetwork component to eliminate the edit functionality, focusing on network creation only.
- Improved the ShowNetworks component by adding a delete action for network management, enhancing user experience.
- Introduced a new SQL migration for the network schema, establishing necessary constraints and types.
- Cleaned up the API by removing the update network functionality, reflecting the immutable nature of Docker networks.
This commit is contained in:
Mauricio Siu
2026-07-21 22:19:22 -06:00
parent 61c2b73f08
commit 94ddbf8ba5
8 changed files with 402 additions and 509 deletions

View File

@@ -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<typeof networkFormSchema>;
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<NetworkFormValues>({
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 ? (
<Button size="sm" variant="outline">
<Pencil className=" size-4" />
Edit
</Button>
) : (
<Button>
<Plus className=" size-4" />
Add network
</Button>
));
const trigger = children ?? (
<Button>
<Plus className=" size-4" />
Add network
</Button>
);
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
@@ -240,274 +183,237 @@ export const HandleNetwork = ({ networkId, children }: HandleNetworkProps) => {
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Network className="size-5 text-muted-foreground" />
{isEdit ? "Edit network" : "Add network"}
Add network
</DialogTitle>
<DialogDescription>
{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.
</DialogDescription>
</DialogHeader>
{isEdit && isLoadingNetwork ? (
<div className="flex items-center justify-center py-8 text-sm text-muted-foreground">
Loading network
</div>
) : (
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="flex w-full flex-col gap-6"
>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="flex w-full flex-col gap-6"
>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input placeholder="my-network" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="driver"
render={({ field }) => (
<FormItem>
<FormLabel>Driver</FormLabel>
<Select onValueChange={field.onChange} value={field.value}>
<FormControl>
<Input placeholder="my-network" {...field} />
<SelectTrigger>
<SelectValue placeholder="Select driver" />
</SelectTrigger>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="driver"
render={({ field }) => (
<FormItem>
<FormLabel>Driver</FormLabel>
<Select
onValueChange={field.onChange}
value={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select driver" />
</SelectTrigger>
</FormControl>
<SelectContent>
{networkDriverEnum.map((d) => (
<SelectItem key={d} value={d}>
{d}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<FormField
control={form.control}
name="serverId"
render={({ field }) => (
<FormItem>
<FormLabel>Server</FormLabel>
<Select
onValueChange={(value) =>
field.onChange(
value === SERVER_LOCAL ? undefined : value,
)
}
value={
field.value ?? (isCloud ? undefined : SERVER_LOCAL)
}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select server" />
</SelectTrigger>
</FormControl>
<SelectContent>
{!isCloud && (
<SelectItem value={SERVER_LOCAL}>
Dokploy server
</SelectItem>
)}
{servers?.map((server) => (
<SelectItem
key={server.serverId}
value={server.serverId}
>
{server.name}
</SelectItem>
))}
</SelectContent>
</Select>
<FormDescription className="text-muted-foreground">
{isCloud
? "Server where this network will be created."
: "Dokploy server is the default local server."}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="scope"
render={({ field }) => (
<FormItem>
<FormLabel>Scope (optional)</FormLabel>
<Select
onValueChange={field.onChange}
value={field.value ?? SCOPE_EMPTY}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select scope" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value={SCOPE_EMPTY}>None</SelectItem>
<SelectItem value="local">local</SelectItem>
<SelectItem value="swarm">swarm</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
{toggleOptions.map((option) => (
<FormField
key={option.name}
control={form.control}
name={option.name}
render={({ field }) => (
<FormItem className="flex flex-row items-start justify-between gap-3 space-y-0 rounded-lg border p-4">
<div className="space-y-1 pr-1">
<FormLabel>{option.label}</FormLabel>
<FormDescription className="text-muted-foreground">
{option.description}
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
))}
</div>
<div className="space-y-4 rounded-lg border p-4">
<div className="space-y-1">
<FormLabel>IPAM</FormLabel>
<p className="text-sm text-muted-foreground">
IP address management settings for this network.
</p>
</div>
<FormField
control={form.control}
name="ipamDriver"
render={({ field }) => (
<FormItem>
<FormLabel className="text-muted-foreground">
Driver (optional)
</FormLabel>
<FormControl>
<Input {...field} placeholder="default" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="space-y-2">
<FormLabel className="text-muted-foreground">
Config (subnet / gateway / IP range)
</FormLabel>
{ipamConfigFieldArray.fields.map((field, index) => (
<div key={field.id} className="flex flex-wrap gap-2">
<FormField
control={form.control}
name={`ipamConfig.${index}.subnet`}
render={({ field: f }) => (
<FormItem className="min-w-[140px] flex-1">
<FormControl>
<Input
{...f}
placeholder="Subnet (e.g. 172.20.0.0/16)"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name={`ipamConfig.${index}.ipRange`}
render={({ field: f }) => (
<FormItem className="min-w-[120px] flex-1">
<FormControl>
<Input {...f} placeholder="IP range" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name={`ipamConfig.${index}.gateway`}
render={({ field: f }) => (
<FormItem className="min-w-[120px] flex-1">
<FormControl>
<Input {...f} placeholder="Gateway" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button
type="button"
variant="outline"
size="icon"
aria-label="Remove IPAM config"
onClick={() => ipamConfigFieldArray.remove(index)}
>
<Trash2 className="size-4" />
</Button>
</div>
))}
<Button
type="button"
variant="outline"
size="sm"
onClick={() =>
ipamConfigFieldArray.append({
subnet: "",
ipRange: "",
gateway: "",
})
<SelectContent>
{networkDriverEnum.map((d) => (
<SelectItem key={d} value={d}>
{d}
</SelectItem>
))}
</SelectContent>
</Select>
<FormDescription className="text-muted-foreground">
bridge for single-server containers; overlay for Swarm
services.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name="serverId"
render={({ field }) => (
<FormItem>
<FormLabel>Server</FormLabel>
<Select
onValueChange={(value) =>
field.onChange(value === SERVER_LOCAL ? undefined : value)
}
value={field.value ?? (isCloud ? undefined : SERVER_LOCAL)}
>
<Plus className="size-4" />
Add IPAM config
</Button>
</div>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select server" />
</SelectTrigger>
</FormControl>
<SelectContent>
{!isCloud && (
<SelectItem value={SERVER_LOCAL}>
Dokploy server
</SelectItem>
)}
{servers?.map((server) => (
<SelectItem
key={server.serverId}
value={server.serverId}
>
{server.name}
</SelectItem>
))}
</SelectContent>
</Select>
<FormDescription className="text-muted-foreground">
{isCloud
? "Server where this network will be created."
: "Dokploy server is the default local server."}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
{toggleOptions.map((option) => (
<FormField
key={option.name}
control={form.control}
name={option.name}
render={({ field }) => (
<FormItem className="flex flex-row items-start justify-between gap-3 space-y-0 rounded-lg border p-4">
<div className="space-y-1 pr-1">
<FormLabel>{option.label}</FormLabel>
<FormDescription className="text-muted-foreground">
{option.description}
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
))}
</div>
<div className="space-y-4 rounded-lg border p-4">
<div className="space-y-1">
<FormLabel>IPAM</FormLabel>
<p className="text-sm text-muted-foreground">
IP address management settings for this network.
</p>
</div>
<DialogFooter>
<FormField
control={form.control}
name="ipamDriver"
render={({ field }) => (
<FormItem>
<FormLabel className="text-muted-foreground">
Driver (optional)
</FormLabel>
<FormControl>
<Input {...field} placeholder="default" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="space-y-2">
<FormLabel className="text-muted-foreground">
Config (subnet / gateway / IP range)
</FormLabel>
{ipamConfigFieldArray.fields.map((field, index) => (
<div key={field.id} className="flex flex-wrap gap-2">
<FormField
control={form.control}
name={`ipamConfig.${index}.subnet`}
render={({ field: f }) => (
<FormItem className="min-w-[140px] flex-1">
<FormControl>
<Input
{...f}
placeholder="Subnet (e.g. 172.20.0.0/16)"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name={`ipamConfig.${index}.ipRange`}
render={({ field: f }) => (
<FormItem className="min-w-[120px] flex-1">
<FormControl>
<Input {...f} placeholder="IP range" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name={`ipamConfig.${index}.gateway`}
render={({ field: f }) => (
<FormItem className="min-w-[120px] flex-1">
<FormControl>
<Input {...f} placeholder="Gateway" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button
type="button"
variant="outline"
size="icon"
aria-label="Remove IPAM config"
onClick={() => ipamConfigFieldArray.remove(index)}
>
<Trash2 className="size-4" />
</Button>
</div>
))}
<Button
type="button"
variant="outline"
onClick={() => setIsOpen(false)}
size="sm"
onClick={() =>
ipamConfigFieldArray.append({
subnet: "",
ipRange: "",
gateway: "",
})
}
>
Cancel
<Plus className="size-4" />
Add IPAM config
</Button>
<Button type="submit" isLoading={isPending}>
{isEdit ? "Update network" : "Create network"}
</Button>
</DialogFooter>
</form>
</Form>
)}
</div>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => setIsOpen(false)}
>
Cancel
</Button>
<Button type="submit" isLoading={isPending}>
Create network
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
);

View File

@@ -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 (
<div className="w-full">
@@ -91,7 +95,7 @@ export const ShowNetworks = () => {
<Badge variant="outline">{n.driver}</Badge>
</TableCell>
<TableCell className="text-muted-foreground">
{n.scope ?? "—"}
{n.driver === "overlay" ? "swarm" : "local"}
</TableCell>
<TableCell className="text-muted-foreground">
{n.internal ? "Yes" : "No"}
@@ -106,15 +110,34 @@ export const ShowNetworks = () => {
{new Date(n.createdAt).toLocaleDateString()}
</TableCell>
<TableCell className="text-right">
<HandleNetwork networkId={n.networkId}>
<DialogAction
title="Delete network"
description={`The network "${n.name}" will be removed from Docker and Dokploy. This action cannot be undone.`}
onClick={async () => {
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",
});
}
}}
>
<Button
variant="ghost"
size="icon-xs"
aria-label="Edit network"
aria-label="Delete network"
>
<Pencil className="size-4" />
<Trash2 className="size-4 text-destructive" />
</Button>
</HandleNetwork>
</DialogAction>
</TableCell>
</TableRow>
))}

View File

@@ -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,

View File

@@ -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": {

View File

@@ -1230,8 +1230,8 @@
{
"idx": 175,
"version": "7",
"when": 1784682163836,
"tag": "0175_wet_grey_gargoyle",
"when": 1784692797270,
"tag": "0175_gray_dreaming_celestial",
"breakpoints": true
}
]

View File

@@ -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 }) => {

View File

@@ -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 });

View File

@@ -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<typeof docker.createNetwork>[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<typeof apiUpdateNetwork>,
) => {
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))