Compare commits

..

27 Commits

Author SHA1 Message Date
autofix-ci[bot]
61c2b73f08 [autofix.ci] apply automated fixes 2026-07-22 03:47:06 +00:00
Mauricio Siu
736a77e112 refactor(networks): update network form schema and enhance handling logic
- Replaced zodResolver with standardSchemaResolver for improved schema validation.
- Added new toggle options for network settings, enhancing user interface clarity.
- Refactored network creation and update logic to streamline payload handling.
- Updated network form schema to remove default values, ensuring explicit user input.
- Introduced SERVER_LOCAL sentinel for local Dokploy server identification.
2026-07-21 21:46:40 -06:00
Mauricio Siu
f4bcc2e8a8 refactor(networks): enhance network display and management interface
- Updated the ShowNetworks component to improve the layout and user experience.
- Introduced a new Pencil icon for editing networks and added a Badge component for displaying network drivers.
- Refactored loading and empty state handling for better visual feedback.
- Modified the API query to include server details for each network, enhancing the data returned for better context.
2026-07-21 19:10:51 -06:00
Mauricio Siu
99e483c1a1 feat(network): add network management schema and update related tables
- Introduced a new SQL file defining the "network" type and its associated table structure.
- Added "networkIds" column to multiple application tables for network association.
- Established foreign key constraints for the "network" table linking to "organization" and "server" tables.
- Updated migration journal and added a new snapshot for versioning.
2026-07-21 19:02:51 -06:00
Mauricio Siu
22c8a1658a Merge branch 'canary' into feat/add-network-management 2026-07-21 19:01:28 -06:00
Mauricio Siu
f0f0db4c7f refactor: remove deprecated network management files and update .gitignore
- Deleted the SQL file and snapshot related to the "network" management schema, cleaning up the database structure.
- Updated the migration journal to reflect these deletions.
- Added .playwright-* to .gitignore to exclude Playwright test files from version control.
2026-07-21 19:01:11 -06:00
Mauricio Siu
8b868c66d6 Merge pull request #4882 from Dokploy/feat/custom-ai-providers
feat(ai): allow organizations to define custom AI provider presets
2026-07-21 03:23:32 -06:00
Mauricio Siu
366e44b75a fix(ai): only show Custom Presets button to owner/admin roles 2026-07-21 03:22:48 -06:00
Mauricio Siu
cb2db0d30a Bump version from v0.29.12 to v0.29.13 2026-07-21 03:22:19 -06:00
Mauricio Siu
7ba3853bab feat(ai): allow organizations to define custom AI provider presets
Organization admins can define their own AI providers (name + API URL)
from the AI settings section. When at least one custom provider is
defined, it replaces the built-in provider list in the Add AI form,
the API URL is auto-filled and locked, and the backend rejects any
configuration whose URL is not in the allowed list.
2026-07-21 03:19:06 -06:00
Mauricio Siu
8def9e933e Merge pull request #4880 from Dokploy/fix/sso-initial-credentials-linking
fix(auth): enable email verification for SSO and user creation
2026-07-21 02:50:51 -06:00
Mauricio Siu
e9b51667e2 fix(auth): enable email verification for SSO and user creation
Updated the SSO configuration to trust verified emails and modified user creation to set emailVerified to true by default. This enhances security and ensures that user email verification is properly handled.
2026-07-21 02:50:22 -06:00
Mauricio Siu
25370cac30 Merge pull request #4847 from ANSUJKMEHER/fix-4666-action-terminology
fix: rename compose "Reload" action to "Rebuild"
2026-07-21 00:52:29 -06:00
Mauricio Siu
52c7db1f66 Merge pull request #4877 from Dokploy/fix/2fa-invalid-code-error-message
fix(2fa): show correct error message for invalid TOTP code
2026-07-21 00:52:04 -06:00
Mauricio Siu
6d65a36aac fix(2fa): show correct error for invalid TOTP code
The verify-totp handler checked for the error code
INVALID_TWO_FACTOR_AUTHENTICATION, which no longer exists in
better-auth 1.6.23 (the two-factor plugin now returns INVALID_CODE).
As a result, entering a wrong TOTP code fell through to the generic
catch and showed "Error verifying 2FA code / Unknown error" instead of
a clear "Invalid verification code" message.

Match the current better-auth error code so the specific, actionable
message is shown.
2026-07-20 18:20:56 -06:00
Mauricio Siu
cbec72ed80 Merge pull request #4876 from Dokploy/fix/collapsed-sidebar-org-menu-width
fix(ui): organization menu clipped when sidebar is collapsed
2026-07-20 18:04:24 -06:00
Ansuj Kumar Meher
15fe3b21c9 fix: rename compose "Reload" action to "Rebuild"
Renames the compose "Reload" action to "Rebuild" to accurately reflect its actual behavior. Clicking "Reload" triggers rebuildCompose() which executes a full Docker build (docker compose up -d --build). Also updates dialog text, toast notifications, and tooltips accordingly.

