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
@@ -1,8 +1,8 @@
"use client"; "use client";
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema"; import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { Network, Pencil, Plus, Trash2 } from "lucide-react"; import { Network, Plus, Trash2 } from "lucide-react";
import { useEffect, useState } from "react"; import { useState } from "react";
import { useFieldArray, useForm } from "react-hook-form"; import { useFieldArray, useForm } from "react-hook-form";
import { toast } from "sonner"; import { toast } from "sonner";
import { z } from "zod"; import { z } from "zod";
@@ -36,17 +36,10 @@ import {
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import { api } from "@/utils/api"; import { api } from "@/utils/api";
const networkDriverEnum = [ // Only bridge and overlay can be created: "host"/"none" are Docker
"bridge", // singletons and macvlan/ipvlan need driver options we don't expose.
"host", const networkDriverEnum = ["bridge", "overlay"] as const;
"overlay",
"macvlan",
"none",
"ipvlan",
] as const;
/** Sentinel for "no scope" */
const SCOPE_EMPTY = "__scope_none__";
/** Sentinel for the local Dokploy server (no serverId) */ /** Sentinel for the local Dokploy server (no serverId) */
const SERVER_LOCAL = "__server_local__"; const SERVER_LOCAL = "__server_local__";
@@ -56,32 +49,45 @@ const ipamConfigEntrySchema = z.object({
gateway: z.string().optional(), gateway: z.string().optional(),
}); });
const networkFormSchema = z.object({ const networkFormSchema = z
name: z.string().min(1, "Name is required"), .object({
driver: z.enum(networkDriverEnum), name: z.string().min(1, "Name is required"),
scope: z.string().optional(), driver: z.enum(networkDriverEnum),
serverId: z.string().optional(), serverId: z.string().optional(),
internal: z.boolean(), internal: z.boolean(),
attachable: z.boolean(), attachable: z.boolean(),
ingress: z.boolean(), enableIPv4: z.boolean(),
configOnly: z.boolean(), enableIPv6: z.boolean(),
enableIPv4: z.boolean(), ipamDriver: z.string().optional(),
enableIPv6: z.boolean(), ipamConfig: z.array(ipamConfigEntrySchema),
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>; type NetworkFormValues = z.infer<typeof networkFormSchema>;
const defaultValues: NetworkFormValues = { const defaultValues: NetworkFormValues = {
name: "", name: "",
driver: "bridge", driver: "bridge",
scope: SCOPE_EMPTY,
serverId: undefined, serverId: undefined,
internal: false, internal: false,
attachable: false, attachable: false,
ingress: false,
configOnly: false,
enableIPv4: true, enableIPv4: true,
enableIPv6: false, enableIPv6: false,
ipamDriver: "", ipamDriver: "",
@@ -97,7 +103,8 @@ const toggleOptions = [
{ {
name: "attachable", name: "attachable",
label: "Attachable", label: "Attachable",
description: "Allow standalone containers to attach to this network.", description:
"Allow standalone containers to attach (overlay networks only).",
}, },
{ {
name: "enableIPv4", name: "enableIPv4",
@@ -109,41 +116,21 @@ const toggleOptions = [
label: "Enable IPv6", label: "Enable IPv6",
description: "Enable IPv6 addressing on the network.", 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; ] as const;
interface HandleNetworkProps { interface HandleNetworkProps {
networkId?: string;
children?: React.ReactNode; 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 [isOpen, setIsOpen] = useState(false);
const { data: isCloud } = api.settings.isCloud.useQuery(); const { data: isCloud } = api.settings.isCloud.useQuery();
const utils = api.useUtils(); const utils = api.useUtils();
const isEdit = !!networkId;
const { data: servers } = api.server.all.useQuery(); const { data: servers } = api.server.all.useQuery();
const { data: network, isLoading: isLoadingNetwork } = const { mutateAsync, isPending } = api.network.create.useMutation();
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 form = useForm<NetworkFormValues>({ const form = useForm<NetworkFormValues>({
resolver: zodResolver(networkFormSchema), resolver: zodResolver(networkFormSchema),
@@ -155,83 +142,39 @@ export const HandleNetwork = ({ networkId, children }: HandleNetworkProps) => {
name: "ipamConfig", 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 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 { try {
if (isEdit && networkId) { await mutateAsync({
await updateMutation.mutateAsync({ networkId, ...payload }); name: data.name,
} else { driver: data.driver,
await createMutation.mutateAsync(payload); 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(); await utils.network.all.invalidate();
if (networkId) await utils.network.one.invalidate({ networkId });
setIsOpen(false); setIsOpen(false);
form.reset(defaultValues); form.reset(defaultValues);
} catch { } catch (error) {
toast.error(isEdit ? "Error updating network" : "Error creating network"); toast.error("Error creating network", {
description: error instanceof Error ? error.message : "Unknown error",
});
} }
}; };
const trigger = const trigger = children ?? (
children ?? <Button>
(isEdit ? ( <Plus className=" size-4" />
<Button size="sm" variant="outline"> Add network
<Pencil className=" size-4" /> </Button>
Edit );
</Button>
) : (
<Button>
<Plus className=" size-4" />
Add network
</Button>
));
return ( return (
<Dialog open={isOpen} onOpenChange={setIsOpen}> <Dialog open={isOpen} onOpenChange={setIsOpen}>
@@ -240,274 +183,237 @@ export const HandleNetwork = ({ networkId, children }: HandleNetworkProps) => {
<DialogHeader> <DialogHeader>
<DialogTitle className="flex items-center gap-2"> <DialogTitle className="flex items-center gap-2">
<Network className="size-5 text-muted-foreground" /> <Network className="size-5 text-muted-foreground" />
{isEdit ? "Edit network" : "Add network"} Add network
</DialogTitle> </DialogTitle>
<DialogDescription> <DialogDescription>
{isEdit Create a new Docker network for your organization. Networks are
? "Update this Docker network. Changes apply to name, driver, and server assignment." immutable: to change one, delete it and create it again.
: "Create a new Docker network for your organization. You can optionally assign it to a server."}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
{isEdit && isLoadingNetwork ? ( <Form {...form}>
<div className="flex items-center justify-center py-8 text-sm text-muted-foreground"> <form
Loading network… onSubmit={form.handleSubmit(onSubmit)}
</div> className="flex w-full flex-col gap-6"
) : ( >
<Form {...form}> <div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<form <FormField
onSubmit={form.handleSubmit(onSubmit)} control={form.control}
className="flex w-full flex-col gap-6" name="name"
> render={({ field }) => (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2"> <FormItem>
<FormField <FormLabel>Name</FormLabel>
control={form.control} <FormControl>
name="name" <Input placeholder="my-network" {...field} />
render={({ field }) => ( </FormControl>
<FormItem> <FormMessage />
<FormLabel>Name</FormLabel> </FormItem>
)}
/>
<FormField
control={form.control}
name="driver"
render={({ field }) => (
<FormItem>
<FormLabel>Driver</FormLabel>
<Select onValueChange={field.onChange} value={field.value}>
<FormControl> <FormControl>
<Input placeholder="my-network" {...field} /> <SelectTrigger>
<SelectValue placeholder="Select driver" />
</SelectTrigger>
</FormControl> </FormControl>
<FormMessage /> <SelectContent>
</FormItem> {networkDriverEnum.map((d) => (
)} <SelectItem key={d} value={d}>
/> {d}
<FormField </SelectItem>
control={form.control} ))}
name="driver" </SelectContent>
render={({ field }) => ( </Select>
<FormItem> <FormDescription className="text-muted-foreground">
<FormLabel>Driver</FormLabel> bridge for single-server containers; overlay for Swarm
<Select services.
onValueChange={field.onChange} </FormDescription>
value={field.value} <FormMessage />
> </FormItem>
<FormControl> )}
<SelectTrigger> />
<SelectValue placeholder="Select driver" /> </div>
</SelectTrigger> <FormField
</FormControl> control={form.control}
<SelectContent> name="serverId"
{networkDriverEnum.map((d) => ( render={({ field }) => (
<SelectItem key={d} value={d}> <FormItem>
{d} <FormLabel>Server</FormLabel>
</SelectItem> <Select
))} onValueChange={(value) =>
</SelectContent> field.onChange(value === SERVER_LOCAL ? undefined : value)
</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: "",
})
} }
value={field.value ?? (isCloud ? undefined : SERVER_LOCAL)}
> >
<Plus className="size-4" /> <FormControl>
Add IPAM config <SelectTrigger>
</Button> <SelectValue placeholder="Select server" />
</div> </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> </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 <Button
type="button" type="button"
variant="outline" variant="outline"
onClick={() => setIsOpen(false)} size="sm"
onClick={() =>
ipamConfigFieldArray.append({
subnet: "",
ipRange: "",
gateway: "",
})
}
> >
Cancel <Plus className="size-4" />
Add IPAM config
</Button> </Button>
<Button type="submit" isLoading={isPending}> </div>
{isEdit ? "Update network" : "Create network"} </div>
</Button> <DialogFooter>
</DialogFooter> <Button
</form> type="button"
</Form> variant="outline"
)} onClick={() => setIsOpen(false)}
>
Cancel
</Button>
<Button type="submit" isLoading={isPending}>
Create network
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
); );
@@ -1,7 +1,9 @@
"use client"; "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 { HandleNetwork } from "@/components/dashboard/networks/handle-network";
import { DialogAction } from "@/components/shared/dialog-action";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
@@ -23,7 +25,9 @@ import {
import { api } from "@/utils/api"; import { api } from "@/utils/api";
export const ShowNetworks = () => { export const ShowNetworks = () => {
const utils = api.useUtils();
const { data: networks, isLoading } = api.network.all.useQuery(); const { data: networks, isLoading } = api.network.all.useQuery();
const { mutateAsync: removeNetwork } = api.network.remove.useMutation();
return ( return (
<div className="w-full"> <div className="w-full">
@@ -91,7 +95,7 @@ export const ShowNetworks = () => {
<Badge variant="outline">{n.driver}</Badge> <Badge variant="outline">{n.driver}</Badge>
</TableCell> </TableCell>
<TableCell className="text-muted-foreground"> <TableCell className="text-muted-foreground">
{n.scope ?? "—"} {n.driver === "overlay" ? "swarm" : "local"}
</TableCell> </TableCell>
<TableCell className="text-muted-foreground"> <TableCell className="text-muted-foreground">
{n.internal ? "Yes" : "No"} {n.internal ? "Yes" : "No"}
@@ -106,15 +110,34 @@ export const ShowNetworks = () => {
{new Date(n.createdAt).toLocaleDateString()} {new Date(n.createdAt).toLocaleDateString()}
</TableCell> </TableCell>
<TableCell className="text-right"> <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 <Button
variant="ghost" variant="ghost"
size="icon-xs" size="icon-xs"
aria-label="Edit network" aria-label="Delete network"
> >
<Pencil className="size-4" /> <Trash2 className="size-4 text-destructive" />
</Button> </Button>
</HandleNetwork> </DialogAction>
</TableCell> </TableCell>
</TableRow> </TableRow>
))} ))}
@@ -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" ( CREATE TABLE "network" (
"networkId" text PRIMARY KEY NOT NULL, "networkId" text PRIMARY KEY NOT NULL,
"name" text NOT NULL, "name" text NOT NULL,
"driver" "networkDriver" DEFAULT 'bridge' NOT NULL, "driver" "networkDriver" DEFAULT 'bridge' NOT NULL,
"scope" text,
"internal" boolean DEFAULT false NOT NULL, "internal" boolean DEFAULT false NOT NULL,
"attachable" 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, "enableIPv4" boolean DEFAULT true NOT NULL,
"enableIPv6" boolean DEFAULT false NOT NULL, "enableIPv6" boolean DEFAULT false NOT NULL,
"ipam" jsonb DEFAULT '{}'::jsonb, "ipam" jsonb DEFAULT '{}'::jsonb,
+2 -26
View File
@@ -1,5 +1,5 @@
{ {
"id": "36b4b7bf-31bb-4d60-8ef8-0a33b328d623", "id": "957b942e-9aeb-4551-8d5d-1174eec70fd6",
"prevId": "daaa9837-09f3-452c-b5ba-232c3d6eeca2", "prevId": "daaa9837-09f3-452c-b5ba-232c3d6eeca2",
"version": "7", "version": "7",
"dialect": "postgresql", "dialect": "postgresql",
@@ -4788,12 +4788,6 @@
"notNull": true, "notNull": true,
"default": "'bridge'" "default": "'bridge'"
}, },
"scope": {
"name": "scope",
"type": "text",
"primaryKey": false,
"notNull": false
},
"internal": { "internal": {
"name": "internal", "name": "internal",
"type": "boolean", "type": "boolean",
@@ -4808,20 +4802,6 @@
"notNull": true, "notNull": true,
"default": false "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": { "enableIPv4": {
"name": "enableIPv4", "name": "enableIPv4",
"type": "boolean", "type": "boolean",
@@ -8604,11 +8584,7 @@
"schema": "public", "schema": "public",
"values": [ "values": [
"bridge", "bridge",
"host", "overlay"
"overlay",
"macvlan",
"none",
"ipvlan"
] ]
}, },
"public.notificationType": { "public.notificationType": {
+2 -2
View File
@@ -1230,8 +1230,8 @@
{ {
"idx": 175, "idx": 175,
"version": "7", "version": "7",
"when": 1784682163836, "when": 1784692797270,
"tag": "0175_wet_grey_gargoyle", "tag": "0175_gray_dreaming_celestial",
"breakpoints": true "breakpoints": true
} }
] ]
+1 -20
View File
@@ -1,9 +1,4 @@
import { import { createNetwork, findNetworkById, removeNetwork } from "@dokploy/server";
createNetwork,
findNetworkById,
removeNetwork,
updateNetwork,
} from "@dokploy/server";
import { TRPCError } from "@trpc/server"; import { TRPCError } from "@trpc/server";
import { desc, eq } from "drizzle-orm"; import { desc, eq } from "drizzle-orm";
import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc"; import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc";
@@ -12,7 +7,6 @@ import {
apiCreateNetwork, apiCreateNetwork,
apiFindOneNetwork, apiFindOneNetwork,
apiRemoveNetwork, apiRemoveNetwork,
apiUpdateNetwork,
network as networkTable, network as networkTable,
} from "@/server/db/schema"; } from "@/server/db/schema";
@@ -50,19 +44,6 @@ export const networkRouter = createTRPCRouter({
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
return createNetwork(input, ctx.session.activeOrganizationId); 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 remove: protectedProcedure
.input(apiRemoveNetwork) .input(apiRemoveNetwork)
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
+39 -40
View File
@@ -6,15 +6,14 @@ import { z } from "zod";
import { organization } from "./account"; import { organization } from "./account";
import { server } from "./server"; import { server } from "./server";
/** Docker network driver types */ /**
export const networkDriver = pgEnum("networkDriver", [ * Docker network driver types. Only bridge and overlay are supported:
"bridge", * "host"/"none" are Docker singletons that cannot be created, and
"host", * macvlan/ipvlan require driver options (parent interface) we don't expose.
"overlay", * Scope is derived from the driver (bridge = local, overlay = swarm), and
"macvlan", * ingress/config-only networks are not manageable from Dokploy.
"none", */
"ipvlan", export const networkDriver = pgEnum("networkDriver", ["bridge", "overlay"]);
]);
export const network = pgTable("network", { export const network = pgTable("network", {
networkId: text("networkId") networkId: text("networkId")
@@ -23,11 +22,8 @@ export const network = pgTable("network", {
.$defaultFn(() => nanoid()), .$defaultFn(() => nanoid()),
name: text("name").notNull(), name: text("name").notNull(),
driver: networkDriver("driver").notNull().default("bridge"), driver: networkDriver("driver").notNull().default("bridge"),
scope: text("scope"), // e.g. "local", "swarm"
internal: boolean("internal").notNull().default(false), internal: boolean("internal").notNull().default(false),
attachable: boolean("attachable").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), enableIPv4: boolean("enableIPv4").notNull().default(true),
enableIPv6: boolean("enableIPv6").notNull().default(false), enableIPv6: boolean("enableIPv6").notNull().default(false),
ipam: jsonb("ipam") ipam: jsonb("ipam")
@@ -61,14 +57,9 @@ export const networkRelations = relations(network, ({ one }) => ({
const createSchema = createInsertSchema(network, { const createSchema = createInsertSchema(network, {
networkId: z.string().min(1), networkId: z.string().min(1),
name: z.string().min(1), name: z.string().min(1),
driver: z driver: z.enum(["bridge", "overlay"]).optional(),
.enum(["bridge", "host", "overlay", "macvlan", "none", "ipvlan"])
.optional(),
scope: z.string().optional(),
internal: z.boolean().optional(), internal: z.boolean().optional(),
attachable: z.boolean().optional(), attachable: z.boolean().optional(),
ingress: z.boolean().optional(),
configOnly: z.boolean().optional(),
enableIPv4: z.boolean().optional(), enableIPv4: z.boolean().optional(),
enableIPv6: z.boolean().optional(), enableIPv6: z.boolean().optional(),
ipam: z ipam: z
@@ -89,22 +80,48 @@ const createSchema = createInsertSchema(network, {
serverId: z.string().optional().nullable(), 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 export const apiCreateNetwork = createSchema
.pick({ .pick({
name: true, name: true,
driver: true, driver: true,
scope: true,
internal: true, internal: true,
attachable: true, attachable: true,
ingress: true,
configOnly: true,
enableIPv4: true, enableIPv4: true,
enableIPv6: true, enableIPv6: true,
ipam: true, ipam: true,
serverId: true, serverId: true,
}) })
.partial() .partial()
.required({ name: true }); .required({ name: true })
.superRefine(validateNetworkInput);
export const apiFindOneNetwork = createSchema export const apiFindOneNetwork = createSchema
.pick({ .pick({
@@ -117,21 +134,3 @@ export const apiRemoveNetwork = createSchema
networkId: true, networkId: true,
}) })
.required(); .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 });
+46 -35
View File
@@ -1,9 +1,5 @@
import { db } from "@dokploy/server/db"; import { db } from "@dokploy/server/db";
import { import { type apiCreateNetwork, network } from "@dokploy/server/db/schema";
type apiCreateNetwork,
type apiUpdateNetwork,
network,
} from "@dokploy/server/db/schema";
import { TRPCError } from "@trpc/server"; import { TRPCError } from "@trpc/server";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import type { z } from "zod"; import type { z } from "zod";
@@ -68,18 +64,32 @@ export const createNetwork = async (
.filter((e) => Object.keys(e).length > 0); .filter((e) => Object.keys(e).length > 0);
const docker = await getRemoteDocker(input.serverId ?? null); const docker = await getRemoteDocker(input.serverId ?? null);
await docker.createNetwork({ try {
Name: row.name, await docker.createNetwork({
Driver: row.driver, Name: row.name,
Internal: row.internal, Driver: row.driver,
Attachable: row.attachable, CheckDuplicate: true,
Ingress: row.ingress, Internal: row.internal,
EnableIPv6: row.enableIPv6, Attachable: row.attachable,
IPAM: { // EnableIPv4 is missing from dockerode's types but supported by
Driver: ipam.driver ?? "default", // the daemon (API >= 1.47); the body is sent as-is
Config: ipamConfig.length > 0 ? ipamConfig : undefined, 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; return row;
}); });
@@ -87,27 +97,28 @@ export const createNetwork = async (
return created; return created;
}; };
export const updateNetwork = async ( // Docker networks are immutable: there is no update, only create and remove.
input: z.infer<typeof apiUpdateNetwork>, export const removeNetwork = async (networkId: string) => {
) => { const row = await findNetworkById(networkId);
const { networkId, ...rest } = input;
const [updated] = await db
.update(network)
.set(rest)
.where(eq(network.networkId, networkId))
.returning();
if (!updated) { const docker = await getRemoteDocker(row.serverId ?? null);
throw new TRPCError({ try {
code: "NOT_FOUND", await docker.getNetwork(row.name).remove();
message: "Network not found", } 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 const [deleted] = await db
.delete(network) .delete(network)
.where(eq(network.networkId, networkId)) .where(eq(network.networkId, networkId))