mirror of
https://github.com/Dokploy/dokploy.git
synced 2026-07-22 06:15:24 +02:00
Compare commits
27 Commits
fix/collap
...
feat/add-n
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
61c2b73f08 | ||
|
|
736a77e112 | ||
|
|
f4bcc2e8a8 | ||
|
|
99e483c1a1 | ||
|
|
22c8a1658a | ||
|
|
f0f0db4c7f | ||
|
|
8b868c66d6 | ||
|
|
366e44b75a | ||
|
|
cb2db0d30a | ||
|
|
7ba3853bab | ||
|
|
8def9e933e | ||
|
|
e9b51667e2 | ||
|
|
25370cac30 | ||
|
|
52c7db1f66 | ||
|
|
6d65a36aac | ||
|
|
cbec72ed80 | ||
|
|
15fe3b21c9 | ||
|
|
5f508163e5 | ||
|
|
fc75b847b5 | ||
|
|
b1abcb9e06 | ||
|
|
d542972522 | ||
|
|
e7c38d4c54 | ||
|
|
a8f941b5d9 | ||
|
|
c68fac55fd | ||
|
|
efcad7bbf5 | ||
|
|
a0566cdbd7 | ||
|
|
69598821ed |
4
.gitignore
vendored
4
.gitignore
vendored
@@ -43,4 +43,6 @@ yarn-error.log*
|
|||||||
*.pem
|
*.pem
|
||||||
|
|
||||||
|
|
||||||
.db
|
.db
|
||||||
|
|
||||||
|
.playwright-*
|
||||||
@@ -84,19 +84,19 @@ export const ComposeActions = ({ composeId }: Props) => {
|
|||||||
)}
|
)}
|
||||||
{canDeploy && (
|
{canDeploy && (
|
||||||
<DialogAction
|
<DialogAction
|
||||||
title="Reload Compose"
|
title="Rebuild Compose"
|
||||||
description="Are you sure you want to reload this compose?"
|
description="Are you sure you want to rebuild this compose?"
|
||||||
type="default"
|
type="default"
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
await redeploy({
|
await redeploy({
|
||||||
composeId: composeId,
|
composeId: composeId,
|
||||||
})
|
})
|
||||||
.then(() => {
|
.then(() => {
|
||||||
toast.success("Compose reloaded successfully");
|
toast.success("Compose rebuilt successfully");
|
||||||
refetch();
|
refetch();
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
toast.error("Error reloading compose");
|
toast.error("Error rebuilding compose");
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -109,12 +109,14 @@ export const ComposeActions = ({ composeId }: Props) => {
|
|||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<RefreshCcw className="size-4 mr-1" />
|
<RefreshCcw className="size-4 mr-1" />
|
||||||
Reload
|
Rebuild
|
||||||
</div>
|
</div>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipPrimitive.Portal>
|
<TooltipPrimitive.Portal>
|
||||||
<TooltipContent sideOffset={5} className="z-60">
|
<TooltipContent sideOffset={5} className="z-60">
|
||||||
<p>Reload the compose without rebuilding it</p>
|
<p>
|
||||||
|
Rebuilds the compose without downloading the source code
|
||||||
|
</p>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</TooltipPrimitive.Portal>
|
</TooltipPrimitive.Portal>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|||||||
514
apps/dokploy/components/dashboard/networks/handle-network.tsx
Normal file
514
apps/dokploy/components/dashboard/networks/handle-network.tsx
Normal file
@@ -0,0 +1,514 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||||
|
import { Network, Pencil, Plus, Trash2 } from "lucide-react";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useFieldArray, useForm } from "react-hook-form";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormDescription,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from "@/components/ui/form";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
import { api } from "@/utils/api";
|
||||||
|
|
||||||
|
const networkDriverEnum = [
|
||||||
|
"bridge",
|
||||||
|
"host",
|
||||||
|
"overlay",
|
||||||
|
"macvlan",
|
||||||
|
"none",
|
||||||
|
"ipvlan",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
/** Sentinel for "no scope" */
|
||||||
|
const SCOPE_EMPTY = "__scope_none__";
|
||||||
|
/** Sentinel for the local Dokploy server (no serverId) */
|
||||||
|
const SERVER_LOCAL = "__server_local__";
|
||||||
|
|
||||||
|
const ipamConfigEntrySchema = z.object({
|
||||||
|
subnet: z.string().optional(),
|
||||||
|
ipRange: z.string().optional(),
|
||||||
|
gateway: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const networkFormSchema = z.object({
|
||||||
|
name: z.string().min(1, "Name is required"),
|
||||||
|
driver: z.enum(networkDriverEnum),
|
||||||
|
scope: z.string().optional(),
|
||||||
|
serverId: z.string().optional(),
|
||||||
|
internal: z.boolean(),
|
||||||
|
attachable: z.boolean(),
|
||||||
|
ingress: z.boolean(),
|
||||||
|
configOnly: z.boolean(),
|
||||||
|
enableIPv4: z.boolean(),
|
||||||
|
enableIPv6: z.boolean(),
|
||||||
|
ipamDriver: z.string().optional(),
|
||||||
|
ipamConfig: z.array(ipamConfigEntrySchema),
|
||||||
|
});
|
||||||
|
|
||||||
|
type NetworkFormValues = z.infer<typeof networkFormSchema>;
|
||||||
|
|
||||||
|
const defaultValues: NetworkFormValues = {
|
||||||
|
name: "",
|
||||||
|
driver: "bridge",
|
||||||
|
scope: SCOPE_EMPTY,
|
||||||
|
serverId: undefined,
|
||||||
|
internal: false,
|
||||||
|
attachable: false,
|
||||||
|
ingress: false,
|
||||||
|
configOnly: false,
|
||||||
|
enableIPv4: true,
|
||||||
|
enableIPv6: false,
|
||||||
|
ipamDriver: "",
|
||||||
|
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 {
|
||||||
|
networkId?: string;
|
||||||
|
children?: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const HandleNetwork = ({ networkId, children }: HandleNetworkProps) => {
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const { data: isCloud } = api.settings.isCloud.useQuery();
|
||||||
|
const utils = api.useUtils();
|
||||||
|
const isEdit = !!networkId;
|
||||||
|
|
||||||
|
const { data: servers } = api.server.all.useQuery();
|
||||||
|
const { data: network, isLoading: isLoadingNetwork } =
|
||||||
|
api.network.one.useQuery(
|
||||||
|
{ networkId: networkId! },
|
||||||
|
{ enabled: isEdit && !!networkId },
|
||||||
|
);
|
||||||
|
|
||||||
|
const createMutation = api.network.create.useMutation();
|
||||||
|
const updateMutation = api.network.update.useMutation();
|
||||||
|
const isPending = isEdit
|
||||||
|
? updateMutation.isPending
|
||||||
|
: createMutation.isPending;
|
||||||
|
|
||||||
|
const form = useForm<NetworkFormValues>({
|
||||||
|
resolver: zodResolver(networkFormSchema),
|
||||||
|
defaultValues,
|
||||||
|
});
|
||||||
|
|
||||||
|
const ipamConfigFieldArray = useFieldArray({
|
||||||
|
control: form.control,
|
||||||
|
name: "ipamConfig",
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isEdit && network && isOpen) {
|
||||||
|
const ipam = network.ipam ?? {};
|
||||||
|
const ipamConfigArr = (ipam.config ?? []).map((c) => ({
|
||||||
|
subnet: c.subnet ?? "",
|
||||||
|
ipRange: c.ipRange ?? "",
|
||||||
|
gateway: c.gateway ?? "",
|
||||||
|
}));
|
||||||
|
form.reset({
|
||||||
|
...defaultValues,
|
||||||
|
name: network.name,
|
||||||
|
driver: network.driver,
|
||||||
|
scope: network.scope ?? SCOPE_EMPTY,
|
||||||
|
serverId: network.serverId || undefined,
|
||||||
|
internal: network.internal,
|
||||||
|
attachable: network.attachable,
|
||||||
|
enableIPv4: network.enableIPv4,
|
||||||
|
enableIPv6: network.enableIPv6,
|
||||||
|
ipamDriver: ipam.driver ?? "",
|
||||||
|
ipamConfig: ipamConfigArr,
|
||||||
|
ingress: network.ingress,
|
||||||
|
configOnly: network.configOnly,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [isEdit, isOpen, network, form]);
|
||||||
|
|
||||||
|
const onSubmit = async (data: NetworkFormValues) => {
|
||||||
|
const scope =
|
||||||
|
data.scope && data.scope !== SCOPE_EMPTY ? data.scope : undefined;
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
name: data.name,
|
||||||
|
driver: data.driver,
|
||||||
|
scope,
|
||||||
|
serverId: data.serverId || undefined,
|
||||||
|
internal: data.internal,
|
||||||
|
attachable: data.attachable,
|
||||||
|
ingress: data.ingress,
|
||||||
|
configOnly: data.configOnly,
|
||||||
|
enableIPv4: data.enableIPv4,
|
||||||
|
enableIPv6: data.enableIPv6,
|
||||||
|
ipam: {
|
||||||
|
driver: data.ipamDriver,
|
||||||
|
config: data.ipamConfig,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (isEdit && networkId) {
|
||||||
|
await updateMutation.mutateAsync({ networkId, ...payload });
|
||||||
|
} else {
|
||||||
|
await createMutation.mutateAsync(payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.success(isEdit ? "Network updated" : "Network created");
|
||||||
|
await utils.network.all.invalidate();
|
||||||
|
if (networkId) await utils.network.one.invalidate({ networkId });
|
||||||
|
setIsOpen(false);
|
||||||
|
form.reset(defaultValues);
|
||||||
|
} catch {
|
||||||
|
toast.error(isEdit ? "Error updating network" : "Error creating network");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const trigger =
|
||||||
|
children ??
|
||||||
|
(isEdit ? (
|
||||||
|
<Button size="sm" variant="outline">
|
||||||
|
<Pencil className=" size-4" />
|
||||||
|
Edit
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button>
|
||||||
|
<Plus className=" size-4" />
|
||||||
|
Add network
|
||||||
|
</Button>
|
||||||
|
));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||||
|
<DialogTrigger asChild>{trigger}</DialogTrigger>
|
||||||
|
<DialogContent className="sm:max-w-2xl max-h-[90vh] overflow-y-auto">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
<Network className="size-5 text-muted-foreground" />
|
||||||
|
{isEdit ? "Edit network" : "Add network"}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{isEdit
|
||||||
|
? "Update this Docker network. Changes apply to name, driver, and server assignment."
|
||||||
|
: "Create a new Docker network for your organization. You can optionally assign it to a server."}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
{isEdit && isLoadingNetwork ? (
|
||||||
|
<div className="flex items-center justify-center py-8 text-sm text-muted-foreground">
|
||||||
|
Loading network…
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Form {...form}>
|
||||||
|
<form
|
||||||
|
onSubmit={form.handleSubmit(onSubmit)}
|
||||||
|
className="flex w-full flex-col gap-6"
|
||||||
|
>
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="name"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Name</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="my-network" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="driver"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Driver</FormLabel>
|
||||||
|
<Select
|
||||||
|
onValueChange={field.onChange}
|
||||||
|
value={field.value}
|
||||||
|
>
|
||||||
|
<FormControl>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select driver" />
|
||||||
|
</SelectTrigger>
|
||||||
|
</FormControl>
|
||||||
|
<SelectContent>
|
||||||
|
{networkDriverEnum.map((d) => (
|
||||||
|
<SelectItem key={d} value={d}>
|
||||||
|
{d}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="serverId"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Server</FormLabel>
|
||||||
|
<Select
|
||||||
|
onValueChange={(value) =>
|
||||||
|
field.onChange(
|
||||||
|
value === SERVER_LOCAL ? undefined : value,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
value={
|
||||||
|
field.value ?? (isCloud ? undefined : SERVER_LOCAL)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<FormControl>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select server" />
|
||||||
|
</SelectTrigger>
|
||||||
|
</FormControl>
|
||||||
|
<SelectContent>
|
||||||
|
{!isCloud && (
|
||||||
|
<SelectItem value={SERVER_LOCAL}>
|
||||||
|
Dokploy server
|
||||||
|
</SelectItem>
|
||||||
|
)}
|
||||||
|
{servers?.map((server) => (
|
||||||
|
<SelectItem
|
||||||
|
key={server.serverId}
|
||||||
|
value={server.serverId}
|
||||||
|
>
|
||||||
|
{server.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<FormDescription className="text-muted-foreground">
|
||||||
|
{isCloud
|
||||||
|
? "Server where this network will be created."
|
||||||
|
: "Dokploy server is the default local server."}
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="scope"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Scope (optional)</FormLabel>
|
||||||
|
<Select
|
||||||
|
onValueChange={field.onChange}
|
||||||
|
value={field.value ?? SCOPE_EMPTY}
|
||||||
|
>
|
||||||
|
<FormControl>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select scope" />
|
||||||
|
</SelectTrigger>
|
||||||
|
</FormControl>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value={SCOPE_EMPTY}>None</SelectItem>
|
||||||
|
<SelectItem value="local">local</SelectItem>
|
||||||
|
<SelectItem value="swarm">swarm</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
|
{toggleOptions.map((option) => (
|
||||||
|
<FormField
|
||||||
|
key={option.name}
|
||||||
|
control={form.control}
|
||||||
|
name={option.name}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="flex flex-row items-start justify-between gap-3 space-y-0 rounded-lg border p-4">
|
||||||
|
<div className="space-y-1 pr-1">
|
||||||
|
<FormLabel>{option.label}</FormLabel>
|
||||||
|
<FormDescription className="text-muted-foreground">
|
||||||
|
{option.description}
|
||||||
|
</FormDescription>
|
||||||
|
</div>
|
||||||
|
<FormControl>
|
||||||
|
<Switch
|
||||||
|
checked={field.value}
|
||||||
|
onCheckedChange={field.onChange}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-4 rounded-lg border p-4">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<FormLabel>IPAM</FormLabel>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
IP address management settings for this network.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="ipamDriver"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel className="text-muted-foreground">
|
||||||
|
Driver (optional)
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input {...field} placeholder="default" />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<FormLabel className="text-muted-foreground">
|
||||||
|
Config (subnet / gateway / IP range)
|
||||||
|
</FormLabel>
|
||||||
|
{ipamConfigFieldArray.fields.map((field, index) => (
|
||||||
|
<div key={field.id} className="flex flex-wrap gap-2">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name={`ipamConfig.${index}.subnet`}
|
||||||
|
render={({ field: f }) => (
|
||||||
|
<FormItem className="min-w-[140px] flex-1">
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
{...f}
|
||||||
|
placeholder="Subnet (e.g. 172.20.0.0/16)"
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name={`ipamConfig.${index}.ipRange`}
|
||||||
|
render={({ field: f }) => (
|
||||||
|
<FormItem className="min-w-[120px] flex-1">
|
||||||
|
<FormControl>
|
||||||
|
<Input {...f} placeholder="IP range" />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name={`ipamConfig.${index}.gateway`}
|
||||||
|
render={({ field: f }) => (
|
||||||
|
<FormItem className="min-w-[120px] flex-1">
|
||||||
|
<FormControl>
|
||||||
|
<Input {...f} placeholder="Gateway" />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
aria-label="Remove IPAM config"
|
||||||
|
onClick={() => ipamConfigFieldArray.remove(index)}
|
||||||
|
>
|
||||||
|
<Trash2 className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() =>
|
||||||
|
ipamConfigFieldArray.append({
|
||||||
|
subnet: "",
|
||||||
|
ipRange: "",
|
||||||
|
gateway: "",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Plus className="size-4" />
|
||||||
|
Add IPAM config
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setIsOpen(false)}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" isLoading={isPending}>
|
||||||
|
{isEdit ? "Update network" : "Create network"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
130
apps/dokploy/components/dashboard/networks/show-networks.tsx
Normal file
130
apps/dokploy/components/dashboard/networks/show-networks.tsx
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Loader2, Network, Pencil } from "lucide-react";
|
||||||
|
import { HandleNetwork } from "@/components/dashboard/networks/handle-network";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardAction,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table";
|
||||||
|
import { api } from "@/utils/api";
|
||||||
|
|
||||||
|
export const ShowNetworks = () => {
|
||||||
|
const { data: networks, isLoading } = api.network.all.useQuery();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full">
|
||||||
|
<Card className="h-full bg-sidebar p-2.5 rounded-xl">
|
||||||
|
<div className="rounded-xl bg-background shadow-md ">
|
||||||
|
<CardHeader className="">
|
||||||
|
<CardTitle className="text-xl flex flex-row gap-2">
|
||||||
|
<Network className="size-6 text-muted-foreground self-center" />
|
||||||
|
Networks
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Manage Docker networks for your organization. Networks can be
|
||||||
|
scoped to a server (optional).
|
||||||
|
</CardDescription>
|
||||||
|
{networks && networks.length > 0 && (
|
||||||
|
<CardAction className="self-center">
|
||||||
|
<HandleNetwork />
|
||||||
|
</CardAction>
|
||||||
|
)}
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent className="space-y-2 py-8 border-t">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex flex-row gap-2 items-center justify-center text-sm text-muted-foreground min-h-[45vh]">
|
||||||
|
<span>Loading...</span>
|
||||||
|
<Loader2 className="animate-spin size-4" />
|
||||||
|
</div>
|
||||||
|
) : !networks?.length ? (
|
||||||
|
<div className="flex min-h-[45vh] w-full flex-col items-center justify-center gap-4 rounded-lg border border-dashed p-8">
|
||||||
|
<div className="rounded-full bg-muted p-4">
|
||||||
|
<Network className="size-10 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1 text-center">
|
||||||
|
<p className="text-sm font-medium">No networks yet</p>
|
||||||
|
<p className="max-w-sm text-sm text-muted-foreground">
|
||||||
|
Create Docker networks for your organization and optionally
|
||||||
|
attach them to a server. Add your first network to get
|
||||||
|
started.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<HandleNetwork />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="rounded-md border overflow-x-auto">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Name</TableHead>
|
||||||
|
<TableHead>Driver</TableHead>
|
||||||
|
<TableHead>Scope</TableHead>
|
||||||
|
<TableHead>Internal</TableHead>
|
||||||
|
<TableHead>Attachable</TableHead>
|
||||||
|
<TableHead>Server</TableHead>
|
||||||
|
<TableHead>Created</TableHead>
|
||||||
|
<TableHead className="w-[80px] text-right">
|
||||||
|
Actions
|
||||||
|
</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{networks.map((n) => (
|
||||||
|
<TableRow key={n.networkId}>
|
||||||
|
<TableCell className="font-medium">{n.name}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant="outline">{n.driver}</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-muted-foreground">
|
||||||
|
{n.scope ?? "—"}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-muted-foreground">
|
||||||
|
{n.internal ? "Yes" : "No"}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-muted-foreground">
|
||||||
|
{n.attachable ? "Yes" : "No"}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{n.server?.name ?? "Dokploy Server"}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-muted-foreground">
|
||||||
|
{new Date(n.createdAt).toLocaleDateString()}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<HandleNetwork networkId={n.networkId}>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-xs"
|
||||||
|
aria-label="Edit network"
|
||||||
|
>
|
||||||
|
<Pencil className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</HandleNetwork>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -13,10 +13,14 @@ import {
|
|||||||
} from "@/components/ui/card";
|
} from "@/components/ui/card";
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
import { HandleAi } from "./handle-ai";
|
import { HandleAi } from "./handle-ai";
|
||||||
|
import { HandleAiProviders } from "./handle-ai-providers";
|
||||||
|
|
||||||
export const AiForm = () => {
|
export const AiForm = () => {
|
||||||
const { data: aiConfigs, refetch, isPending } = api.ai.getAll.useQuery();
|
const { data: aiConfigs, refetch, isPending } = api.ai.getAll.useQuery();
|
||||||
const { mutateAsync, isPending: isRemoving } = api.ai.delete.useMutation();
|
const { mutateAsync, isPending: isRemoving } = api.ai.delete.useMutation();
|
||||||
|
const { data: currentUser } = api.user.get.useQuery();
|
||||||
|
const isOrgAdmin =
|
||||||
|
currentUser?.role === "owner" || currentUser?.role === "admin";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
@@ -30,7 +34,10 @@ export const AiForm = () => {
|
|||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription>Manage your AI configurations</CardDescription>
|
<CardDescription>Manage your AI configurations</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
{aiConfigs && aiConfigs?.length > 0 && <HandleAi />}
|
<div className="flex flex-row gap-2">
|
||||||
|
{isOrgAdmin && <HandleAiProviders />}
|
||||||
|
{aiConfigs && aiConfigs?.length > 0 && <HandleAi />}
|
||||||
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-2 py-8 border-t">
|
<CardContent className="space-y-2 py-8 border-t">
|
||||||
{isPending ? (
|
{isPending ? (
|
||||||
|
|||||||
@@ -0,0 +1,158 @@
|
|||||||
|
"use client";
|
||||||
|
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||||
|
import { PlusIcon, ServerIcon, Trash2 } from "lucide-react";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useFieldArray, useForm } from "react-hook-form";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormMessage,
|
||||||
|
} from "@/components/ui/form";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { api } from "@/utils/api";
|
||||||
|
|
||||||
|
const Schema = z.object({
|
||||||
|
providers: z.array(
|
||||||
|
z.object({
|
||||||
|
name: z.string().min(1, { message: "Name is required" }),
|
||||||
|
apiUrl: z.string().url({ message: "Please enter a valid URL" }),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
type Schema = z.infer<typeof Schema>;
|
||||||
|
|
||||||
|
export const HandleAiProviders = () => {
|
||||||
|
const utils = api.useUtils();
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const { data: providers } = api.ai.getCustomProviders.useQuery();
|
||||||
|
const { mutateAsync, isPending } = api.ai.saveCustomProviders.useMutation();
|
||||||
|
|
||||||
|
const form = useForm<Schema>({
|
||||||
|
resolver: zodResolver(Schema),
|
||||||
|
defaultValues: {
|
||||||
|
providers: [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { fields, append, remove } = useFieldArray({
|
||||||
|
control: form.control,
|
||||||
|
name: "providers",
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
form.reset({ providers: providers ?? [] });
|
||||||
|
}
|
||||||
|
}, [open, providers, form]);
|
||||||
|
|
||||||
|
const onSubmit = async (data: Schema) => {
|
||||||
|
try {
|
||||||
|
await mutateAsync({ providers: data.providers });
|
||||||
|
await utils.ai.getCustomProviders.invalidate();
|
||||||
|
toast.success("Custom providers saved successfully");
|
||||||
|
setOpen(false);
|
||||||
|
} catch (error) {
|
||||||
|
toast.error("Failed to save custom providers", {
|
||||||
|
description: error instanceof Error ? error.message : "Unknown error",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button variant="outline" className="cursor-pointer space-x-3">
|
||||||
|
<ServerIcon className="h-4 w-4" />
|
||||||
|
Custom Presets
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent className="sm:max-w-xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Custom AI Providers</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Define your own AI providers, like an internal LLM platform. When at
|
||||||
|
least one is defined, only these providers can be used in AI
|
||||||
|
configurations.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||||
|
{fields.length === 0 && (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
No custom providers defined. The built-in provider list will be
|
||||||
|
used.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{fields.map((fieldItem, index) => (
|
||||||
|
<div key={fieldItem.id} className="flex gap-2 items-start">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name={`providers.${index}.name`}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="flex-1">
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="Internal LLM" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name={`providers.${index}.apiUrl`}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="flex-[2]">
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
placeholder="https://llm.internal.company/v1"
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="group hover:bg-red-500/10"
|
||||||
|
onClick={() => remove(index)}
|
||||||
|
>
|
||||||
|
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => append({ name: "", apiUrl: "" })}
|
||||||
|
>
|
||||||
|
<PlusIcon className="h-4 w-4" />
|
||||||
|
Add Provider
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" isLoading={isPending}>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -99,6 +99,11 @@ export const HandleAi = ({ aiId }: Props) => {
|
|||||||
enabled: !!aiId,
|
enabled: !!aiId,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
const { data: customProviders } = api.ai.getCustomProviders.useQuery();
|
||||||
|
const hasCustomProviders = (customProviders?.length ?? 0) > 0;
|
||||||
|
const providerOptions: { name: string; apiUrl: string }[] = hasCustomProviders
|
||||||
|
? (customProviders ?? [])
|
||||||
|
: [...AI_PROVIDERS];
|
||||||
const { mutateAsync, isPending } = aiId
|
const { mutateAsync, isPending } = aiId
|
||||||
? api.ai.update.useMutation()
|
? api.ai.update.useMutation()
|
||||||
: api.ai.create.useMutation();
|
: api.ai.create.useMutation();
|
||||||
@@ -210,7 +215,9 @@ export const HandleAi = ({ aiId }: Props) => {
|
|||||||
<FormLabel>Provider</FormLabel>
|
<FormLabel>Provider</FormLabel>
|
||||||
<Select
|
<Select
|
||||||
onValueChange={(value) => {
|
onValueChange={(value) => {
|
||||||
const provider = AI_PROVIDERS.find((p) => p.apiUrl === value);
|
const provider = providerOptions.find(
|
||||||
|
(p) => p.apiUrl === value,
|
||||||
|
);
|
||||||
if (provider) {
|
if (provider) {
|
||||||
form.setValue("name", provider.name);
|
form.setValue("name", provider.name);
|
||||||
form.setValue("apiUrl", provider.apiUrl);
|
form.setValue("apiUrl", provider.apiUrl);
|
||||||
@@ -222,15 +229,20 @@ export const HandleAi = ({ aiId }: Props) => {
|
|||||||
<SelectValue placeholder="Select a provider preset..." />
|
<SelectValue placeholder="Select a provider preset..." />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{AI_PROVIDERS.map((provider) => (
|
{providerOptions.map((provider) => (
|
||||||
<SelectItem key={provider.apiUrl} value={provider.apiUrl}>
|
<SelectItem
|
||||||
|
key={`${provider.name}-${provider.apiUrl}`}
|
||||||
|
value={provider.apiUrl}
|
||||||
|
>
|
||||||
{provider.name}
|
{provider.name}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
<p className="text-[0.8rem] text-muted-foreground">
|
<p className="text-[0.8rem] text-muted-foreground">
|
||||||
Quick-fill provider name and URL, or configure manually below
|
{hasCustomProviders
|
||||||
|
? "Select one of the providers defined by your organization"
|
||||||
|
: "Quick-fill provider name and URL, or configure manually below"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -260,6 +272,7 @@ export const HandleAi = ({ aiId }: Props) => {
|
|||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
placeholder="https://api.openai.com/v1"
|
placeholder="https://api.openai.com/v1"
|
||||||
|
disabled={hasCustomProviders}
|
||||||
{...field}
|
{...field}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
field.onChange(e);
|
field.onChange(e);
|
||||||
@@ -271,7 +284,9 @@ export const HandleAi = ({ aiId }: Props) => {
|
|||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormDescription>
|
<FormDescription>
|
||||||
The base URL for your AI provider's API
|
{hasCustomProviders
|
||||||
|
? "The API URL is defined by your organization's providers"
|
||||||
|
: "The base URL for your AI provider's API"}
|
||||||
</FormDescription>
|
</FormDescription>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
|
|||||||
@@ -100,7 +100,7 @@ export const Enable2FA = () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (result.error) {
|
if (result.error) {
|
||||||
if (result.error.code === "INVALID_TWO_FACTOR_AUTHENTICATION") {
|
if (result.error.code === "INVALID_CODE") {
|
||||||
toast.error("Invalid verification code");
|
toast.error("Invalid verification code");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import {
|
|||||||
Loader2,
|
Loader2,
|
||||||
LogIn,
|
LogIn,
|
||||||
type LucideIcon,
|
type LucideIcon,
|
||||||
|
Network,
|
||||||
Package,
|
Package,
|
||||||
Palette,
|
Palette,
|
||||||
PieChart,
|
PieChart,
|
||||||
@@ -209,6 +210,20 @@ const MENU: Menu = {
|
|||||||
// Only enabled for users with access to Docker
|
// Only enabled for users with access to Docker
|
||||||
isEnabled: ({ permissions }) => !!permissions?.docker.read,
|
isEnabled: ({ permissions }) => !!permissions?.docker.read,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
isSingle: true,
|
||||||
|
title: "Networks",
|
||||||
|
url: "/dashboard/networks",
|
||||||
|
icon: Network,
|
||||||
|
// Only enabled for admins and users with access to Docker in non-cloud environments
|
||||||
|
isEnabled: ({ auth, isCloud }) =>
|
||||||
|
!!(
|
||||||
|
(auth?.role === "owner" ||
|
||||||
|
auth?.role === "admin" ||
|
||||||
|
auth?.canAccessToDocker) &&
|
||||||
|
!isCloud
|
||||||
|
),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
isSingle: true,
|
isSingle: true,
|
||||||
title: "Requests",
|
title: "Requests",
|
||||||
|
|||||||
27
apps/dokploy/drizzle/0175_wet_grey_gargoyle.sql
Normal file
27
apps/dokploy/drizzle/0175_wet_grey_gargoyle.sql
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
CREATE TYPE "public"."networkDriver" AS ENUM('bridge', 'host', 'overlay', 'macvlan', 'none', 'ipvlan');--> statement-breakpoint
|
||||||
|
CREATE TABLE "network" (
|
||||||
|
"networkId" text PRIMARY KEY NOT NULL,
|
||||||
|
"name" text NOT NULL,
|
||||||
|
"driver" "networkDriver" DEFAULT 'bridge' NOT NULL,
|
||||||
|
"scope" text,
|
||||||
|
"internal" boolean DEFAULT false NOT NULL,
|
||||||
|
"attachable" boolean DEFAULT false NOT NULL,
|
||||||
|
"ingress" boolean DEFAULT false NOT NULL,
|
||||||
|
"configOnly" boolean DEFAULT false NOT NULL,
|
||||||
|
"enableIPv4" boolean DEFAULT true NOT NULL,
|
||||||
|
"enableIPv6" boolean DEFAULT false NOT NULL,
|
||||||
|
"ipam" jsonb DEFAULT '{}'::jsonb,
|
||||||
|
"createdAt" text NOT NULL,
|
||||||
|
"organizationId" text NOT NULL,
|
||||||
|
"serverId" text
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "application" ADD COLUMN "networkIds" text[] DEFAULT '{}';--> statement-breakpoint
|
||||||
|
ALTER TABLE "compose" ADD COLUMN "networkIds" text[] DEFAULT '{}';--> statement-breakpoint
|
||||||
|
ALTER TABLE "mariadb" ADD COLUMN "networkIds" text[] DEFAULT '{}';--> statement-breakpoint
|
||||||
|
ALTER TABLE "mongo" ADD COLUMN "networkIds" text[] DEFAULT '{}';--> statement-breakpoint
|
||||||
|
ALTER TABLE "mysql" ADD COLUMN "networkIds" text[] DEFAULT '{}';--> statement-breakpoint
|
||||||
|
ALTER TABLE "postgres" ADD COLUMN "networkIds" text[] DEFAULT '{}';--> statement-breakpoint
|
||||||
|
ALTER TABLE "redis" ADD COLUMN "networkIds" text[] DEFAULT '{}';--> statement-breakpoint
|
||||||
|
ALTER TABLE "network" ADD CONSTRAINT "network_organizationId_organization_id_fk" FOREIGN KEY ("organizationId") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "network" ADD CONSTRAINT "network_serverId_server_serverId_fk" FOREIGN KEY ("serverId") REFERENCES "public"."server"("serverId") ON DELETE cascade ON UPDATE no action;
|
||||||
8745
apps/dokploy/drizzle/meta/0175_snapshot.json
Normal file
8745
apps/dokploy/drizzle/meta/0175_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1226,6 +1226,13 @@
|
|||||||
"when": 1783674181297,
|
"when": 1783674181297,
|
||||||
"tag": "0174_great_naoko",
|
"tag": "0174_great_naoko",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 175,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1784682163836,
|
||||||
|
"tag": "0175_wet_grey_gargoyle",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "dokploy",
|
"name": "dokploy",
|
||||||
"version": "v0.29.12",
|
"version": "v0.29.13",
|
||||||
"private": true,
|
"private": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
84
apps/dokploy/pages/dashboard/networks.tsx
Normal file
84
apps/dokploy/pages/dashboard/networks.tsx
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
import { IS_CLOUD } from "@dokploy/server/constants";
|
||||||
|
import { validateRequest } from "@dokploy/server/lib/auth";
|
||||||
|
import { createServerSideHelpers } from "@trpc/react-query/server";
|
||||||
|
import type { GetServerSidePropsContext } from "next";
|
||||||
|
import type { ReactElement } from "react";
|
||||||
|
import superjson from "superjson";
|
||||||
|
import { ShowNetworks } from "@/components/dashboard/networks/show-networks";
|
||||||
|
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
|
||||||
|
import { appRouter } from "@/server/api/root";
|
||||||
|
|
||||||
|
const Dashboard = () => {
|
||||||
|
return <ShowNetworks />;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Dashboard;
|
||||||
|
|
||||||
|
Dashboard.getLayout = (page: ReactElement) => {
|
||||||
|
return <DashboardLayout>{page}</DashboardLayout>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function getServerSideProps(
|
||||||
|
ctx: GetServerSidePropsContext<{ serviceId: string }>,
|
||||||
|
) {
|
||||||
|
if (IS_CLOUD) {
|
||||||
|
return {
|
||||||
|
redirect: {
|
||||||
|
permanent: true,
|
||||||
|
destination: "/dashboard/projects",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const { user, session } = await validateRequest(ctx.req);
|
||||||
|
if (!user) {
|
||||||
|
return {
|
||||||
|
redirect: {
|
||||||
|
permanent: true,
|
||||||
|
destination: "/",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const { req } = ctx;
|
||||||
|
|
||||||
|
const helpers = createServerSideHelpers({
|
||||||
|
router: appRouter,
|
||||||
|
ctx: {
|
||||||
|
req: req as any,
|
||||||
|
res: ctx.res as any,
|
||||||
|
db: null as any,
|
||||||
|
session: session as any,
|
||||||
|
user: user as any,
|
||||||
|
},
|
||||||
|
transformer: superjson,
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await helpers.project.all.prefetch();
|
||||||
|
|
||||||
|
if (user.role === "member") {
|
||||||
|
const userR = await helpers.user.one.fetch({
|
||||||
|
userId: user.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!userR?.canAccessToDocker) {
|
||||||
|
return {
|
||||||
|
redirect: {
|
||||||
|
permanent: true,
|
||||||
|
destination: "/",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await helpers.network.all.prefetch();
|
||||||
|
|
||||||
|
return {
|
||||||
|
props: {
|
||||||
|
trpcState: helpers.dehydrate(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return {
|
||||||
|
props: {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,6 +21,7 @@ import { mariadbRouter } from "./routers/mariadb";
|
|||||||
import { mongoRouter } from "./routers/mongo";
|
import { mongoRouter } from "./routers/mongo";
|
||||||
import { mountRouter } from "./routers/mount";
|
import { mountRouter } from "./routers/mount";
|
||||||
import { mysqlRouter } from "./routers/mysql";
|
import { mysqlRouter } from "./routers/mysql";
|
||||||
|
import { networkRouter } from "./routers/network";
|
||||||
import { notificationRouter } from "./routers/notification";
|
import { notificationRouter } from "./routers/notification";
|
||||||
import { organizationRouter } from "./routers/organization";
|
import { organizationRouter } from "./routers/organization";
|
||||||
import { patchRouter } from "./routers/patch";
|
import { patchRouter } from "./routers/patch";
|
||||||
@@ -60,6 +61,7 @@ export const appRouter = createTRPCRouter({
|
|||||||
application: applicationRouter,
|
application: applicationRouter,
|
||||||
backup: backupRouter,
|
backup: backupRouter,
|
||||||
bitbucket: bitbucketRouter,
|
bitbucket: bitbucketRouter,
|
||||||
|
network: networkRouter,
|
||||||
certificates: certificateRouter,
|
certificates: certificateRouter,
|
||||||
cluster: clusterRouter,
|
cluster: clusterRouter,
|
||||||
compose: composeRouter,
|
compose: composeRouter,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { IS_CLOUD } from "@dokploy/server/constants";
|
import { IS_CLOUD } from "@dokploy/server/constants";
|
||||||
import {
|
import {
|
||||||
apiCreateAi,
|
apiCreateAi,
|
||||||
|
apiSaveAiCustomProviders,
|
||||||
apiUpdateAi,
|
apiUpdateAi,
|
||||||
deploySuggestionSchema,
|
deploySuggestionSchema,
|
||||||
} from "@dokploy/server/db/schema/ai";
|
} from "@dokploy/server/db/schema/ai";
|
||||||
@@ -13,7 +14,9 @@ import {
|
|||||||
deleteAiSettings,
|
deleteAiSettings,
|
||||||
getAiSettingById,
|
getAiSettingById,
|
||||||
getAiSettingsByOrganizationId,
|
getAiSettingsByOrganizationId,
|
||||||
|
getCustomAiProviders,
|
||||||
saveAiSettings,
|
saveAiSettings,
|
||||||
|
saveCustomAiProviders,
|
||||||
suggestVariants,
|
suggestVariants,
|
||||||
} from "@dokploy/server/services/ai";
|
} from "@dokploy/server/services/ai";
|
||||||
import { createComposeByTemplate } from "@dokploy/server/services/compose";
|
import { createComposeByTemplate } from "@dokploy/server/services/compose";
|
||||||
@@ -200,6 +203,19 @@ export const aiRouter = createTRPCRouter({
|
|||||||
return await deleteAiSettings(input.aiId);
|
return await deleteAiSettings(input.aiId);
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
getCustomProviders: protectedProcedure.query(async ({ ctx }) => {
|
||||||
|
return await getCustomAiProviders(ctx.session.activeOrganizationId);
|
||||||
|
}),
|
||||||
|
|
||||||
|
saveCustomProviders: adminProcedure
|
||||||
|
.input(apiSaveAiCustomProviders)
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
return await saveCustomAiProviders(
|
||||||
|
ctx.session.activeOrganizationId,
|
||||||
|
input.providers,
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
|
||||||
getEnabledProviders: protectedProcedure.query(async ({ ctx }) => {
|
getEnabledProviders: protectedProcedure.query(async ({ ctx }) => {
|
||||||
const settings = await getAiSettingsByOrganizationId(
|
const settings = await getAiSettingsByOrganizationId(
|
||||||
ctx.session.activeOrganizationId,
|
ctx.session.activeOrganizationId,
|
||||||
|
|||||||
78
apps/dokploy/server/api/routers/network.ts
Normal file
78
apps/dokploy/server/api/routers/network.ts
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
import {
|
||||||
|
createNetwork,
|
||||||
|
findNetworkById,
|
||||||
|
removeNetwork,
|
||||||
|
updateNetwork,
|
||||||
|
} from "@dokploy/server";
|
||||||
|
import { TRPCError } from "@trpc/server";
|
||||||
|
import { desc, eq } from "drizzle-orm";
|
||||||
|
import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc";
|
||||||
|
import { db } from "@/server/db";
|
||||||
|
import {
|
||||||
|
apiCreateNetwork,
|
||||||
|
apiFindOneNetwork,
|
||||||
|
apiRemoveNetwork,
|
||||||
|
apiUpdateNetwork,
|
||||||
|
network as networkTable,
|
||||||
|
} from "@/server/db/schema";
|
||||||
|
|
||||||
|
export const networkRouter = createTRPCRouter({
|
||||||
|
all: protectedProcedure.query(async ({ ctx }) => {
|
||||||
|
const rows = await db.query.network.findMany({
|
||||||
|
where: eq(networkTable.organizationId, ctx.session.activeOrganizationId),
|
||||||
|
with: {
|
||||||
|
server: {
|
||||||
|
columns: {
|
||||||
|
serverId: true,
|
||||||
|
name: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: desc(networkTable.createdAt),
|
||||||
|
});
|
||||||
|
return rows;
|
||||||
|
}),
|
||||||
|
|
||||||
|
one: protectedProcedure
|
||||||
|
.input(apiFindOneNetwork)
|
||||||
|
.query(async ({ ctx, input }) => {
|
||||||
|
const row = await findNetworkById(input.networkId);
|
||||||
|
if (row.organizationId !== ctx.session.activeOrganizationId) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "NOT_FOUND",
|
||||||
|
message: "Network not found",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return row;
|
||||||
|
}),
|
||||||
|
create: protectedProcedure
|
||||||
|
.input(apiCreateNetwork)
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
return createNetwork(input, ctx.session.activeOrganizationId);
|
||||||
|
}),
|
||||||
|
update: protectedProcedure
|
||||||
|
.input(apiUpdateNetwork)
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
const network = await findNetworkById(input.networkId);
|
||||||
|
if (network.organizationId !== ctx.session.activeOrganizationId) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "UNAUTHORIZED",
|
||||||
|
message: "Not authorized to update this network",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return updateNetwork(input);
|
||||||
|
}),
|
||||||
|
|
||||||
|
remove: protectedProcedure
|
||||||
|
.input(apiRemoveNetwork)
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
const network = await findNetworkById(input.networkId);
|
||||||
|
if (network.organizationId !== ctx.session.activeOrganizationId) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "UNAUTHORIZED",
|
||||||
|
message: "Not authorized to delete this network",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return removeNetwork(input.networkId);
|
||||||
|
}),
|
||||||
|
});
|
||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
timestamp,
|
timestamp,
|
||||||
} from "drizzle-orm/pg-core";
|
} from "drizzle-orm/pg-core";
|
||||||
import { nanoid } from "nanoid";
|
import { nanoid } from "nanoid";
|
||||||
|
import { network } from "./network";
|
||||||
import { projects } from "./project";
|
import { projects } from "./project";
|
||||||
import { server } from "./server";
|
import { server } from "./server";
|
||||||
import { ssoProvider } from "./sso";
|
import { ssoProvider } from "./sso";
|
||||||
@@ -108,6 +109,7 @@ export const organizationRelations = relations(
|
|||||||
references: [user.id],
|
references: [user.id],
|
||||||
}),
|
}),
|
||||||
servers: many(server),
|
servers: many(server),
|
||||||
|
networks: many(network),
|
||||||
projects: many(projects),
|
projects: many(projects),
|
||||||
members: many(member),
|
members: many(member),
|
||||||
ssoProviders: many(ssoProvider),
|
ssoProviders: many(ssoProvider),
|
||||||
|
|||||||
@@ -54,6 +54,15 @@ export const apiUpdateAi = createSchema
|
|||||||
})
|
})
|
||||||
.omit({ organizationId: true });
|
.omit({ organizationId: true });
|
||||||
|
|
||||||
|
export const aiCustomProviderSchema = z.object({
|
||||||
|
name: z.string().min(1, { message: "Name is required" }),
|
||||||
|
apiUrl: z.string().url({ message: "Please enter a valid URL" }),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const apiSaveAiCustomProviders = z.object({
|
||||||
|
providers: z.array(aiCustomProviderSchema),
|
||||||
|
});
|
||||||
|
|
||||||
export const deploySuggestionSchema = z.object({
|
export const deploySuggestionSchema = z.object({
|
||||||
environmentId: z.string().min(1),
|
environmentId: z.string().min(1),
|
||||||
id: z.string().min(1),
|
id: z.string().min(1),
|
||||||
|
|||||||
@@ -233,6 +233,7 @@ export const applications = pgTable("application", {
|
|||||||
onDelete: "set null",
|
onDelete: "set null",
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
networkIds: text("networkIds").array().default([]),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const applicationsRelations = relations(
|
export const applicationsRelations = relations(
|
||||||
@@ -374,6 +375,7 @@ const createSchema = createInsertSchema(applications, {
|
|||||||
previewRequireCollaboratorPermissions: z.boolean().optional(),
|
previewRequireCollaboratorPermissions: z.boolean().optional(),
|
||||||
watchPaths: z.array(z.string()).optional().optional(),
|
watchPaths: z.array(z.string()).optional().optional(),
|
||||||
previewLabels: z.array(z.string()).optional(),
|
previewLabels: z.array(z.string()).optional(),
|
||||||
|
networkIds: z.array(z.string()).optional(),
|
||||||
cleanCache: z.boolean().optional(),
|
cleanCache: z.boolean().optional(),
|
||||||
stopGracePeriodSwarm: z.number().nullable(),
|
stopGracePeriodSwarm: z.number().nullable(),
|
||||||
endpointSpecSwarm: EndpointSpecSwarmSchema.nullable(),
|
endpointSpecSwarm: EndpointSpecSwarmSchema.nullable(),
|
||||||
|
|||||||
@@ -113,6 +113,7 @@ export const compose = pgTable("compose", {
|
|||||||
serverId: text("serverId").references(() => server.serverId, {
|
serverId: text("serverId").references(() => server.serverId, {
|
||||||
onDelete: "cascade",
|
onDelete: "cascade",
|
||||||
}),
|
}),
|
||||||
|
networkIds: text("networkIds").array().default([]),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const composeRelations = relations(compose, ({ one, many }) => ({
|
export const composeRelations = relations(compose, ({ one, many }) => ({
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ export * from "./libsql";
|
|||||||
export * from "./mariadb";
|
export * from "./mariadb";
|
||||||
export * from "./mongo";
|
export * from "./mongo";
|
||||||
export * from "./mount";
|
export * from "./mount";
|
||||||
|
export * from "./network";
|
||||||
export * from "./mysql";
|
export * from "./mysql";
|
||||||
export * from "./notification";
|
export * from "./notification";
|
||||||
export * from "./patch";
|
export * from "./patch";
|
||||||
|
|||||||
@@ -88,6 +88,7 @@ export const mariadb = pgTable("mariadb", {
|
|||||||
serverId: text("serverId").references(() => server.serverId, {
|
serverId: text("serverId").references(() => server.serverId, {
|
||||||
onDelete: "cascade",
|
onDelete: "cascade",
|
||||||
}),
|
}),
|
||||||
|
networkIds: text("networkIds").array().default([]),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const mariadbRelations = relations(mariadb, ({ one, many }) => ({
|
export const mariadbRelations = relations(mariadb, ({ one, many }) => ({
|
||||||
|
|||||||
@@ -92,6 +92,7 @@ export const mongo = pgTable("mongo", {
|
|||||||
onDelete: "cascade",
|
onDelete: "cascade",
|
||||||
}),
|
}),
|
||||||
replicaSets: boolean("replicaSets").default(false),
|
replicaSets: boolean("replicaSets").default(false),
|
||||||
|
networkIds: text("networkIds").array().default([]),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const mongoRelations = relations(mongo, ({ one, many }) => ({
|
export const mongoRelations = relations(mongo, ({ one, many }) => ({
|
||||||
|
|||||||
@@ -86,6 +86,7 @@ export const mysql = pgTable("mysql", {
|
|||||||
serverId: text("serverId").references(() => server.serverId, {
|
serverId: text("serverId").references(() => server.serverId, {
|
||||||
onDelete: "cascade",
|
onDelete: "cascade",
|
||||||
}),
|
}),
|
||||||
|
networkIds: text("networkIds").array().default([]),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const mysqlRelations = relations(mysql, ({ one, many }) => ({
|
export const mysqlRelations = relations(mysql, ({ one, many }) => ({
|
||||||
|
|||||||
137
packages/server/src/db/schema/network.ts
Normal file
137
packages/server/src/db/schema/network.ts
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
import { relations } from "drizzle-orm";
|
||||||
|
import { boolean, jsonb, pgEnum, pgTable, text } from "drizzle-orm/pg-core";
|
||||||
|
import { createInsertSchema } from "drizzle-zod";
|
||||||
|
import { nanoid } from "nanoid";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { organization } from "./account";
|
||||||
|
import { server } from "./server";
|
||||||
|
|
||||||
|
/** Docker network driver types */
|
||||||
|
export const networkDriver = pgEnum("networkDriver", [
|
||||||
|
"bridge",
|
||||||
|
"host",
|
||||||
|
"overlay",
|
||||||
|
"macvlan",
|
||||||
|
"none",
|
||||||
|
"ipvlan",
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const network = pgTable("network", {
|
||||||
|
networkId: text("networkId")
|
||||||
|
.notNull()
|
||||||
|
.primaryKey()
|
||||||
|
.$defaultFn(() => nanoid()),
|
||||||
|
name: text("name").notNull(),
|
||||||
|
driver: networkDriver("driver").notNull().default("bridge"),
|
||||||
|
scope: text("scope"), // e.g. "local", "swarm"
|
||||||
|
internal: boolean("internal").notNull().default(false),
|
||||||
|
attachable: boolean("attachable").notNull().default(false),
|
||||||
|
ingress: boolean("ingress").notNull().default(false),
|
||||||
|
configOnly: boolean("configOnly").notNull().default(false),
|
||||||
|
enableIPv4: boolean("enableIPv4").notNull().default(true),
|
||||||
|
enableIPv6: boolean("enableIPv6").notNull().default(false),
|
||||||
|
ipam: jsonb("ipam")
|
||||||
|
.$type<{
|
||||||
|
driver?: string;
|
||||||
|
config?: Array<{ subnet?: string; gateway?: string; ipRange?: string }>;
|
||||||
|
}>()
|
||||||
|
.default({}),
|
||||||
|
createdAt: text("createdAt")
|
||||||
|
.notNull()
|
||||||
|
.$defaultFn(() => new Date().toISOString()),
|
||||||
|
organizationId: text("organizationId")
|
||||||
|
.notNull()
|
||||||
|
.references(() => organization.id, { onDelete: "cascade" }),
|
||||||
|
serverId: text("serverId").references(() => server.serverId, {
|
||||||
|
onDelete: "cascade",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const networkRelations = relations(network, ({ one }) => ({
|
||||||
|
organization: one(organization, {
|
||||||
|
fields: [network.organizationId],
|
||||||
|
references: [organization.id],
|
||||||
|
}),
|
||||||
|
server: one(server, {
|
||||||
|
fields: [network.serverId],
|
||||||
|
references: [server.serverId],
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const createSchema = createInsertSchema(network, {
|
||||||
|
networkId: z.string().min(1),
|
||||||
|
name: z.string().min(1),
|
||||||
|
driver: z
|
||||||
|
.enum(["bridge", "host", "overlay", "macvlan", "none", "ipvlan"])
|
||||||
|
.optional(),
|
||||||
|
scope: z.string().optional(),
|
||||||
|
internal: z.boolean().optional(),
|
||||||
|
attachable: z.boolean().optional(),
|
||||||
|
ingress: z.boolean().optional(),
|
||||||
|
configOnly: z.boolean().optional(),
|
||||||
|
enableIPv4: z.boolean().optional(),
|
||||||
|
enableIPv6: z.boolean().optional(),
|
||||||
|
ipam: z
|
||||||
|
.object({
|
||||||
|
driver: z.string().optional(),
|
||||||
|
config: z
|
||||||
|
.array(
|
||||||
|
z.object({
|
||||||
|
subnet: z.string().optional(),
|
||||||
|
gateway: z.string().optional(),
|
||||||
|
ipRange: z.string().optional(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.optional(),
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
|
organizationId: z.string().min(1),
|
||||||
|
serverId: z.string().optional().nullable(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const apiCreateNetwork = createSchema
|
||||||
|
.pick({
|
||||||
|
name: true,
|
||||||
|
driver: true,
|
||||||
|
scope: true,
|
||||||
|
internal: true,
|
||||||
|
attachable: true,
|
||||||
|
ingress: true,
|
||||||
|
configOnly: true,
|
||||||
|
enableIPv4: true,
|
||||||
|
enableIPv6: true,
|
||||||
|
ipam: true,
|
||||||
|
serverId: true,
|
||||||
|
})
|
||||||
|
.partial()
|
||||||
|
.required({ name: true });
|
||||||
|
|
||||||
|
export const apiFindOneNetwork = createSchema
|
||||||
|
.pick({
|
||||||
|
networkId: true,
|
||||||
|
})
|
||||||
|
.required();
|
||||||
|
|
||||||
|
export const apiRemoveNetwork = createSchema
|
||||||
|
.pick({
|
||||||
|
networkId: true,
|
||||||
|
})
|
||||||
|
.required();
|
||||||
|
|
||||||
|
export const apiUpdateNetwork = createSchema
|
||||||
|
.pick({
|
||||||
|
networkId: true,
|
||||||
|
name: true,
|
||||||
|
driver: true,
|
||||||
|
scope: true,
|
||||||
|
internal: true,
|
||||||
|
attachable: true,
|
||||||
|
ingress: true,
|
||||||
|
configOnly: true,
|
||||||
|
enableIPv4: true,
|
||||||
|
enableIPv6: true,
|
||||||
|
ipam: true,
|
||||||
|
serverId: true,
|
||||||
|
})
|
||||||
|
.partial()
|
||||||
|
.required({ networkId: true });
|
||||||
@@ -86,6 +86,7 @@ export const postgres = pgTable("postgres", {
|
|||||||
serverId: text("serverId").references(() => server.serverId, {
|
serverId: text("serverId").references(() => server.serverId, {
|
||||||
onDelete: "cascade",
|
onDelete: "cascade",
|
||||||
}),
|
}),
|
||||||
|
networkIds: text("networkIds").array().default([]),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const postgresRelations = relations(postgres, ({ one, many }) => ({
|
export const postgresRelations = relations(postgres, ({ one, many }) => ({
|
||||||
|
|||||||
@@ -80,6 +80,7 @@ export const redis = pgTable("redis", {
|
|||||||
serverId: text("serverId").references(() => server.serverId, {
|
serverId: text("serverId").references(() => server.serverId, {
|
||||||
onDelete: "cascade",
|
onDelete: "cascade",
|
||||||
}),
|
}),
|
||||||
|
networkIds: text("networkIds").array().default([]),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const redisRelations = relations(redis, ({ one, many }) => ({
|
export const redisRelations = relations(redis, ({ one, many }) => ({
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import { libsql } from "./libsql";
|
|||||||
import { mariadb } from "./mariadb";
|
import { mariadb } from "./mariadb";
|
||||||
import { mongo } from "./mongo";
|
import { mongo } from "./mongo";
|
||||||
import { mysql } from "./mysql";
|
import { mysql } from "./mysql";
|
||||||
|
import { network } from "./network";
|
||||||
import { postgres } from "./postgres";
|
import { postgres } from "./postgres";
|
||||||
import { redis } from "./redis";
|
import { redis } from "./redis";
|
||||||
import { schedules } from "./schedule";
|
import { schedules } from "./schedule";
|
||||||
@@ -125,6 +126,7 @@ export const serverRelations = relations(server, ({ one, many }) => ({
|
|||||||
mysql: many(mysql),
|
mysql: many(mysql),
|
||||||
postgres: many(postgres),
|
postgres: many(postgres),
|
||||||
certificates: many(certificates),
|
certificates: many(certificates),
|
||||||
|
networks: many(network),
|
||||||
organization: one(organization, {
|
organization: one(organization, {
|
||||||
fields: [server.organizationId],
|
fields: [server.organizationId],
|
||||||
references: [organization.id],
|
references: [organization.id],
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ export * from "./services/mariadb";
|
|||||||
export * from "./services/mongo";
|
export * from "./services/mongo";
|
||||||
export * from "./services/mount";
|
export * from "./services/mount";
|
||||||
export * from "./services/mysql";
|
export * from "./services/mysql";
|
||||||
|
export * from "./services/network";
|
||||||
export * from "./services/notification";
|
export * from "./services/notification";
|
||||||
export * from "./services/patch";
|
export * from "./services/patch";
|
||||||
export * from "./services/patch-repo";
|
export * from "./services/patch-repo";
|
||||||
|
|||||||
@@ -419,7 +419,7 @@ const createBetterAuth = () =>
|
|||||||
enableMetadata: true,
|
enableMetadata: true,
|
||||||
references: "user",
|
references: "user",
|
||||||
}),
|
}),
|
||||||
sso(),
|
sso({ trustEmailVerified: true }),
|
||||||
scim({
|
scim({
|
||||||
beforeSCIMTokenGenerated: async ({ user }) => {
|
beforeSCIMTokenGenerated: async ({ user }) => {
|
||||||
const dbUser = await db.query.user.findFirst({
|
const dbUser = await db.query.user.findFirst({
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { db } from "@dokploy/server/db";
|
import { db } from "@dokploy/server/db";
|
||||||
import { ai } from "@dokploy/server/db/schema";
|
import { ai, organization } from "@dokploy/server/db/schema";
|
||||||
|
import { aiCustomProviderSchema } from "@dokploy/server/db/schema/ai";
|
||||||
import { selectAIProvider } from "@dokploy/server/utils/ai/select-ai-provider";
|
import { selectAIProvider } from "@dokploy/server/utils/ai/select-ai-provider";
|
||||||
import { TRPCError } from "@trpc/server";
|
import { TRPCError } from "@trpc/server";
|
||||||
import { generateText, Output } from "ai";
|
import { generateText, Output } from "ai";
|
||||||
@@ -48,9 +49,74 @@ export const getAiSettingById = async (aiId: string) => {
|
|||||||
return aiSetting;
|
return aiSetting;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type AiCustomProvider = z.infer<typeof aiCustomProviderSchema>;
|
||||||
|
|
||||||
|
const parseOrgMetadata = (metadata: string | null) => {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(metadata || "{}");
|
||||||
|
return typeof parsed === "object" && parsed !== null
|
||||||
|
? (parsed as Record<string, unknown>)
|
||||||
|
: {};
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getCustomAiProviders = async (organizationId: string) => {
|
||||||
|
const org = await db.query.organization.findFirst({
|
||||||
|
where: eq(organization.id, organizationId),
|
||||||
|
});
|
||||||
|
const metadata = parseOrgMetadata(org?.metadata ?? null);
|
||||||
|
const result = z
|
||||||
|
.array(aiCustomProviderSchema)
|
||||||
|
.safeParse(metadata.aiProviders);
|
||||||
|
return result.success ? result.data : [];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const saveCustomAiProviders = async (
|
||||||
|
organizationId: string,
|
||||||
|
providers: AiCustomProvider[],
|
||||||
|
) => {
|
||||||
|
const org = await db.query.organization.findFirst({
|
||||||
|
where: eq(organization.id, organizationId),
|
||||||
|
});
|
||||||
|
if (!org) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "NOT_FOUND",
|
||||||
|
message: "Organization not found",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const metadata = parseOrgMetadata(org.metadata);
|
||||||
|
metadata.aiProviders = providers;
|
||||||
|
await db
|
||||||
|
.update(organization)
|
||||||
|
.set({ metadata: JSON.stringify(metadata) })
|
||||||
|
.where(eq(organization.id, organizationId));
|
||||||
|
return providers;
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeApiUrl = (url: string) => url.trim().replace(/\/+$/, "");
|
||||||
|
|
||||||
export const saveAiSettings = async (organizationId: string, settings: any) => {
|
export const saveAiSettings = async (organizationId: string, settings: any) => {
|
||||||
const aiId = settings.aiId;
|
const aiId = settings.aiId;
|
||||||
|
|
||||||
|
if (settings.apiUrl) {
|
||||||
|
const customProviders = await getCustomAiProviders(organizationId);
|
||||||
|
if (customProviders.length > 0) {
|
||||||
|
const isAllowed = customProviders.some(
|
||||||
|
(provider) =>
|
||||||
|
normalizeApiUrl(provider.apiUrl) === normalizeApiUrl(settings.apiUrl),
|
||||||
|
);
|
||||||
|
if (!isAllowed) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "FORBIDDEN",
|
||||||
|
message:
|
||||||
|
"This API URL is not in your organization's allowed AI providers",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return db
|
return db
|
||||||
.insert(ai)
|
.insert(ai)
|
||||||
.values({
|
.values({
|
||||||
|
|||||||
124
packages/server/src/services/network.ts
Normal file
124
packages/server/src/services/network.ts
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
import { db } from "@dokploy/server/db";
|
||||||
|
import {
|
||||||
|
type apiCreateNetwork,
|
||||||
|
type apiUpdateNetwork,
|
||||||
|
network,
|
||||||
|
} from "@dokploy/server/db/schema";
|
||||||
|
import { TRPCError } from "@trpc/server";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import type { z } from "zod";
|
||||||
|
import { IS_CLOUD } from "../constants";
|
||||||
|
import { getRemoteDocker } from "../utils/servers/remote-docker";
|
||||||
|
|
||||||
|
export const findNetworkById = async (networkId: string) => {
|
||||||
|
const [row] = await db
|
||||||
|
.select()
|
||||||
|
.from(network)
|
||||||
|
.where(eq(network.networkId, networkId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!row) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "NOT_FOUND",
|
||||||
|
message: "Network not found",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return row;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createNetwork = async (
|
||||||
|
input: z.infer<typeof apiCreateNetwork>,
|
||||||
|
organizationId: string,
|
||||||
|
) => {
|
||||||
|
if (IS_CLOUD) {
|
||||||
|
if (!input.serverId) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "BAD_REQUEST",
|
||||||
|
message: "Server is required",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const created = await db.transaction(async (tx) => {
|
||||||
|
const [row] = await tx
|
||||||
|
.insert(network)
|
||||||
|
.values({
|
||||||
|
...input,
|
||||||
|
organizationId,
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
if (!row) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "INTERNAL_SERVER_ERROR",
|
||||||
|
message: "Failed to create network",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const ipam = row.ipam ?? {};
|
||||||
|
const ipamConfig = (ipam.config ?? [])
|
||||||
|
.map((c) => {
|
||||||
|
const entry: Record<string, string> = {};
|
||||||
|
if (c.subnet) entry.Subnet = c.subnet;
|
||||||
|
if (c.gateway) entry.Gateway = c.gateway;
|
||||||
|
if (c.ipRange) entry.IPRange = c.ipRange;
|
||||||
|
return entry;
|
||||||
|
})
|
||||||
|
.filter((e) => Object.keys(e).length > 0);
|
||||||
|
|
||||||
|
const docker = await getRemoteDocker(input.serverId ?? null);
|
||||||
|
await docker.createNetwork({
|
||||||
|
Name: row.name,
|
||||||
|
Driver: row.driver,
|
||||||
|
Internal: row.internal,
|
||||||
|
Attachable: row.attachable,
|
||||||
|
Ingress: row.ingress,
|
||||||
|
EnableIPv6: row.enableIPv6,
|
||||||
|
IPAM: {
|
||||||
|
Driver: ipam.driver ?? "default",
|
||||||
|
Config: ipamConfig.length > 0 ? ipamConfig : undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return row;
|
||||||
|
});
|
||||||
|
|
||||||
|
return created;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updateNetwork = async (
|
||||||
|
input: z.infer<typeof apiUpdateNetwork>,
|
||||||
|
) => {
|
||||||
|
const { networkId, ...rest } = input;
|
||||||
|
const [updated] = await db
|
||||||
|
.update(network)
|
||||||
|
.set(rest)
|
||||||
|
.where(eq(network.networkId, networkId))
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
if (!updated) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "NOT_FOUND",
|
||||||
|
message: "Network not found",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return updated;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const removeNetwork = async (networkId: string) => {
|
||||||
|
const [deleted] = await db
|
||||||
|
.delete(network)
|
||||||
|
.where(eq(network.networkId, networkId))
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
if (!deleted) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "NOT_FOUND",
|
||||||
|
message: "Network not found",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return deleted;
|
||||||
|
};
|
||||||
@@ -430,7 +430,7 @@ export const createOrganizationUserWithCredentials = async ({
|
|||||||
.insert(user)
|
.insert(user)
|
||||||
.values({
|
.values({
|
||||||
email: normalizedEmail,
|
email: normalizedEmail,
|
||||||
emailVerified: false,
|
emailVerified: true,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
})
|
})
|
||||||
.returning({
|
.returning({
|
||||||
|
|||||||
Reference in New Issue
Block a user