mirror of
https://github.com/andwati/dokploy.git
synced 2026-11-09 13:35:19 +00:00
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:
@@ -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
-4
@@ -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,
|
||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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,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 }) => {
|
||||||
|
|||||||
@@ -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 });
|
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
Reference in New Issue
Block a user