mirror of
https://github.com/andwati/dokploy.git
synced 2026-10-18 03:02:56 +00:00
validateDomain returns isValid: true for CDN-fronted domains but reuses the `error` field to carry the provider's informational warning. Both domain tooltips branched on `error` alone, so the message rendered under a red "Error:" heading even though the badge correctly showed a green "Behind Cloudflare" state. Check the valid+message case first, matching how the badge already distinguishes the two. Applied to both the table view (columns.tsx) and the card view (show-domains.tsx), which shares the same tooltip. Fixes #4910 Co-Authored-By: Claude Opus 5 <[email protected]>
658 lines
19 KiB
TypeScript
658 lines
19 KiB
TypeScript
import {
|
|
type ColumnFiltersState,
|
|
flexRender,
|
|
getCoreRowModel,
|
|
getFilteredRowModel,
|
|
getPaginationRowModel,
|
|
getSortedRowModel,
|
|
type SortingState,
|
|
useReactTable,
|
|
type VisibilityState,
|
|
} from "@tanstack/react-table";
|
|
import {
|
|
CheckCircle2,
|
|
ChevronDown,
|
|
ExternalLink,
|
|
GlobeIcon,
|
|
InfoIcon,
|
|
LayoutGrid,
|
|
LayoutList,
|
|
Loader2,
|
|
PenBoxIcon,
|
|
RefreshCw,
|
|
Server,
|
|
Trash2,
|
|
XCircle,
|
|
} from "lucide-react";
|
|
import Link from "next/link";
|
|
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 {
|
|
Card,
|
|
CardContent,
|
|
CardDescription,
|
|
CardHeader,
|
|
CardTitle,
|
|
} from "@/components/ui/card";
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuCheckboxItem,
|
|
DropdownMenuContent,
|
|
DropdownMenuTrigger,
|
|
} from "@/components/ui/dropdown-menu";
|
|
import { Input } from "@/components/ui/input";
|
|
import {
|
|
Table,
|
|
TableBody,
|
|
TableCell,
|
|
TableHead,
|
|
TableHeader,
|
|
TableRow,
|
|
} from "@/components/ui/table";
|
|
import {
|
|
Tooltip,
|
|
TooltipContent,
|
|
TooltipProvider,
|
|
TooltipTrigger,
|
|
} from "@/components/ui/tooltip";
|
|
import { api } from "@/utils/api";
|
|
import { createColumns } from "./columns";
|
|
import { DnsHelperModal } from "./dns-helper-modal";
|
|
import { AddDomain } from "./handle-domain";
|
|
import { HandleForwardAuth } from "./handle-forward-auth";
|
|
|
|
export type ValidationState = {
|
|
isLoading: boolean;
|
|
isValid?: boolean;
|
|
error?: string;
|
|
resolvedIp?: string;
|
|
message?: string;
|
|
cdnProvider?: string;
|
|
};
|
|
|
|
export type ValidationStates = Record<string, ValidationState>;
|
|
|
|
interface Props {
|
|
id: string;
|
|
type: "application" | "compose";
|
|
}
|
|
|
|
export const ShowDomains = ({ id, type }: Props) => {
|
|
const { data: permissions } = api.user.getPermissions.useQuery();
|
|
const canCreateDomain = permissions?.domain.create ?? false;
|
|
const canDeleteDomain = permissions?.domain.delete ?? false;
|
|
const { data: application } =
|
|
type === "application"
|
|
? api.application.one.useQuery(
|
|
{
|
|
applicationId: id,
|
|
},
|
|
{
|
|
enabled: !!id,
|
|
},
|
|
)
|
|
: api.compose.one.useQuery(
|
|
{
|
|
composeId: id,
|
|
},
|
|
{
|
|
enabled: !!id,
|
|
},
|
|
);
|
|
const [validationStates, setValidationStates] = useState<ValidationStates>(
|
|
{},
|
|
);
|
|
const [viewMode, setViewMode] = useState<"grid" | "table">(() => {
|
|
if (typeof window !== "undefined") {
|
|
return (
|
|
(localStorage.getItem("domains-view-mode") as "grid" | "table") ??
|
|
"grid"
|
|
);
|
|
}
|
|
return "grid";
|
|
});
|
|
const [sorting, setSorting] = useState<SortingState>([]);
|
|
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
|
|
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({});
|
|
const [rowSelection, setRowSelection] = useState({});
|
|
const { data: ip } = api.settings.getIp.useQuery();
|
|
|
|
const {
|
|
data,
|
|
refetch,
|
|
isLoading: isLoadingDomains,
|
|
} = type === "application"
|
|
? api.domain.byApplicationId.useQuery(
|
|
{
|
|
applicationId: id,
|
|
},
|
|
{
|
|
enabled: !!id,
|
|
},
|
|
)
|
|
: api.domain.byComposeId.useQuery(
|
|
{
|
|
composeId: id,
|
|
},
|
|
{
|
|
enabled: !!id,
|
|
},
|
|
);
|
|
|
|
const { mutateAsync: validateDomain } =
|
|
api.domain.validateDomain.useMutation();
|
|
const { mutateAsync: deleteDomain, isPending: isRemoving } =
|
|
api.domain.delete.useMutation();
|
|
|
|
const handleDeleteDomain = async (domainId: string) => {
|
|
try {
|
|
await deleteDomain({ domainId });
|
|
refetch();
|
|
toast.success("Domain deleted successfully");
|
|
} catch {
|
|
toast.error("Error deleting domain");
|
|
}
|
|
};
|
|
|
|
const handleValidateDomain = async (host: string) => {
|
|
setValidationStates((prev) => ({
|
|
...prev,
|
|
[host]: { isLoading: true },
|
|
}));
|
|
|
|
try {
|
|
const result = await validateDomain({
|
|
domain: host,
|
|
serverIp:
|
|
application?.server?.ipAddress?.toString() || ip?.toString() || "",
|
|
});
|
|
|
|
setValidationStates((prev) => ({
|
|
...prev,
|
|
[host]: {
|
|
isLoading: false,
|
|
isValid: result.isValid,
|
|
error: result.error,
|
|
resolvedIp: result.resolvedIp,
|
|
cdnProvider: result.cdnProvider,
|
|
message: result.error && result.isValid ? result.error : undefined,
|
|
},
|
|
}));
|
|
} catch (err) {
|
|
const error = err as Error;
|
|
setValidationStates((prev) => ({
|
|
...prev,
|
|
[host]: {
|
|
isLoading: false,
|
|
isValid: false,
|
|
error: error.message || "Failed to validate domain",
|
|
},
|
|
}));
|
|
}
|
|
};
|
|
|
|
const columns = createColumns({
|
|
id,
|
|
type,
|
|
validationStates,
|
|
handleValidateDomain,
|
|
handleDeleteDomain,
|
|
isDeleting: isRemoving,
|
|
serverIp: application?.server?.ipAddress?.toString() || ip?.toString(),
|
|
canCreateDomain,
|
|
canDeleteDomain,
|
|
});
|
|
|
|
const table = useReactTable({
|
|
data: data ?? [],
|
|
columns,
|
|
onSortingChange: setSorting,
|
|
onColumnFiltersChange: setColumnFilters,
|
|
getCoreRowModel: getCoreRowModel(),
|
|
getPaginationRowModel: getPaginationRowModel(),
|
|
getSortedRowModel: getSortedRowModel(),
|
|
getFilteredRowModel: getFilteredRowModel(),
|
|
onColumnVisibilityChange: setColumnVisibility,
|
|
onRowSelectionChange: setRowSelection,
|
|
state: {
|
|
sorting,
|
|
columnFilters,
|
|
columnVisibility,
|
|
rowSelection,
|
|
},
|
|
});
|
|
|
|
return (
|
|
<div className="flex w-full flex-col gap-5 ">
|
|
<Card className="bg-background">
|
|
<CardHeader className="flex flex-row items-center flex-wrap gap-4 justify-between">
|
|
<div className="flex flex-col gap-1">
|
|
<CardTitle className="text-xl">Domains</CardTitle>
|
|
<CardDescription>
|
|
Domains are used to access to the application
|
|
</CardDescription>
|
|
</div>
|
|
|
|
<div className="flex flex-row gap-2 flex-wrap">
|
|
{data && data?.length > 0 && (
|
|
<>
|
|
<Button
|
|
variant="outline"
|
|
size="icon"
|
|
onClick={() => {
|
|
const next = viewMode === "grid" ? "table" : "grid";
|
|
localStorage.setItem("domains-view-mode", next);
|
|
setViewMode(next);
|
|
}}
|
|
>
|
|
{viewMode === "grid" ? (
|
|
<LayoutList className="size-4" />
|
|
) : (
|
|
<LayoutGrid className="size-4" />
|
|
)}
|
|
</Button>
|
|
{canCreateDomain && (
|
|
<AddDomain id={id} type={type}>
|
|
<Button>
|
|
<GlobeIcon className="size-4" /> Add Domain
|
|
</Button>
|
|
</AddDomain>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent className="flex w-full flex-row gap-4">
|
|
{isLoadingDomains ? (
|
|
<div className="flex w-full flex-row gap-4 min-h-[40vh] justify-center items-center">
|
|
<Loader2 className="size-5 animate-spin text-muted-foreground" />
|
|
<span className="text-base text-muted-foreground">
|
|
Loading domains...
|
|
</span>
|
|
</div>
|
|
) : data?.length === 0 ? (
|
|
<div className="flex w-full flex-col items-center justify-center gap-3 min-h-[40vh]">
|
|
<GlobeIcon className="size-8 text-muted-foreground" />
|
|
<span className="text-base text-muted-foreground">
|
|
To access the application it is required to set at least 1
|
|
domain
|
|
</span>
|
|
{canCreateDomain && (
|
|
<div className="flex flex-row gap-4 flex-wrap">
|
|
<AddDomain id={id} type={type}>
|
|
<Button>
|
|
<GlobeIcon className="size-4" /> Add Domain
|
|
</Button>
|
|
</AddDomain>
|
|
</div>
|
|
)}
|
|
</div>
|
|
) : viewMode === "table" ? (
|
|
<div className="flex flex-col gap-4 w-full">
|
|
<div className="flex items-center gap-2 max-sm:flex-wrap">
|
|
<Input
|
|
placeholder="Filter by host..."
|
|
value={
|
|
(table.getColumn("host")?.getFilterValue() as string) ?? ""
|
|
}
|
|
onChange={(event) =>
|
|
table.getColumn("host")?.setFilterValue(event.target.value)
|
|
}
|
|
className="md:max-w-sm"
|
|
/>
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<Button
|
|
variant="outline"
|
|
className="sm:ml-auto max-sm:w-full"
|
|
>
|
|
Columns <ChevronDown className="ml-2 h-4 w-4" />
|
|
</Button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="end">
|
|
{table
|
|
.getAllColumns()
|
|
.filter((column) => column.getCanHide())
|
|
.map((column) => {
|
|
return (
|
|
<DropdownMenuCheckboxItem
|
|
key={column.id}
|
|
className="capitalize"
|
|
checked={column.getIsVisible()}
|
|
onCheckedChange={(value) =>
|
|
column.toggleVisibility(!!value)
|
|
}
|
|
>
|
|
{column.id}
|
|
</DropdownMenuCheckboxItem>
|
|
);
|
|
})}
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
</div>
|
|
<div className="rounded-md border">
|
|
<Table>
|
|
<TableHeader>
|
|
{table.getHeaderGroups().map((headerGroup) => (
|
|
<TableRow key={headerGroup.id}>
|
|
{headerGroup.headers.map((header) => {
|
|
return (
|
|
<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}
|
|
data-state={row.getIsSelected() && "selected"}
|
|
>
|
|
{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"
|
|
>
|
|
No results.
|
|
</TableCell>
|
|
</TableRow>
|
|
)}
|
|
</TableBody>
|
|
</Table>
|
|
</div>
|
|
{data && data?.length > 0 && (
|
|
<div className="flex items-center justify-end space-x-2 py-4">
|
|
<div className="space-x-2 flex flex-wrap">
|
|
<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>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<div className="grid grid-cols-1 gap-4 xl:grid-cols-2 w-full min-h-[40vh] ">
|
|
{data?.map((item) => {
|
|
const validationState = validationStates[item.host];
|
|
return (
|
|
<Card
|
|
key={item.domainId}
|
|
className="relative overflow-hidden w-full border transition-all hover:shadow-md bg-transparent h-fit"
|
|
>
|
|
<CardContent className="p-6">
|
|
<div className="flex flex-col gap-4">
|
|
{/* Service & Domain Info */}
|
|
<div className="flex items-center justify-between flex-wrap gap-y-2">
|
|
{item.serviceName && (
|
|
<Badge variant="outline" className="w-fit">
|
|
<Server className="size-3 mr-1" />
|
|
{item.serviceName}
|
|
</Badge>
|
|
)}
|
|
<div className="flex gap-2 flex-wrap">
|
|
{!item.host.includes("sslip.io") && (
|
|
<DnsHelperModal
|
|
domain={{
|
|
host: item.host,
|
|
https: item.https,
|
|
path: item.path || undefined,
|
|
}}
|
|
serverIp={
|
|
application?.server?.ipAddress?.toString() ||
|
|
ip?.toString()
|
|
}
|
|
/>
|
|
)}
|
|
{canCreateDomain && (
|
|
<AddDomain
|
|
id={id}
|
|
type={type}
|
|
domainId={item.domainId}
|
|
>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="group hover:bg-blue-500/10"
|
|
>
|
|
<PenBoxIcon className="size-3.5 text-primary group-hover:text-blue-500" />
|
|
</Button>
|
|
</AddDomain>
|
|
)}
|
|
{canCreateDomain && type === "application" && (
|
|
<HandleForwardAuth
|
|
domainId={item.domainId}
|
|
applicationId={id}
|
|
/>
|
|
)}
|
|
{canDeleteDomain && (
|
|
<DialogAction
|
|
title="Delete Domain"
|
|
description="Are you sure you want to delete this domain?"
|
|
type="destructive"
|
|
onClick={async () => {
|
|
await deleteDomain({
|
|
domainId: item.domainId,
|
|
})
|
|
.then((_data) => {
|
|
refetch();
|
|
toast.success(
|
|
"Domain deleted successfully",
|
|
);
|
|
})
|
|
.catch(() => {
|
|
toast.error("Error deleting domain");
|
|
});
|
|
}}
|
|
>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="group hover:bg-red-500/10"
|
|
isLoading={isRemoving}
|
|
>
|
|
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
|
|
</Button>
|
|
</DialogAction>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<div className="w-full break-all">
|
|
<Link
|
|
className="flex items-center gap-2 text-base font-medium hover:underline"
|
|
target="_blank"
|
|
href={`${item.https ? "https" : "http"}://${item.host}${item.path}`}
|
|
>
|
|
{item.host}
|
|
<ExternalLink className="size-4 min-w-4" />
|
|
</Link>
|
|
</div>
|
|
|
|
{/* Domain Details */}
|
|
<div className="flex flex-wrap gap-3">
|
|
<TooltipProvider>
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<Badge variant="secondary">
|
|
<InfoIcon className="size-3 mr-1" />
|
|
Path: {item.path || "/"}
|
|
</Badge>
|
|
</TooltipTrigger>
|
|
<TooltipContent>
|
|
<p>URL path for this service</p>
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
</TooltipProvider>
|
|
|
|
<TooltipProvider>
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<Badge variant="secondary">
|
|
<InfoIcon className="size-3 mr-1" />
|
|
Port: {item.port}
|
|
</Badge>
|
|
</TooltipTrigger>
|
|
<TooltipContent>
|
|
<p>Container port exposed</p>
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
</TooltipProvider>
|
|
|
|
<TooltipProvider>
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<Badge
|
|
variant={item.https ? "outline" : "secondary"}
|
|
>
|
|
{item.https ? "HTTPS" : "HTTP"}
|
|
</Badge>
|
|
</TooltipTrigger>
|
|
<TooltipContent>
|
|
<p>
|
|
{item.https
|
|
? "Secure HTTPS connection"
|
|
: "Standard HTTP connection"}
|
|
</p>
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
</TooltipProvider>
|
|
|
|
{item.certificateType && (
|
|
<TooltipProvider>
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<Badge variant="outline">
|
|
Cert: {item.certificateType}
|
|
</Badge>
|
|
</TooltipTrigger>
|
|
<TooltipContent>
|
|
<p>SSL Certificate Provider</p>
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
</TooltipProvider>
|
|
)}
|
|
|
|
{item.middlewares?.map((middleware, index) => (
|
|
<TooltipProvider key={`${middleware}-${index}`}>
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<Badge variant="secondary">
|
|
<InfoIcon className="size-3 mr-1" />
|
|
Middleware: {middleware}
|
|
</Badge>
|
|
</TooltipTrigger>
|
|
<TooltipContent>
|
|
<p>Traefik middleware reference</p>
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
</TooltipProvider>
|
|
))}
|
|
|
|
<TooltipProvider>
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<Badge
|
|
variant="outline"
|
|
className={
|
|
validationState?.isValid
|
|
? "bg-green-500/10 text-green-500 cursor-pointer"
|
|
: validationState?.error
|
|
? "bg-red-500/10 text-red-500 cursor-pointer"
|
|
: "bg-yellow-500/10 text-yellow-500 cursor-pointer"
|
|
}
|
|
onClick={() =>
|
|
handleValidateDomain(item.host)
|
|
}
|
|
>
|
|
{validationState?.isLoading ? (
|
|
<>
|
|
<Loader2 className="size-3 mr-1 animate-spin" />
|
|
Checking DNS...
|
|
</>
|
|
) : validationState?.isValid ? (
|
|
<>
|
|
<CheckCircle2 className="size-3 mr-1" />
|
|
{validationState.message &&
|
|
validationState.cdnProvider
|
|
? `Behind ${validationState.cdnProvider}`
|
|
: "DNS Valid"}
|
|
</>
|
|
) : validationState?.error ? (
|
|
<>
|
|
<XCircle className="size-3 mr-1" />
|
|
{validationState.error}
|
|
</>
|
|
) : (
|
|
<>
|
|
<RefreshCw className="size-3 mr-1" />
|
|
Validate DNS
|
|
</>
|
|
)}
|
|
</Badge>
|
|
</TooltipTrigger>
|
|
<TooltipContent className="max-w-xs">
|
|
{validationState?.isValid &&
|
|
validationState?.message ? (
|
|
<p>{validationState.message}</p>
|
|
) : validationState?.error ? (
|
|
<div className="flex flex-col gap-1">
|
|
<p className="font-medium text-red-500">
|
|
Error:
|
|
</p>
|
|
<p>{validationState.error}</p>
|
|
</div>
|
|
) : (
|
|
"Click to validate DNS configuration"
|
|
)}
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
</TooltipProvider>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
);
|
|
};
|