[优化] 添加 WAF IP 组功能,包括 CRUD 接口和前端页面支持

This commit is contained in:
ryan
2026-06-01 08:46:24 +08:00
parent d5f46138d5
commit 95d7045b4a
32 changed files with 2214 additions and 44 deletions
@@ -0,0 +1,5 @@
import { WAFIPGroupsPage } from '@/features/waf/components/ip-groups-page';
export default function WAFIPGroupsRoute() {
return <WAFIPGroupsPage />;
}
@@ -3,6 +3,9 @@ import { apiRequest } from '@/lib/api/client';
import type {
WAFRuleGroup,
WAFRuleGroupPayload,
WAFIPGroup,
WAFIPGroupPayload,
WAFIPGroupSyncResult,
WAFSiteRuleGroups,
} from '@/features/waf/types';
@@ -47,3 +50,37 @@ export function replaceWAFSiteRuleGroups(routeId: number, ids: number[]) {
body: JSON.stringify({ ids }),
});
}
export function getWAFIPGroups() {
return apiRequest<WAFIPGroup[]>('/waf/ip-groups');
}
export function getWAFIPGroup(id: number) {
return apiRequest<WAFIPGroup>(`/waf/ip-groups/${id}`);
}
export function createWAFIPGroup(payload: WAFIPGroupPayload) {
return apiRequest<WAFIPGroup>('/waf/ip-groups', {
method: 'POST',
body: JSON.stringify(payload),
});
}
export function updateWAFIPGroup(id: number, payload: WAFIPGroupPayload) {
return apiRequest<WAFIPGroup>(`/waf/ip-groups/${id}/update`, {
method: 'POST',
body: JSON.stringify(payload),
});
}
export function deleteWAFIPGroup(id: number) {
return apiRequest<void>(`/waf/ip-groups/${id}/delete`, {
method: 'POST',
});
}
export function syncWAFIPGroup(id: number) {
return apiRequest<WAFIPGroupSyncResult>(`/waf/ip-groups/${id}/sync`, {
method: 'POST',
});
}
@@ -38,6 +38,8 @@ export const emptyDraft: WAFRuleGroupPayload = {
block_response_body: '',
ip_whitelist: [],
ip_blacklist: [],
ip_whitelist_group_ids: [],
ip_blacklist_group_ids: [],
country_whitelist: [],
country_blacklist: [],
region_whitelist: [],
@@ -52,6 +54,7 @@ export const defaultRuleModalState: RuleModalState = {
listType: 'whitelist',
dimension: 'ip',
ipValue: '',
ipGroupIDs: [],
countryValues: [],
};
@@ -104,6 +107,8 @@ export function buildDraft(group: WAFRuleGroup | null): WAFRuleGroupPayload {
block_response_body: group.block_response_body ?? '',
ip_whitelist: group.ip_whitelist ?? [],
ip_blacklist: group.ip_blacklist ?? [],
ip_whitelist_group_ids: group.ip_whitelist_group_ids ?? [],
ip_blacklist_group_ids: group.ip_blacklist_group_ids ?? [],
country_whitelist: group.country_whitelist ?? [],
country_blacklist: group.country_blacklist ?? [],
region_whitelist: group.region_whitelist ?? [],
@@ -118,6 +123,8 @@ export function countRuleEntries(group: RuleListRenderable) {
return (
group.ip_whitelist.length +
group.ip_blacklist.length +
group.ip_whitelist_group_ids.length +
group.ip_blacklist_group_ids.length +
group.country_whitelist.length +
group.country_blacklist.length +
group.region_whitelist.length +
@@ -166,6 +173,11 @@ export function getListFieldKey(
if (dimension === 'ip') {
return listType === 'whitelist' ? 'ip_whitelist' : 'ip_blacklist';
}
if (dimension === 'ip_group') {
return listType === 'whitelist'
? 'ip_whitelist_group_ids'
: 'ip_blacklist_group_ids';
}
return listType === 'whitelist' ? 'country_whitelist' : 'country_blacklist';
}
@@ -179,6 +191,20 @@ export function updateDraftList(
return { ...draft, ip_whitelist: updater(draft.ip_whitelist) };
case 'ip_blacklist':
return { ...draft, ip_blacklist: updater(draft.ip_blacklist) };
case 'ip_whitelist_group_ids':
return {
...draft,
ip_whitelist_group_ids: updater(
draft.ip_whitelist_group_ids.map(String),
).map(Number),
};
case 'ip_blacklist_group_ids':
return {
...draft,
ip_blacklist_group_ids: updater(
draft.ip_blacklist_group_ids.map(String),
).map(Number),
};
case 'country_whitelist':
return { ...draft, country_whitelist: updater(draft.country_whitelist) };
case 'country_blacklist':
@@ -0,0 +1,490 @@
'use client';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { ArrowLeft, Download, Plus, Save, Trash2 } from 'lucide-react';
import { useRouter } from 'next/navigation';
import { useEffect, useMemo, useState } from 'react';
import { EmptyState } from '@/components/feedback/empty-state';
import { ErrorState } from '@/components/feedback/error-state';
import { InlineMessage } from '@/components/feedback/inline-message';
import { LoadingState } from '@/components/feedback/loading-state';
import { PageHeader } from '@/components/layout/page-header';
import { AppCard } from '@/components/ui/app-card';
import {
createWAFIPGroup,
deleteWAFIPGroup,
getWAFIPGroups,
syncWAFIPGroup,
updateWAFIPGroup,
} from '@/features/waf/api/waf';
import type {
WAFIPGroup,
WAFIPGroupPayload,
WAFIPGroupSubscriptionFormat,
WAFIPGroupType,
} from '@/features/waf/types';
import {
DangerButton,
PrimaryButton,
ResourceField,
ResourceInput,
ResourceSelect,
ResourceTextarea,
SecondaryButton,
ToggleField,
} from '@/features/shared/components/resource-primitives';
import { cn } from '@/lib/utils/cn';
import { getErrorMessage, listToText, parseTextareaList } from './helpers';
type FeedbackState = {
tone: 'success' | 'danger' | 'info';
message: string;
};
type IPGroupDraft = WAFIPGroupPayload & {
ip_list_text: string;
auto_config_text: string;
};
const emptyIPGroupDraft: IPGroupDraft = {
name: '',
type: 'manual',
enabled: true,
ip_list: [],
ip_list_text: '',
auto_config: {},
auto_config_text: '{}',
subscription_url: '',
subscription_format: 'text',
subscription_mapping_rule: '',
sync_interval_minutes: 1440,
remark: '',
};
const typeLabels: Record<WAFIPGroupType, string> = {
manual: '手动',
automatic: '自动',
subscription: '订阅',
};
function buildDraft(group: WAFIPGroup | null): IPGroupDraft {
if (!group) {
return { ...emptyIPGroupDraft };
}
return {
name: group.name,
type: group.type,
enabled: group.enabled,
ip_list: group.ip_list ?? [],
ip_list_text: listToText(group.ip_list),
auto_config: group.auto_config ?? {},
auto_config_text: JSON.stringify(group.auto_config ?? {}, null, 2),
subscription_url: group.subscription_url ?? '',
subscription_format: group.subscription_format ?? 'text',
subscription_mapping_rule: group.subscription_mapping_rule ?? '',
sync_interval_minutes: group.sync_interval_minutes || 1440,
remark: group.remark ?? '',
};
}
function buildPayload(draft: IPGroupDraft): WAFIPGroupPayload {
let autoConfig: Record<string, unknown> = {};
if (draft.type === 'automatic') {
const parsed = JSON.parse(draft.auto_config_text || '{}') as unknown;
if (!parsed || Array.isArray(parsed) || typeof parsed !== 'object') {
throw new Error('自动配置必须是 JSON 对象。');
}
autoConfig = parsed as Record<string, unknown>;
}
return {
name: draft.name,
type: draft.type,
enabled: draft.enabled,
ip_list: parseTextareaList(draft.ip_list_text),
auto_config: autoConfig,
subscription_url: draft.subscription_url,
subscription_format: draft.subscription_format,
subscription_mapping_rule: draft.subscription_mapping_rule,
sync_interval_minutes: draft.sync_interval_minutes,
remark: draft.remark,
};
}
export function WAFIPGroupsPage() {
const router = useRouter();
const queryClient = useQueryClient();
const [selectedID, setSelectedID] = useState<number | null>(null);
const [draft, setDraft] = useState<IPGroupDraft>(emptyIPGroupDraft);
const [feedback, setFeedback] = useState<FeedbackState | null>(null);
const groupsQuery = useQuery({
queryKey: ['waf', 'ip-groups'],
queryFn: getWAFIPGroups,
});
const groups = useMemo(() => groupsQuery.data ?? [], [groupsQuery.data]);
const selectedGroup = useMemo(
() =>
selectedID === 0
? null
: (groups.find((group) => group.id === selectedID) ??
groups[0] ??
null),
[groups, selectedID],
);
useEffect(() => {
if (selectedGroup) {
setSelectedID(selectedGroup.id);
setDraft(buildDraft(selectedGroup));
}
}, [selectedGroup]);
const invalidate = async () => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: ['waf', 'ip-groups'] }),
queryClient.invalidateQueries({ queryKey: ['waf', 'rule-groups'] }),
queryClient.invalidateQueries({ queryKey: ['config-versions', 'diff'] }),
]);
};
const saveMutation = useMutation({
mutationFn: (payload: WAFIPGroupPayload) => {
if (selectedGroup) {
return updateWAFIPGroup(selectedGroup.id, payload);
}
return createWAFIPGroup(payload);
},
onSuccess: async (group) => {
setSelectedID(group.id);
setFeedback({ tone: 'success', message: 'IP 组已保存。' });
await invalidate();
},
onError: (error) => {
setFeedback({ tone: 'danger', message: getErrorMessage(error) });
},
});
const deleteMutation = useMutation({
mutationFn: deleteWAFIPGroup,
onSuccess: async () => {
setSelectedID(null);
setFeedback({ tone: 'success', message: 'IP 组已删除。' });
await invalidate();
},
onError: (error) => {
setFeedback({ tone: 'danger', message: getErrorMessage(error) });
},
});
const syncMutation = useMutation({
mutationFn: syncWAFIPGroup,
onSuccess: async (result) => {
setSelectedID(result.group.id);
setFeedback({ tone: 'success', message: result.message });
await invalidate();
},
onError: (error) => {
setFeedback({ tone: 'danger', message: getErrorMessage(error) });
},
});
if (groupsQuery.isLoading) {
return <LoadingState />;
}
if (groupsQuery.isError) {
return (
<ErrorState
title="IP 组加载失败"
description={getErrorMessage(groupsQuery.error)}
/>
);
}
const saveDraft = () => {
try {
saveMutation.mutate(buildPayload(draft));
} catch (error) {
setFeedback({ tone: 'danger', message: getErrorMessage(error) });
}
};
return (
<div className="space-y-6">
<PageHeader
title="IP 组"
description="维护可被 WAF IP 黑白名单引用的手动、自动与订阅 IP 集合。"
action={
<div className="flex flex-wrap gap-3">
<SecondaryButton type="button" onClick={() => router.push('/waf')}>
<ArrowLeft className="mr-2 h-4 w-4" />
返回 WAF
</SecondaryButton>
<PrimaryButton
type="button"
onClick={() => {
setSelectedID(0);
setDraft({ ...emptyIPGroupDraft, name: '自定义 IP 组' });
}}
>
<Plus className="mr-2 h-4 w-4" />
新建 IP 组
</PrimaryButton>
</div>
}
/>
{feedback ? (
<InlineMessage tone={feedback.tone} message={feedback.message} />
) : null}
<div className="grid gap-5 xl:grid-cols-[360px_minmax(0,1fr)]">
<AppCard title="IP 组列表">
{groups.length === 0 && selectedID !== 0 ? (
<EmptyState title="暂无 IP 组" />
) : (
<div className="space-y-2">
{groups.map((group) => (
<button
key={group.id}
type="button"
onClick={() => setSelectedID(group.id)}
className={cn(
'w-full rounded-2xl border px-4 py-3 text-left transition',
selectedGroup?.id === group.id
? 'border-[var(--border-strong)] bg-[var(--accent-soft)]'
: 'border-[var(--border-default)] bg-[var(--surface-elevated)] hover:bg-[var(--surface-muted)]',
)}
>
<span className="flex items-center justify-between gap-3">
<span className="truncate text-sm font-semibold text-[var(--foreground-primary)]">
{group.name}
</span>
<span className="text-xs text-[var(--foreground-secondary)]">
{typeLabels[group.type]}
</span>
</span>
<span className="mt-2 block text-xs text-[var(--foreground-secondary)]">
{group.enabled ? '启用' : '停用'} · {group.ip_list.length}{' '}
条 · 被引用 {group.referenced_by_rule_count} 次
</span>
</button>
))}
</div>
)}
</AppCard>
<AppCard
title={selectedGroup ? selectedGroup.name : '新建 IP 组'}
description={
draft.type === 'automatic'
? '自动 IP 组第一版仅保存配置,暂不执行日志挖掘。'
: '保存后可在 WAF 规则组黑白名单中引用。'
}
action={
<div className="flex flex-wrap gap-3">
{selectedGroup?.type === 'subscription' ? (
<SecondaryButton
type="button"
disabled={syncMutation.isPending}
onClick={() => syncMutation.mutate(selectedGroup.id)}
>
<Download className="mr-2 h-4 w-4" />
{syncMutation.isPending ? '同步中...' : '立即同步'}
</SecondaryButton>
) : null}
<PrimaryButton
type="button"
disabled={saveMutation.isPending}
onClick={saveDraft}
>
<Save className="mr-2 h-4 w-4" />
{saveMutation.isPending ? '保存中...' : '保存 IP 组'}
</PrimaryButton>
</div>
}
>
<div className="space-y-6">
<div className="grid gap-5 xl:grid-cols-2">
<ResourceField label="IP 组名称">
<ResourceInput
value={draft.name}
onChange={(event) =>
setDraft((current) => ({
...current,
name: event.target.value,
}))
}
/>
</ResourceField>
<ResourceField label="类型">
<ResourceSelect
value={draft.type}
onChange={(event) =>
setDraft((current) => ({
...current,
type: event.target.value as WAFIPGroupType,
}))
}
>
<option value="manual">手动</option>
<option value="automatic">自动</option>
<option value="subscription">订阅</option>
</ResourceSelect>
</ResourceField>
<ToggleField
label="启用 IP 组"
description="关闭后保留配置,但发布时不会展开到 WAF 运行时名单。"
checked={draft.enabled}
onChange={(checked) =>
setDraft((current) => ({ ...current, enabled: checked }))
}
/>
<ResourceField label="备注">
<ResourceInput
value={draft.remark}
onChange={(event) =>
setDraft((current) => ({
...current,
remark: event.target.value,
}))
}
/>
</ResourceField>
</div>
{draft.type === 'subscription' ? (
<div className="grid gap-5 xl:grid-cols-2">
<ResourceField label="订阅 URL">
<ResourceInput
value={draft.subscription_url}
placeholder="https://example.com/ip-list.txt"
onChange={(event) =>
setDraft((current) => ({
...current,
subscription_url: event.target.value,
}))
}
/>
</ResourceField>
<ResourceField label="订阅格式">
<ResourceSelect
value={draft.subscription_format}
onChange={(event) =>
setDraft((current) => ({
...current,
subscription_format: event.target
.value as WAFIPGroupSubscriptionFormat,
}))
}
>
<option value="text">文本列表</option>
<option value="json">JSON</option>
</ResourceSelect>
</ResourceField>
<ResourceField
label="同步间隔(分钟)"
hint="最小 5 分钟,默认 1440 分钟。"
>
<ResourceInput
type="number"
min={5}
value={draft.sync_interval_minutes}
onChange={(event) =>
setDraft((current) => ({
...current,
sync_interval_minutes: Number(event.target.value),
}))
}
/>
</ResourceField>
<ResourceField
label="JSON 映射规则"
hint="留空表示根数组;示例:data.items[]。文本格式无需填写。"
>
<ResourceInput
value={draft.subscription_mapping_rule}
disabled={draft.subscription_format !== 'json'}
onChange={(event) =>
setDraft((current) => ({
...current,
subscription_mapping_rule: event.target.value,
}))
}
/>
</ResourceField>
</div>
) : null}
{draft.type === 'automatic' ? (
<ResourceField
label="自动配置 JSON"
hint="当前版本只保存配置,不会执行请求日志挖掘。"
>
<ResourceTextarea
value={draft.auto_config_text}
className="min-h-64 font-mono"
onChange={(event) =>
setDraft((current) => ({
...current,
auto_config_text: event.target.value,
}))
}
/>
</ResourceField>
) : (
<ResourceField
label="IP / IP 段"
hint={
draft.type === 'subscription'
? '订阅同步会覆盖此列表;也可以先手动保存当前内容。'
: '支持单个 IP 或 CIDR,每行一个。'
}
>
<ResourceTextarea
value={draft.ip_list_text}
className="min-h-72 font-mono"
placeholder={'203.0.113.10\n198.51.100.0/24'}
onChange={(event) =>
setDraft((current) => ({
...current,
ip_list_text: event.target.value,
}))
}
/>
</ResourceField>
)}
{selectedGroup ? (
<div className="flex flex-wrap justify-between gap-3 border-t border-[var(--border-default)] pt-6">
<div className="text-sm text-[var(--foreground-secondary)]">
{selectedGroup.last_sync_status
? `${selectedGroup.last_sync_status}: ${selectedGroup.last_sync_message}`
: '尚无同步记录'}
</div>
<DangerButton
type="button"
disabled={deleteMutation.isPending}
onClick={() => {
if (
window.confirm(
`确认删除 IP 组 ${selectedGroup.name} 吗?`,
)
) {
deleteMutation.mutate(selectedGroup.id);
}
}}
>
<Trash2 className="mr-2 h-4 w-4" />
删除
</DangerButton>
</div>
) : null}
</div>
</AppCard>
</div>
</div>
);
}
@@ -1,6 +1,7 @@
import { useEffect, useMemo, useState } from 'react';
import { Search } from 'lucide-react';
import { AppModal } from '@/components/ui/app-modal';
import type { WAFIPGroup } from '@/features/waf/types';
import {
PrimaryButton,
ResourceField,
@@ -9,11 +10,17 @@ import {
} from '@/features/shared/components/resource-primitives';
import { cn } from '@/lib/utils/cn';
import { normalizeItems } from './helpers';
import type { CountryOption, RuleListType, RuleDimension, RuleModalState } from './types';
import type {
CountryOption,
RuleListType,
RuleDimension,
RuleModalState,
} from './types';
export function RuleEntryModal({
state,
countryOptions,
ipGroups,
pending,
onClose,
onChange,
@@ -21,6 +28,7 @@ export function RuleEntryModal({
}: {
state: RuleModalState;
countryOptions: CountryOption[];
ipGroups: WAFIPGroup[];
pending: boolean;
onClose: () => void;
onChange: (patch: Partial<RuleModalState>) => void;
@@ -39,6 +47,10 @@ export function RuleEntryModal({
() => new Set(state.countryValues),
[state.countryValues],
);
const selectedIPGroupSet = useMemo(
() => new Set(state.ipGroupIDs),
[state.ipGroupIDs],
);
const filteredCountries = useMemo(() => {
const normalized = keyword.trim().toLowerCase();
@@ -71,9 +83,20 @@ export function RuleEntryModal({
};
const clearCountries = () => onChange({ countryValues: [] });
const toggleIPGroup = (id: number) => {
const values = selectedIPGroupSet.has(id)
? state.ipGroupIDs.filter((item) => item !== id)
: [...state.ipGroupIDs, id].sort((left, right) => left - right);
onChange({ ipGroupIDs: values });
};
const typeLabel = state.listType === 'blacklist' ? '黑名单' : '白名单';
const dimensionLabel = state.dimension === 'ip' ? 'IP' : '地域';
const dimensionLabel =
state.dimension === 'ip'
? 'IP'
: state.dimension === 'ip_group'
? 'IP 组'
: '地域';
return (
<AppModal
@@ -120,9 +143,10 @@ export function RuleEntryModal({
</div>
</ResourceField>
<ResourceField label="维度" container="div">
<div className="grid grid-cols-2 gap-3">
<div className="grid grid-cols-3 gap-3">
{[
{ value: 'ip', label: 'IP' },
{ value: 'ip_group', label: 'IP 组' },
{ value: 'country', label: '地域' },
].map((option) => (
<button
@@ -156,6 +180,60 @@ export function RuleEntryModal({
onChange={(event) => onChange({ ipValue: event.target.value })}
/>
</ResourceField>
) : state.dimension === 'ip_group' ? (
<div className="rounded-[26px] border border-[var(--border-default)] bg-[var(--surface-elevated)] p-5">
<div className="flex items-center justify-between gap-3">
<div>
<h3 className="text-sm font-semibold text-[var(--foreground-primary)]">
选择 IP 组
</h3>
<p className="mt-1 text-xs leading-5 text-[var(--foreground-secondary)]">
被引用的 IP 组会在发布配置时展开到 WAF 运行时名单。
</p>
</div>
<span className="rounded-full border border-[var(--border-default)] px-2.5 py-1 text-xs font-medium text-[var(--foreground-secondary)]">
已选 {state.ipGroupIDs.length}
</span>
</div>
<div className="mt-4 max-h-80 space-y-2 overflow-y-auto pr-1">
{ipGroups.length > 0 ? (
ipGroups.map((group) => {
const selected = selectedIPGroupSet.has(group.id);
return (
<label
key={group.id}
className={cn(
'flex cursor-pointer items-center gap-3 rounded-2xl border px-4 py-3 transition',
selected
? 'border-[var(--border-strong)] bg-[var(--accent-soft)]'
: 'border-[var(--border-default)] bg-[var(--surface-panel)] hover:bg-[var(--surface-muted)]',
)}
>
<input
type="checkbox"
checked={selected}
onChange={() => toggleIPGroup(group.id)}
className="h-4 w-4 rounded border-[var(--border-default)] accent-[var(--brand-primary)]"
/>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium text-[var(--foreground-primary)]">
{group.name}
</span>
<span className="mt-1 block text-xs text-[var(--foreground-secondary)]">
{group.type} · {group.ip_list.length} 条 ·{' '}
{group.enabled ? '启用' : '停用'}
</span>
</span>
</label>
);
})
) : (
<p className="text-sm text-[var(--foreground-muted)]">
暂无 IP 组,请先进入 IP 组管理页面创建。
</p>
)}
</div>
</div>
) : (
<div className="space-y-4">
<div className="flex items-center gap-3 rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-3">
@@ -4,17 +4,22 @@ export function RuleListSection({
title,
description,
items,
groupItems = [],
tone,
emptyText,
onRemove,
onRemoveGroup,
}: {
title: string;
description: string;
items: string[];
groupItems?: Array<{ id: number; name: string; enabled: boolean }>;
tone: 'whitelist' | 'blacklist';
emptyText: string;
onRemove: (item: string) => void;
onRemoveGroup?: (id: number) => void;
}) {
const total = items.length + groupItems.length;
return (
<div className="rounded-[26px] border border-[var(--border-default)] bg-[var(--surface-elevated)] p-5">
<div className="flex items-start justify-between gap-4">
@@ -27,12 +32,20 @@ export function RuleListSection({
</p>
</div>
<span className="shrink-0 rounded-full bg-[var(--surface-muted)] px-3 py-1 text-xs font-semibold text-[var(--foreground-primary)]">
{items.length}
{total}
</span>
</div>
<div className="mt-5">
{items.length > 0 ? (
{total > 0 ? (
<div className="flex flex-wrap gap-2">
{groupItems.map((group) => (
<RuleChip
key={`group-${group.id}`}
label={`IP组: ${group.name}${group.enabled ? '' : ' (停用)'}`}
tone={tone}
onRemove={() => onRemoveGroup?.(group.id)}
/>
))}
{items.map((item) => (
<RuleChip
key={item}
@@ -7,10 +7,12 @@ export type FeedbackState = {
export type WAFTab = 'basic' | 'lists' | 'pow' | 'block';
export type RuleListType = 'whitelist' | 'blacklist';
export type RuleDimension = 'ip' | 'country';
export type RuleDimension = 'ip' | 'ip_group' | 'country';
export type ListFieldKey =
| 'ip_whitelist'
| 'ip_blacklist'
| 'ip_whitelist_group_ids'
| 'ip_blacklist_group_ids'
| 'country_whitelist'
| 'country_blacklist';
@@ -26,6 +28,7 @@ export type RuleModalState = {
listType: RuleListType;
dimension: RuleDimension;
ipValue: string;
ipGroupIDs: number[];
countryValues: string[];
};
@@ -33,6 +36,8 @@ export type RuleListRenderable = Pick<
WAFRuleGroupPayload,
| 'ip_whitelist'
| 'ip_blacklist'
| 'ip_whitelist_group_ids'
| 'ip_blacklist_group_ids'
| 'country_whitelist'
| 'country_blacklist'
| 'region_whitelist'
@@ -3,13 +3,8 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import type { ReactNode } from 'react';
import { useEffect, useMemo, useState } from 'react';
import {
Globe2,
Plus,
Save,
ShieldCheck,
Trash2,
} from 'lucide-react';
import { useRouter } from 'next/navigation';
import { Globe2, Network, Plus, Save, ShieldCheck, Trash2 } from 'lucide-react';
import { EmptyState } from '@/components/feedback/empty-state';
import { ErrorState } from '@/components/feedback/error-state';
@@ -30,11 +25,16 @@ import {
import {
createWAFRuleGroup,
deleteWAFRuleGroup,
getWAFIPGroups,
getWAFRuleGroups,
replaceWAFRuleGroupSites,
updateWAFRuleGroup,
} from '@/features/waf/api/waf';
import type { WAFRuleGroup, WAFRuleGroupPayload } from '@/features/waf/types';
import type {
WAFIPGroup,
WAFRuleGroup,
WAFRuleGroupPayload,
} from '@/features/waf/types';
import { cn } from '@/lib/utils/cn';
import { RuleEntryModal } from './rule-entry-modal';
@@ -56,9 +56,15 @@ import {
textToList,
updateDraftList,
} from './helpers';
import type { FeedbackState, ListFieldKey, RuleModalState, WAFTab } from './types';
import type {
FeedbackState,
ListFieldKey,
RuleModalState,
WAFTab,
} from './types';
export function WAFPage() {
const router = useRouter();
const queryClient = useQueryClient();
const [selectedID, setSelectedID] = useState<number | null>(null);
const [activeTab, setActiveTab] = useState<WAFTab>('basic');
@@ -73,12 +79,20 @@ export function WAFPage() {
queryKey: ['waf', 'rule-groups'],
queryFn: getWAFRuleGroups,
});
const ipGroupsQuery = useQuery({
queryKey: ['waf', 'ip-groups'],
queryFn: getWAFIPGroups,
});
const routesQuery = useQuery({
queryKey: ['proxy-routes'],
queryFn: getProxyRoutes,
});
const groups = useMemo(() => groupsQuery.data ?? [], [groupsQuery.data]);
const ipGroups = useMemo(
() => ipGroupsQuery.data ?? [],
[ipGroupsQuery.data],
);
const routes = useMemo(() => routesQuery.data ?? [], [routesQuery.data]);
const countryOptions = useMemo(() => buildCountryOptions(), []);
const countryLabelMap = useMemo(
@@ -106,6 +120,7 @@ export function WAFPage() {
const invalidate = async () => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: ['waf', 'rule-groups'] }),
queryClient.invalidateQueries({ queryKey: ['waf', 'ip-groups'] }),
queryClient.invalidateQueries({ queryKey: ['config-versions', 'diff'] }),
]);
};
@@ -152,7 +167,11 @@ export function WAFPage() {
},
});
if (groupsQuery.isLoading || routesQuery.isLoading) {
if (
groupsQuery.isLoading ||
routesQuery.isLoading ||
ipGroupsQuery.isLoading
) {
return <LoadingState />;
}
if (groupsQuery.isError) {
@@ -171,6 +190,14 @@ export function WAFPage() {
/>
);
}
if (ipGroupsQuery.isError) {
return (
<ErrorState
title="IP 组加载失败"
description={getErrorMessage(ipGroupsQuery.error)}
/>
);
}
if (!selectedGroup && groups.length === 0) {
return (
<EmptyState
@@ -189,6 +216,13 @@ export function WAFPage() {
routes.find((route) => route.id === id)?.site_name ?? `网站 #${id}`,
)
.sort((left, right) => left.localeCompare(right));
const ipGroupByID = new Map(ipGroups.map((group) => [group.id, group]));
const whitelistGroupItems = draft.ip_whitelist_group_ids
.map((id) => ipGroupByID.get(id))
.filter((group): group is WAFIPGroup => Boolean(group));
const blacklistGroupItems = draft.ip_blacklist_group_ids
.map((id) => ipGroupByID.get(id))
.filter((group): group is WAFIPGroup => Boolean(group));
const openRuleModal = () => {
setRuleModal({ ...defaultRuleModalState, open: true });
@@ -202,7 +236,9 @@ export function WAFPage() {
const values =
ruleModal.dimension === 'ip'
? textToList(ruleModal.ipValue)
: normalizeItems(ruleModal.countryValues);
: ruleModal.dimension === 'ip_group'
? ruleModal.ipGroupIDs.map(String)
: normalizeItems(ruleModal.countryValues);
if (values.length === 0) {
setFeedback({
@@ -210,7 +246,9 @@ export function WAFPage() {
message:
ruleModal.dimension === 'ip'
? '请先输入 IP 或 IP 段。'
: '请先选择地域。',
: ruleModal.dimension === 'ip_group'
? '请先选择 IP 组。'
: '请先选择地域。',
});
return;
}
@@ -236,6 +274,13 @@ export function WAFPage() {
),
);
};
const removeRuleGroup = (key: ListFieldKey, id: number) => {
setDraft((current) =>
updateDraftList(current, key, (items) =>
items.filter((item) => item !== String(id)),
),
);
};
const overviewItems: Array<{ label: string; value: ReactNode }> = [
{
@@ -271,17 +316,26 @@ export function WAFPage() {
title="WAF"
description="按规则组维护 WAF 与 PoW 防护规则,全局规则组始终应用到所有网站。"
action={
<PrimaryButton
type="button"
onClick={() => {
setSelectedID(0);
setActiveTab('basic');
setDraft({ ...emptyDraft, name: '自定义规则组' });
}}
>
<Plus className="mr-2 h-4 w-4" />
新建规则组
</PrimaryButton>
<div className="flex flex-wrap gap-3">
<SecondaryButton
type="button"
onClick={() => router.push('/waf/ip-groups')}
>
<Network className="mr-2 h-4 w-4" />
管理 IP 组
</SecondaryButton>
<PrimaryButton
type="button"
onClick={() => {
setSelectedID(0);
setActiveTab('basic');
setDraft({ ...emptyDraft, name: '自定义规则组' });
}}
>
<Plus className="mr-2 h-4 w-4" />
新建规则组
</PrimaryButton>
</div>
}
/>
@@ -487,17 +541,25 @@ export function WAFPage() {
title="IP 白名单"
description="命中后直接放行,不再继续判断黑名单。"
items={draft.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={draft.ip_blacklist}
groupItems={blacklistGroupItems}
tone="blacklist"
emptyText="暂无 IP 黑名单规则。"
onRemove={(item) => removeRuleItem('ip_blacklist', item)}
onRemoveGroup={(id) =>
removeRuleGroup('ip_blacklist_group_ids', id)
}
/>
<RuleListSection
title="地域白名单"
@@ -646,6 +708,7 @@ export function WAFPage() {
<RuleEntryModal
state={ruleModal}
countryOptions={countryOptions}
ipGroups={ipGroups}
pending={saveMutation.isPending}
onClose={closeRuleModal}
onChange={(patch) =>
@@ -9,6 +9,8 @@ export interface WAFRuleGroup {
block_response_body: string;
ip_whitelist: string[];
ip_blacklist: string[];
ip_whitelist_group_ids: number[];
ip_blacklist_group_ids: number[];
country_whitelist: string[];
country_blacklist: string[];
region_whitelist: string[];
@@ -29,6 +31,8 @@ export interface WAFRuleGroupPayload {
block_response_body: string;
ip_whitelist: string[];
ip_blacklist: string[];
ip_whitelist_group_ids: number[];
ip_blacklist_group_ids: number[];
country_whitelist: string[];
country_blacklist: string[];
region_whitelist: string[];
@@ -45,3 +49,49 @@ export interface WAFSiteRuleGroups {
applied_rule_groups: WAFRuleGroup[];
applied_ids: number[];
}
export type WAFIPGroupType = 'manual' | 'automatic' | 'subscription';
export type WAFIPGroupSubscriptionFormat = 'text' | 'json';
export interface WAFIPGroup {
id: number;
name: string;
type: WAFIPGroupType;
enabled: boolean;
ip_list: string[];
auto_config: Record<string, unknown>;
subscription_url: string;
subscription_format: WAFIPGroupSubscriptionFormat;
subscription_mapping_rule: string;
sync_interval_minutes: number;
last_synced_at?: string;
next_sync_at?: string;
last_sync_status: string;
last_sync_message: string;
remark: string;
referenced_by_rule_count: number;
created_at: string;
updated_at: string;
}
export interface WAFIPGroupPayload {
name: string;
type: WAFIPGroupType;
enabled: boolean;
ip_list: string[];
auto_config: Record<string, unknown>;
subscription_url: string;
subscription_format: WAFIPGroupSubscriptionFormat;
subscription_mapping_rule: string;
sync_interval_minutes: number;
remark: string;
}
export interface WAFIPGroupSyncResult {
group: WAFIPGroup;
ip_count: number;
synced_at: string;
next_sync_at: string;
status: string;
message: string;
}
@@ -0,0 +1,249 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import type { ReactNode } from 'react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { ThemeProvider } from '@/components/providers/theme-provider';
import { WAFIPGroupsPage } from '@/features/waf/components/ip-groups-page';
import { WAFPage } from '@/features/waf/components/waf-page';
const pushMock = vi.fn();
vi.mock('next/navigation', () => ({
useRouter: () => ({
push: pushMock,
}),
}));
function renderWithProviders(ui: ReactNode) {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
render(
<QueryClientProvider client={queryClient}>
<ThemeProvider>{ui}</ThemeProvider>
</QueryClientProvider>,
);
}
function stubMatchMedia() {
vi.stubGlobal(
'matchMedia',
vi.fn().mockImplementation(() => ({
matches: false,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
})),
);
}
function buildIPGroup(overrides: Record<string, unknown> = {}) {
return {
id: 3,
name: 'edge blacklist',
type: 'manual',
enabled: true,
ip_list: ['203.0.113.10'],
auto_config: {},
subscription_url: '',
subscription_format: 'text',
subscription_mapping_rule: '',
sync_interval_minutes: 1440,
last_sync_status: '',
last_sync_message: '',
remark: '',
referenced_by_rule_count: 0,
created_at: '2026-06-01T00:00:00Z',
updated_at: '2026-06-01T00:00:00Z',
...overrides,
};
}
function buildRuleGroup(overrides: Record<string, unknown> = {}) {
return {
id: 1,
name: '全局规则组',
enabled: true,
is_global: true,
block_status_code: 418,
block_response_body: '',
ip_whitelist: [],
ip_blacklist: [],
ip_whitelist_group_ids: [],
ip_blacklist_group_ids: [],
country_whitelist: [],
country_blacklist: [],
region_whitelist: [],
region_blacklist: [],
pow_enabled: false,
pow_config: {
difficulty: 4,
algorithm: 'fast',
session_ttl: 600,
challenge_ttl: 300,
whitelist: {
ips: [],
ip_cidrs: [],
paths: [],
path_regexes: [],
user_agents: [],
},
blacklist: {
ips: [],
ip_cidrs: [],
paths: [],
path_regexes: [],
user_agents: [],
},
},
remark: '',
applied_site_ids: [],
applied_site_count: 0,
created_at: '2026-06-01T00:00:00Z',
updated_at: '2026-06-01T00:00:00Z',
...overrides,
};
}
describe('WAF IP groups', () => {
beforeEach(() => {
pushMock.mockReset();
stubMatchMedia();
});
afterEach(() => {
vi.unstubAllGlobals();
});
it('renders empty state and saves a manual IP group', async () => {
let groups: Array<Record<string, unknown>> = [];
vi.stubGlobal(
'fetch',
vi.fn((input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input);
const method = init?.method?.toUpperCase() ?? 'GET';
if (url.includes('/waf/ip-groups') && method === 'POST') {
const payload = JSON.parse(String(init?.body));
const created = buildIPGroup({
id: 7,
name: payload.name,
ip_list: payload.ip_list,
});
groups = [created];
return Promise.resolve(
new Response(
JSON.stringify({ success: true, message: '', data: created }),
),
);
}
if (url.includes('/waf/ip-groups')) {
return Promise.resolve(
new Response(
JSON.stringify({ success: true, message: '', data: groups }),
),
);
}
return Promise.reject(new Error(`Unhandled fetch: ${url}`));
}),
);
renderWithProviders(<WAFIPGroupsPage />);
expect(await screen.findByText('暂无 IP 组')).toBeInTheDocument();
await userEvent.click(screen.getByRole('button', { name: /新建 IP 组/ }));
await userEvent.clear(screen.getByLabelText('IP 组名称'));
await userEvent.type(screen.getByLabelText('IP 组名称'), 'blocked edge');
await userEvent.type(
screen.getByPlaceholderText(/203\.0\.113\.10/),
'203.0.113.10',
);
await userEvent.click(screen.getByRole('button', { name: /保存 IP 组/ }));
expect(await screen.findByText('IP 组已保存。')).toBeInTheDocument();
await waitFor(() => expect(groups).toHaveLength(1));
});
it('opens IP group management from WAF page and references an IP group', async () => {
vi.stubGlobal(
'fetch',
vi.fn((input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input);
const method = init?.method?.toUpperCase() ?? 'GET';
if (url.includes('/waf/rule-groups/1/update') && method === 'POST') {
const payload = JSON.parse(String(init?.body));
return Promise.resolve(
new Response(
JSON.stringify({
success: true,
message: '',
data: buildRuleGroup({
ip_blacklist_group_ids: payload.ip_blacklist_group_ids,
}),
}),
),
);
}
if (url.includes('/waf/rule-groups')) {
return Promise.resolve(
new Response(
JSON.stringify({
success: true,
message: '',
data: [buildRuleGroup()],
}),
),
);
}
if (url.includes('/waf/ip-groups')) {
return Promise.resolve(
new Response(
JSON.stringify({
success: true,
message: '',
data: [buildIPGroup()],
}),
),
);
}
if (url.includes('/proxy-routes/')) {
return Promise.resolve(
new Response(
JSON.stringify({ success: true, message: '', data: [] }),
),
);
}
return Promise.reject(new Error(`Unhandled fetch: ${url}`));
}),
);
renderWithProviders(<WAFPage />);
await userEvent.click(
await screen.findByRole('button', { name: /管理 IP 组/ }),
);
expect(pushMock).toHaveBeenCalledWith('/waf/ip-groups');
await userEvent.click(screen.getByRole('button', { name: /黑白名单/ }));
await userEvent.click(screen.getByRole('button', { name: /添加/ }));
await userEvent.click(screen.getByRole('button', { name: 'IP 组' }));
await userEvent.click(await screen.findByLabelText(/edge blacklist/));
const addButtons = screen.getAllByRole('button', { name: '添加' });
await userEvent.click(addButtons[addButtons.length - 1]);
expect(await screen.findByText(/IP组: edge blacklist/)).toBeInTheDocument();
});
});