Merge pull request #3774 from Dokploy/feat/add-network-management

Feat/add network management
This commit is contained in:
Mauricio Siu
2026-07-27 00:43:28 -06:00
committed by GitHub
73 changed files with 12149 additions and 183 deletions
@@ -185,7 +185,7 @@ export const ShowImport = ({ composeId }: Props) => {
</Button>
</div>
<Dialog open={showModal} onOpenChange={setShowModal}>
<DialogContent className="max-w-[50vw]">
<DialogContent className="sm:max-w-3xl max-h-[85vh] flex flex-col">
<DialogHeader>
<DialogTitle className="text-2xl font-bold">
Template Information
@@ -199,7 +199,7 @@ export const ShowImport = ({ composeId }: Props) => {
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-6 flex-1 min-h-0 overflow-y-auto pr-1">
<div className="space-y-4">
<div className="flex items-center gap-2">
<Code2 className="h-5 w-5 text-primary" />
@@ -207,12 +207,14 @@ export const ShowImport = ({ composeId }: Props) => {
Docker Compose
</h3>
</div>
<CodeEditor
language="yaml"
value={templateInfo?.compose || ""}
className="font-mono"
readOnly
/>
<div className="max-h-[45vh] overflow-auto rounded-md border">
<CodeEditor
language="yaml"
value={templateInfo?.compose || ""}
className="font-mono"
readOnly
/>
</div>
</div>
<Separator />
@@ -6,6 +6,7 @@ import { toast } from "sonner";
import { z } from "zod";
import { AlertBlock } from "@/components/shared/alert-block";
import { CodeEditor } from "@/components/shared/code-editor";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
@@ -111,7 +112,10 @@ export const IsolatedDeploymentTab = ({ composeId }: Props) => {
return (
<Card className="bg-background">
<CardHeader>
<CardTitle className="text-xl">Enable Isolated Deployment</CardTitle>
<CardTitle className="text-xl flex items-center gap-2">
Enable Isolated Deployment
<Badge variant="yellow">Deprecated</Badge>
</CardTitle>
<CardDescription>
Configure isolated deployment to the compose file.
<div className="text-sm text-muted-foreground flex flex-col gap-2">
@@ -138,6 +142,11 @@ export const IsolatedDeploymentTab = ({ composeId }: Props) => {
</CardHeader>
<CardContent>
<div className="space-y-4">
<AlertBlock type="warning">
Isolated deployment is deprecated. Use the Networks section above to
attach networks per service and detach them from dokploy-network —
it is declarative and does not break on restarts.
</AlertBlock>
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
<Form {...form}>
<form
@@ -52,7 +52,7 @@ export const ShowConvertedCompose = ({ composeId }: Props) => {
Preview Compose
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-6xl max-h-200">
<DialogContent className="sm:max-w-6xl max-h-[85vh] flex flex-col">
<DialogHeader>
<DialogTitle>Converted Compose</DialogTitle>
<DialogDescription>
@@ -62,10 +62,6 @@ export const ShowConvertedCompose = ({ composeId }: Props) => {
</DialogHeader>
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
<AlertBlock type="info" className="mb-4">
Preview your docker-compose file with added domains. Note: At least
one domain must be specified for this conversion to take effect.
</AlertBlock>
{isPending ? (
<div className="flex flex-row items-center justify-center min-h-100 border p-4 rounded-md">
<Loader2 className="h-8 w-8 text-muted-foreground mb-2 animate-spin" />
@@ -79,7 +75,7 @@ export const ShowConvertedCompose = ({ composeId }: Props) => {
</div>
) : (
<>
<div className="flex flex-row gap-2 justify-end my-4">
<div className="flex flex-row gap-2 justify-end">
<Button
variant="secondary"
isLoading={isPending}
@@ -100,14 +96,15 @@ export const ShowConvertedCompose = ({ composeId }: Props) => {
</Button>
</div>
<pre>
<div className="flex-1 min-h-0 overflow-auto rounded-md border">
<CodeEditor
value={compose || ""}
language="yaml"
readOnly
height="50rem"
height="100%"
wrapperClassName="h-full"
/>
</pre>
</div>
</>
)}
</DialogContent>
@@ -0,0 +1,347 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { Check, ChevronsUpDown, Loader2, RefreshCw } from "lucide-react";
import { useEffect, useState } from "react";
import { useFieldArray, useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { AlertBlock } from "@/components/shared/alert-block";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox";
import {
Command,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import { Form, FormControl, FormField, FormItem } from "@/components/ui/form";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { Switch } from "@/components/ui/switch";
import { cn } from "@/lib/utils";
import { api } from "@/utils/api";
interface Props {
composeId: string;
}
const serviceSchema = z.object({
serviceName: z.string(),
networkIds: z.array(z.string()),
detachDokployNetwork: z.boolean(),
});
const formSchema = z.object({
services: z.array(serviceSchema),
});
type FormValues = z.infer<typeof formSchema>;
export const AssignComposeNetworks = ({ composeId }: Props) => {
const [cacheType, setCacheType] = useState<"cache" | "fetch">("cache");
const { data: compose } = api.compose.one.useQuery({ composeId });
const {
data: services,
isLoading: isLoadingServices,
error: servicesError,
refetch: refetchServices,
isRefetching: isRefetchingServices,
} = api.compose.loadServices.useQuery(
{ composeId, type: cacheType },
{ retry: false },
);
const onRetry = () => {
setCacheType("fetch");
setTimeout(() => refetchServices(), 0);
};
const { data: networks } = api.network.all.useQuery(
{ serverId: compose?.serverId ?? undefined },
{ enabled: compose !== undefined },
);
const { mutateAsync, isPending } = api.compose.update.useMutation();
const utils = api.useUtils();
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: { services: [] },
});
const { fields } = useFieldArray({
control: form.control,
name: "services",
});
useEffect(() => {
if (!services) return;
const serviceNetworks = compose?.serviceNetworks ?? [];
form.reset({
services: services.map((serviceName) => {
const config = serviceNetworks.find(
(s) => s.serviceName === serviceName,
);
return {
serviceName,
networkIds: config?.networkIds ?? [],
detachDokployNetwork: config?.detachDokployNetwork ?? false,
};
}),
});
}, [services, compose?.serviceNetworks, form]);
const allowsBridge = compose?.composeType === "docker-compose";
const availableNetworks = (networks ?? []).filter(
(n) => allowsBridge || n.driver === "overlay",
);
const onSubmit = async (values: FormValues) => {
try {
const serviceNetworks = values.services.filter(
(s) => s.networkIds.length > 0 || s.detachDokployNetwork,
);
await mutateAsync({
composeId,
serviceNetworks,
});
toast.success("Networks updated. Redeploy the compose to apply them.");
await utils.compose.one.invalidate({ composeId });
} catch (error) {
toast.error("Error updating networks", {
description: error instanceof Error ? error.message : "Unknown error",
});
}
};
return (
<Card className="bg-background">
<CardHeader className="flex flex-row items-start justify-between flex-wrap gap-2">
<div className="flex flex-col gap-1">
<CardTitle className="text-xl">Networks</CardTitle>
<CardDescription>
Attach Docker networks per service and detach it from
dokploy-network. Takes effect on the next deploy.
</CardDescription>
</div>
<Button
variant="outline"
size="sm"
isLoading={isRefetchingServices}
onClick={onRetry}
>
<RefreshCw className="size-4" />
Refresh
</Button>
</CardHeader>
<CardContent className="flex flex-col gap-4">
{servicesError ? (
<div className="flex flex-col gap-3">
<AlertBlock type="error">
Could not load the compose services. If this compose was just
created from a template, it hasn't been cloned yet — click Reload
to clone the repository and read its services.
</AlertBlock>
<div className="flex justify-end">
<Button
variant="outline"
size="sm"
isLoading={isRefetchingServices}
onClick={onRetry}
>
<RefreshCw className="size-4" />
Reload services
</Button>
</div>
</div>
) : isLoadingServices ? (
<div className="flex flex-row gap-2 items-center justify-center py-10 text-sm text-muted-foreground">
<span>Loading services...</span>
<Loader2 className="animate-spin size-4" />
</div>
) : !services?.length ? (
<span className="py-6 text-center text-sm text-muted-foreground">
No services found in this compose.
</span>
) : (
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="flex flex-col gap-4"
>
{fields.map((fieldItem, index) => (
<ServiceRow
key={fieldItem.id}
control={form.control}
index={index}
service={fieldItem.serviceName}
availableNetworks={availableNetworks}
/>
))}
<div className="flex justify-end">
<Button
type="submit"
disabled={!form.formState.isDirty}
isLoading={isPending}
>
Save
</Button>
</div>
</form>
</Form>
)}
</CardContent>
</Card>
);
};
type NetworkOption = { networkId: string; name: string; driver: string };
const ServiceRow = ({
control,
index,
service,
availableNetworks,
}: {
control: ReturnType<typeof useForm<FormValues>>["control"];
index: number;
service: string;
availableNetworks: NetworkOption[];
}) => {
const [open, setOpen] = useState(false);
return (
<div className="flex flex-col gap-3 rounded-lg border p-4">
<FormField
control={control}
name={`services.${index}.detachDokployNetwork`}
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between gap-3 space-y-0">
<span className="text-sm font-medium">{service}</span>
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground">
Detach dokploy-network
</span>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</div>
</FormItem>
)}
/>
<FormField
control={control}
name={`services.${index}.networkIds`}
render={({ field }) => {
const selectedNetworks = availableNetworks.filter((n) =>
field.value.includes(n.networkId),
);
const toggle = (networkId: string) => {
field.onChange(
field.value.includes(networkId)
? field.value.filter((id) => id !== networkId)
: [...field.value, networkId],
);
};
return (
<FormItem className="space-y-3">
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
type="button"
variant="outline"
aria-expanded={open}
className="w-full justify-between"
>
<span className="text-muted-foreground">
{selectedNetworks.length > 0
? `${selectedNetworks.length} network${
selectedNetworks.length > 1 ? "s" : ""
} selected`
: "Select networks..."}
</span>
<ChevronsUpDown className="ml-2 size-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent
className="w-[var(--radix-popover-trigger-width)] p-0"
align="start"
>
<Command>
<CommandInput
placeholder="Search networks..."
className="h-9"
/>
<CommandList className="max-h-60">
{availableNetworks.length === 0 ? (
<div className="py-6 text-center text-sm text-muted-foreground">
No networks available on this server.
</div>
) : (
<CommandGroup>
{availableNetworks.map((n) => {
const isSelected = field.value.includes(
n.networkId,
);
return (
<CommandItem
key={n.networkId}
value={n.name}
onSelect={() => toggle(n.networkId)}
className="cursor-pointer"
>
<Checkbox
checked={isSelected}
className="mr-2"
onCheckedChange={() => toggle(n.networkId)}
/>
<span>{n.name}</span>
<Badge variant="outline" className="ml-2">
{n.driver}
</Badge>
<Check
className={cn(
"ml-auto size-4",
isSelected ? "opacity-100" : "opacity-0",
)}
/>
</CommandItem>
);
})}
</CommandGroup>
)}
</CommandList>
</Command>
</PopoverContent>
</Popover>
{selectedNetworks.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{selectedNetworks.map((n) => (
<Badge key={n.networkId} variant="secondary">
{n.name}
</Badge>
))}
</div>
)}
</FormItem>
);
}}
/>
</div>
);
};
@@ -0,0 +1,355 @@
import { Check, ChevronsUpDown, X } from "lucide-react";
import { useEffect, 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 {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { Switch } from "@/components/ui/switch";
import { cn } from "@/lib/utils";
import { api } from "@/utils/api";
type ServiceType =
| "application"
| "postgres"
| "mysql"
| "mariadb"
| "mongo"
| "redis"
| "libsql";
interface Props {
id: string;
type: ServiceType;
}
export const AssignNetworks = ({ id, type }: Props) => {
const [open, setOpen] = useState(false);
const [selected, setSelected] = useState<string[]>([]);
const [detached, setDetached] = useState(false);
const {
service,
serverId,
networkIds,
detachDokployNetwork,
updateAsync,
isUpdating,
refetch,
} = useServiceNetworks(id, type);
const { data: networks } = api.network.all.useQuery(
{ serverId: serverId ?? undefined },
{ enabled: service !== undefined },
);
const { data: applicationDomains } = api.domain.byApplicationId.useQuery(
{ applicationId: id },
{ enabled: type === "application" },
);
const hasDomains = (applicationDomains?.length ?? 0) > 0;
useEffect(() => {
setSelected(networkIds ?? []);
setDetached(detachDokployNetwork ?? false);
}, [networkIds, detachDokployNetwork]);
const availableNetworks = (networks ?? []).filter(
(n) => n.driver === "overlay",
);
const selectedNetworks = availableNetworks.filter((n) =>
selected.includes(n.networkId),
);
const isDirty =
selected.length !== (networkIds?.length ?? 0) ||
selected.some((networkId) => !networkIds?.includes(networkId)) ||
detached !== detachDokployNetwork;
const toggle = (networkId: string) => {
setSelected((prev) =>
prev.includes(networkId)
? prev.filter((id) => id !== networkId)
: [...prev, networkId],
);
};
const onSave = async () => {
try {
await updateAsync({
networkIds: selected,
detachDokployNetwork: detached,
});
toast.success("Networks updated. Redeploy the service to apply them.");
await refetch();
} catch (error) {
toast.error("Error updating networks", {
description: error instanceof Error ? error.message : "Unknown error",
});
}
};
return (
<Card className="bg-background">
<CardHeader className="flex flex-row items-center justify-between flex-wrap gap-2">
<div className="flex flex-col gap-1">
<CardTitle className="text-xl">Networks</CardTitle>
<CardDescription>
Attach additional Docker networks to this service so it can reach
services on those networks. Takes effect on the next deploy.
</CardDescription>
</div>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<div className="flex flex-row items-start justify-between gap-3 rounded-lg border p-4">
<div className="space-y-1 pr-1">
<div className="flex items-center gap-2">
<span className="text-sm font-medium">
Detach from dokploy-network
</span>
<Badge variant="secondary">dokploy-network</Badge>
</div>
<p className="text-sm text-muted-foreground">
By default the service joins the shared dokploy-network. Detach it
to keep it reachable only through the networks you attach below.
</p>
</div>
<Switch checked={detached} onCheckedChange={setDetached} />
</div>
{detached && hasDomains && (
<AlertBlock type="warning">
Warning: this service has domains. Detaching it from dokploy-network
will break Traefik routing, and its domains will stop working.
</AlertBlock>
)}
{detached && !hasDomains && selected.length === 0 && (
<AlertBlock type="warning">
This service is detached from dokploy-network but has no other
network attached. It would be unreachable, so dokploy-network will
be kept until you attach a network below.
</AlertBlock>
)}
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
aria-expanded={open}
className="w-full justify-between"
>
<span className="text-muted-foreground">
{selectedNetworks.length > 0
? `${selectedNetworks.length} network${
selectedNetworks.length > 1 ? "s" : ""
} selected`
: "Select networks..."}
</span>
<ChevronsUpDown className="ml-2 size-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent
className="w-[var(--radix-popover-trigger-width)] p-0"
align="start"
>
<Command>
<CommandInput placeholder="Search networks..." className="h-9" />
<CommandList className="max-h-60">
{availableNetworks.length === 0 ? (
<div className="py-6 text-center text-sm text-muted-foreground">
No overlay networks on this server.
</div>
) : (
<>
<CommandEmpty className="py-6 text-center text-sm text-muted-foreground">
No networks found.
</CommandEmpty>
<CommandGroup>
{availableNetworks.map((n) => {
const isSelected = selected.includes(n.networkId);
return (
<CommandItem
key={n.networkId}
value={n.name}
onSelect={() => toggle(n.networkId)}
className="cursor-pointer"
>
<Checkbox
checked={isSelected}
className="mr-2"
onCheckedChange={() => toggle(n.networkId)}
/>
<span>{n.name}</span>
<Badge variant="outline" className="ml-2">
{n.driver}
</Badge>
<Check
className={cn(
"ml-auto size-4",
isSelected ? "opacity-100" : "opacity-0",
)}
/>
</CommandItem>
);
})}
</CommandGroup>
</>
)}
</CommandList>
</Command>
</PopoverContent>
</Popover>
{selectedNetworks.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{selectedNetworks.map((n) => (
<Badge
key={n.networkId}
variant="secondary"
className="flex items-center gap-1 pr-1"
>
{n.name}
<button
type="button"
onClick={() => toggle(n.networkId)}
className="ml-0.5 rounded-full outline-hidden hover:opacity-70"
>
<X className="size-3" />
<span className="sr-only">Remove {n.name}</span>
</button>
</Badge>
))}
</div>
)}
<div className="flex justify-end">
<Button disabled={!isDirty} isLoading={isUpdating} onClick={onSave}>
Save
</Button>
</div>
</CardContent>
</Card>
);
};
// Maps a service type to its one-query and update-mutation, normalizing the
// per-type id field name and the networkIds field.
const useServiceNetworks = (id: string, type: ServiceType) => {
const application = api.application.one.useQuery(
{ applicationId: id },
{ enabled: type === "application" },
);
const postgres = api.postgres.one.useQuery(
{ postgresId: id },
{ enabled: type === "postgres" },
);
const mysql = api.mysql.one.useQuery(
{ mysqlId: id },
{ enabled: type === "mysql" },
);
const mariadb = api.mariadb.one.useQuery(
{ mariadbId: id },
{ enabled: type === "mariadb" },
);
const mongo = api.mongo.one.useQuery(
{ mongoId: id },
{ enabled: type === "mongo" },
);
const redis = api.redis.one.useQuery(
{ redisId: id },
{ enabled: type === "redis" },
);
const libsql = api.libsql.one.useQuery(
{ libsqlId: id },
{ enabled: type === "libsql" },
);
const applicationUpdate = api.application.update.useMutation();
const postgresUpdate = api.postgres.update.useMutation();
const mysqlUpdate = api.mysql.update.useMutation();
const mariadbUpdate = api.mariadb.update.useMutation();
const mongoUpdate = api.mongo.update.useMutation();
const redisUpdate = api.redis.update.useMutation();
const libsqlUpdate = api.libsql.update.useMutation();
const map = {
application: {
query: application,
mutation: applicationUpdate,
save: (payload: SavePayload) =>
applicationUpdate.mutateAsync({ applicationId: id, ...payload }),
},
postgres: {
query: postgres,
mutation: postgresUpdate,
save: (payload: SavePayload) =>
postgresUpdate.mutateAsync({ postgresId: id, ...payload }),
},
mysql: {
query: mysql,
mutation: mysqlUpdate,
save: (payload: SavePayload) =>
mysqlUpdate.mutateAsync({ mysqlId: id, ...payload }),
},
mariadb: {
query: mariadb,
mutation: mariadbUpdate,
save: (payload: SavePayload) =>
mariadbUpdate.mutateAsync({ mariadbId: id, ...payload }),
},
mongo: {
query: mongo,
mutation: mongoUpdate,
save: (payload: SavePayload) =>
mongoUpdate.mutateAsync({ mongoId: id, ...payload }),
},
redis: {
query: redis,
mutation: redisUpdate,
save: (payload: SavePayload) =>
redisUpdate.mutateAsync({ redisId: id, ...payload }),
},
libsql: {
query: libsql,
mutation: libsqlUpdate,
save: (payload: SavePayload) =>
libsqlUpdate.mutateAsync({ libsqlId: id, ...payload }),
},
}[type];
const service = map.query.data;
return {
service,
serverId: service?.serverId ?? null,
networkIds: service?.networkIds ?? [],
detachDokployNetwork: service?.detachDokployNetwork ?? false,
updateAsync: map.save,
isUpdating: map.mutation.isPending,
refetch: map.query.refetch,
};
};
type SavePayload = {
networkIds: string[];
detachDokployNetwork: boolean;
};
@@ -0,0 +1,373 @@
"use client";
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { Network, Plus, Trash2 } from "lucide-react";
import { 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";
// Only bridge and overlay can be created: "host"/"none" are Docker
// singletons and macvlan/ipvlan need driver options we don't expose.
const networkDriverEnum = ["bridge", "overlay"] as const;
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),
internal: z.boolean(),
attachable: z.boolean(),
enableIPv4: z.boolean(),
enableIPv6: z.boolean(),
ipamDriver: z.string().optional(),
ipamConfig: z.array(ipamConfigEntrySchema),
})
.superRefine((input, ctx) => {
if (!input.enableIPv4 && !input.enableIPv6) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["enableIPv4"],
message: "IPv4 or IPv6 must be enabled",
});
}
for (const [index, entry] of input.ipamConfig.entries()) {
if (!entry.subnet && (entry.gateway || entry.ipRange)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["ipamConfig", index, "subnet"],
message: "Gateway and IP range require a subnet",
});
}
}
});
type NetworkFormValues = z.infer<typeof networkFormSchema>;
const defaultValues: NetworkFormValues = {
name: "",
driver: "bridge",
internal: false,
attachable: 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 (overlay networks only).",
},
{
name: "enableIPv4",
label: "Enable IPv4",
description: "Enable IPv4 addressing on the network.",
},
{
name: "enableIPv6",
label: "Enable IPv6",
description: "Enable IPv6 addressing on the network.",
},
] 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 = ({ serverId, children }: HandleNetworkProps) => {
const [isOpen, setIsOpen] = useState(false);
const utils = api.useUtils();
const { mutateAsync, isPending } = api.network.create.useMutation();
const form = useForm<NetworkFormValues>({
resolver: zodResolver(networkFormSchema),
defaultValues,
});
const ipamConfigFieldArray = useFieldArray({
control: form.control,
name: "ipamConfig",
});
const onSubmit = async (data: NetworkFormValues) => {
try {
await mutateAsync({
name: data.name,
driver: data.driver,
serverId,
internal: data.internal,
attachable: data.attachable,
enableIPv4: data.enableIPv4,
enableIPv6: data.enableIPv6,
ipam: {
driver: data.ipamDriver || undefined,
config: data.ipamConfig,
},
});
toast.success("Network created");
await utils.network.all.invalidate();
setIsOpen(false);
form.reset(defaultValues);
} catch (error) {
toast.error("Error creating network", {
description: error instanceof Error ? error.message : "Unknown error",
});
}
};
const trigger = children ?? (
<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" />
Add network
</DialogTitle>
<DialogDescription>
Create a new Docker network for your organization. Networks are
immutable: to change one, delete it and create it again.
</DialogDescription>
</DialogHeader>
<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>
<FormDescription className="text-muted-foreground">
bridge for single-server containers; overlay for Swarm
services.
</FormDescription>
<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}>
Create network
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
);
};
@@ -0,0 +1,69 @@
"use client";
import { Eye, Loader2 } from "lucide-react";
import { useState } from "react";
import { AlertBlock } from "@/components/shared/alert-block";
import { CodeEditor } from "@/components/shared/code-editor";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { api } from "@/utils/api";
interface Props {
networkId: string;
networkName: string;
}
export const ShowNetworkConfig = ({ networkId, networkName }: Props) => {
const [open, setOpen] = useState(false);
const { data, isLoading, error } = api.network.inspect.useQuery(
{ networkId },
{ enabled: open },
);
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="ghost" size="icon-sm" aria-label="View network config">
<Eye className="size-4" />
</Button>
</DialogTrigger>
<DialogContent className="w-full md:w-[70vw] min-w-[70vw]">
<DialogHeader>
<DialogTitle>Network Config</DialogTitle>
<DialogDescription>
docker network inspect output for "{networkName}"
</DialogDescription>
</DialogHeader>
{error ? (
<AlertBlock type="error">{error.message}</AlertBlock>
) : isLoading ? (
<div className="flex flex-row gap-2 items-center justify-center py-10 text-sm text-muted-foreground">
<span>Loading...</span>
<Loader2 className="animate-spin size-4" />
</div>
) : (
<div className="text-wrap rounded-lg border p-4 overflow-y-auto text-sm bg-card max-h-[80vh]">
<code>
<pre className="whitespace-pre-wrap wrap-break-word">
<CodeEditor
language="json"
lineWrapping
lineNumbers={false}
readOnly
value={JSON.stringify(data, null, 2)}
/>
</pre>
</code>
</div>
)}
</DialogContent>
</Dialog>
);
};
@@ -0,0 +1,494 @@
"use client";
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";
import { SyncNetworks } from "@/components/dashboard/networks/sync-networks";
import { DialogAction } from "@/components/shared/dialog-action";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
CardAction,
CardContent,
CardDescription,
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,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import type { AppRouter } from "@/server/api/root";
import { api } from "@/utils/api";
type NetworkRow = inferRouterOutputs<AppRouter>["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;
}) => (
<Button
variant="ghost"
className="-ml-3 h-8"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
>
{title}
<ArrowUpDown className="ml-2 size-4" />
</Button>
);
export const ShowNetworks = ({ serverId }: Props) => {
const utils = api.useUtils();
const [verified, setVerified] = useState(false);
const [sorting, setSorting] = useState<SortingState>([
{ id: "createdAt", desc: true },
]);
const [globalFilter, setGlobalFilter] = useState("");
const [driverFilter, setDriverFilter] = useState<string>("all");
const [pagination, setPagination] = useState<PaginationState>({
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<ColumnDef<NetworkRow>[]>(
() => [
{
accessorKey: "name",
header: ({ column }) => <SortableHeader column={column} title="Name" />,
cell: ({ row }) => (
<div className="flex items-center gap-2 font-medium">
{row.original.name}
{verified &&
syncStatus &&
(missingIds.has(row.original.networkId) ? (
<>
<Badge variant="red">Missing in Docker</Badge>
<Button
variant="outline"
size="xs"
isLoading={recreateMutation.isPending}
onClick={async () => {
try {
await recreateMutation.mutateAsync({
networkId: row.original.networkId,
});
toast.success(
`Network "${row.original.name}" recreated in Docker`,
);
await utils.network.networksToSync.invalidate();
} catch (error) {
toast.error("Error recreating network", {
description:
error instanceof Error
? error.message
: "Unknown error",
});
}
}}
>
<RotateCcw className="size-3.5" />
Recreate
</Button>
</>
) : (
<Badge variant="green">In sync</Badge>
))}
</div>
),
},
{
accessorKey: "driver",
header: ({ column }) => (
<SortableHeader column={column} title="Driver" />
),
cell: ({ row }) => (
<div className="flex items-center gap-2">
<Badge variant="outline">{row.original.driver}</Badge>
<span className="text-xs text-muted-foreground">
{row.original.driver === "overlay" ? "swarm" : "local"}
</span>
</div>
),
},
{
id: "subnet",
accessorFn: (row) => getIpamEntries(row)[0]?.subnet ?? "",
header: ({ column }) => (
<SortableHeader column={column} title="Subnet" />
),
cell: ({ row }) => {
const ipamEntries = getIpamEntries(row.original);
if (ipamEntries.length === 0) {
return <span className="text-muted-foreground">Auto</span>;
}
return (
<div className="flex flex-col gap-1">
{ipamEntries.map((c, index) => (
<div
key={`${row.original.networkId}-ipam-${index}`}
className="flex flex-col"
>
<span>{c.subnet ?? "—"}</span>
{(c.gateway || c.ipRange) && (
<span className="text-xs text-muted-foreground">
{[
c.gateway && `gw ${c.gateway}`,
c.ipRange && `range ${c.ipRange}`,
]
.filter(Boolean)
.join(" · ")}
</span>
)}
</div>
))}
</div>
);
},
},
{
accessorKey: "internal",
header: ({ column }) => (
<SortableHeader column={column} title="Internal" />
),
cell: ({ row }) => (
<span className="text-muted-foreground">
{row.original.internal ? "Yes" : "No"}
</span>
),
},
{
accessorKey: "attachable",
header: ({ column }) => (
<SortableHeader column={column} title="Attachable" />
),
cell: ({ row }) => (
<span className="text-muted-foreground">
{row.original.attachable ? "Yes" : "No"}
</span>
),
},
{
accessorKey: "createdAt",
header: ({ column }) => (
<SortableHeader column={column} title="Created" />
),
cell: ({ row }) => (
<span className="text-muted-foreground whitespace-nowrap">
{new Date(row.original.createdAt).toLocaleDateString()}
</span>
),
},
{
id: "actions",
enableSorting: false,
header: () => <div className="text-right">Actions</div>,
cell: ({ row }) => (
<div className="flex items-center justify-end gap-3">
<ShowNetworkConfig
networkId={row.original.networkId}
networkName={row.original.name}
/>
<DialogAction
title="Delete network"
description={`The network "${row.original.name}" will be removed from Docker and Dokploy. This action cannot be undone.`}
onClick={async () => {
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",
});
}
}}
>
<Button
variant="ghost"
size="icon-sm"
aria-label="Delete network"
>
<Trash2 className="size-4 text-destructive" />
</Button>
</DialogAction>
</div>
),
},
],
[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 (
<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 the Docker networks of the selected server.
</CardDescription>
<CardAction className="self-center">
<div className="flex items-center gap-2">
{networks && networks.length > 0 && (
<Button
variant="outline"
isLoading={isVerifying}
onClick={onVerify}
>
<ShieldCheck className="size-4" />
Verify
</Button>
)}
<SyncNetworks serverId={serverId} />
{networks && networks.length > 0 && (
<HandleNetwork serverId={serverId} />
)}
</div>
</CardAction>
</CardHeader>
<CardContent className="space-y-4 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 serverId={serverId} />
</div>
) : (
<>
<div className="flex flex-wrap items-center gap-2">
<Input
placeholder="Search by name, subnet, gateway..."
value={globalFilter}
onChange={(e) => setGlobalFilter(e.target.value)}
className="max-w-xs"
/>
<Select value={driverFilter} onValueChange={setDriverFilter}>
<SelectTrigger className="w-[150px]">
<SelectValue placeholder="Driver" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All drivers</SelectItem>
<SelectItem value="bridge">bridge</SelectItem>
<SelectItem value="overlay">overlay</SelectItem>
</SelectContent>
</Select>
</div>
<div className="rounded-md border overflow-x-auto">
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableHead key={header.id}>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext(),
)}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow key={row.id}>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext(),
)}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell
colSpan={columns.length}
className="h-24 text-center text-muted-foreground"
>
No networks match your filters.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
{table.getPageCount() > 1 && (
<div className="flex items-center justify-end gap-4">
<span className="text-sm text-muted-foreground">
Page {table.getState().pagination.pageIndex + 1} of{" "}
{table.getPageCount()}
</span>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
Previous
</Button>
<Button
variant="outline"
size="sm"
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
>
Next
</Button>
</div>
</div>
)}
</>
)}
</CardContent>
</div>
</Card>
</div>
);
};
@@ -0,0 +1,249 @@
"use client";
import { Loader2, RefreshCw, RotateCcw, 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 {
serverId?: string;
}
export const SyncNetworks = ({ serverId }: Props) => {
const [open, setOpen] = useState(false);
const [selected, setSelected] = useState<Set<string>>(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 recreateMutation = api.network.recreate.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",
});
}
};
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 (
<Dialog
open={open}
onOpenChange={(value) => {
setOpen(value);
if (!value) setSelected(new Set());
}}
>
<DialogTrigger asChild>
<Button variant="outline">
<RefreshCw className="size-4" />
Sync
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<RefreshCw className="size-5 text-muted-foreground" />
Sync networks
</DialogTitle>
<DialogDescription>
Import networks that exist in Docker but not in Dokploy, and clean
up records whose network no longer exists.
</DialogDescription>
</DialogHeader>
{error ? (
<AlertBlock type="error">{error.message}</AlertBlock>
) : isLoading ? (
<div className="flex flex-row gap-2 items-center justify-center py-10 text-sm text-muted-foreground">
<span>Scanning Docker networks...</span>
<Loader2 className="animate-spin size-4" />
</div>
) : (
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<span className="text-sm font-medium">
Found in Docker ({data?.importable.length ?? 0})
</span>
{data?.importable.length ? (
data.importable.map((dockerNetwork) => (
<label
key={dockerNetwork.name}
htmlFor={`import-network-${dockerNetwork.name}`}
className="flex cursor-pointer items-center justify-between gap-3 rounded-lg border p-3"
>
<div className="flex items-center gap-3">
<Checkbox
id={`import-network-${dockerNetwork.name}`}
checked={selected.has(dockerNetwork.name)}
onCheckedChange={() =>
toggleSelected(dockerNetwork.name)
}
/>
<div className="flex flex-col">
<span className="text-sm font-medium">
{dockerNetwork.name}
</span>
{dockerNetwork.subnets.length > 0 && (
<span className="text-xs text-muted-foreground">
{dockerNetwork.subnets.join(" · ")}
</span>
)}
</div>
</div>
<Badge variant="outline">{dockerNetwork.driver}</Badge>
</label>
))
) : (
<span className="text-sm text-muted-foreground">
Nothing to import — everything is in sync.
</span>
)}
</div>
{!!data?.missing.length && (
<>
<Separator />
<div className="flex flex-col gap-2">
<span className="text-sm font-medium">
Missing in Docker ({data.missing.length})
</span>
<span className="text-xs text-muted-foreground">
These records exist in Dokploy but their network is gone
from Docker.
</span>
{data.missing.map((stale) => (
<div
key={stale.networkId}
className="flex items-center justify-between gap-3 rounded-lg border border-dashed p-3"
>
<span className="text-sm">{stale.name}</span>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="xs"
isLoading={recreateMutation.isPending}
onClick={() =>
onRecreate(stale.networkId, stale.name)
}
>
<RotateCcw className="size-3.5" />
Recreate
</Button>
<Button
variant="ghost"
size="icon-sm"
aria-label={`Remove stale record ${stale.name}`}
isLoading={removeMutation.isPending}
onClick={() =>
onRemoveStale(stale.networkId, stale.name)
}
>
<Trash2 className="size-4 text-destructive" />
</Button>
</div>
</div>
))}
</div>
</>
)}
</div>
)}
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => setOpen(false)}
>
Close
</Button>
<Button
type="button"
disabled={selected.size === 0}
isLoading={importMutation.isPending}
onClick={onImport}
>
Import selected ({selected.size})
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
@@ -328,7 +328,7 @@ export const RequestsTable = ({ dateRange }: RequestsTableProps) => {
open={!!selectedRow}
onOpenChange={(_open) => setSelectedRow(undefined)}
>
<SheetContent className="sm:max-w-[740px] flex flex-col">
<SheetContent className="w-full sm:max-w-[740px]! flex flex-col">
<SheetHeader>
<SheetTitle>Request log</SheetTitle>
<SheetDescription>
@@ -1,5 +1,6 @@
import { ShowResources } from "@/components/dashboard/application/advanced/show-resources";
import { ShowVolumes } from "@/components/dashboard/application/advanced/volumes/show-volumes";
import { AssignNetworks } from "@/components/dashboard/networks/assign-networks";
import { ShowCustomCommand } from "@/components/dashboard/postgres/advanced/show-custom-command";
import { ShowClusterSettings } from "../application/advanced/cluster/show-cluster-settings";
import { RebuildDatabase } from "./rebuild-database";
@@ -21,6 +22,7 @@ export const ShowDatabaseAdvancedSettings = ({ id, type }: Props) => {
<ShowClusterSettings id={id} type={type} />
) : null}
<ShowVolumes id={id} type={type} />
<AssignNetworks id={id} type={type} />
<ShowResources id={id} type={type} />
<RebuildDatabase id={id} type={type} />
</div>
+15
View File
@@ -25,6 +25,7 @@ import {
Loader2,
LogIn,
type LucideIcon,
Network,
Package,
Palette,
PieChart,
@@ -209,6 +210,20 @@ const MENU: Menu = {
// Only enabled for users with access to Docker
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,
title: "Requests",
@@ -95,6 +95,7 @@ const RESOURCE_LABELS: Record<string, string> = {
notification: "Notification",
settings: "Settings",
session: "Session",
network: "Network",
};
function MetadataCell({ metadata }: { metadata: string | null }) {
@@ -47,7 +47,7 @@ export const DrawerLogs = ({ isOpen, onClose, filteredLogs }: Props) => {
onClose();
}}
>
<SheetContent className="sm:max-w-[740px] flex flex-col">
<SheetContent className="w-full sm:max-w-[740px]! flex flex-col">
<SheetHeader>
<SheetTitle>Deployment Logs</SheetTitle>
<SheetDescription>Details of the request log entry.</SheetDescription>
+1 -1
View File
@@ -6,7 +6,7 @@ import type * as React from "react";
import { cn } from "@/lib/utils";
const buttonVariants = cva(
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"group/button inline-flex shrink-0 cursor-pointer items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
+1 -1
View File
@@ -7,7 +7,7 @@ function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
<textarea
data-slot="textarea"
className={cn(
"flex field-sizing-content min-h-[80px] w-full rounded-lg border border-input bg-transparent px-3 py-2 text-base transition-colors outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
"flex field-sizing-content min-h-[80px] w-full [overflow-wrap:anywhere] rounded-lg border border-input bg-transparent px-3 py-2 text-base transition-colors outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
className,
)}
{...props}