Fixes #4666
2026-07-18 21:04:59 +05:30
Mauricio Siu
5f508163e5 feat: add network management schema and update related tables
- Introduced a new ENUM type "networkDriver" and created the "network" table with various attributes for network management.
- Added "networkIds" column to multiple existing tables (application, compose, mariadb, mongo, mysql, postgres, redis) to support network associations.
- Established foreign key constraints for "organizationId" and "serverId" in the "network" table to ensure referential integrity.
- Updated migration journal and added a new snapshot for version 0166 to reflect these changes.
2026-04-13 16:12:57 -06:00
Mauricio Siu
fc75b847b5 Merge branch 'canary' into feat/add-network-management 2026-04-13 15:59:23 -06:00
Mauricio Siu
b1abcb9e06 refactor: remove deprecated network schema and related snapshots
- Deleted the SQL file for the "network" table and its associated constraints, as well as the snapshot for version 0146, to clean up the database schema.
- Updated the migration journal to reflect these deletions, ensuring consistency in the database structure.
2026-04-13 15:58:21 -06:00
Mauricio Siu
d542972522 fix: enhance server validation in createNetwork function
- Updated the createNetwork function to ensure serverId is required when in cloud mode, improving error handling for network creation.
- Added braces for clarity in the conditional statement, enhancing code readability.
2026-02-22 01:58:28 -06:00
Mauricio Siu
e7c38d4c54 feat: create network table and update related schemas
- Added a new "network" table with various attributes including networkId, name, driver, and constraints for organizationId and serverId.
- Introduced a custom ENUM type "networkDriver" for network drivers.
- Updated existing tables (application, compose, mariadb, mongo, mysql, postgres, redis) to include a new "networkIds" column of type text[].
- Created a snapshot for version 7 to reflect these schema changes in the database.
2026-02-22 01:57:18 -06:00
autofix-ci[bot]
a8f941b5d9 [autofix.ci] apply automated fixes 2026-02-22 07:56:34 +00:00
Mauricio Siu
c68fac55fd refactor: remove deprecated network and snapshot SQL files
- Deleted the SQL files for the "network" table and its associated constraints, as well as the snapshots for versions 0146 and 0147, to clean up the database schema and remove unused components.
- Updated the migration journal to reflect these deletions, ensuring the database remains consistent and manageable.
2026-02-22 01:56:07 -06:00
Mauricio Siu
efcad7bbf5 feat: add networkIds column to multiple database tables
- Introduced a new column "networkIds" of type text[] with a default value of an empty array to the application, compose, mariadb, mongo, mysql, postgres, and redis tables.
- Updated the application and compose schemas to include the new networkIds field in their respective TypeScript definitions.
- Added corresponding entries in the migration journal and created a snapshot for version 7 to reflect these changes.
2026-02-22 01:53:36 -06:00
Mauricio Siu
a0566cdbd7 refactor: remove unused server value and update network handling logic
- Eliminated the unused DOKPLOY_SERVER_VALUE constant from the network handling component.
- Updated the default serverId to undefined in the network form values and adjusted related logic to ensure compatibility with cloud environments.
- Enhanced server validation in the createNetwork function to require a serverId when in cloud mode, improving error handling for network creation.
2026-02-21 14:59:50 -06:00
Mauricio Siu
69598821ed feat: implement Docker network management functionality
- Added components for handling and displaying Docker networks, including creation, editing, and listing of networks.
- Introduced a new API router for network operations, integrating with the database schema for network management.
- Updated the sidebar layout to include a link to the networks dashboard, ensuring user access to network features.
- Created necessary database migrations for the network table and its associated types.
- Enhanced the dashboard layout to support the new network management interface.
2026-02-21 14:53:27 -06:00
34 changed files with 10170 additions and 18 deletions

2
.gitignore vendored
View File

@@ -44,3 +44,5 @@ yarn-error.log*
.db .db
.playwright-*

View File

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

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

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

View File

@@ -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 ? (

View File

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

View File

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

View File

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

View File

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

View 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;

File diff suppressed because it is too large Load Diff

View File

@@ -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
} }
] ]
} }

View File

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

View 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: {},
};
}
}

View File

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

View File

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

View 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);
}),
});

View File

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

View File

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

View File

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

View File

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

View File

@@ -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";

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

@@ -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";

View File

@@ -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({

View File

@@ -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({

View 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;
};

View File

@@ -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({