From 24cc4551686bf288d0094e604bcbf6ba4736d588 Mon Sep 17 00:00:00 2001 From: Romain PINSOLLE Date: Thu, 9 Apr 2026 13:17:09 +0200 Subject: [PATCH 01/37] fix: improve responsive layout for application logs page Co-Authored-By: Claude Sonnet 4.6 --- .../components/dashboard/application/logs/show.tsx | 2 +- .../dashboard/docker/logs/docker-logs-id.tsx | 10 +++++----- .../components/dashboard/docker/logs/terminal-line.tsx | 6 +++--- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/apps/dokploy/components/dashboard/application/logs/show.tsx b/apps/dokploy/components/dashboard/application/logs/show.tsx index 06b257766..37ca6c6fb 100644 --- a/apps/dokploy/components/dashboard/application/logs/show.tsx +++ b/apps/dokploy/components/dashboard/application/logs/show.tsx @@ -104,7 +104,7 @@ export const ShowDockerLogs = ({ appName, serverId }: Props) => { -
+
diff --git a/apps/dokploy/components/dashboard/docker/logs/docker-logs-id.tsx b/apps/dokploy/components/dashboard/docker/logs/docker-logs-id.tsx index 59b939008..7e330e20e 100644 --- a/apps/dokploy/components/dashboard/docker/logs/docker-logs-id.tsx +++ b/apps/dokploy/components/dashboard/docker/logs/docker-logs-id.tsx @@ -337,11 +337,11 @@ export const DockerLogsId: React.FC = ({ />
-
+
+
+ + {isError && {error?.message}} + +
+ + ( + + Type + + + + )} + /> + ( + + Name + + + + + Use @ for the root domain. + + + + )} + /> + {type === "A" && ipSuggestions.length > 0 && ( + + Fill from server (optional) + + + )} + ( + + {valueFields[type].label} + + + + {valueFields[type].hint && ( + {valueFields[type].hint} + )} + + + )} + /> + {canProxy && ( + ( + + Proxy status +
+ Proxy status +
+ + {field.value + ? "Traffic runs through Cloudflare and the origin IP stays hidden." + : "Cloudflare only answers the DNS query; traffic reaches the origin directly."} + +
+ )} + /> + )} + ( + + TTL (optional) + + + + {usesAutomaticTtl && ( + + Proxied records always use automatic TTL. + + )} + + + )} + /> +
+ + +
+ + +
+ ); +}; diff --git a/apps/dokploy/components/dashboard/settings/dns/dns-record-type-badge.tsx b/apps/dokploy/components/dashboard/settings/dns/dns-record-type-badge.tsx new file mode 100644 index 000000000..2673bd0c7 --- /dev/null +++ b/apps/dokploy/components/dashboard/settings/dns/dns-record-type-badge.tsx @@ -0,0 +1,33 @@ +import { cn } from "@/lib/utils"; + +const recordTypeStyles: Record = { + A: "bg-blue-500/10 text-blue-700 ring-blue-500/25 dark:text-blue-300", + AAAA: "bg-indigo-500/10 text-indigo-700 ring-indigo-500/25 dark:text-indigo-300", + CNAME: + "bg-violet-500/10 text-violet-700 ring-violet-500/25 dark:text-violet-300", + MX: "bg-amber-500/10 text-amber-700 ring-amber-500/25 dark:text-amber-300", + TXT: "bg-emerald-500/10 text-emerald-700 ring-emerald-500/25 dark:text-emerald-300", + NS: "bg-cyan-500/10 text-cyan-700 ring-cyan-500/25 dark:text-cyan-300", + SRV: "bg-rose-500/10 text-rose-700 ring-rose-500/25 dark:text-rose-300", + CAA: "bg-teal-500/10 text-teal-700 ring-teal-500/25 dark:text-teal-300", + PTR: "bg-orange-500/10 text-orange-700 ring-orange-500/25 dark:text-orange-300", + SOA: "bg-slate-500/10 text-slate-700 ring-slate-500/25 dark:text-slate-300", +}; + +interface Props { + type: string; + className?: string; +} + +export const DnsRecordTypeBadge = ({ type, className }: Props) => ( + + {type.toUpperCase()} + +); diff --git a/apps/dokploy/components/dashboard/settings/dns/handle-dns-provider.tsx b/apps/dokploy/components/dashboard/settings/dns/handle-dns-provider.tsx index 67d6a7547..166b00c3e 100644 --- a/apps/dokploy/components/dashboard/settings/dns/handle-dns-provider.tsx +++ b/apps/dokploy/components/dashboard/settings/dns/handle-dns-provider.tsx @@ -33,6 +33,11 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip"; import { api } from "@/utils/api"; const providerLabels = { @@ -180,22 +185,30 @@ export const HandleDnsProvider = ({ dnsProviderId }: Props) => { return ( - - {dnsProviderId ? ( - - ) : ( - + + + Edit provider + + ) : ( + + - )} - + + )} diff --git a/apps/dokploy/components/dashboard/settings/dns/handle-dns-record.tsx b/apps/dokploy/components/dashboard/settings/dns/handle-dns-record.tsx deleted file mode 100644 index 00ab06074..000000000 --- a/apps/dokploy/components/dashboard/settings/dns/handle-dns-record.tsx +++ /dev/null @@ -1,271 +0,0 @@ -import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema"; -import { PenBoxIcon, PlusIcon } from "lucide-react"; -import { useState } from "react"; -import { useForm } from "react-hook-form"; -import { toast } from "sonner"; -import { z } from "zod"; -import { AlertBlock } from "@/components/shared/alert-block"; -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 { api } from "@/utils/api"; - -const DnsRecordSchema = z.object({ - type: z.enum(["A", "CNAME"]), - name: z.string().min(1, { message: "Name is required" }), - content: z.string().min(1, { message: "Content is required" }), - ttl: z.string(), -}); - -type DnsRecordForm = z.infer; - -interface DnsRecordValue { - id: string; - type: string; - name: string; - content: string; - ttl: number; -} - -interface Props { - dnsProviderId: string; - zoneId: string; - zoneName: string; - record?: DnsRecordValue; -} - -export const HandleDnsRecord = ({ - dnsProviderId, - zoneId, - zoneName, - record, -}: Props) => { - const utils = api.useUtils(); - const [isOpen, setIsOpen] = useState(false); - - const { mutateAsync, isPending, error, isError } = record - ? api.dnsProvider.updateRecord.useMutation() - : api.dnsProvider.createRecord.useMutation(); - - const { data: servers } = api.server.all.useQuery(undefined, { - enabled: isOpen, - }); - const { data: panelPublicIp } = api.server.publicIp.useQuery(undefined, { - enabled: isOpen, - }); - const { data: panelStoredIp } = api.settings.getIp.useQuery(undefined, { - enabled: isOpen, - }); - - const panelIp = panelPublicIp || panelStoredIp; - const ipSuggestions = [ - ...(panelIp ? [{ ip: panelIp, label: "This Dokploy server" }] : []), - ...(servers ?? []).map((server) => ({ - ip: server.ipAddress, - label: server.name, - })), - ].filter( - (suggestion, index, all) => - !!suggestion.ip && all.findIndex((s) => s.ip === suggestion.ip) === index, - ); - - const form = useForm({ - defaultValues: record - ? { - type: record.type === "CNAME" ? "CNAME" : "A", - name: record.name, - content: record.content, - ttl: record.ttl && record.ttl !== 1 ? String(record.ttl) : "", - } - : { type: "A", name: "", content: "", ttl: "" }, - resolver: zodResolver(DnsRecordSchema), - }); - - const type = form.watch("type"); - - const onSubmit = async (data: DnsRecordForm) => { - const name = data.name.trim() === "@" ? zoneName : data.name; - const payload = { - dnsProviderId, - zoneId, - type: data.type, - name, - content: data.content, - ttl: data.ttl ? Number(data.ttl) : undefined, - ...(record && { recordId: record.id }), - }; - await mutateAsync(payload as any) - .then(() => { - toast.success(record ? "Record updated" : "Record created"); - utils.dnsProvider.listRecords.invalidate({ dnsProviderId, zoneId }); - setIsOpen(false); - }) - .catch(() => {}); - }; - - return ( - - - {record ? ( - - ) : ( - - )} - - - - {record ? "Edit Record" : "Add Record"} - - {record - ? "Update this DNS record." - : "Create a new A or CNAME record in this zone."} - - - {isError && {error?.message}} -
- - ( - - Type - - - - )} - /> - ( - - Name - - - - - Use @ for the root domain. - - - - )} - /> - {type === "A" && ipSuggestions.length > 0 && ( - - Fill from server (optional) - - - )} - ( - - - {type === "A" ? "IPv4 Address" : "Target"} - - - - - - - )} - /> - ( - - TTL (optional) - - - - - - )} - /> - - - - - -
-
- ); -}; diff --git a/apps/dokploy/components/dashboard/settings/dns/show-dns-provider-zones.tsx b/apps/dokploy/components/dashboard/settings/dns/show-dns-provider-zones.tsx deleted file mode 100644 index 2ca61abfa..000000000 --- a/apps/dokploy/components/dashboard/settings/dns/show-dns-provider-zones.tsx +++ /dev/null @@ -1,218 +0,0 @@ -import { - ChevronDown, - ChevronRight, - Globe, - Loader2, - Trash2, -} from "lucide-react"; -import { useState } from "react"; -import { toast } from "sonner"; -import { DialogAction } from "@/components/shared/dialog-action"; -import { Badge } from "@/components/ui/badge"; -import { Button } from "@/components/ui/button"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogHeader, - DialogTitle, - DialogTrigger, -} from "@/components/ui/dialog"; -import { api } from "@/utils/api"; -import { HandleDnsRecord } from "./handle-dns-record"; - -interface ZoneRecordsProps { - dnsProviderId: string; - zoneId: string; - zoneName: string; -} - -const ZoneRecords = ({ dnsProviderId, zoneId, zoneName }: ZoneRecordsProps) => { - const utils = api.useUtils(); - const { data, isLoading, isError, error } = - api.dnsProvider.listRecords.useQuery({ dnsProviderId, zoneId }); - const { data: permissions } = api.user.getPermissions.useQuery(); - const { mutateAsync: deleteRecord, isPending: isDeleting } = - api.dnsProvider.deleteRecord.useMutation(); - - const canWrite = !!permissions?.dnsProvider.update; - const canDelete = !!permissions?.dnsProvider.delete; - - return ( -
- {isLoading && ( -
- Loading records... - -
- )} - {isError && ( -

{error?.message}

- )} - {data && data.length === 0 && ( -

- No records found in this zone. -

- )} - {data?.map((record) => { - const isEditable = record.type === "A" || record.type === "CNAME"; - return ( -
- - {record.type} - - {record.name} - - → {record.content} - - {canWrite && isEditable && ( - - )} - {canDelete && ( - { - await deleteRecord({ - dnsProviderId, - zoneId, - recordId: record.id, - }) - .then(() => { - toast.success("Record deleted"); - utils.dnsProvider.listRecords.invalidate({ - dnsProviderId, - zoneId, - }); - }) - .catch(() => { - toast.error("Error deleting the record"); - }); - }} - > - - - )} -
- ); - })} - {canWrite && ( -
- -
- )} -
- ); -}; - -interface Props { - dnsProviderId: string; - providerName: string; -} - -export const ShowDnsProviderZones = ({ - dnsProviderId, - providerName, -}: Props) => { - const [isOpen, setIsOpen] = useState(false); - const [expandedZoneId, setExpandedZoneId] = useState(null); - - const { data, isLoading, isError, error } = - api.dnsProvider.listZones.useQuery({ dnsProviderId }, { enabled: isOpen }); - - return ( - { - setIsOpen(open); - if (!open) { - setExpandedZoneId(null); - } - }} - > - - - - - - Domains for {providerName} - - Zones this provider's API token can manage. Click a zone to see and - manage its records. - - - {isLoading && ( -
- Loading... - -
- )} - {isError && ( -

{error?.message}

- )} - {data && data.length === 0 && ( -

- No zones found for this token. Make sure it has access to at least - one zone. -

- )} - {data && data.length > 0 && ( -
- {data.map((zone) => { - const isExpanded = expandedZoneId === zone.id; - return ( -
- - {isExpanded && ( - - )} -
- ); - })} -
- )} -
-
- ); -}; diff --git a/apps/dokploy/components/dashboard/settings/dns/show-dns-providers.tsx b/apps/dokploy/components/dashboard/settings/dns/show-dns-providers.tsx index 9797ab93d..4d511ff35 100644 --- a/apps/dokploy/components/dashboard/settings/dns/show-dns-providers.tsx +++ b/apps/dokploy/components/dashboard/settings/dns/show-dns-providers.tsx @@ -1,8 +1,9 @@ -import { Globe, Loader2, Trash2 } from "lucide-react"; +import { EyeIcon, Globe, Trash2 } from "lucide-react"; +import Link from "next/link"; +import { useState } from "react"; import { toast } from "sonner"; import { dnsProviderIcons } from "@/components/icons/dns-provider-icons"; import { DialogAction } from "@/components/shared/dialog-action"; -import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Card, @@ -11,9 +12,14 @@ import { CardHeader, CardTitle, } from "@/components/ui/card"; +import { Skeleton } from "@/components/ui/skeleton"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip"; import { api } from "@/utils/api"; import { HandleDnsProvider } from "./handle-dns-provider"; -import { ShowDnsProviderZones } from "./show-dns-provider-zones"; const providerLabels: Record = { cloudflare: "Cloudflare", @@ -21,121 +27,143 @@ const providerLabels: Record = { }; export const ShowDnsProviders = () => { - const { mutateAsync, isPending: isRemoving } = - api.dnsProvider.remove.useMutation(); + const [removingId, setRemovingId] = useState(null); + const { mutateAsync } = api.dnsProvider.remove.useMutation(); const { data, isPending, refetch } = api.dnsProvider.all.useQuery(); const { data: permissions } = api.user.getPermissions.useQuery(); return (
- +
- - - - DNS Providers - - - Connect a DNS provider so Dokploy can create the A/CNAME record - for a domain instead of you setting it up by hand. - - - +
+ + + + DNS Providers + + + Connect a DNS provider so Dokploy can create the A/CNAME record + for a domain instead of you setting it up by hand. + + + {permissions?.dnsProvider.create && } +
+ + {isPending ? ( -
- Loading... - +
+ {[0, 1, 2].map((row) => ( + + ))} +
+ ) : data?.length === 0 ? ( +
+ + + No DNS providers connected + + + Add Cloudflare or Route53 credentials to manage domain records + without leaving Dokploy. +
) : ( - <> - {data?.length === 0 ? ( -
- - - You don't have any DNS providers configured - - {permissions?.dnsProvider.create && } -
- ) : ( -
-
- {data?.map((provider) => { - const ProviderIcon = - dnsProviderIcons[provider.providerType]; - return ( -
-
-
- -
- - {provider.name} - - - {providerLabels[provider.providerType] ?? - provider.providerType} - -
-
- -
- - {permissions?.dnsProvider.update && ( - - )} - {permissions?.dnsProvider.delete && ( - { - await mutateAsync({ - dnsProviderId: provider.dnsProviderId, - }) - .then(() => { - toast.success("DNS provider deleted"); - refetch(); - }) - .catch(() => { - toast.error( - "Error deleting the DNS provider", - ); - }); - }} - > - - - )} -
-
-
- ); - })} -
- - {permissions?.dnsProvider.create && ( -
- +
    + {data?.map((provider) => { + const ProviderIcon = dnsProviderIcons[provider.providerType]; + const href = `/dashboard/settings/dns/${provider.dnsProviderId}`; + return ( +
  • + + +
    + + {provider.name} + + + {providerLabels[provider.providerType] ?? + provider.providerType} +
    - )} -
- )} - + +
+ + + + + View domains + + + {permissions?.dnsProvider.update && ( + + )} + {permissions?.dnsProvider.delete && ( + + { + setRemovingId(provider.dnsProviderId); + await mutateAsync({ + dnsProviderId: provider.dnsProviderId, + }) + .then(() => { + toast.success("DNS provider deleted"); + refetch(); + }) + .catch(() => { + toast.error( + "Error deleting the DNS provider", + ); + }) + .finally(() => setRemovingId(null)); + }} + > + + + + + Delete provider + + )} +
+ + ); + })} + )}
diff --git a/apps/dokploy/components/dashboard/settings/dns/show-dns-records.tsx b/apps/dokploy/components/dashboard/settings/dns/show-dns-records.tsx new file mode 100644 index 000000000..9431545dc --- /dev/null +++ b/apps/dokploy/components/dashboard/settings/dns/show-dns-records.tsx @@ -0,0 +1,566 @@ +import { + type ColumnDef, + flexRender, + getCoreRowModel, + getPaginationRowModel, + getSortedRowModel, + type PaginationState, + type SortingState, + useReactTable, +} from "@tanstack/react-table"; +import { + ArrowLeft, + ArrowUpDown, + Cloud, + CloudOff, + ListTree, + PenBoxIcon, + PlusIcon, + Search, + Trash2, +} from "lucide-react"; +import Link from "next/link"; +import { useMemo, useState } from "react"; +import { toast } from "sonner"; +import { AlertBlock } from "@/components/shared/alert-block"; +import { DialogAction } from "@/components/shared/dialog-action"; +import { FocusShortcutInput } from "@/components/shared/focus-shortcut-input"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Skeleton } from "@/components/ui/skeleton"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { cn } from "@/lib/utils"; +import { api } from "@/utils/api"; +import { + DNS_RECORD_TYPES, + DnsRecordPanel, + type DnsRecordValue, + PROXIABLE_TYPES, +} from "./dns-record-panel"; +import { DnsRecordTypeBadge } from "./dns-record-type-badge"; + +interface Props { + dnsProviderId: string; + zoneId: string; +} + +const PAGE_SIZES = [10, 20, 50, 100]; + +const SortableHeader = ({ + column, + title, + className, +}: { + column: { + getIsSorted: () => false | "asc" | "desc"; + toggleSorting: (asc: boolean) => void; + }; + title: string; + className?: string; +}) => ( + +); + +export const ShowDnsRecords = ({ dnsProviderId, zoneId }: Props) => { + const utils = api.useUtils(); + const [isPanelOpen, setIsPanelOpen] = useState(false); + const [editing, setEditing] = useState(null); + const [deletingId, setDeletingId] = useState(null); + const [search, setSearch] = useState(""); + const [typeFilter, setTypeFilter] = useState("all"); + const [sorting, setSorting] = useState([ + { id: "name", desc: false }, + ]); + const [pagination, setPagination] = useState({ + pageIndex: 0, + pageSize: 10, + }); + + const { data: provider } = api.dnsProvider.one.useQuery({ dnsProviderId }); + const { data: zones } = api.dnsProvider.listZones.useQuery({ dnsProviderId }); + const { data, isPending, isError, error } = + api.dnsProvider.listRecords.useQuery({ dnsProviderId, zoneId }); + const { data: permissions } = api.user.getPermissions.useQuery(); + const { mutateAsync: deleteRecord } = + api.dnsProvider.deleteRecord.useMutation(); + + const zoneName = zones?.find((zone) => zone.id === zoneId)?.name ?? ""; + const isCloudflare = provider?.providerType === "cloudflare"; + const canWrite = !!permissions?.dnsProvider.update; + const canDelete = !!permissions?.dnsProvider.delete; + + const openEdit = (record: DnsRecordValue) => { + setEditing(record); + setIsPanelOpen(true); + }; + + const availableTypes = useMemo( + () => [...new Set((data ?? []).map((record) => record.type))].sort(), + [data], + ); + + const filteredRecords = useMemo(() => { + const query = search.trim().toLowerCase(); + return (data ?? []).filter((record) => { + if (typeFilter !== "all" && record.type !== typeFilter) return false; + if (!query) return true; + return ( + record.name.toLowerCase().includes(query) || + record.content.toLowerCase().includes(query) || + record.type.toLowerCase().includes(query) + ); + }); + }, [data, search, typeFilter]); + + const handleDelete = async (record: DnsRecordValue) => { + setDeletingId(record.id); + await deleteRecord({ dnsProviderId, zoneId, recordId: record.id }) + .then(() => { + toast.success("Record deleted"); + utils.dnsProvider.listRecords.invalidate({ dnsProviderId, zoneId }); + if (editing?.id === record.id) setIsPanelOpen(false); + }) + .catch(() => { + toast.error("Error deleting the record"); + }) + .finally(() => setDeletingId(null)); + }; + + const columns = useMemo[]>( + () => [ + { + accessorKey: "type", + header: ({ column }) => , + cell: ({ row }) => , + }, + { + accessorKey: "name", + header: ({ column }) => , + cell: ({ row }) => ( + + {row.original.name} + + ), + }, + { + accessorKey: "content", + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + + {row.original.content} + + ), + }, + { + accessorKey: "ttl", + header: ({ column }) => , + cell: ({ row }) => ( + + {row.original.ttl === 1 ? "Auto" : row.original.ttl} + + ), + }, + ...(isCloudflare + ? [ + { + accessorKey: "proxied", + header: ({ column }) => ( + + ), + cell: ({ row }) => { + if (!PROXIABLE_TYPES.includes(row.original.type)) { + return null; + } + const proxied = !!row.original.proxied; + return ( + + + + {proxied ? ( + + ) : ( + + )} + + {proxied ? "Proxied" : "DNS only"} + + + + + {proxied ? "Proxied" : "DNS only"} + + + ); + }, + } satisfies ColumnDef, + ] + : []), + { + id: "actions", + enableSorting: false, + header: () => Actions, + cell: ({ row }) => { + const record = row.original; + const isEditable = (DNS_RECORD_TYPES as readonly string[]).includes( + record.type, + ); + return ( +
+ {canWrite && isEditable && ( + + + + + Edit record + + )} + {canDelete && ( + + handleDelete(record)} + > + + + + + Delete record + + )} +
+ ); + }, + }, + ], + [canWrite, canDelete, deletingId, editing?.id, isCloudflare], + ); + + const table = useReactTable({ + data: filteredRecords, + columns, + getRowId: (row) => row.id, + state: { sorting, pagination }, + onSortingChange: setSorting, + onPaginationChange: setPagination, + getCoreRowModel: getCoreRowModel(), + getSortedRowModel: getSortedRowModel(), + getPaginationRowModel: getPaginationRowModel(), + }); + + return ( +
+ +
+
+
+ + + + {zoneName || "DNS records"} + + + Records managed through {provider?.name ?? "this provider"}. + Changes are written straight to the provider. + + +
+ {canWrite && ( + + )} +
+ + + {isError && {error?.message}} +
+
+ {isPending ? ( + + ) : data?.length === 0 ? ( +
+
+ +
+
+

+ No records in this zone +

+

+ Add an A or CNAME record to point this domain at one of + your servers. +

+
+
+ ) : ( + <> +
+
+ setSearch(e.target.value)} + className="pr-10" + /> + +
+ + + {filteredRecords.length} of {data?.length ?? 0} + +
+ +
+ + + {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) => { + const isEditable = ( + DNS_RECORD_TYPES as readonly string[] + ).includes(row.original.type); + const isSelected = + isPanelOpen && editing?.id === row.original.id; + return ( + openEdit(row.original) + : undefined + } + className={cn( + "duration-150 ease-out", + canWrite && isEditable && "cursor-pointer", + )} + > + {row.getVisibleCells().map((cell) => ( + event.stopPropagation() + : undefined + } + className="px-4 py-2" + > + {flexRender( + cell.column.columnDef.cell, + cell.getContext(), + )} + + ))} + + ); + }) + ) : ( + + + No records match your filters. + + + )} + +
+
+ +
+
+ + Rows per page + + +
+
+ + Page {table.getState().pagination.pageIndex + 1} of{" "} + {Math.max(table.getPageCount(), 1)} + +
+ + +
+
+
+ + )} +
+ +
+
+
+ {canWrite && ( + setIsPanelOpen(false)} + /> + )} +
+
+
+
+
+
+
+
+ ); +}; diff --git a/apps/dokploy/components/dashboard/settings/dns/show-dns-zones.tsx b/apps/dokploy/components/dashboard/settings/dns/show-dns-zones.tsx new file mode 100644 index 000000000..839c2083b --- /dev/null +++ b/apps/dokploy/components/dashboard/settings/dns/show-dns-zones.tsx @@ -0,0 +1,134 @@ +import { ArrowLeft, Globe } from "lucide-react"; +import Link from "next/link"; +import { AlertBlock } from "@/components/shared/alert-block"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Skeleton } from "@/components/ui/skeleton"; +import { api } from "@/utils/api"; + +interface Props { + dnsProviderId: string; +} + +const RecordCount = ({ + dnsProviderId, + zoneId, +}: { + dnsProviderId: string; + zoneId: string; +}) => { + const { data, isPending, isError } = api.dnsProvider.listRecords.useQuery({ + dnsProviderId, + zoneId, + }); + + if (isPending) { + return ; + } + + if (isError) { + return ( + Records unavailable + ); + } + + return ( + + {data.length === 0 + ? "No records" + : `${data.length} record${data.length === 1 ? "" : "s"}`} + + ); +}; + +export const ShowDnsZones = ({ dnsProviderId }: Props) => { + const { data: provider } = api.dnsProvider.one.useQuery({ dnsProviderId }); + const { data, isPending, isError, error } = + api.dnsProvider.listZones.useQuery({ dnsProviderId }); + + return ( +
+ +
+
+
+ + + + {provider?.name ?? "Domains"} + + + Domains this provider's credentials can manage. Open one to + see and edit its DNS records. + + +
+
+ + + {isError && {error?.message}} + {isPending ? ( +
+ {[0, 1, 2, 3, 4, 5].map((card) => ( + + ))} +
+ ) : data?.length === 0 ? ( +
+
+ +
+
+

No domains found

+

+ These credentials can't reach any zone. Check that the token + has access to at least one domain. +

+
+
+ ) : ( +
+ {data?.map((zone) => ( + +
+ + + + + {zone.name} + +
+
+ +
+ + ))} +
+ )} +
+
+
+
+ ); +}; diff --git a/apps/dokploy/pages/dashboard/settings/dns.tsx b/apps/dokploy/pages/dashboard/settings/dns.tsx index 60aaae76e..0600117b1 100644 --- a/apps/dokploy/pages/dashboard/settings/dns.tsx +++ b/apps/dokploy/pages/dashboard/settings/dns.tsx @@ -3,15 +3,16 @@ import { createServerSideHelpers } from "@trpc/react-query/server"; import type { GetServerSidePropsContext } from "next"; import type { ReactElement } from "react"; import superjson from "superjson"; +import { DnsPageTransition } from "@/components/dashboard/settings/dns/dns-page-transition"; import { ShowDnsProviders } from "@/components/dashboard/settings/dns/show-dns-providers"; import { DashboardLayout } from "@/components/layouts/dashboard-layout"; import { appRouter } from "@/server/api/root"; const Page = () => { return ( -
+ -
+ ); }; diff --git a/apps/dokploy/pages/dashboard/settings/dns/[dnsProviderId].tsx b/apps/dokploy/pages/dashboard/settings/dns/[dnsProviderId].tsx new file mode 100644 index 000000000..105b6d378 --- /dev/null +++ b/apps/dokploy/pages/dashboard/settings/dns/[dnsProviderId].tsx @@ -0,0 +1,62 @@ +import { validateRequest } from "@dokploy/server"; +import { createServerSideHelpers } from "@trpc/react-query/server"; +import type { GetServerSidePropsContext } from "next"; +import type { ReactElement } from "react"; +import superjson from "superjson"; +import { DnsPageTransition } from "@/components/dashboard/settings/dns/dns-page-transition"; +import { ShowDnsZones } from "@/components/dashboard/settings/dns/show-dns-zones"; +import { DashboardLayout } from "@/components/layouts/dashboard-layout"; +import { appRouter } from "@/server/api/root"; + +interface Props { + dnsProviderId: string; +} + +const Page = ({ dnsProviderId }: Props) => { + return ( + + + + ); +}; + +export default Page; + +Page.getLayout = (page: ReactElement) => { + return {page}; +}; + +export async function getServerSideProps( + ctx: GetServerSidePropsContext<{ dnsProviderId: string }>, +) { + const { req, res, params } = ctx; + const { user, session } = await validateRequest(req); + if (!user || user.role === "member" || !params?.dnsProviderId) { + return { + redirect: { + permanent: false, + destination: "/", + }, + }; + } + const helpers = createServerSideHelpers({ + router: appRouter, + ctx: { + req: req as any, + res: res as any, + db: null as any, + session: session as any, + user: user as any, + }, + transformer: superjson, + }); + await helpers.user.get.prefetch(); + await helpers.settings.isCloud.prefetch(); + + return { + props: { + trpcState: helpers.dehydrate(), + dnsProviderId: params.dnsProviderId, + }, + }; +} diff --git a/apps/dokploy/pages/dashboard/settings/dns/[dnsProviderId]/[zoneId].tsx b/apps/dokploy/pages/dashboard/settings/dns/[dnsProviderId]/[zoneId].tsx new file mode 100644 index 000000000..c307738f1 --- /dev/null +++ b/apps/dokploy/pages/dashboard/settings/dns/[dnsProviderId]/[zoneId].tsx @@ -0,0 +1,69 @@ +import { validateRequest } from "@dokploy/server"; +import { createServerSideHelpers } from "@trpc/react-query/server"; +import type { GetServerSidePropsContext } from "next"; +import type { ReactElement } from "react"; +import superjson from "superjson"; +import { DnsPageTransition } from "@/components/dashboard/settings/dns/dns-page-transition"; +import { ShowDnsRecords } from "@/components/dashboard/settings/dns/show-dns-records"; +import { DashboardLayout } from "@/components/layouts/dashboard-layout"; +import { appRouter } from "@/server/api/root"; + +interface Props { + dnsProviderId: string; + zoneId: string; +} + +const Page = ({ dnsProviderId, zoneId }: Props) => { + return ( + + + + ); +}; + +export default Page; + +Page.getLayout = (page: ReactElement) => { + return {page}; +}; + +export async function getServerSideProps( + ctx: GetServerSidePropsContext<{ dnsProviderId: string; zoneId: string }>, +) { + const { req, res, params } = ctx; + const { user, session } = await validateRequest(req); + if ( + !user || + user.role === "member" || + !params?.dnsProviderId || + !params?.zoneId + ) { + return { + redirect: { + permanent: false, + destination: "/", + }, + }; + } + const helpers = createServerSideHelpers({ + router: appRouter, + ctx: { + req: req as any, + res: res as any, + db: null as any, + session: session as any, + user: user as any, + }, + transformer: superjson, + }); + await helpers.user.get.prefetch(); + await helpers.settings.isCloud.prefetch(); + + return { + props: { + trpcState: helpers.dehydrate(), + dnsProviderId: params.dnsProviderId, + zoneId: params.zoneId, + }, + }; +} diff --git a/apps/dokploy/server/api/routers/dns-provider.ts b/apps/dokploy/server/api/routers/dns-provider.ts index 673c91a21..88796101d 100644 --- a/apps/dokploy/server/api/routers/dns-provider.ts +++ b/apps/dokploy/server/api/routers/dns-provider.ts @@ -162,6 +162,7 @@ export const dnsProviderRouter = createTRPCRouter({ name: input.name, content: input.content, ttl: input.ttl, + proxied: input.proxied, }); await audit(ctx, { action: "create", @@ -188,6 +189,7 @@ export const dnsProviderRouter = createTRPCRouter({ name: input.name, content: input.content, ttl: input.ttl, + proxied: input.proxied, }, ); await audit(ctx, { diff --git a/apps/dokploy/styles/globals.css b/apps/dokploy/styles/globals.css index 016513952..4dcc3d697 100644 --- a/apps/dokploy/styles/globals.css +++ b/apps/dokploy/styles/globals.css @@ -531,3 +531,98 @@ body[data-scroll-locked] [data-slot="sidebar-inset"] { --color-sidebar-border: var(--sidebar-border); --color-sidebar-ring: var(--sidebar-ring); } + +:root { + --duration-quick: 150ms; + --duration-fast: 250ms; + --duration-medium: 350ms; + --duration-slow: 400ms; + --ease-smooth-out: cubic-bezier(0.22, 1, 0.36, 1); + --distance-base: 8px; + --blur-medium: 3px; + --panel-open-dur: 400ms; + --panel-close-dur: 350ms; + --panel-translate-x: 32px; + --panel-blur: 2px; + --panel-ease: cubic-bezier(0.22, 1, 0.36, 1); +} + +@layer components { + .t-panel-slide-x { + transform: translateX(var(--panel-translate-x)); + opacity: 0; + filter: blur(var(--panel-blur)); + pointer-events: none; + transition: + transform var(--panel-close-dur) var(--panel-ease), + opacity var(--panel-close-dur) var(--panel-ease), + filter var(--panel-close-dur) var(--panel-ease); + will-change: transform, opacity, filter; + } + + .t-panel-slide-x[data-open="true"] { + transform: translateX(0); + opacity: 1; + filter: blur(0); + pointer-events: auto; + transition: + transform var(--panel-open-dur) var(--panel-ease), + opacity var(--panel-open-dur) var(--panel-ease), + filter var(--panel-open-dur) var(--panel-ease); + } + + .t-panel-track { + transition: + grid-template-columns var(--panel-open-dur) var(--panel-ease), + grid-template-rows var(--panel-open-dur) var(--panel-ease); + } + + @media (prefers-reduced-motion: reduce) { + .t-panel-slide-x, + .t-panel-track { + transition: none !important; + } + } +} + +@layer components { + .t-page-enter { + animation: t-page-enter var(--duration-fast) var(--ease-smooth-out) both; + } + + .t-page-enter[data-direction="back"] { + animation-name: t-page-enter-back; + } + + @keyframes t-page-enter { + from { + opacity: 0; + transform: translateX(var(--distance-base)); + filter: blur(var(--blur-medium)); + } + to { + opacity: 1; + transform: translateX(0); + filter: blur(0); + } + } + + @keyframes t-page-enter-back { + from { + opacity: 0; + transform: translateX(calc(var(--distance-base) * -1)); + filter: blur(var(--blur-medium)); + } + to { + opacity: 1; + transform: translateX(0); + filter: blur(0); + } + } + + @media (prefers-reduced-motion: reduce) { + .t-page-enter { + animation: none; + } + } +} diff --git a/packages/server/src/db/schema/dns-provider.ts b/packages/server/src/db/schema/dns-provider.ts index 50677e646..fa2135173 100644 --- a/packages/server/src/db/schema/dns-provider.ts +++ b/packages/server/src/db/schema/dns-provider.ts @@ -96,11 +96,28 @@ export const apiListDnsRecords = z.object({ zoneId: z.string().min(1), }); +export const dnsRecordTypes = [ + "A", + "AAAA", + "CNAME", + "MX", + "TXT", + "NS", + "SRV", + "CAA", + "PTR", +] as const; + +export type DnsRecordType = (typeof dnsRecordTypes)[number]; + +export const proxiableDnsRecordTypes = ["A", "AAAA", "CNAME"] as const; + const dnsRecordFieldsSchema = z.object({ - type: z.enum(["A", "CNAME"]), + type: z.enum(dnsRecordTypes), name: z.string().min(1), content: z.string().min(1), ttl: z.number().int().positive().optional(), + proxied: z.boolean().optional(), }); export const apiCreateDnsRecord = dnsRecordFieldsSchema.extend({ diff --git a/packages/server/src/utils/dns/cloudflare.ts b/packages/server/src/utils/dns/cloudflare.ts index 9fedd00e5..51eeaee31 100644 --- a/packages/server/src/utils/dns/cloudflare.ts +++ b/packages/server/src/utils/dns/cloudflare.ts @@ -1,4 +1,7 @@ -import type { cloudflareDnsConfigSchema } from "@dokploy/server/db/schema"; +import { + type cloudflareDnsConfigSchema, + proxiableDnsRecordTypes, +} from "@dokploy/server/db/schema"; import type { z } from "zod"; import { type DnsClient, dnsFetch } from "./types"; @@ -13,6 +16,31 @@ type CloudflareResponse = { const CLOUDFLARE_API = "https://api.cloudflare.com/client/v4"; +const inlinePriority = (record: { + type: string; + content: string; + priority?: number; +}) => + record.type === "MX" && typeof record.priority === "number" + ? `${record.priority} ${record.content}` + : record.content; + +const proxySettings = (record: { type: string; proxied?: boolean }) => + record.proxied !== undefined && + (proxiableDnsRecordTypes as readonly string[]).includes(record.type) + ? { proxied: record.proxied } + : {}; + +const splitPriority = (record: { type: string; content: string }) => { + if (record.type !== "MX") { + return { content: record.content }; + } + const match = /^\s*(\d+)\s+(\S.*)$/.exec(record.content); + return match + ? { content: match[2] as string, priority: Number(match[1]) } + : { content: record.content.trim(), priority: 10 }; +}; + const cfFetch = async ( config: CloudflareConfig, path: string, @@ -72,9 +100,20 @@ export const cloudflareClient: DnsClient = { name: string; content: string; ttl: number; + priority?: number; + proxied?: boolean; }[] >(config, `/zones/${zoneId}/dns_records?per_page=50&page=${page}`); - records.push(...result); + records.push( + ...result.map((record) => ({ + id: record.id, + type: record.type, + name: record.name, + content: inlinePriority(record), + ttl: record.ttl, + proxied: record.proxied, + })), + ); if (result.length < 50) { break; } @@ -92,7 +131,8 @@ export const cloudflareClient: DnsClient = { const payload = { type: record.type, name: record.name, - content: record.content, + ...splitPriority(record), + ...proxySettings(record), ttl: record.ttl ?? 1, }; @@ -123,7 +163,8 @@ export const cloudflareClient: DnsClient = { body: JSON.stringify({ type: record.type, name: record.name, - content: record.content, + ...splitPriority(record), + ...proxySettings(record), ttl: record.ttl ?? 1, }), }, diff --git a/packages/server/src/utils/dns/route53.ts b/packages/server/src/utils/dns/route53.ts index 112f56689..c00bfbd68 100644 --- a/packages/server/src/utils/dns/route53.ts +++ b/packages/server/src/utils/dns/route53.ts @@ -5,7 +5,10 @@ import { type ResourceRecordSet, Route53Client, } from "@aws-sdk/client-route-53"; -import type { route53DnsConfigSchema } from "@dokploy/server/db/schema"; +import type { + DnsRecordType, + route53DnsConfigSchema, +} from "@dokploy/server/db/schema"; import type { z } from "zod"; import type { DnsClient } from "./types"; @@ -66,13 +69,13 @@ const findExactRecordSet = async ( }; const buildRecordSet = (record: { - type: "A" | "CNAME"; + type: DnsRecordType; name: string; content: string; ttl?: number; }): ResourceRecordSet => ({ Name: ensureTrailingDot(record.name), - Type: record.type, + Type: record.type as ResourceRecordSet["Type"], TTL: record.ttl ?? 300, ResourceRecords: [{ Value: record.content }], }); diff --git a/packages/server/src/utils/dns/types.ts b/packages/server/src/utils/dns/types.ts index c939c2230..e7e800a2a 100644 --- a/packages/server/src/utils/dns/types.ts +++ b/packages/server/src/utils/dns/types.ts @@ -1,4 +1,7 @@ -import type { DnsProviderConfig } from "@dokploy/server/db/schema"; +import type { + DnsProviderConfig, + DnsRecordType, +} from "@dokploy/server/db/schema"; export interface DnsZone { id: string; @@ -7,10 +10,11 @@ export interface DnsZone { export interface DnsRecordInput { zoneId: string; - type: "A" | "CNAME"; + type: DnsRecordType; name: string; content: string; ttl?: number; + proxied?: boolean; } export interface DnsRecord { @@ -19,6 +23,7 @@ export interface DnsRecord { name: string; content: string; ttl: number; + proxied?: boolean; } export interface DnsClient { From db2eb4f60a5ce2b2a114b281b6742a1322d5c517 Mon Sep 17 00:00:00 2001 From: logical-tech Date: Fri, 21 Aug 2026 11:47:29 +0200 Subject: [PATCH 06/37] fix(dns): send SRV and CAA values to Cloudflare as structured data Cloudflare treats content as read-only for SRV and CAA and expects a data object instead, so both types were rejected on write. The client now parses the inline value into the fields Cloudflare wants, and builds the payload before the lookup request so a malformed value fails without spending an API call. The form rejects a malformed SRV or CAA value up front and shows the expected shape, so the error lands on the field instead of coming back from the provider. --- apps/dokploy/__test__/dns/cloudflare.test.ts | 70 +++++++++++++++++++ .../settings/dns/dns-record-panel.tsx | 30 ++++++-- packages/server/src/utils/dns/cloudflare.ts | 69 +++++++++++++----- 3 files changed, 146 insertions(+), 23 deletions(-) diff --git a/apps/dokploy/__test__/dns/cloudflare.test.ts b/apps/dokploy/__test__/dns/cloudflare.test.ts index f2abbc4bb..f27e7bc60 100644 --- a/apps/dokploy/__test__/dns/cloudflare.test.ts +++ b/apps/dokploy/__test__/dns/cloudflare.test.ts @@ -164,6 +164,76 @@ describe("cloudflareClient MX priority", () => { }); }); +describe("cloudflareClient structured records", () => { + const stubCreate = () => + mockFetch + .mockResolvedValueOnce(cfSuccess([])) + .mockResolvedValueOnce(cfSuccess({ id: "rec-1" })); + + it("sends SRV values as structured data", async () => { + stubCreate(); + + await cloudflareClient.upsertRecord(config, { + zoneId: "zone-1", + type: "SRV", + name: "_sip._tcp.example.com", + content: "1 10 5269 talk.example.com", + }); + + const [, init] = mockFetch.mock.calls[1] as [string, RequestInit]; + const body = JSON.parse(init.body as string); + expect(body.data).toEqual({ + priority: 1, + weight: 10, + port: 5269, + target: "talk.example.com", + }); + expect(body.content).toBeUndefined(); + }); + + it("sends CAA values as structured data and drops the quotes", async () => { + stubCreate(); + + await cloudflareClient.upsertRecord(config, { + zoneId: "zone-1", + type: "CAA", + name: "example.com", + content: '0 issue "letsencrypt.org"', + }); + + const [, init] = mockFetch.mock.calls[1] as [string, RequestInit]; + const body = JSON.parse(init.body as string); + expect(body.data).toEqual({ + flags: 0, + tag: "issue", + value: "letsencrypt.org", + }); + expect(body.content).toBeUndefined(); + }); + + it("rejects an SRV value that is missing a part", async () => { + await expect( + cloudflareClient.upsertRecord(config, { + zoneId: "zone-1", + type: "SRV", + name: "_sip._tcp.example.com", + content: "1 10 5269", + }), + ).rejects.toThrow(/SRV value/); + }); + + it("rejects a CAA value that has no tag", async () => { + await expect( + cloudflareClient.upsertRecord(config, { + zoneId: "zone-1", + type: "CAA", + name: "example.com", + content: "0", + }), + ).rejects.toThrow(/CAA value/); + }); +}); + describe("cloudflareClient proxy status", () => { it("sends the proxy status for proxiable types", async () => { mockFetch diff --git a/apps/dokploy/components/dashboard/settings/dns/dns-record-panel.tsx b/apps/dokploy/components/dashboard/settings/dns/dns-record-panel.tsx index 3f06581a5..237c2f32c 100644 --- a/apps/dokploy/components/dashboard/settings/dns/dns-record-panel.tsx +++ b/apps/dokploy/components/dashboard/settings/dns/dns-record-panel.tsx @@ -68,13 +68,29 @@ const valueFields: Record< PTR: { label: "Target", placeholder: "host.example.com" }, }; -const DnsRecordSchema = z.object({ - type: z.enum(DNS_RECORD_TYPES), - name: z.string().min(1, { message: "Name is required" }), - content: z.string().min(1, { message: "Content is required" }), - ttl: z.string(), - proxied: z.boolean(), -}); +const structuredValuePatterns: Partial> = { + SRV: /^\d+\s+\d+\s+\d+\s+\S+$/, + CAA: /^\d+\s+\S+\s+.+$/, +}; + +const DnsRecordSchema = z + .object({ + type: z.enum(DNS_RECORD_TYPES), + name: z.string().min(1, { message: "Name is required" }), + content: z.string().min(1, { message: "Content is required" }), + ttl: z.string(), + proxied: z.boolean(), + }) + .superRefine((data, ctx) => { + const pattern = structuredValuePatterns[data.type]; + if (pattern && !pattern.test(data.content.trim())) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["content"], + message: `Expected ${valueFields[data.type].placeholder}`, + }); + } + }); type DnsRecordForm = z.infer; diff --git a/packages/server/src/utils/dns/cloudflare.ts b/packages/server/src/utils/dns/cloudflare.ts index 51eeaee31..24cb9c196 100644 --- a/packages/server/src/utils/dns/cloudflare.ts +++ b/packages/server/src/utils/dns/cloudflare.ts @@ -31,14 +31,51 @@ const proxySettings = (record: { type: string; proxied?: boolean }) => ? { proxied: record.proxied } : {}; -const splitPriority = (record: { type: string; content: string }) => { - if (record.type !== "MX") { - return { content: record.content }; +const buildValue = (record: { type: string; content: string }) => { + const value = record.content.trim(); + + if (record.type === "MX") { + const match = /^(\d+)\s+(\S.*)$/.exec(value); + return match + ? { content: match[2] as string, priority: Number(match[1]) } + : { content: value, priority: 10 }; } - const match = /^\s*(\d+)\s+(\S.*)$/.exec(record.content); - return match - ? { content: match[2] as string, priority: Number(match[1]) } - : { content: record.content.trim(), priority: 10 }; + + if (record.type === "SRV") { + const parts = value.split(/\s+/); + const [priority, weight, port, target] = parts; + if (parts.length !== 4 || !target) { + throw new Error( + `Cloudflare: an SRV value must be "priority weight port target", got "${value}"`, + ); + } + return { + data: { + priority: Number(priority), + weight: Number(weight), + port: Number(port), + target, + }, + }; + } + + if (record.type === "CAA") { + const match = /^(\d+)\s+(\S+)\s+"?([^"]+)"?$/.exec(value); + if (!match) { + throw new Error( + `Cloudflare: a CAA value must be \`flags tag "value"\`, got "${value}"`, + ); + } + return { + data: { + flags: Number(match[1]), + tag: match[2] as string, + value: match[3] as string, + }, + }; + } + + return { content: value }; }; const cfFetch = async ( @@ -123,19 +160,19 @@ export const cloudflareClient: DnsClient = { }, async upsertRecord(config, record) { + const payload = { + type: record.type, + name: record.name, + ...buildValue(record), + ...proxySettings(record), + ttl: record.ttl ?? 1, + }; + const existing = await cfFetch<{ id: string }[]>( config, `/zones/${record.zoneId}/dns_records?type=${record.type}&name=${encodeURIComponent(record.name)}`, ); - const payload = { - type: record.type, - name: record.name, - ...splitPriority(record), - ...proxySettings(record), - ttl: record.ttl ?? 1, - }; - const existingRecord = existing[0]; if (existingRecord) { const updated = await cfFetch<{ id: string }>( @@ -163,7 +200,7 @@ export const cloudflareClient: DnsClient = { body: JSON.stringify({ type: record.type, name: record.name, - ...splitPriority(record), + ...buildValue(record), ...proxySettings(record), ttl: record.ttl ?? 1, }), From 130be40b49c0bfb0944a2631fc43e3d105bf32a6 Mon Sep 17 00:00:00 2001 From: logical-tech Date: Fri, 21 Aug 2026 11:47:29 +0200 Subject: [PATCH 07/37] fix(dns): keep the record table on a valid page while filtering Filtering from a later page left pageIndex past the end of the filtered set, so the table said no records matched while the counter above it reported matches. The page index is clamped to the available page count, and changing the search or the type filter goes back to the first page. --- .../settings/dns/show-dns-records.tsx | 30 +++++++++++++++---- 1 file changed, 25 insertions(+), 5 deletions(-) diff --git a/apps/dokploy/components/dashboard/settings/dns/show-dns-records.tsx b/apps/dokploy/components/dashboard/settings/dns/show-dns-records.tsx index 9431545dc..57129452e 100644 --- a/apps/dokploy/components/dashboard/settings/dns/show-dns-records.tsx +++ b/apps/dokploy/components/dashboard/settings/dns/show-dns-records.tsx @@ -294,11 +294,23 @@ export const ShowDnsRecords = ({ dnsProviderId, zoneId }: Props) => { [canWrite, canDelete, deletingId, editing?.id, isCloudflare], ); + const pageCount = Math.max( + 1, + Math.ceil(filteredRecords.length / pagination.pageSize), + ); + const pageIndex = Math.min(pagination.pageIndex, pageCount - 1); + + const resetToFirstPage = () => + setPagination((previous) => ({ ...previous, pageIndex: 0 })); + const table = useReactTable({ data: filteredRecords, columns, getRowId: (row) => row.id, - state: { sorting, pagination }, + state: { + sorting, + pagination: { pageIndex, pageSize: pagination.pageSize }, + }, onSortingChange: setSorting, onPaginationChange: setPagination, getCoreRowModel: getCoreRowModel(), @@ -369,12 +381,21 @@ export const ShowDnsRecords = ({ dnsProviderId, zoneId }: Props) => { setSearch(e.target.value)} + onChange={(e) => { + setSearch(e.target.value); + resetToFirstPage(); + }} className="pr-10" />
- { + setTypeFilter(value); + resetToFirstPage(); + }} + > @@ -504,8 +525,7 @@ export const ShowDnsRecords = ({ dnsProviderId, zoneId }: Props) => {
- Page {table.getState().pagination.pageIndex + 1} of{" "} - {Math.max(table.getPageCount(), 1)} + Page {pageIndex + 1} of {pageCount}