Skip to content

Commit b7adb7f

Browse files
authored
Merge pull request #3159 from quochuydev/feat/add-domains-grid-table-toggle
feat: add grid/table view toggle for domains page
2 parents c51d718 + e4f6e5e commit b7adb7f

File tree

2 files changed

+528
-6
lines changed

2 files changed

+528
-6
lines changed
Lines changed: 303 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,303 @@
1+
import type { ColumnDef } from "@tanstack/react-table";
2+
import {
3+
ArrowUpDown,
4+
CheckCircle2,
5+
ExternalLink,
6+
Loader2,
7+
PenBoxIcon,
8+
RefreshCw,
9+
Server,
10+
Trash2,
11+
XCircle,
12+
} from "lucide-react";
13+
import Link from "next/link";
14+
import { DialogAction } from "@/components/shared/dialog-action";
15+
import { Badge } from "@/components/ui/badge";
16+
import { Button } from "@/components/ui/button";
17+
import {
18+
Tooltip,
19+
TooltipContent,
20+
TooltipProvider,
21+
TooltipTrigger,
22+
} from "@/components/ui/tooltip";
23+
import type { RouterOutputs } from "@/utils/api";
24+
import type { ValidationStates } from "./show-domains";
25+
import { AddDomain } from "./handle-domain";
26+
import { DnsHelperModal } from "./dns-helper-modal";
27+
28+
export type Domain =
29+
| RouterOutputs["domain"]["byApplicationId"][0]
30+
| RouterOutputs["domain"]["byComposeId"][0];
31+
32+
interface ColumnsProps {
33+
id: string;
34+
type: "application" | "compose";
35+
validationStates: ValidationStates;
36+
handleValidateDomain: (host: string) => Promise<void>;
37+
handleDeleteDomain: (domainId: string) => Promise<void>;
38+
isDeleting: boolean;
39+
serverIp?: string;
40+
canCreateDomain: boolean;
41+
canDeleteDomain: boolean;
42+
}
43+
44+
export const createColumns = ({
45+
id,
46+
type,
47+
validationStates,
48+
handleValidateDomain,
49+
handleDeleteDomain,
50+
isDeleting,
51+
serverIp,
52+
canCreateDomain,
53+
canDeleteDomain,
54+
}: ColumnsProps): ColumnDef<Domain>[] => [
55+
...(type === "compose"
56+
? [
57+
{
58+
accessorKey: "serviceName",
59+
header: "Service",
60+
cell: ({ row }: { row: { getValue: (key: string) => unknown } }) => {
61+
const serviceName = row.getValue("serviceName") as string | null;
62+
if (!serviceName) return null;
63+
return (
64+
<Badge variant="outline">
65+
<Server className="size-3 mr-1" />
66+
{serviceName}
67+
</Badge>
68+
);
69+
},
70+
} satisfies ColumnDef<Domain>,
71+
]
72+
: []),
73+
{
74+
accessorKey: "host",
75+
header: ({ column }) => {
76+
return (
77+
<Button
78+
variant="ghost"
79+
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
80+
>
81+
Host
82+
<ArrowUpDown className="ml-2 h-4 w-4" />
83+
</Button>
84+
);
85+
},
86+
cell: ({ row }) => {
87+
const domain = row.original;
88+
return (
89+
<Link
90+
className="flex items-center gap-2 font-medium hover:underline"
91+
target="_blank"
92+
href={`${domain.https ? "https" : "http"}://${domain.host}${domain.path}`}
93+
>
94+
{domain.host}
95+
<ExternalLink className="size-3" />
96+
</Link>
97+
);
98+
},
99+
},
100+
{
101+
accessorKey: "path",
102+
header: ({ column }) => {
103+
return (
104+
<Button
105+
variant="ghost"
106+
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
107+
>
108+
Path
109+
<ArrowUpDown className="ml-2 h-4 w-4" />
110+
</Button>
111+
);
112+
},
113+
cell: ({ row }) => {
114+
const path = row.getValue("path") as string;
115+
return <div className="font-mono text-sm">{path || "/"}</div>;
116+
},
117+
},
118+
{
119+
accessorKey: "port",
120+
header: ({ column }) => {
121+
return (
122+
<Button
123+
variant="ghost"
124+
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
125+
>
126+
Port
127+
<ArrowUpDown className="ml-2 h-4 w-4" />
128+
</Button>
129+
);
130+
},
131+
cell: ({ row }) => {
132+
const port = row.getValue("port") as number;
133+
return <Badge variant="secondary">{port}</Badge>;
134+
},
135+
},
136+
{
137+
accessorKey: "customEntrypoint",
138+
header: "Entrypoint",
139+
cell: ({ row }) => {
140+
const entrypoint = row.getValue("customEntrypoint") as string | null;
141+
if (!entrypoint) return <span className="text-muted-foreground">-</span>;
142+
return <div className="font-mono text-sm">{entrypoint}</div>;
143+
},
144+
},
145+
{
146+
accessorKey: "https",
147+
header: "Protocol",
148+
cell: ({ row }) => {
149+
const https = row.getValue("https") as boolean;
150+
return (
151+
<Badge variant={https ? "outline" : "secondary"}>
152+
{https ? "HTTPS" : "HTTP"}
153+
</Badge>
154+
);
155+
},
156+
},
157+
{
158+
id: "certificate",
159+
header: "Certificate",
160+
cell: ({ row }) => {
161+
const domain = row.original;
162+
const validationState = validationStates[domain.host];
163+
164+
return (
165+
<div className="flex items-center gap-2">
166+
{domain.certificateType && (
167+
<Badge variant="outline" className="capitalize">
168+
{domain.certificateType}
169+
</Badge>
170+
)}
171+
{!domain.host.includes("traefik.me") && (
172+
<TooltipProvider>
173+
<Tooltip>
174+
<TooltipTrigger asChild>
175+
<Badge
176+
variant="outline"
177+
className={
178+
validationState?.isValid
179+
? "bg-green-500/10 text-green-500 cursor-pointer"
180+
: validationState?.error
181+
? "bg-red-500/10 text-red-500 cursor-pointer"
182+
: "bg-yellow-500/10 text-yellow-500 cursor-pointer"
183+
}
184+
onClick={() => handleValidateDomain(domain.host)}
185+
>
186+
{validationState?.isLoading ? (
187+
<>
188+
<Loader2 className="size-3 mr-1 animate-spin" />
189+
Checking...
190+
</>
191+
) : validationState?.isValid ? (
192+
<>
193+
<CheckCircle2 className="size-3 mr-1" />
194+
{validationState.message && validationState.cdnProvider
195+
? `${validationState.cdnProvider}`
196+
: "Valid"}
197+
</>
198+
) : validationState?.error ? (
199+
<>
200+
<XCircle className="size-3 mr-1" />
201+
Invalid
202+
</>
203+
) : (
204+
<>
205+
<RefreshCw className="size-3 mr-1" />
206+
Validate
207+
</>
208+
)}
209+
</Badge>
210+
</TooltipTrigger>
211+
<TooltipContent className="max-w-xs">
212+
{validationState?.error ? (
213+
<div className="flex flex-col gap-1">
214+
<p className="font-medium text-red-500">Error:</p>
215+
<p>{validationState.error}</p>
216+
</div>
217+
) : (
218+
"Click to validate DNS configuration"
219+
)}
220+
</TooltipContent>
221+
</Tooltip>
222+
</TooltipProvider>
223+
)}
224+
</div>
225+
);
226+
},
227+
},
228+
{
229+
accessorKey: "createdAt",
230+
header: ({ column }) => {
231+
return (
232+
<Button
233+
variant="ghost"
234+
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
235+
>
236+
Created
237+
<ArrowUpDown className="ml-2 h-4 w-4" />
238+
</Button>
239+
);
240+
},
241+
cell: ({ row }) => {
242+
const createdAt = row.getValue("createdAt") as string;
243+
return (
244+
<div className="text-sm text-muted-foreground">
245+
{new Date(createdAt).toLocaleDateString()}
246+
</div>
247+
);
248+
},
249+
},
250+
{
251+
id: "actions",
252+
header: "Actions",
253+
enableHiding: false,
254+
cell: ({ row }) => {
255+
const domain = row.original;
256+
257+
return (
258+
<div className="flex items-center gap-2">
259+
{!domain.host.includes("traefik.me") && (
260+
<DnsHelperModal
261+
domain={{
262+
host: domain.host,
263+
https: domain.https,
264+
path: domain.path || undefined,
265+
}}
266+
serverIp={serverIp}
267+
/>
268+
)}
269+
{canCreateDomain && (
270+
<AddDomain id={id} type={type} domainId={domain.domainId}>
271+
<Button
272+
variant="ghost"
273+
size="icon"
274+
className="group hover:bg-blue-500/10 h-8 w-8"
275+
>
276+
<PenBoxIcon className="size-3.5 text-primary group-hover:text-blue-500" />
277+
</Button>
278+
</AddDomain>
279+
)}
280+
{canDeleteDomain && (
281+
<DialogAction
282+
title="Delete Domain"
283+
description="Are you sure you want to delete this domain?"
284+
type="destructive"
285+
onClick={async () => {
286+
await handleDeleteDomain(domain.domainId);
287+
}}
288+
>
289+
<Button
290+
variant="ghost"
291+
size="icon"
292+
className="group hover:bg-red-500/10 h-8 w-8"
293+
isLoading={isDeleting}
294+
>
295+
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
296+
</Button>
297+
</DialogAction>
298+
)}
299+
</div>
300+
);
301+
},
302+
},
303+
];

0 commit comments

Comments
 (0)