diff --git a/apps/dokploy/components/dashboard/networks/handle-network.tsx b/apps/dokploy/components/dashboard/networks/handle-network.tsx index 8c006cfdb..8f522c40d 100644 --- a/apps/dokploy/components/dashboard/networks/handle-network.tsx +++ b/apps/dokploy/components/dashboard/networks/handle-network.tsx @@ -40,9 +40,6 @@ import { api } from "@/utils/api"; // singletons and macvlan/ipvlan need driver options we don't expose. const networkDriverEnum = ["bridge", "overlay"] as const; -/** 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(), @@ -53,7 +50,6 @@ const networkFormSchema = z .object({ name: z.string().min(1, "Name is required"), driver: z.enum(networkDriverEnum), - serverId: z.string().optional(), internal: z.boolean(), attachable: z.boolean(), enableIPv4: z.boolean(), @@ -85,7 +81,6 @@ type NetworkFormValues = z.infer; const defaultValues: NetworkFormValues = { name: "", driver: "bridge", - serverId: undefined, internal: false, attachable: false, enableIPv4: true, @@ -119,17 +114,17 @@ const toggleOptions = [ ] as const; interface HandleNetworkProps { + /** Target server; undefined creates on the local Dokploy server */ + serverId?: string; children?: React.ReactNode; } // Docker networks are immutable, so this dialog only creates them; // changing a network means deleting and recreating it. -export const HandleNetwork = ({ children }: HandleNetworkProps) => { +export const HandleNetwork = ({ serverId, children }: HandleNetworkProps) => { const [isOpen, setIsOpen] = useState(false); - const { data: isCloud } = api.settings.isCloud.useQuery(); const utils = api.useUtils(); - const { data: servers } = api.server.all.useQuery(); const { mutateAsync, isPending } = api.network.create.useMutation(); const form = useForm({ @@ -147,7 +142,7 @@ export const HandleNetwork = ({ children }: HandleNetworkProps) => { await mutateAsync({ name: data.name, driver: data.driver, - serverId: data.serverId || undefined, + serverId, internal: data.internal, attachable: data.attachable, enableIPv4: data.enableIPv4, @@ -238,48 +233,6 @@ export const HandleNetwork = ({ children }: HandleNetworkProps) => { )} /> - ( - - Server - - - {isCloud - ? "Server where this network will be created." - : "Dokploy server is the default local server."} - - - - )} - />
{toggleOptions.map((option) => ( { +interface Props { + /** Selected server; undefined shows the local Dokploy server */ + serverId?: string; +} + +export const ShowNetworks = ({ serverId }: Props) => { const utils = api.useUtils(); - const { data: networks, isLoading } = api.network.all.useQuery(); + const { data: networks, isLoading } = api.network.all.useQuery({ serverId }); const { mutateAsync: removeNetwork } = api.network.remove.useMutation(); return ( @@ -40,14 +46,16 @@ export const ShowNetworks = () => { Networks - Manage Docker networks for your organization. Networks can be - scoped to a server (optional). + Manage the Docker networks of the selected server. - {networks && networks.length > 0 && ( - - - - )} + +
+ + {networks && networks.length > 0 && ( + + )} +
+
@@ -69,7 +77,7 @@ export const ShowNetworks = () => { started.

- + ) : (
@@ -78,9 +86,9 @@ export const ShowNetworks = () => { Name Driver + Subnet Internal Attachable - Server Created Actions @@ -88,67 +96,99 @@ export const ShowNetworks = () => { - {networks.map((n) => ( - - {n.name} - -
- {n.driver} - - {n.driver === "overlay" ? "swarm" : "local"} - -
-
- - {n.internal ? "Yes" : "No"} - - - {n.attachable ? "Yes" : "No"} - - - {n.server?.name ?? "Dokploy Server"} - - - {new Date(n.createdAt).toLocaleDateString()} - - -
- - { - try { - await removeNetwork({ - networkId: n.networkId, - }); - toast.success("Network deleted"); - await utils.network.all.invalidate(); - } catch (error) { - toast.error("Error deleting network", { - description: - error instanceof Error - ? error.message - : "Unknown error", - }); - } - }} - > - - -
-
-
- ))} + + +
+ + + ); + })} diff --git a/apps/dokploy/components/dashboard/networks/sync-networks.tsx b/apps/dokploy/components/dashboard/networks/sync-networks.tsx new file mode 100644 index 000000000..4b031d70b --- /dev/null +++ b/apps/dokploy/components/dashboard/networks/sync-networks.tsx @@ -0,0 +1,225 @@ +"use client"; + +import { Loader2, RefreshCw, Trash2 } from "lucide-react"; +import { useState } from "react"; +import { toast } from "sonner"; +import { AlertBlock } from "@/components/shared/alert-block"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { Separator } from "@/components/ui/separator"; +import { api } from "@/utils/api"; + +interface Props { + /** Target server; undefined syncs the local Dokploy server */ + serverId?: string; +} + +// Compares the Docker daemon's networks against Dokploy's records: +// networks that only exist in Docker can be imported, and records whose +// network no longer exists in Docker can be cleaned up. +export const SyncNetworks = ({ serverId }: Props) => { + const [open, setOpen] = useState(false); + const [selected, setSelected] = useState>(new Set()); + const utils = api.useUtils(); + + const { data, isLoading, error, refetch } = + api.network.networksToSync.useQuery({ serverId }, { enabled: open }); + + const importMutation = api.network.import.useMutation(); + const removeMutation = api.network.remove.useMutation(); + + const toggleSelected = (name: string) => { + setSelected((prev) => { + const next = new Set(prev); + if (next.has(name)) { + next.delete(name); + } else { + next.add(name); + } + return next; + }); + }; + + const onImport = async () => { + try { + const result = await importMutation.mutateAsync({ + serverId, + names: Array.from(selected), + }); + + if (result.imported.length > 0) { + toast.success(`Imported ${result.imported.length} network(s)`); + } + for (const failure of result.errors) { + toast.error(`Could not import "${failure.name}"`, { + description: failure.error, + }); + } + + setSelected(new Set()); + await utils.network.all.invalidate(); + + setOpen(false); + await refetch(); + } catch (error) { + toast.error("Error importing networks", { + description: error instanceof Error ? error.message : "Unknown error", + }); + } + }; + + const onRemoveStale = async (networkId: string, name: string) => { + try { + await removeMutation.mutateAsync({ networkId }); + toast.success(`Removed stale record "${name}"`); + await utils.network.all.invalidate(); + await refetch(); + } catch (error) { + toast.error("Error removing record", { + description: error instanceof Error ? error.message : "Unknown error", + }); + } + }; + + return ( + { + setOpen(value); + if (!value) setSelected(new Set()); + }} + > + + + + + + + + Sync networks + + + Import networks that exist in Docker but not in Dokploy, and clean + up records whose network no longer exists. + + + + {error ? ( + {error.message} + ) : isLoading ? ( +
+ Scanning Docker networks... + +
+ ) : ( +
+
+ + Found in Docker ({data?.importable.length ?? 0}) + + {data?.importable.length ? ( + data.importable.map((dockerNetwork) => ( + + )) + ) : ( + + Nothing to import — everything is in sync. + + )} +
+ + {!!data?.missing.length && ( + <> + +
+ + Missing in Docker ({data.missing.length}) + + + These records exist in Dokploy but their network is gone + from Docker. + + {data.missing.map((stale) => ( +
+ {stale.name} + +
+ ))} +
+ + )} +
+ )} + + + + + +
+
+ ); +}; diff --git a/apps/dokploy/pages/dashboard/networks.tsx b/apps/dokploy/pages/dashboard/networks.tsx index d9d65cd8e..cf11fcd3d 100644 --- a/apps/dokploy/pages/dashboard/networks.tsx +++ b/apps/dokploy/pages/dashboard/networks.tsx @@ -6,10 +6,15 @@ 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 { ServerFilter } from "@/components/shared/server-filter"; import { appRouter } from "@/server/api/root"; const Dashboard = () => { - return ; + return ( + + {(serverId) => } + + ); }; export default Dashboard; @@ -69,7 +74,7 @@ export async function getServerSideProps( } } - await helpers.network.all.prefetch(); + await helpers.network.all.prefetch({}); return { props: { diff --git a/apps/dokploy/server/api/routers/network.ts b/apps/dokploy/server/api/routers/network.ts index ac8a71310..ca1260265 100644 --- a/apps/dokploy/server/api/routers/network.ts +++ b/apps/dokploy/server/api/routers/network.ts @@ -1,11 +1,14 @@ import { createNetwork, findNetworkById, + findNetworksToSync, + importDockerNetworks, inspectNetwork, removeNetwork, } from "@dokploy/server"; import { TRPCError } from "@trpc/server"; -import { desc, eq } from "drizzle-orm"; +import { and, desc, eq, isNull } from "drizzle-orm"; +import { z } from "zod"; import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc"; import { db } from "@/server/db"; import { @@ -16,21 +19,20 @@ import { } 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; - }), + all: protectedProcedure + .input(z.object({ serverId: z.string().optional() })) + .query(async ({ ctx, input }) => { + const rows = await db.query.network.findMany({ + where: and( + eq(networkTable.organizationId, ctx.session.activeOrganizationId), + input.serverId + ? eq(networkTable.serverId, input.serverId) + : isNull(networkTable.serverId), + ), + orderBy: desc(networkTable.createdAt), + }); + return rows; + }), one: protectedProcedure .input(apiFindOneNetwork) @@ -49,6 +51,30 @@ export const networkRouter = createTRPCRouter({ .mutation(async ({ ctx, input }) => { return createNetwork(input, ctx.session.activeOrganizationId); }), + networksToSync: protectedProcedure + .input(z.object({ serverId: z.string().optional() })) + .query(async ({ ctx, input }) => { + return findNetworksToSync( + ctx.session.activeOrganizationId, + input.serverId ?? null, + ); + }), + + import: protectedProcedure + .input( + z.object({ + serverId: z.string().optional(), + names: z.array(z.string().min(1)).min(1), + }), + ) + .mutation(async ({ ctx, input }) => { + return importDockerNetworks( + ctx.session.activeOrganizationId, + input.serverId ?? null, + input.names, + ); + }), + inspect: protectedProcedure .input(apiFindOneNetwork) .query(async ({ ctx, input }) => { diff --git a/packages/server/src/services/network.ts b/packages/server/src/services/network.ts index f01a3044f..1c21598ad 100644 --- a/packages/server/src/services/network.ts +++ b/packages/server/src/services/network.ts @@ -1,11 +1,183 @@ import { db } from "@dokploy/server/db"; import { type apiCreateNetwork, network } from "@dokploy/server/db/schema"; import { TRPCError } from "@trpc/server"; -import { eq } from "drizzle-orm"; +import { and, eq, isNull } from "drizzle-orm"; import type { z } from "zod"; import { IS_CLOUD } from "../constants"; import { getRemoteDocker } from "../utils/servers/remote-docker"; +// Networks managed by Docker/Dokploy itself that must never be imported +// or deleted through the networks UI +const RESERVED_NETWORKS = [ + "bridge", + "host", + "none", + "ingress", + "docker_gwbridge", + "dokploy-network", +]; + +type DockerNetworkInfo = { + Name: string; + Driver: string; + Internal?: boolean; + Attachable?: boolean; + Ingress?: boolean; + EnableIPv4?: boolean; + EnableIPv6?: boolean; + IPAM?: { + Driver?: string; + Config?: Array<{ + Subnet?: string; + Gateway?: string; + IPRange?: string; + }> | null; + }; +}; + +const isImportableDockerNetwork = (dockerNetwork: DockerNetworkInfo) => + !RESERVED_NETWORKS.includes(dockerNetwork.Name) && + !dockerNetwork.Ingress && + (dockerNetwork.Driver === "bridge" || dockerNetwork.Driver === "overlay"); + +const mapDockerNetworkToRow = ( + dockerNetwork: DockerNetworkInfo, + organizationId: string, + serverId: string | null, +) => ({ + name: dockerNetwork.Name, + driver: dockerNetwork.Driver as "bridge" | "overlay", + internal: dockerNetwork.Internal ?? false, + attachable: dockerNetwork.Attachable ?? false, + // Older daemons don't report EnableIPv4; IPv4 is always on there + enableIPv4: dockerNetwork.EnableIPv4 ?? true, + enableIPv6: dockerNetwork.EnableIPv6 ?? false, + ipam: { + driver: dockerNetwork.IPAM?.Driver, + config: (dockerNetwork.IPAM?.Config ?? []).map((c) => ({ + subnet: c.Subnet, + gateway: c.Gateway, + ipRange: c.IPRange, + })), + }, + organizationId, + serverId, +}); + +const findNetworksByServer = async ( + organizationId: string, + serverId: string | null, +) => { + return await db.query.network.findMany({ + where: and( + eq(network.organizationId, organizationId), + serverId ? eq(network.serverId, serverId) : isNull(network.serverId), + ), + }); +}; + +export const findNetworksToSync = async ( + organizationId: string, + serverId: string | null, +) => { + if (IS_CLOUD && !serverId) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Server is required", + }); + } + + const docker = await getRemoteDocker(serverId); + let dockerNetworks: DockerNetworkInfo[] = []; + try { + dockerNetworks = (await docker.listNetworks()) as DockerNetworkInfo[]; + } catch (error) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: + error instanceof Error + ? error.message + : "Failed to list Docker networks", + cause: error, + }); + } + + const existing = await findNetworksByServer(organizationId, serverId); + const existingNames = new Set(existing.map((row) => row.name)); + const dockerNames = new Set(dockerNetworks.map((d) => d.Name)); + + const importable = dockerNetworks + .filter( + (dockerNetwork) => + isImportableDockerNetwork(dockerNetwork) && + !existingNames.has(dockerNetwork.Name), + ) + .map((dockerNetwork) => ({ + name: dockerNetwork.Name, + driver: dockerNetwork.Driver, + internal: dockerNetwork.Internal ?? false, + attachable: dockerNetwork.Attachable ?? false, + subnets: (dockerNetwork.IPAM?.Config ?? []) + .map((c) => c.Subnet) + .filter((s): s is string => !!s), + })); + + // Rows in Dokploy whose network no longer exists in Docker + const missing = existing + .filter((row) => !dockerNames.has(row.name)) + .map((row) => ({ networkId: row.networkId, name: row.name })); + + return { importable, missing }; +}; + +export const importDockerNetworks = async ( + organizationId: string, + serverId: string | null, + names: string[], +) => { + if (IS_CLOUD && !serverId) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Server is required", + }); + } + + const docker = await getRemoteDocker(serverId); + const existing = await findNetworksByServer(organizationId, serverId); + const existingNames = new Set(existing.map((row) => row.name)); + + const imported: string[] = []; + const errors: { name: string; error: string }[] = []; + + for (const name of names) { + if (existingNames.has(name)) { + errors.push({ name, error: "Already imported" }); + continue; + } + try { + const info = (await docker + .getNetwork(name) + .inspect()) as DockerNetworkInfo; + if (!isImportableDockerNetwork(info)) { + errors.push({ name, error: "Network is reserved or not supported" }); + continue; + } + await db + .insert(network) + .values(mapDockerNetworkToRow(info, organizationId, serverId)); + imported.push(name); + } catch (error) { + errors.push({ + name, + error: + error instanceof Error ? error.message : "Failed to inspect network", + }); + } + } + + return { imported, errors }; +}; + export const findNetworkById = async (networkId: string) => { const [row] = await db .select()