'use client'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { ArrowLeft, Download, Play, Plus, Save, Trash2, Eye } 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 { AppModal } from '@/components/ui/app-modal'; import { createWAFIPGroup, deleteWAFIPGroup, getWAFIPGroups, syncWAFIPGroup, testWAFIPGroupAutoConfig, updateWAFIPGroup, } from '@/features/waf/api/waf'; import type { WAFIPGroup, WAFIPGroupAutoTestResult, 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 = { manual: '手动', automatic: '自动', subscription: '订阅', }; const automaticPresetRules = [ { name: '单 IP 404 高频扫描', expr: 'request_count > 100 && status_404_ratio >= 0.8', }, { name: '单 IP 直连访问异常', expr: 'ip_host_count > 50 && ip_host_ratio > 0.5', }, ]; 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 { const autoConfig = draft.type === 'automatic' ? parseAutomaticConfig(draft.auto_config_text) : {}; 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, }; } function parseAutomaticConfig(text: string): Record { const parsed = JSON.parse(text || '{}') as unknown; if (!parsed || Array.isArray(parsed) || typeof parsed !== 'object') { throw new Error('自动配置必须是 JSON 对象。'); } return parsed as Record; } function appendAutomaticPresetRule( autoConfigText: string, rule: (typeof automaticPresetRules)[number], ) { const parsed = JSON.parse(autoConfigText || '{}') as unknown; if (!parsed || Array.isArray(parsed) || typeof parsed !== 'object') { throw new Error('自动配置必须是 JSON 对象。'); } const config = parsed as Record; const rules = Array.isArray(config.rules) ? config.rules : []; const exists = rules.some( (item) => item && typeof item === 'object' && 'expr' in item && (item as { expr?: unknown }).expr === rule.expr, ); const nextRules = exists ? rules : [...rules, rule]; return JSON.stringify( { lookback_minutes: typeof config.lookback_minutes === 'number' ? config.lookback_minutes : 60, ...config, rules: nextRules, }, null, 2, ); } export function WAFIPGroupsPage() { const router = useRouter(); const queryClient = useQueryClient(); const [selectedID, setSelectedID] = useState(null); const [draft, setDraft] = useState(emptyIPGroupDraft); const [feedback, setFeedback] = useState(null); const [autoTestResult, setAutoTestResult] = useState(null); const [isCapturedIPsModalOpen, setIsCapturedIPsModalOpen] = useState(false); 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) }); }, }); const testMutation = useMutation({ mutationFn: testWAFIPGroupAutoConfig, onSuccess: (result) => { setAutoTestResult(result); setFeedback({ tone: result.matched_count > 0 ? 'success' : 'info', message: result.matched_count > 0 ? `规则测试完成,命中 ${result.matched_count} 个 IP。` : '规则测试完成,当前未命中任何 IP。', }); }, onError: (error) => { setAutoTestResult(null); setFeedback({ tone: 'danger', message: getErrorMessage(error) }); }, }); useEffect(() => { setAutoTestResult(null); }, [draft.type, draft.auto_config_text, selectedID]); if (groupsQuery.isLoading) { return ; } if (groupsQuery.isError) { return ( ); } const saveDraft = () => { try { saveMutation.mutate(buildPayload(draft)); } catch (error) { setFeedback({ tone: 'danger', message: getErrorMessage(error) }); } }; const testDraft = () => { try { testMutation.mutate({ auto_config: parseAutomaticConfig(draft.auto_config_text), }); } catch (error) { setAutoTestResult(null); setFeedback({ tone: 'danger', message: getErrorMessage(error) }); } }; return (
router.push('/waf')}> 返回 WAF { setSelectedID(0); setDraft({ ...emptyIPGroupDraft, name: '自定义 IP 组' }); }} > 新建 IP 组
} /> {feedback ? ( setFeedback(null)} /> ) : null}
{groups.length === 0 && selectedID !== 0 ? ( ) : (
{groups.map((group) => ( ))}
)}
{draft.type === 'automatic' ? ( {testMutation.isPending ? '测试中...' : '测试规则'} ) : null} {selectedGroup?.type === 'subscription' || selectedGroup?.type === 'automatic' ? ( syncMutation.mutate(selectedGroup.id)} > {syncMutation.isPending ? '执行中...' : selectedGroup.type === 'automatic' ? '立即执行' : '立即同步'} ) : null} {selectedGroup?.type === 'automatic' ? ( setIsCapturedIPsModalOpen(true)} > 查看已抓取 IP ) : null} {saveMutation.isPending ? '保存中...' : '保存 IP 组'}
} >
setDraft((current) => ({ ...current, name: event.target.value, })) } /> setDraft((current) => ({ ...current, type: event.target.value as WAFIPGroupType, })) } > setDraft((current) => ({ ...current, enabled: checked })) } /> setDraft((current) => ({ ...current, remark: event.target.value, })) } />
{draft.type === 'subscription' ? (
setDraft((current) => ({ ...current, subscription_url: event.target.value, })) } /> setDraft((current) => ({ ...current, subscription_format: event.target .value as WAFIPGroupSubscriptionFormat, })) } > setDraft((current) => ({ ...current, sync_interval_minutes: Number(event.target.value), })) } /> setDraft((current) => ({ ...current, subscription_mapping_rule: event.target.value, })) } />
) : null} {draft.type === 'automatic' ? (
setDraft((current) => ({ ...current, sync_interval_minutes: Number(event.target.value), })) } />
{automaticPresetRules.map((rule) => ( { try { setDraft((current) => ({ ...current, auto_config_text: appendAutomaticPresetRule( current.auto_config_text, rule, ), })); } catch (error) { setFeedback({ tone: 'danger', message: getErrorMessage(error), }); } }} > {rule.name} ))}
setDraft((current) => ({ ...current, auto_config_text: event.target.value, })) } /> {autoTestResult ? (
测试结果
回看 {autoTestResult.lookback_minutes} 分钟 · 规则{' '} {autoTestResult.rule_count} 条
0 ? 'success' : 'info' } message={ autoTestResult.matched_count > 0 ? `命中 ${autoTestResult.matched_count} 个 IP。` : '当前没有匹配到任何 IP。' } /> {autoTestResult.matched_count > 0 ? (
命中 IP 列表
                          {autoTestResult.matched_ips.join('\n')}
                        
) : null}
) : null}
) : ( setDraft((current) => ({ ...current, ip_list_text: event.target.value, })) } /> )} {selectedGroup ? (
{selectedGroup.last_sync_status ? `${selectedGroup.last_sync_status}: ${selectedGroup.last_sync_message}` : '尚无同步记录'}
{ if ( window.confirm( `确认删除 IP 组 ${selectedGroup.name} 吗?`, ) ) { deleteMutation.mutate(selectedGroup.id); } }} > 删除
) : null}
{selectedGroup?.type === 'automatic' ? ( setIsCapturedIPsModalOpen(false)} footer={
setIsCapturedIPsModalOpen(false)}> 关闭
} >
{!selectedGroup.ext_ips || selectedGroup.ext_ips.length === 0 ? ( ) : (
{selectedGroup.ext_ips.map((item) => { const autoConfig = selectedGroup.auto_config as { ttl?: number } | undefined; const ttl = autoConfig?.ttl ?? -1; let expireText = '永久'; if (ttl > 0) { const capturedDate = new Date(item.captured_at); const expireDate = new Date(capturedDate.getTime() + ttl * 1000); const now = new Date(); if (expireDate.getTime() <= now.getTime()) { expireText = '已过期'; } else { const diffMs = expireDate.getTime() - now.getTime(); const diffMins = Math.round(diffMs / (60 * 1000)); if (diffMins < 60) { expireText = `${diffMins} 分钟后`; } else { const diffHours = Math.round(diffMins / 60); expireText = `${diffHours} 小时后`; } } } return ( ); })}
IP 地址 抓取时间 封禁剩余时间
{item.ip} {new Date(item.captured_at).toLocaleString()} 0 ? "text-amber-500" : "text-emerald-500")}>{expireText}
)}
) : null} ); }