Added rules frontend

This commit is contained in:
2026-04-08 18:11:56 +02:00
parent 1978a31cbf
commit 88323ed4fe
23 changed files with 1412 additions and 81 deletions
+106
View File
@@ -0,0 +1,106 @@
import { makeApiUrl } from '.';
import type {
CreateCustomHeadersRequest,
CreateHeaderRequest,
CustomHeaders,
Header,
} from './types/site';
export const getCustomHeaders = async (siteId: string): Promise<CustomHeaders[]> => {
const response = await fetch(makeApiUrl(`/sites/${siteId}/custom-headers`), {
method: 'GET',
});
if (!response.ok) {
throw new Error('Failed to fetch custom headers');
}
return response.json();
};
export const createCustomHeaders = async (
siteId: string,
data: CreateCustomHeadersRequest
): Promise<CustomHeaders> => {
const response = await fetch(makeApiUrl(`/sites/${siteId}/custom-headers`), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(data),
});
if (!response.ok) {
throw new Error('Failed to create custom headers');
}
return response.json();
};
export const updateCustomHeaders = async (
siteId: string,
groupId: string,
data: CreateCustomHeadersRequest
): Promise<CustomHeaders> => {
const response = await fetch(makeApiUrl(`/sites/${siteId}/custom-headers/${groupId}`), {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(data),
});
if (!response.ok) {
throw new Error('Failed to update custom headers');
}
return response.json();
};
export const deleteCustomHeaders = async (siteId: string, groupId: string): Promise<void> => {
const response = await fetch(makeApiUrl(`/sites/${siteId}/custom-headers/${groupId}`), {
method: 'DELETE',
});
if (!response.ok) {
throw new Error('Failed to delete custom headers');
}
};
export const createHeader = async (
siteId: string,
groupId: string,
data: CreateHeaderRequest
): Promise<Header> => {
const response = await fetch(makeApiUrl(`/sites/${siteId}/custom-headers/${groupId}/headers`), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(data),
});
if (!response.ok) {
throw new Error('Failed to create header');
}
return response.json();
};
export const updateHeader = async (
siteId: string,
headerId: string,
data: CreateHeaderRequest
): Promise<Header> => {
const response = await fetch(makeApiUrl(`/sites/${siteId}/headers/${headerId}`), {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(data),
});
if (!response.ok) {
throw new Error('Failed to update header');
}
return response.json();
};
export const deleteHeader = async (siteId: string, headerId: string): Promise<void> => {
const response = await fetch(makeApiUrl(`/sites/${siteId}/headers/${headerId}`), {
method: 'DELETE',
});
if (!response.ok) {
throw new Error('Failed to delete header');
}
};
+56
View File
@@ -0,0 +1,56 @@
import { makeApiUrl } from '.';
import type { CreateForwardRuleRequest, ForwardRule } from './types/site';
export const getForwardRules = async (siteId: string): Promise<ForwardRule[]> => {
const response = await fetch(makeApiUrl(`/sites/${siteId}/forward-rules`), {
method: 'GET',
});
if (!response.ok) {
throw new Error('Failed to fetch forward rules');
}
return response.json();
};
export const createForwardRule = async (
siteId: string,
data: CreateForwardRuleRequest
): Promise<ForwardRule> => {
const response = await fetch(makeApiUrl(`/sites/${siteId}/forward-rules`), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(data),
});
if (!response.ok) {
throw new Error('Failed to create forward rule');
}
return response.json();
};
export const updateForwardRule = async (
siteId: string,
ruleId: string,
data: CreateForwardRuleRequest
): Promise<ForwardRule> => {
const response = await fetch(makeApiUrl(`/sites/${siteId}/forward-rules/${ruleId}`), {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(data),
});
if (!response.ok) {
throw new Error('Failed to update forward rule');
}
return response.json();
};
export const deleteForwardRule = async (siteId: string, ruleId: string): Promise<void> => {
const response = await fetch(makeApiUrl(`/sites/${siteId}/forward-rules/${ruleId}`), {
method: 'DELETE',
});
if (!response.ok) {
throw new Error('Failed to delete forward rule');
}
};
+17
View File
@@ -64,3 +64,20 @@ export interface CreateSiteResponse {
export interface ToggleSiteEnabledResponse {
enabled: boolean;
}
export interface CreateForwardRuleRequest {
source: string;
destination: string;
status_code: number;
regex: boolean;
}
export interface CreateCustomHeadersRequest {
source: string;
regex: boolean;
}
export interface CreateHeaderRequest {
key: string;
value: string;
}
+79
View File
@@ -0,0 +1,79 @@
import * as React from "react"
import { Accordion as AccordionPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
import { ChevronDownIcon, ChevronUpIcon } from "lucide-react"
function Accordion({
className,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Root>) {
return (
<AccordionPrimitive.Root
data-slot="accordion"
className={cn("flex w-full flex-col", className)}
{...props}
/>
)
}
function AccordionItem({
className,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Item>) {
return (
<AccordionPrimitive.Item
data-slot="accordion-item"
className={cn("not-last:border-b", className)}
{...props}
/>
)
}
function AccordionTrigger({
className,
children,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Trigger>) {
return (
<AccordionPrimitive.Header className="flex">
<AccordionPrimitive.Trigger
data-slot="accordion-trigger"
className={cn(
"group/accordion-trigger relative flex flex-1 items-start justify-between rounded-lg border border-transparent py-2.5 text-left text-sm font-medium transition-all outline-none hover:underline focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 focus-visible:after:border-ring disabled:pointer-events-none disabled:opacity-50 **:data-[slot=accordion-trigger-icon]:ml-auto **:data-[slot=accordion-trigger-icon]:size-4 **:data-[slot=accordion-trigger-icon]:text-muted-foreground",
className
)}
{...props}
>
{children}
<ChevronDownIcon data-slot="accordion-trigger-icon" className="pointer-events-none shrink-0 group-aria-expanded/accordion-trigger:hidden" />
<ChevronUpIcon data-slot="accordion-trigger-icon" className="pointer-events-none hidden shrink-0 group-aria-expanded/accordion-trigger:inline" />
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
)
}
function AccordionContent({
className,
children,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Content>) {
return (
<AccordionPrimitive.Content
data-slot="accordion-content"
className="overflow-hidden text-sm data-open:animate-accordion-down data-closed:animate-accordion-up"
{...props}
>
<div
className={cn(
"h-(--radix-accordion-content-height) pt-0 pb-2.5 [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground [&_p:not(:last-child)]:mb-4",
className
)}
>
{children}
</div>
</AccordionPrimitive.Content>
)
}
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }
+197
View File
@@ -0,0 +1,197 @@
import * as React from "react"
import { AlertDialog as AlertDialogPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
function AlertDialog({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />
}
function AlertDialogTrigger({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
return (
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
)
}
function AlertDialogPortal({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
return (
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
)
}
function AlertDialogOverlay({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) {
return (
<AlertDialogPrimitive.Overlay
data-slot="alert-dialog-overlay"
className={cn(
"fixed inset-0 z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
className
)}
{...props}
/>
)
}
function AlertDialogContent({
className,
size = "default",
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Content> & {
size?: "default" | "sm"
}) {
return (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
data-slot="alert-dialog-content"
data-size={size}
className={cn(
"group/alert-dialog-content fixed top-1/2 left-1/2 z-50 grid w-full -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none data-[size=default]:max-w-xs data-[size=sm]:max-w-xs data-[size=default]:sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
{...props}
/>
</AlertDialogPortal>
)
}
function AlertDialogHeader({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-header"
className={cn(
"grid grid-rows-[auto_1fr] place-items-center gap-1.5 text-center has-data-[slot=alert-dialog-media]:grid-rows-[auto_auto_1fr] has-data-[slot=alert-dialog-media]:gap-x-4 sm:group-data-[size=default]/alert-dialog-content:place-items-start sm:group-data-[size=default]/alert-dialog-content:text-left sm:group-data-[size=default]/alert-dialog-content:has-data-[slot=alert-dialog-media]:grid-rows-[auto_1fr]",
className
)}
{...props}
/>
)
}
function AlertDialogFooter({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-footer"
className={cn(
"-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 group-data-[size=sm]/alert-dialog-content:grid group-data-[size=sm]/alert-dialog-content:grid-cols-2 sm:flex-row sm:justify-end",
className
)}
{...props}
/>
)
}
function AlertDialogMedia({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-media"
className={cn(
"mb-2 inline-flex size-10 items-center justify-center rounded-md bg-muted sm:group-data-[size=default]/alert-dialog-content:row-span-2 *:[svg:not([class*='size-'])]:size-6",
className
)}
{...props}
/>
)
}
function AlertDialogTitle({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
return (
<AlertDialogPrimitive.Title
data-slot="alert-dialog-title"
className={cn(
"text-base font-medium sm:group-data-[size=default]/alert-dialog-content:group-has-data-[slot=alert-dialog-media]/alert-dialog-content:col-start-2",
className
)}
{...props}
/>
)
}
function AlertDialogDescription({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
return (
<AlertDialogPrimitive.Description
data-slot="alert-dialog-description"
className={cn(
"text-sm text-balance text-muted-foreground md:text-pretty *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
className
)}
{...props}
/>
)
}
function AlertDialogAction({
className,
variant = "default",
size = "default",
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Action> &
Pick<React.ComponentProps<typeof Button>, "variant" | "size">) {
return (
<Button variant={variant} size={size} asChild>
<AlertDialogPrimitive.Action
data-slot="alert-dialog-action"
className={cn(className)}
{...props}
/>
</Button>
)
}
function AlertDialogCancel({
className,
variant = "outline",
size = "default",
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel> &
Pick<React.ComponentProps<typeof Button>, "variant" | "size">) {
return (
<Button variant={variant} size={size} asChild>
<AlertDialogPrimitive.Cancel
data-slot="alert-dialog-cancel"
className={cn(className)}
{...props}
/>
</Button>
)
}
export {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogMedia,
AlertDialogOverlay,
AlertDialogPortal,
AlertDialogTitle,
AlertDialogTrigger,
}
@@ -0,0 +1,15 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import type { CreateForwardRuleRequest } from '../../../api/types/site';
import { createForwardRule } from '../../../api/forwardrules.api';
export function useCreateForwardRule(siteId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: async (data: CreateForwardRuleRequest) => createForwardRule(siteId, data),
onSuccess: () => {
qc.invalidateQueries({
queryKey: ['forwardRules', siteId],
});
},
});
}
@@ -0,0 +1,14 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { deleteForwardRule } from '../../../api/forwardrules.api';
export function useDeleteForwardRule(siteId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: async (ruleId: string) => deleteForwardRule(siteId, ruleId),
onSuccess: () => {
qc.invalidateQueries({
queryKey: ['forwardRules', siteId],
});
},
});
}
@@ -0,0 +1,10 @@
import { useQuery } from '@tanstack/react-query';
import { getForwardRules } from '../../../api/forwardrules.api';
export function useForwardRules(siteId: string) {
return useQuery({
queryKey: ['forwardRules', siteId],
queryFn: async () => getForwardRules(siteId),
enabled: !!siteId,
});
}
@@ -0,0 +1,16 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { updateForwardRule } from '../../../api/forwardrules.api';
import type { CreateForwardRuleRequest } from '../../../api/types/site';
export function useUpdateForwardRule(siteId: string, ruleId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: async (data: CreateForwardRuleRequest) =>
updateForwardRule(siteId, ruleId, data),
onSuccess: () => {
qc.invalidateQueries({
queryKey: ['forwardRules', siteId],
});
},
});
}
@@ -0,0 +1,15 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import type { CreateCustomHeadersRequest } from '../../api/types/site';
import { createCustomHeaders } from '../../api/customHeaders.api';
export function useCreateCustomHeaders(siteId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: (data: CreateCustomHeadersRequest) => createCustomHeaders(siteId, data),
onSuccess: () => {
qc.invalidateQueries({
queryKey: ['customHeaders', siteId],
});
},
});
}
+15
View File
@@ -0,0 +1,15 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import type { CreateHeaderRequest } from '../../api/types/site';
import { createHeader } from '../../api/customHeaders.api';
export function useCreateHeader(siteId: string, groupId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: (data: CreateHeaderRequest) => createHeader(siteId, groupId, data),
onSuccess: () => {
qc.invalidateQueries({
queryKey: ['customHeaders', siteId],
});
},
});
}
@@ -0,0 +1,11 @@
import { useQuery } from '@tanstack/react-query';
import { getCustomHeaders } from '../../api/customHeaders.api';
import type { CustomHeaders } from '../../api/types/site';
export function useCustomHeaders(siteId: string) {
return useQuery<CustomHeaders[]>({
queryKey: ['customHeaders', siteId],
queryFn: () => getCustomHeaders(siteId),
enabled: !!siteId,
});
}
@@ -0,0 +1,14 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { deleteCustomHeaders } from '../../api/customHeaders.api';
export function useDeleteCustomHeaders(groupId: string, siteId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: () => deleteCustomHeaders(siteId, groupId),
onSuccess: () => {
qc.invalidateQueries({
queryKey: ['customHeaders', siteId],
});
},
});
}
+14
View File
@@ -0,0 +1,14 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { deleteHeader } from '../../api/customHeaders.api';
export function useDeleteHeader(siteId: string, headerId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: () => deleteHeader(siteId, headerId),
onSuccess: () => {
qc.invalidateQueries({
queryKey: ['customHeaders', siteId],
});
},
});
}
@@ -0,0 +1,16 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import type { CreateCustomHeadersRequest } from '../../api/types/site';
import { updateCustomHeaders } from '../../api/customHeaders.api';
export function useUpdateCustomHeaders(siteId: string, groupId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: (data: CreateCustomHeadersRequest) =>
updateCustomHeaders(siteId, groupId, data),
onSuccess: () => {
qc.invalidateQueries({
queryKey: ['customHeaders', siteId],
});
},
});
}
+15
View File
@@ -0,0 +1,15 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import type { CreateHeaderRequest } from '../../api/types/site';
import { updateHeader } from '../../api/customHeaders.api';
export function useUpdateHeader(siteId: string, headerId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: (data: CreateHeaderRequest) => updateHeader(siteId, headerId, data),
onSuccess: () => {
qc.invalidateQueries({
queryKey: ['customHeaders', siteId],
});
},
});
}
@@ -0,0 +1,713 @@
import { useState } from 'react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Switch } from '@/components/ui/switch';
import { Badge } from '@/components/ui/badge';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from '@/components/ui/accordion';
import { Check, Pencil, Plus, Route, ShieldPlus, Trash2, X } from 'lucide-react';
import type { Site, ForwardRule, CustomHeaders, Header } from '../../api/types/site';
import { useForwardRules } from '@/hooks/api/forwardRules/useForwardRules';
import { useCreateForwardRule } from '@/hooks/api/forwardRules/useCreateForwardRule';
import { useUpdateForwardRule } from '@/hooks/api/forwardRules/useUpdateForwardRule';
import { useDeleteForwardRule } from '@/hooks/api/forwardRules/useDeleteForwardRule';
import { useCustomHeaders } from '@/hooks/api/useCustomHeaders';
import {
Empty,
EmptyDescription,
EmptyHeader as EmptyHdr,
EmptyMedia,
EmptyTitle,
} from '@/components/ui/empty';
import { useCreateCustomHeaders } from '../../hooks/api/useCreateCustomHeaders';
import { useDeleteCustomHeaders } from '../../hooks/api/useDeleteCustomHeaders';
import { useCreateHeader } from '../../hooks/api/useCreateHeader';
import { useDeleteHeader } from '../../hooks/api/useDeleteHeader';
import { useUpdateHeader } from '../../hooks/api/useUpdateHeader';
interface ForwardRuleFormData {
source: string;
destination: string;
status_code: number;
regex: boolean;
}
const EMPTY_FORWARD_RULE: ForwardRuleFormData = {
source: '',
destination: '',
status_code: 301,
regex: false,
};
const STATUS_CODE_OPTIONS = [
{ value: 301, label: '301 — Permanent Redirect' },
{ value: 302, label: '302 — Temporary Redirect' },
{ value: 307, label: '307 — Temporary (preserve method)' },
{ value: 308, label: '308 — Permanent (preserve method)' },
];
function ForwardRuleDialog({
rule,
siteId,
trigger,
}: {
rule?: ForwardRule;
siteId: string;
trigger: React.ReactNode;
}) {
const isEdit = !!rule;
const [open, setOpen] = useState(false);
const [form, setForm] = useState<ForwardRuleFormData>(
rule
? {
source: rule.source,
destination: rule.destination,
status_code: rule.status_code,
regex: rule.regex,
}
: EMPTY_FORWARD_RULE
);
const createRule = useCreateForwardRule(siteId);
const updateRule = useUpdateForwardRule(siteId, rule?.id ?? '');
const handleSubmit = () => {
const mutation = isEdit ? updateRule : createRule;
mutation.mutate(form, {
onSuccess: () => {
setOpen(false);
if (!isEdit) setForm(EMPTY_FORWARD_RULE);
},
});
};
const isPending = createRule.isPending || updateRule.isPending;
const isValid = form.source.trim() !== '' && form.destination.trim() !== '';
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>{trigger}</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>{isEdit ? 'Edit' : 'New'} Forward Rule</DialogTitle>
<DialogDescription>
{isEdit
? 'Update the source, destination, and options for this rule.'
: 'Redirect requests from one path to another.'}
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-2">
<div className="grid gap-2">
<Label htmlFor="fr-source">Source path</Label>
<Input
id="fr-source"
placeholder="/old-page"
value={form.source}
onChange={(e) => setForm((f) => ({ ...f, source: e.target.value }))}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="fr-dest">Destination</Label>
<Input
id="fr-dest"
placeholder="/new-page or https://example.com"
value={form.destination}
onChange={(e) =>
setForm((f) => ({ ...f, destination: e.target.value }))
}
/>
</div>
<div className="grid gap-2">
<Label>Status Code</Label>
<Select
value={String(form.status_code)}
onValueChange={(v) =>
setForm((f) => ({ ...f, status_code: Number(v) }))
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{STATUS_CODE_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={String(opt.value)}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Label className="flex items-center gap-2">
<Switch
checked={form.regex}
onCheckedChange={(v) => setForm((f) => ({ ...f, regex: v }))}
/>
<span>Use regex matching</span>
</Label>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setOpen(false)} disabled={isPending}>
Cancel
</Button>
<Button onClick={handleSubmit} disabled={isPending || !isValid}>
{isPending ? 'Saving…' : isEdit ? 'Save Changes' : 'Create Rule'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
function ForwardRulesSection({ site }: { site: Site }) {
const { data: rules, isLoading } = useForwardRules(site.id);
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0">
<div className="space-y-1">
<CardTitle className="text-base">Forward Rules</CardTitle>
<CardDescription>Redirect or rewrite incoming request paths.</CardDescription>
</div>
<ForwardRuleDialog
siteId={site.id}
trigger={
<Button size="sm">
<Plus className="h-4 w-4 mr-1" />
Add Rule
</Button>
}
/>
</CardHeader>
<CardContent>
{isLoading && <p className="text-sm text-muted-foreground">Loading</p>}
{!isLoading && (!rules || rules.length === 0) && (
<Empty>
<EmptyHdr>
<EmptyMedia variant="icon">
<Route />
</EmptyMedia>
<EmptyTitle>No forward rules</EmptyTitle>
<EmptyDescription>
Add a rule to redirect or rewrite request paths.
</EmptyDescription>
</EmptyHdr>
</Empty>
)}
{rules && rules.length > 0 && (
<Table>
<TableHeader>
<TableRow>
<TableHead>Source</TableHead>
<TableHead>Destination</TableHead>
<TableHead className="hidden sm:table-cell">Status</TableHead>
<TableHead className="hidden sm:table-cell">Regex</TableHead>
<TableHead className="w-20" />
</TableRow>
</TableHeader>
<TableBody>
{rules.map((rule) => (
<ForwardRuleRow key={rule.id} rule={rule} siteId={site.id} />
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
);
}
function ForwardRuleRow({ rule, siteId }: { rule: ForwardRule; siteId: string }) {
const deleteRule = useDeleteForwardRule(siteId);
return (
<TableRow>
<TableCell className="font-mono text-sm max-w-50 truncate">{rule.source}</TableCell>
<TableCell className="font-mono text-sm max-w-50 truncate">
{rule.destination}
</TableCell>
<TableCell className="hidden sm:table-cell">
<Badge variant="secondary">{rule.status_code}</Badge>
</TableCell>
<TableCell className="hidden sm:table-cell">
{rule.regex ? <Check className="h-4 w-4" /> : <X className="h-4 w-4" />}
</TableCell>
<TableCell>
<div className="flex items-center gap-1">
<ForwardRuleDialog
rule={rule}
siteId={siteId}
trigger={
<Button variant="ghost" size="icon" className="h-8 w-8">
<Pencil className="h-3.5 w-3.5" />
</Button>
}
/>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-destructive"
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete forward rule?</AlertDialogTitle>
<AlertDialogDescription>
This will remove the redirect from{' '}
<code className="text-sm">{rule.source}</code>. This action
cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => deleteRule.mutate(rule.id)}
disabled={deleteRule.isPending}
>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</TableCell>
</TableRow>
);
}
interface HeaderGroupFormData {
source: string;
regex: boolean;
}
interface HeaderFormData {
key: string;
value: string;
}
function CustomHeadersSection({ site }: { site: Site }) {
const { data: headerGroups, isLoading } = useCustomHeaders(site.id);
const [groupOpen, setGroupOpen] = useState(false);
const [groupForm, setGroupForm] = useState<HeaderGroupFormData>({ source: '', regex: false });
const createGroup = useCreateCustomHeaders(site.id);
const handleCreateGroup = () => {
createGroup.mutate(groupForm, {
onSuccess: () => {
setGroupOpen(false);
setGroupForm({ source: '', regex: false });
},
});
};
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0">
<div className="space-y-1">
<CardTitle className="text-base">Custom Headers</CardTitle>
<CardDescription>
Attach custom response headers to matching paths.
</CardDescription>
</div>
<Dialog open={groupOpen} onOpenChange={setGroupOpen}>
<DialogTrigger asChild>
<Button size="sm">
<Plus className="h-4 w-4 mr-1" />
Add Group
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>New Header Group</DialogTitle>
<DialogDescription>
Define a path pattern and then add headers to it.
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-2">
<div className="grid gap-2">
<Label htmlFor="hg-source">Path pattern</Label>
<Input
id="hg-source"
placeholder="/*"
value={groupForm.source}
onChange={(e) =>
setGroupForm((f) => ({ ...f, source: e.target.value }))
}
/>
</div>
<Label className="flex items-center gap-2">
<Switch
checked={groupForm.regex}
onCheckedChange={(v) =>
setGroupForm((f) => ({ ...f, regex: v }))
}
/>
<span>Use regex matching</span>
</Label>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setGroupOpen(false)}
disabled={createGroup.isPending}
>
Cancel
</Button>
<Button
onClick={handleCreateGroup}
disabled={createGroup.isPending || groupForm.source.trim() === ''}
>
{createGroup.isPending ? 'Creating…' : 'Create Group'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</CardHeader>
<CardContent>
{isLoading && <p className="text-sm text-muted-foreground">Loading</p>}
{!isLoading && (!headerGroups || headerGroups.length === 0) && (
<Empty>
<EmptyHdr>
<EmptyMedia variant="icon">
<ShieldPlus />
</EmptyMedia>
<EmptyTitle>No custom headers</EmptyTitle>
<EmptyDescription>
Add a header group to attach custom response headers to matching
paths.
</EmptyDescription>
</EmptyHdr>
</Empty>
)}
{headerGroups && headerGroups.length > 0 && (
<Accordion type="multiple" className="w-full">
{headerGroups.map((group) => (
<HeaderGroupItem key={group.id} group={group} siteId={site.id} />
))}
</Accordion>
)}
</CardContent>
</Card>
);
}
function HeaderGroupItem({ group, siteId }: { group: CustomHeaders; siteId: string }) {
const deleteGroup = useDeleteCustomHeaders(group.id, siteId);
const createHeader = useCreateHeader(siteId, group.id);
const [headerOpen, setHeaderOpen] = useState(false);
const [headerForm, setHeaderForm] = useState<HeaderFormData>({ key: '', value: '' });
const handleCreateHeader = () => {
createHeader.mutate(headerForm, {
onSuccess: () => {
setHeaderOpen(false);
setHeaderForm({ key: '', value: '' });
},
});
};
return (
<AccordionItem value={group.id}>
<AccordionTrigger className="hover:no-underline">
<div className="flex items-center gap-2">
<code className="text-sm font-mono">{group.source}</code>
{group.regex && <Badge variant="outline">regex</Badge>}
<Badge variant="secondary">
{group.headers?.length ?? 0} header
{(group.headers?.length ?? 0) !== 1 && 's'}
</Badge>
</div>
</AccordionTrigger>
<AccordionContent>
<div className="space-y-3 pt-1">
{group.headers && group.headers.length > 0 && (
<Table>
<TableHeader>
<TableRow>
<TableHead>Key</TableHead>
<TableHead>Value</TableHead>
<TableHead className="w-20" />
</TableRow>
</TableHeader>
<TableBody>
{group.headers.map((header) => (
<HeaderRow key={header.id} header={header} siteId={siteId} />
))}
</TableBody>
</Table>
)}
{(!group.headers || group.headers.length === 0) && (
<p className="text-sm text-muted-foreground py-2">
No headers yet. Add one below.
</p>
)}
<div className="flex items-center gap-2">
<Dialog open={headerOpen} onOpenChange={setHeaderOpen}>
<DialogTrigger asChild>
<Button variant="outline" size="sm">
<Plus className="h-3.5 w-3.5 mr-1" />
Add Header
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>New Header</DialogTitle>
<DialogDescription>
Add a response header for{' '}
<code className="text-sm">{group.source}</code>
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-2">
<div className="grid gap-2">
<Label htmlFor="h-key">Header name</Label>
<Input
id="h-key"
placeholder="X-Frame-Options"
value={headerForm.key}
onChange={(e) =>
setHeaderForm((f) => ({
...f,
key: e.target.value,
}))
}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="h-value">Header value</Label>
<Input
id="h-value"
placeholder="DENY"
value={headerForm.value}
onChange={(e) =>
setHeaderForm((f) => ({
...f,
value: e.target.value,
}))
}
/>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setHeaderOpen(false)}
disabled={createHeader.isPending}
>
Cancel
</Button>
<Button
onClick={handleCreateHeader}
disabled={
createHeader.isPending ||
headerForm.key.trim() === '' ||
headerForm.value.trim() === ''
}
>
{createHeader.isPending ? 'Adding…' : 'Add Header'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="ghost" size="sm" className="text-destructive">
<Trash2 className="h-3.5 w-3.5 mr-1" />
Delete Group
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete header group?</AlertDialogTitle>
<AlertDialogDescription>
This will remove all headers for{' '}
<code className="text-sm">{group.source}</code>. This cannot
be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => deleteGroup.mutate()}
disabled={deleteGroup.isPending}
>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</div>
</AccordionContent>
</AccordionItem>
);
}
function HeaderRow({ header, siteId }: { header: Header; siteId: string }) {
const deleteHeader = useDeleteHeader(siteId, header.id);
const updateHeader = useUpdateHeader(siteId, header.id);
const [editOpen, setEditOpen] = useState(false);
const [form, setForm] = useState<HeaderFormData>({ key: header.key, value: header.value });
const handleUpdate = () => {
updateHeader.mutate(form, {
onSuccess: () => setEditOpen(false),
});
};
return (
<TableRow>
<TableCell className="font-mono text-sm">{header.key}</TableCell>
<TableCell className="font-mono text-sm max-w-62.5 truncate">{header.value}</TableCell>
<TableCell>
<div className="flex items-center gap-1">
<Dialog open={editOpen} onOpenChange={setEditOpen}>
<DialogTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8">
<Pencil className="h-3.5 w-3.5" />
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Edit Header</DialogTitle>
</DialogHeader>
<div className="grid gap-4 py-2">
<div className="grid gap-2">
<Label>Header name</Label>
<Input
value={form.key}
onChange={(e) =>
setForm((f) => ({ ...f, key: e.target.value }))
}
/>
</div>
<div className="grid gap-2">
<Label>Header value</Label>
<Input
value={form.value}
onChange={(e) =>
setForm((f) => ({ ...f, value: e.target.value }))
}
/>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setEditOpen(false)}
disabled={updateHeader.isPending}
>
Cancel
</Button>
<Button
onClick={handleUpdate}
disabled={
updateHeader.isPending ||
form.key.trim() === '' ||
form.value.trim() === ''
}
>
{updateHeader.isPending ? 'Saving…' : 'Save'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-destructive"
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete header?</AlertDialogTitle>
<AlertDialogDescription>
Remove <code className="text-sm">{header.key}</code>? This
cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => deleteHeader.mutate()}
disabled={deleteHeader.isPending}
>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</TableCell>
</TableRow>
);
}
export default function RulesTab({ site }: { site: Site }) {
return (
<div className="space-y-4">
<ForwardRulesSection site={site} />
<CustomHeadersSection site={site} />
</div>
);
}
@@ -5,15 +5,7 @@ import { Switch } from '@/components/ui/switch';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Card, CardContent, CardHeader } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
import {
ArrowRightLeft,
FileCode,
Globe,
LayoutDashboard,
Rocket,
Settings,
X,
} from 'lucide-react';
import { Globe, LayoutDashboard, Rocket, Settings, X, Route } from 'lucide-react';
import { useSite } from '../../hooks/api/useSite';
import { useToggleSiteEnabled } from '../../hooks/api/useToggleSiteEnabled';
import SettingsTab from './SettingsTab';
@@ -28,14 +20,9 @@ import {
EmptyTitle,
} from '../../components/ui/empty';
import { useDeploymentsForSite } from '../../hooks/api/useDeploymentsForSite';
import RulesTab from './RulesTab';
const VALID_TABS = [
'overview',
'deployments',
'forward-rules',
'custom-headers',
'settings',
] as const;
const VALID_TABS = ['overview', 'deployments', 'rules', 'settings'] as const;
type TabValue = (typeof VALID_TABS)[number];
const DEFAULT_TAB: TabValue = 'overview';
@@ -163,7 +150,7 @@ const SiteOverview = () => {
</div>
<Tabs value={activeTab} onValueChange={handleTabChange}>
<div className="flex items-center">
<div className="flex items-center mb-4">
<TabsList>
<TabsTrigger value="overview">
<LayoutDashboard />
@@ -173,13 +160,9 @@ const SiteOverview = () => {
<Rocket />
Deployments
</TabsTrigger>
<TabsTrigger value="forward-rules">
<ArrowRightLeft />
Forward Rules
</TabsTrigger>
<TabsTrigger value="custom-headers">
<FileCode />
Custom Headers
<TabsTrigger value="rules">
<Route />
Rules
</TabsTrigger>
<TabsTrigger value="settings">
<Settings />
@@ -206,6 +189,10 @@ const SiteOverview = () => {
<DeploymentsTab site={site} deployments={deployments} />
</TabsContent>
<TabsContent value="rules">
<RulesTab site={site} />
</TabsContent>
<TabsContent value="settings">
<SettingsTab site={site} />
</TabsContent>