From 8a376266369dcdf2e8473429a5464213ac6c7f08 Mon Sep 17 00:00:00 2001 From: Mauricio Siu Date: Tue, 21 Jul 2026 23:10:33 -0600 Subject: [PATCH] feat(networks): implement network recreation functionality and enhance synchronization - Added a new API endpoint for recreating Docker networks that have been removed, allowing users to restore networks directly from the Dokploy interface. - Updated the ShowNetworks and SyncNetworks components to include options for recreating networks, improving user management capabilities. - Enhanced the network synchronization process to better handle stale records, providing users with clear actions for network maintenance. - Refactored network creation logic to streamline the process and ensure consistency across network management operations. --- .../dashboard/networks/show-networks.tsx | 517 ++++++++++++++---- .../dashboard/networks/sync-networks.tsx | 56 +- apps/dokploy/server/api/routers/network.ts | 14 + packages/server/src/services/network.ts | 88 +-- 4 files changed, 509 insertions(+), 166 deletions(-) diff --git a/apps/dokploy/components/dashboard/networks/show-networks.tsx b/apps/dokploy/components/dashboard/networks/show-networks.tsx index 3059076bc..d87278b1e 100644 --- a/apps/dokploy/components/dashboard/networks/show-networks.tsx +++ b/apps/dokploy/components/dashboard/networks/show-networks.tsx @@ -1,6 +1,25 @@ "use client"; -import { Loader2, Network, Trash2 } from "lucide-react"; +import { + type ColumnDef, + flexRender, + getCoreRowModel, + getPaginationRowModel, + getSortedRowModel, + type PaginationState, + type SortingState, + useReactTable, +} from "@tanstack/react-table"; +import type { inferRouterOutputs } from "@trpc/server"; +import { + ArrowUpDown, + Loader2, + Network, + RotateCcw, + ShieldCheck, + Trash2, +} from "lucide-react"; +import { useMemo, useState } from "react"; import { toast } from "sonner"; import { HandleNetwork } from "@/components/dashboard/networks/handle-network"; import { ShowNetworkConfig } from "@/components/dashboard/networks/show-network-config"; @@ -16,6 +35,14 @@ import { CardHeader, CardTitle, } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; import { Table, TableBody, @@ -24,17 +51,294 @@ import { TableHeader, TableRow, } from "@/components/ui/table"; +import type { AppRouter } from "@/server/api/root"; import { api } from "@/utils/api"; +type NetworkRow = inferRouterOutputs["network"]["all"][number]; + interface Props { /** Selected server; undefined shows the local Dokploy server */ serverId?: string; } +const getIpamEntries = (row: NetworkRow) => + (row.ipam?.config ?? []).filter((c) => c.subnet || c.gateway || c.ipRange); + +const SortableHeader = ({ + column, + title, +}: { + column: { + getIsSorted: () => false | "asc" | "desc"; + toggleSorting: (asc: boolean) => void; + }; + title: string; +}) => ( + +); + export const ShowNetworks = ({ serverId }: Props) => { const utils = api.useUtils(); + const [verified, setVerified] = useState(false); + const [sorting, setSorting] = useState([ + { id: "createdAt", desc: true }, + ]); + const [globalFilter, setGlobalFilter] = useState(""); + const [driverFilter, setDriverFilter] = useState("all"); + const [pagination, setPagination] = useState({ + pageIndex: 0, + pageSize: 10, + }); + const { data: networks, isLoading } = api.network.all.useQuery({ serverId }); const { mutateAsync: removeNetwork } = api.network.remove.useMutation(); + const recreateMutation = api.network.recreate.useMutation(); + + // Same query the Sync dialog uses; "missing" tells us which records + // no longer have a real network in Docker + const { + data: syncStatus, + isFetching: isVerifying, + refetch: refetchVerify, + } = api.network.networksToSync.useQuery({ serverId }, { enabled: verified }); + + const missingIds = useMemo( + () => new Set(syncStatus?.missing.map((m) => m.networkId) ?? []), + [syncStatus], + ); + + const onVerify = async () => { + setVerified(true); + const { data: result, error } = await refetchVerify(); + if (error) { + toast.error("Error verifying networks", { + description: error.message, + }); + return; + } + if (!result) return; + if (result.missing.length === 0) { + toast.success("All networks exist in Docker"); + } else { + toast.warning( + `${result.missing.length} network(s) no longer exist in Docker`, + ); + } + }; + + const filteredData = useMemo(() => { + let list = networks ?? []; + if (driverFilter !== "all") { + list = list.filter((n) => n.driver === driverFilter); + } + if (globalFilter.trim()) { + const query = globalFilter.toLowerCase(); + list = list.filter( + (n) => + n.name.toLowerCase().includes(query) || + (n.ipam?.config ?? []).some( + (c) => + c.subnet?.toLowerCase().includes(query) || + c.gateway?.toLowerCase().includes(query) || + c.ipRange?.toLowerCase().includes(query), + ), + ); + } + return list; + }, [networks, driverFilter, globalFilter]); + + const columns = useMemo[]>( + () => [ + { + accessorKey: "name", + header: ({ column }) => , + cell: ({ row }) => ( +
+ {row.original.name} + {verified && + syncStatus && + (missingIds.has(row.original.networkId) ? ( + <> + Missing in Docker + + + ) : ( + In sync + ))} +
+ ), + }, + { + accessorKey: "driver", + header: ({ column }) => ( + + ), + cell: ({ row }) => ( +
+ {row.original.driver} + + {row.original.driver === "overlay" ? "swarm" : "local"} + +
+ ), + }, + { + id: "subnet", + accessorFn: (row) => getIpamEntries(row)[0]?.subnet ?? "", + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const ipamEntries = getIpamEntries(row.original); + if (ipamEntries.length === 0) { + return Auto; + } + return ( +
+ {ipamEntries.map((c, index) => ( +
+ {c.subnet ?? "—"} + {(c.gateway || c.ipRange) && ( + + {[ + c.gateway && `gw ${c.gateway}`, + c.ipRange && `range ${c.ipRange}`, + ] + .filter(Boolean) + .join(" · ")} + + )} +
+ ))} +
+ ); + }, + }, + { + accessorKey: "internal", + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + + {row.original.internal ? "Yes" : "No"} + + ), + }, + { + accessorKey: "attachable", + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + + {row.original.attachable ? "Yes" : "No"} + + ), + }, + { + accessorKey: "createdAt", + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + + {new Date(row.original.createdAt).toLocaleDateString()} + + ), + }, + { + id: "actions", + enableSorting: false, + header: () =>
Actions
, + cell: ({ row }) => ( +
+ + { + try { + await removeNetwork({ + networkId: row.original.networkId, + }); + toast.success("Network deleted"); + await utils.network.all.invalidate(); + await utils.network.networksToSync.invalidate(); + } catch (error) { + toast.error("Error deleting network", { + description: + error instanceof Error ? error.message : "Unknown error", + }); + } + }} + > + + +
+ ), + }, + ], + [verified, syncStatus, missingIds, removeNetwork, recreateMutation, utils], + ); + + const table = useReactTable({ + data: filteredData, + columns, + state: { + sorting, + pagination, + }, + onSortingChange: setSorting, + onPaginationChange: setPagination, + getCoreRowModel: getCoreRowModel(), + getSortedRowModel: getSortedRowModel(), + getPaginationRowModel: getPaginationRowModel(), + }); return (
@@ -50,6 +354,16 @@ export const ShowNetworks = ({ serverId }: Props) => {
+ {networks && networks.length > 0 && ( + + )} {networks && networks.length > 0 && ( @@ -58,7 +372,7 @@ export const ShowNetworks = ({ serverId }: Props) => { - + {isLoading ? (
Loading... @@ -80,118 +394,97 @@ export const ShowNetworks = ({ serverId }: Props) => {
) : ( -
- - - - Name - Driver - Subnet - Internal - Attachable - Created - - Actions - - - - - {networks.map((n) => { - const ipamEntries = (n.ipam?.config ?? []).filter( - (c) => c.subnet || c.gateway || c.ipRange, - ); - return ( - - - {n.name} - - -
- {n.driver} - - {n.driver === "overlay" ? "swarm" : "local"} - -
-
- - {ipamEntries.length > 0 ? ( -
- {ipamEntries.map((c, index) => ( -
- {c.subnet ?? "—"} - {(c.gateway || c.ipRange) && ( - - {[ - c.gateway && `gw ${c.gateway}`, - c.ipRange && `range ${c.ipRange}`, - ] - .filter(Boolean) - .join(" · ")} - - )} -
- ))} -
- ) : ( - - Auto - - )} -
- - {n.internal ? "Yes" : "No"} - - - {n.attachable ? "Yes" : "No"} - - - {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", - }); - } - }} - > - - -
+ <> +
+ setGlobalFilter(e.target.value)} + className="max-w-xs" + /> + +
+
+
+ + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => ( + + {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef.header, + header.getContext(), + )} + + ))} + + ))} + + + {table.getRowModel().rows?.length ? ( + table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender( + cell.column.columnDef.cell, + cell.getContext(), + )} + + ))} + + )) + ) : ( + + + No networks match your filters. - ); - })} - -
-
+ )} + + +
+ {table.getPageCount() > 1 && ( +
+ + Page {table.getState().pagination.pageIndex + 1} of{" "} + {table.getPageCount()} + +
+ + +
+
+ )} + )}
diff --git a/apps/dokploy/components/dashboard/networks/sync-networks.tsx b/apps/dokploy/components/dashboard/networks/sync-networks.tsx index 4b031d70b..2a0403048 100644 --- a/apps/dokploy/components/dashboard/networks/sync-networks.tsx +++ b/apps/dokploy/components/dashboard/networks/sync-networks.tsx @@ -1,6 +1,6 @@ "use client"; -import { Loader2, RefreshCw, Trash2 } from "lucide-react"; +import { Loader2, RefreshCw, RotateCcw, Trash2 } from "lucide-react"; import { useState } from "react"; import { toast } from "sonner"; import { AlertBlock } from "@/components/shared/alert-block"; @@ -20,13 +20,9 @@ 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()); @@ -37,6 +33,7 @@ export const SyncNetworks = ({ serverId }: Props) => { const importMutation = api.network.import.useMutation(); const removeMutation = api.network.remove.useMutation(); + const recreateMutation = api.network.recreate.useMutation(); const toggleSelected = (name: string) => { setSelected((prev) => { @@ -91,6 +88,20 @@ export const SyncNetworks = ({ serverId }: Props) => { } }; + const onRecreate = async (networkId: string, name: string) => { + try { + await recreateMutation.mutateAsync({ networkId }); + toast.success(`Network "${name}" recreated in Docker`); + await utils.network.all.invalidate(); + await utils.network.networksToSync.invalidate(); + await refetch(); + } catch (error) { + toast.error("Error recreating network", { + description: error instanceof Error ? error.message : "Unknown error", + }); + } + }; + return ( { className="flex items-center justify-between gap-3 rounded-lg border border-dashed p-3" > {stale.name} - +
+ + +
))} diff --git a/apps/dokploy/server/api/routers/network.ts b/apps/dokploy/server/api/routers/network.ts index ca1260265..68372b4d8 100644 --- a/apps/dokploy/server/api/routers/network.ts +++ b/apps/dokploy/server/api/routers/network.ts @@ -4,6 +4,7 @@ import { findNetworksToSync, importDockerNetworks, inspectNetwork, + recreateNetwork, removeNetwork, } from "@dokploy/server"; import { TRPCError } from "@trpc/server"; @@ -88,6 +89,19 @@ export const networkRouter = createTRPCRouter({ return inspectNetwork(input.networkId); }), + recreate: protectedProcedure + .input(apiFindOneNetwork) + .mutation(async ({ ctx, input }) => { + const network = await findNetworkById(input.networkId); + if (network.organizationId !== ctx.session.activeOrganizationId) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Network not found", + }); + } + return recreateNetwork(input.networkId); + }), + remove: protectedProcedure .input(apiRemoveNetwork) .mutation(async ({ ctx, input }) => { diff --git a/packages/server/src/services/network.ts b/packages/server/src/services/network.ts index 1c21598ad..c63e73c8c 100644 --- a/packages/server/src/services/network.ts +++ b/packages/server/src/services/network.ts @@ -224,44 +224,7 @@ export const createNetwork = async ( }); } - const ipam = row.ipam ?? {}; - const ipamConfig = (ipam.config ?? []) - .map((c) => { - const entry: Record = {}; - 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); - try { - await docker.createNetwork({ - Name: row.name, - Driver: row.driver, - CheckDuplicate: true, - Internal: row.internal, - Attachable: row.attachable, - // EnableIPv4 is missing from dockerode's types but supported by - // the daemon (API >= 1.47); the body is sent as-is - EnableIPv4: row.enableIPv4, - EnableIPv6: row.enableIPv6, - IPAM: { - Driver: ipam.driver || "default", - Config: ipamConfig.length > 0 ? ipamConfig : undefined, - }, - } as Parameters[0]); - } catch (error) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: - error instanceof Error - ? error.message - : "Failed to create Docker network", - cause: error, - }); - } + await createDockerNetworkFromRow(row); return row; }); @@ -269,6 +232,55 @@ export const createNetwork = async ( return created; }; +const createDockerNetworkFromRow = async (row: typeof network.$inferSelect) => { + const ipam = row.ipam ?? {}; + const ipamConfig = (ipam.config ?? []) + .map((c) => { + const entry: Record = {}; + 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(row.serverId ?? null); + try { + await docker.createNetwork({ + Name: row.name, + Driver: row.driver, + CheckDuplicate: true, + Internal: row.internal, + Attachable: row.attachable, + // EnableIPv4 is missing from dockerode's types but supported by + // the daemon (API >= 1.47); the body is sent as-is + EnableIPv4: row.enableIPv4, + EnableIPv6: row.enableIPv6, + IPAM: { + Driver: ipam.driver || "default", + Config: ipamConfig.length > 0 ? ipamConfig : undefined, + }, + } as Parameters[0]); + } catch (error) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: + error instanceof Error + ? error.message + : "Failed to create Docker network", + cause: error, + }); + } +}; + +// Re-creates the Docker network from the stored record, for records whose +// network was removed from Docker outside of Dokploy +export const recreateNetwork = async (networkId: string) => { + const row = await findNetworkById(networkId); + await createDockerNetworkFromRow(row); + return row; +}; + export const inspectNetwork = async (networkId: string) => { const row = await findNetworkById(networkId);