feat(frontend): create orchestrated waf rules

This commit is contained in:
ryan
2026-07-13 12:00:24 +08:00
parent af20e2e838
commit 4000366856
13 changed files with 539 additions and 493 deletions
@@ -0,0 +1,87 @@
'use client';
import {useEffect, useState} from 'react';
import {Button} from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {Field, FieldGroup, FieldLabel} from '@/components/ui/field';
import {Input} from '@/components/ui/input';
import {Spinner} from '@/components/ui/spinner';
interface CreateRuleDialogProps {
open: boolean;
pending: boolean;
onOpenChange: (open: boolean) => void;
onCreate: (name: string) => Promise<void>;
}
export function CreateRuleDialog({
open,
pending,
onOpenChange,
onCreate,
}: CreateRuleDialogProps) {
const [name, setName] = useState('');
useEffect(() => {
if (!open) setName('');
}, [open]);
const trimmedName = name.trim();
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<form
className="flex flex-col gap-4"
onSubmit={async (event) => {
event.preventDefault();
if (!trimmedName || pending) return;
await onCreate(trimmedName);
}}
>
<DialogHeader>
<DialogTitle>新建 WAF 规则</DialogTitle>
<DialogDescription>
创建后将进入编排页面,通过处理单元配置规则执行流程。
</DialogDescription>
</DialogHeader>
<FieldGroup>
<Field>
<FieldLabel htmlFor="waf-rule-name">规则名称</FieldLabel>
<Input
id="waf-rule-name"
value={name}
onChange={(event) => setName(event.target.value)}
placeholder="例如:入口防护"
autoComplete="off"
autoFocus
/>
</Field>
</FieldGroup>
<DialogFooter>
<Button
type="button"
variant="outline"
disabled={pending}
onClick={() => onOpenChange(false)}
>
取消
</Button>
<Button type="submit" disabled={!trimmedName || pending}>
{pending ? <Spinner data-icon="inline-start" /> : null}
创建并编排
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
@@ -1,375 +0,0 @@
'use client';
import {useEffect, useMemo, useState} from 'react';
import {zodResolver} from '@hookform/resolvers/zod';
import {Plus} from 'lucide-react';
import {useForm} from 'react-hook-form';
import {z} from 'zod';
import {Button} from '@/components/ui/button';
import {Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage,} from '@/components/ui/form';
import {Input} from '@/components/ui/input';
import {Sheet, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle,} from '@/components/ui/sheet';
import {Switch} from '@/components/ui/switch';
import {Tabs, TabsContent, TabsList, TabsTrigger} from '@/components/ui/tabs';
import {Textarea} from '@/components/ui/textarea';
import type {WAFIPGroup, WAFRuleGroup, WAFRuleGroupPayload} from '@/lib/services/openflare';
import {
buildCountryOptions,
buildRuleGroupDraft,
countRuleEntries,
emptyRuleGroupDraft,
formatCountryItem,
getListFieldKey,
type ListFieldKey,
normalizeItems,
textToList,
updateDraftList,
} from './helpers';
import {PowConfigPanel} from './pow-config-panel';
import {RuleEntryDialog, type RuleEntryFormValues} from './rule-entry-dialog';
import {RuleListSection} from './rule-list-section';
const ruleGroupSchema = z.object({
name: z.string().trim().min(1, '请输入规则组名称').max(255, '名称不能超过 255 个字符'),
enabled: z.boolean(),
block_status_code: z.number().int().min(400).max(599),
block_response_body: z.string(),
pow_enabled: z.boolean(),
});
type RuleGroupFormValues = z.infer<typeof ruleGroupSchema>;
interface RuleGroupSheetProps {
open: boolean;
group: WAFRuleGroup | null;
ipGroups: WAFIPGroup[];
submitting: boolean;
onOpenChange: (open: boolean) => void;
onSubmit: (payload: WAFRuleGroupPayload) => Promise<void>;
}
export function RuleGroupSheet({
open,
group,
ipGroups,
submitting,
onOpenChange,
onSubmit,
}: RuleGroupSheetProps) {
const [listsDraft, setListsDraft] = useState<WAFRuleGroupPayload>(emptyRuleGroupDraft);
const [ruleEntryOpen, setRuleEntryOpen] = useState(false);
const countryOptions = useMemo(() => buildCountryOptions(), []);
const countryLabelMap = useMemo(
() => new Map(countryOptions.map((option) => [option.code, option.label])),
[countryOptions],
);
const form = useForm<RuleGroupFormValues>({
resolver: zodResolver(ruleGroupSchema),
defaultValues: {
name: '',
enabled: true,
block_status_code: 418,
block_response_body: '',
pow_enabled: false,
},
});
useEffect(() => {
if (!open) return;
const draft = buildRuleGroupDraft(group);
setListsDraft(draft);
form.reset({
name: draft.name,
enabled: draft.enabled,
block_status_code: draft.block_status_code,
block_response_body: draft.block_response_body,
pow_enabled: draft.pow_enabled,
});
setRuleEntryOpen(false);
}, [form, group, open]);
const ipGroupByID = new Map(ipGroups.map((item) => [item.id, item]));
const whitelistGroupItems = listsDraft.ip_whitelist_group_ids
.map((id) => ipGroupByID.get(id))
.filter((item): item is WAFIPGroup => Boolean(item));
const blacklistGroupItems = listsDraft.ip_blacklist_group_ids
.map((id) => ipGroupByID.get(id))
.filter((item): item is WAFIPGroup => Boolean(item));
const removeRuleItem = (key: ListFieldKey, value: string) => {
setListsDraft((current) =>
updateDraftList(current, key, (items) => items.filter((item) => item !== value)),
);
};
const removeRuleGroup = (key: ListFieldKey, id: number) => {
setListsDraft((current) =>
updateDraftList(current, key, (items) =>
items.filter((item) => item !== String(id)),
),
);
};
const applyRuleEntry = (entry: RuleEntryFormValues) => {
const values =
entry.dimension === 'ip'
? textToList(entry.ipValue)
: entry.dimension === 'ip_group'
? entry.ipGroupIDs.map(String)
: normalizeItems(entry.countryValues);
const listKey = getListFieldKey(entry.listType, entry.dimension);
setListsDraft((current) =>
updateDraftList(current, listKey, (items) =>
normalizeItems([...items, ...values]),
),
);
setRuleEntryOpen(false);
};
const handleSubmit = form.handleSubmit(async (values) => {
const payload: WAFRuleGroupPayload = {
...listsDraft,
name: values.name,
enabled: values.enabled,
block_status_code: values.block_status_code,
block_response_body: values.block_response_body,
pow_enabled: values.pow_enabled,
};
try {
await onSubmit(payload);
onOpenChange(false);
} catch (error) {
form.setError('root', {
message: error instanceof Error ? error.message : '保存失败',
});
}
});
const isGlobal = group?.is_global ?? false;
return (
<>
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent side="right" className="flex h-svh w-full flex-col gap-0 p-0 sm:max-w-4xl">
<SheetHeader className="border-b px-4 py-4">
<SheetTitle>{group ? `编辑 ${group.name}` : '新建规则组'}</SheetTitle>
<SheetDescription>
白名单命中后直接放行;未命中白名单时继续判断黑名单。当前规则数{' '}
{countRuleEntries(listsDraft)} 条。
</SheetDescription>
</SheetHeader>
<Form {...form}>
<form
id="rule-group-form"
onSubmit={handleSubmit}
className="flex min-h-0 flex-1 flex-col"
>
<div className="flex-1 space-y-4 overflow-y-auto px-4 py-4">
<Tabs defaultValue="basic">
<TabsList className="grid w-full grid-cols-4">
<TabsTrigger value="basic">基本信息</TabsTrigger>
<TabsTrigger value="lists">黑白名单</TabsTrigger>
<TabsTrigger value="pow">PoW</TabsTrigger>
<TabsTrigger value="block">拦截返回</TabsTrigger>
</TabsList>
<TabsContent value="basic" className="mt-4 space-y-4">
<div className="grid gap-4">
<FormField
control={form.control}
name="enabled"
render={({ field }) => (
<FormItem className="flex items-center justify-between rounded-lg border border-dashed p-4">
<div className="space-y-0.5">
<FormLabel>启用规则组</FormLabel>
<FormDescription>关闭后保留配置,但不会参与匹配。</FormDescription>
</div>
<FormControl>
<Switch checked={field.value} onCheckedChange={field.onChange} />
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>规则组名称</FormLabel>
<FormControl>
<Input {...field} disabled={isGlobal} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
</TabsContent>
<TabsContent value="lists" className="mt-4 space-y-4">
<div className="flex items-center justify-between">
<p className="text-sm text-muted-foreground">
通过弹窗添加 IP、IP 组或地域规则。
</p>
<Button type="button" size="sm" onClick={() => setRuleEntryOpen(true)}>
<Plus className="mr-1 size-3.5" />
添加规则
</Button>
</div>
<div className="grid gap-4 xl:grid-cols-2">
<RuleListSection
title="IP 白名单"
description="命中后直接放行,不再继续判断黑名单。"
items={listsDraft.ip_whitelist}
groupItems={whitelistGroupItems}
tone="whitelist"
emptyText="暂无 IP 白名单规则。"
onRemove={(item) => removeRuleItem('ip_whitelist', item)}
onRemoveGroup={(id) => removeRuleGroup('ip_whitelist_group_ids', id)}
/>
<RuleListSection
title="IP 黑名单"
description="未命中白名单时,命中这些 IP / IP 段将被拦截。"
items={listsDraft.ip_blacklist}
groupItems={blacklistGroupItems}
tone="blacklist"
emptyText="暂无 IP 黑名单规则。"
onRemove={(item) => removeRuleItem('ip_blacklist', item)}
onRemoveGroup={(id) => removeRuleGroup('ip_blacklist_group_ids', id)}
/>
<RuleListSection
title="地域白名单"
description="显示格式为国家代码与中文名,命中后直接放行。"
items={listsDraft.country_whitelist.map((code) =>
formatCountryItem(code, countryLabelMap),
)}
tone="whitelist"
emptyText="暂无地域白名单规则。"
onRemove={(item) => {
const code = item.split(' ')[0] ?? item;
removeRuleItem('country_whitelist', code);
}}
/>
<RuleListSection
title="地域黑名单"
description="当请求未命中白名单时,命中这些地域将被拦截。"
items={listsDraft.country_blacklist.map((code) =>
formatCountryItem(code, countryLabelMap),
)}
tone="blacklist"
emptyText="暂无地域黑名单规则。"
onRemove={(item) => {
const code = item.split(' ')[0] ?? item;
removeRuleItem('country_blacklist', code);
}}
/>
</div>
</TabsContent>
<TabsContent value="pow" className="mt-4">
<PowConfigPanel
enabled={form.watch('pow_enabled')}
config={listsDraft.pow_config}
onChange={(enabled, config) => {
form.setValue('pow_enabled', enabled);
setListsDraft((current) => ({
...current,
pow_enabled: enabled,
pow_config: config,
}));
}}
/>
</TabsContent>
<TabsContent value="block" className="mt-4 space-y-4">
<div className="grid gap-4">
<FormField
control={form.control}
name="block_status_code"
render={({ field }) => (
<FormItem>
<FormLabel>拦截状态码</FormLabel>
<FormControl>
<Input
type="number"
min={400}
max={599}
value={field.value}
onChange={(event) =>
field.onChange(Number(event.target.value))
}
/>
</FormControl>
<div className="flex flex-wrap gap-2 pt-2">
{[403, 418, 451, 503].map((code) => (
<Button
key={code}
type="button"
size="sm"
variant={field.value === code ? 'default' : 'outline'}
onClick={() => field.onChange(code)}
>
{code}
</Button>
))}
</div>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="block_response_body"
render={({ field }) => (
<FormItem>
<FormLabel>拦截页面</FormLabel>
<FormControl>
<Textarea
className="min-h-48"
placeholder="<html><body><h1>Request blocked</h1></body></html>"
{...field}
/>
</FormControl>
<FormDescription>留空时只返回状态码。</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
</TabsContent>
</Tabs>
{form.formState.errors.root ? (
<p className="text-sm text-destructive">{form.formState.errors.root.message}</p>
) : null}
</div>
<SheetFooter className="flex-row justify-end border-t px-4 py-4">
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
取消
</Button>
<Button type="submit" disabled={submitting}>
{submitting ? '保存中...' : '保存规则组'}
</Button>
</SheetFooter>
</form>
</Form>
</SheetContent>
</Sheet>
<RuleEntryDialog
open={ruleEntryOpen}
countryOptions={countryOptions}
ipGroups={ipGroups}
onOpenChange={setRuleEntryOpen}
onSubmit={applyRuleEntry}
/>
</>
);
}
@@ -7,21 +7,20 @@ import {Button} from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import {Table, TableBody, TableCell, TableHead, TableHeader, TableRow,} from '@/components/ui/table';
import type {WAFRuleGroup} from '@/lib/services/openflare';
import type {WAFRule} from '@/lib/services/openflare';
import {formatDateTime} from '@/lib/utils';
import {countRuleEntries} from './helpers';
interface RuleGroupsTableProps {
groups: WAFRuleGroup[];
onEdit: (group: WAFRuleGroup) => void;
onDelete: (group: WAFRuleGroup) => void;
onBindSites: (group: WAFRuleGroup) => void;
groups: WAFRule[];
onEdit: (group: WAFRule) => void;
onDelete: (group: WAFRule) => void;
onBindSites: (group: WAFRule) => void;
}
export function RuleGroupsTable({
@@ -37,7 +36,7 @@ export function RuleGroupsTable({
<TableHead>名称</TableHead>
<TableHead>类型</TableHead>
<TableHead>状态</TableHead>
<TableHead>规则数</TableHead>
<TableHead>节点数</TableHead>
<TableHead>应用范围</TableHead>
<TableHead>更新时间</TableHead>
<TableHead className="w-[80px] text-right">操作</TableHead>
@@ -64,7 +63,7 @@ export function RuleGroupsTable({
{group.enabled ? '启用' : '停用'}
</Badge>
</TableCell>
<TableCell>{countRuleEntries(group)}</TableCell>
<TableCell>{group.graph.nodes.length}</TableCell>
<TableCell>
{group.is_global
? '全部网站'
@@ -77,30 +76,34 @@ export function RuleGroupsTable({
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="size-8">
<MoreHorizontal className="size-4" />
<MoreHorizontal />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => onEdit(group)}>
<Pencil className="size-4 mr-2" />
编辑
</DropdownMenuItem>
{!group.is_global ? (
<DropdownMenuItem onClick={() => onBindSites(group)}>
<Users className="size-4 mr-2" />
绑定网站
<DropdownMenuGroup>
<DropdownMenuItem onClick={() => onEdit(group)}>
<Pencil />
编排
</DropdownMenuItem>
) : null}
{!group.is_global ? (
<DropdownMenuItem onClick={() => onBindSites(group)}>
<Users />
绑定网站
</DropdownMenuItem>
) : null}
</DropdownMenuGroup>
{!group.is_global ? (
<>
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onClick={() => onDelete(group)}
>
<Trash2 className="size-4 mr-2" />
删除
</DropdownMenuItem>
<DropdownMenuGroup>
<DropdownMenuItem
variant="destructive"
onClick={() => onDelete(group)}
>
<Trash2 />
删除
</DropdownMenuItem>
</DropdownMenuGroup>
</>
) : null}
</DropdownMenuContent>
@@ -7,10 +7,10 @@ import {Button} from '@/components/ui/button';
import {Input} from '@/components/ui/input';
import {Sheet, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle,} from '@/components/ui/sheet';
import {cn} from '@/lib/utils';
import type {ProxyRouteItem, WAFRuleGroup} from '@/lib/services/openflare';
import type {ProxyRouteItem, WAFRule} from '@/lib/services/openflare';
interface SiteBindingSheetProps {
group: WAFRuleGroup | null;
group: WAFRule | null;
routes: ProxyRouteItem[];
open: boolean;
pending: boolean;