refactor(networks): update network form schema and enhance handling logic

- Replaced zodResolver with standardSchemaResolver for improved schema validation.
- Added new toggle options for network settings, enhancing user interface clarity.
- Refactored network creation and update logic to streamline payload handling.
- Updated network form schema to remove default values, ensuring explicit user input.
- Introduced SERVER_LOCAL sentinel for local Dokploy server identification.
This commit is contained in:
Mauricio Siu
2026-07-21 21:46:40 -06:00
parent f4bcc2e8a8
commit 736a77e112
2 changed files with 174 additions and 222 deletions
@@ -1,7 +1,7 @@
"use client"; "use client";
import { zodResolver } from "@hookform/resolvers/zod"; import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { Network, Pencil, Plus } from "lucide-react"; import { Network, Pencil, Plus, Trash2 } from "lucide-react";
import { useEffect, useState } from "react"; import { useEffect, 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";
@@ -47,6 +47,8 @@ const networkDriverEnum = [
/** Sentinel for "no scope" */ /** Sentinel for "no scope" */
const SCOPE_EMPTY = "__scope_none__"; const SCOPE_EMPTY = "__scope_none__";
/** Sentinel for the local Dokploy server (no serverId) */
const SERVER_LOCAL = "__server_local__";
const ipamConfigEntrySchema = z.object({ const ipamConfigEntrySchema = z.object({
subnet: z.string().optional(), subnet: z.string().optional(),
@@ -56,17 +58,17 @@ const ipamConfigEntrySchema = z.object({
const networkFormSchema = z.object({ const networkFormSchema = z.object({
name: z.string().min(1, "Name is required"), name: z.string().min(1, "Name is required"),
driver: z.enum(networkDriverEnum).default("bridge"), driver: z.enum(networkDriverEnum),
scope: z.string().optional(), scope: z.string().optional(),
serverId: z.string().optional(), serverId: z.string().optional(),
internal: z.boolean().default(false), internal: z.boolean(),
attachable: z.boolean().default(false), attachable: z.boolean(),
ingress: z.boolean().default(false), ingress: z.boolean(),
configOnly: z.boolean().default(false), configOnly: z.boolean(),
enableIPv4: z.boolean().default(true), enableIPv4: z.boolean(),
enableIPv6: z.boolean().default(false), enableIPv6: z.boolean(),
ipamDriver: z.string().optional(), ipamDriver: z.string().optional(),
ipamConfig: z.array(ipamConfigEntrySchema).default([]), ipamConfig: z.array(ipamConfigEntrySchema),
}); });
type NetworkFormValues = z.infer<typeof networkFormSchema>; type NetworkFormValues = z.infer<typeof networkFormSchema>;
@@ -86,6 +88,39 @@ const defaultValues: NetworkFormValues = {
ipamConfig: [], 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 to this network.",
},
{
name: "enableIPv4",
label: "Enable IPv4",
description: "Enable IPv4 addressing on the network.",
},
{
name: "enableIPv6",
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 { interface HandleNetworkProps {
networkId?: string; networkId?: string;
children?: React.ReactNode; children?: React.ReactNode;
@@ -104,9 +139,9 @@ export const HandleNetwork = ({ networkId, children }: HandleNetworkProps) => {
{ enabled: isEdit && !!networkId }, { enabled: isEdit && !!networkId },
); );
const { mutateAsync, isLoading: isPending } = networkId const createMutation = api.network.create.useMutation();
? api.network.update.useMutation() const updateMutation = api.network.update.useMutation();
: api.network.create.useMutation(); const isPending = isEdit ? updateMutation.isPending : createMutation.isPending;
const form = useForm<NetworkFormValues>({ const form = useForm<NetworkFormValues>({
resolver: zodResolver(networkFormSchema), resolver: zodResolver(networkFormSchema),
@@ -148,25 +183,31 @@ export const HandleNetwork = ({ networkId, children }: HandleNetworkProps) => {
const scope = const scope =
data.scope && data.scope !== SCOPE_EMPTY ? data.scope : undefined; data.scope && data.scope !== SCOPE_EMPTY ? data.scope : undefined;
try { const payload = {
await mutateAsync({ name: data.name,
networkId: networkId ?? "", driver: data.driver,
name: data.name, scope,
driver: data.driver, serverId: data.serverId || undefined,
scope, internal: data.internal,
serverId: data.serverId || undefined, attachable: data.attachable,
internal: data.internal, ingress: data.ingress,
attachable: data.attachable, configOnly: data.configOnly,
ingress: data.ingress, enableIPv4: data.enableIPv4,
configOnly: data.configOnly, enableIPv6: data.enableIPv6,
enableIPv4: data.enableIPv4, ipam: {
enableIPv6: data.enableIPv6, driver: data.ipamDriver,
ipam: { config: data.ipamConfig,
driver: data.ipamDriver, },
config: data.ipamConfig, };
},
});
try {
if (isEdit && networkId) {
await updateMutation.mutateAsync({ networkId, ...payload });
} else {
await createMutation.mutateAsync(payload);
}
toast.success(isEdit ? "Network updated" : "Network created");
await utils.network.all.invalidate(); await utils.network.all.invalidate();
if (networkId) await utils.network.one.invalidate({ networkId }); if (networkId) await utils.network.one.invalidate({ networkId });
setIsOpen(false); setIsOpen(false);
@@ -193,7 +234,7 @@ export const HandleNetwork = ({ networkId, children }: HandleNetworkProps) => {
return ( return (
<Dialog open={isOpen} onOpenChange={setIsOpen}> <Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>{trigger}</DialogTrigger> <DialogTrigger asChild>{trigger}</DialogTrigger>
<DialogContent className="sm:max-w-xl max-h-[90vh] overflow-y-auto"> <DialogContent className="sm:max-w-2xl max-h-[90vh] overflow-y-auto">
<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" />
@@ -257,199 +298,111 @@ export const HandleNetwork = ({ networkId, children }: HandleNetworkProps) => {
)} )}
/> />
</div> </div>
<FormField
control={form.control}
name="serverId"
render={({ field }) => (
<FormItem>
<FormLabel>Server</FormLabel>
<Select
onValueChange={field.onChange}
value={field.value ?? undefined}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select server" />
</SelectTrigger>
</FormControl>
<SelectContent>
{!isCloud && (
<SelectItem value={undefined}>
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; or choose a specific 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 className="grid grid-cols-1 gap-4 sm:grid-cols-2"> <div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<FormField <FormField
control={form.control} control={form.control}
name="internal" name="serverId"
render={({ field }) => ( render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-4"> <FormItem>
<div className="space-y-0.5"> <FormLabel>Server</FormLabel>
<FormLabel className="text-base">Internal</FormLabel> <Select
<FormDescription className="text-muted-foreground"> onValueChange={(value) =>
Restrict external access; containers on this network field.onChange(
cannot reach external networks. value === SERVER_LOCAL ? undefined : value,
</FormDescription> )
</div> }
<FormControl> value={
<Switch field.value ?? (isCloud ? undefined : SERVER_LOCAL)
checked={field.value} }
onCheckedChange={field.onChange} >
/> <FormControl>
</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> </FormItem>
)} )}
/> />
<FormField <FormField
control={form.control} control={form.control}
name="attachable" name="scope"
render={({ field }) => ( render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-4"> <FormItem>
<div className="space-y-0.5"> <FormLabel>Scope (optional)</FormLabel>
<FormLabel className="text-base">Attachable</FormLabel> <Select
<FormDescription className="text-muted-foreground"> onValueChange={field.onChange}
Allow standalone containers to attach to this network value={field.value ?? SCOPE_EMPTY}
(e.g. in Swarm, not only services). >
</FormDescription> <FormControl>
</div> <SelectTrigger>
<FormControl> <SelectValue placeholder="Select scope" />
<Switch </SelectTrigger>
checked={field.value} </FormControl>
onCheckedChange={field.onChange} <SelectContent>
/> <SelectItem value={SCOPE_EMPTY}>None</SelectItem>
</FormControl> <SelectItem value="local">local</SelectItem>
</FormItem> <SelectItem value="swarm">swarm</SelectItem>
)} </SelectContent>
/> </Select>
<FormField <FormMessage />
control={form.control}
name="enableIPv4"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-4">
<div className="space-y-0.5">
<FormLabel className="text-base">Enable IPv4</FormLabel>
<FormDescription className="text-muted-foreground">
Enable IPv4 addressing on the network.
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="enableIPv6"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-4">
<div className="space-y-0.5">
<FormLabel className="text-base">Enable IPv6</FormLabel>
<FormDescription className="text-muted-foreground">
Enable IPv6 addressing on the network.
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="ingress"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-4">
<div className="space-y-0.5">
<FormLabel className="text-base">Ingress</FormLabel>
<FormDescription className="text-muted-foreground">
Use as the routing-mesh network in Swarm mode (load
balancing between nodes).
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="configOnly"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-4">
<div className="space-y-0.5">
<FormLabel className="text-base">Config only</FormLabel>
<FormDescription className="text-muted-foreground">
Create a placeholder network whose config is reused by
other networks; cannot run containers on it.
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem> </FormItem>
)} )}
/> />
</div> </div>
<div className="space-y-2 rounded-lg border p-4"> <div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<FormLabel>IPAM</FormLabel> {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 <FormField
control={form.control} control={form.control}
name="ipamDriver" name="ipamDriver"
@@ -514,9 +467,10 @@ export const HandleNetwork = ({ networkId, children }: HandleNetworkProps) => {
type="button" type="button"
variant="outline" variant="outline"
size="icon" size="icon"
aria-label="Remove IPAM config"
onClick={() => ipamConfigFieldArray.remove(index)} onClick={() => ipamConfigFieldArray.remove(index)}
> >
− <Trash2 className="size-4" />
</Button> </Button>
</div> </div>
))} ))}
@@ -532,6 +486,7 @@ export const HandleNetwork = ({ networkId, children }: HandleNetworkProps) => {
}) })
} }
> >
<Plus className="size-4" />
Add IPAM config Add IPAM config
</Button> </Button>
</div> </div>
@@ -544,14 +499,8 @@ export const HandleNetwork = ({ networkId, children }: HandleNetworkProps) => {
> >
Cancel Cancel
</Button> </Button>
<Button type="submit" disabled={isPending}> <Button type="submit" isLoading={isPending}>
{isPending {isEdit ? "Update network" : "Create network"}
? isEdit
? "Updating…"
: "Creating…"
: isEdit
? "Update network"
: "Create network"}
</Button> </Button>
</DialogFooter> </DialogFooter>
</form> </form>
+5 -2
View File
@@ -6,6 +6,7 @@ import {
} from "@dokploy/server/db/schema"; } 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 { IS_CLOUD } from "../constants"; import { IS_CLOUD } from "../constants";
import { getRemoteDocker } from "../utils/servers/remote-docker"; import { getRemoteDocker } from "../utils/servers/remote-docker";
@@ -27,7 +28,7 @@ export const findNetworkById = async (networkId: string) => {
}; };
export const createNetwork = async ( export const createNetwork = async (
input: typeof apiCreateNetwork._type, input: z.infer<typeof apiCreateNetwork>,
organizationId: string, organizationId: string,
) => { ) => {
if (IS_CLOUD) { if (IS_CLOUD) {
@@ -86,7 +87,9 @@ export const createNetwork = async (
return created; return created;
}; };
export const updateNetwork = async (input: typeof apiUpdateNetwork._type) => { export const updateNetwork = async (
input: z.infer<typeof apiUpdateNetwork>,
) => {
const { networkId, ...rest } = input; const { networkId, ...rest } = input;
const [updated] = await db const [updated] = await db
.update(network) .update(network)