From 056c75a8539d508a0d2c70bf713b3ebf0dfbc760 Mon Sep 17 00:00:00 2001 From: ryan Date: Thu, 18 Jun 2026 20:20:34 +0800 Subject: [PATCH] =?UTF-8?q?=E5=89=8D=E7=AB=AF=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../app/(main)/waf/components/helpers.ts | 68 ++++++- .../waf/components/ip-group-view-dialog.tsx | 189 ++++++++++++++++++ .../(main)/waf/components/ip-groups-table.tsx | 8 +- .../waf/components/rule-group-dialog.tsx | 29 ++- .../app/(main)/waf/ip-groups/page.tsx | 60 +++++- .../frontend/lib/navigation/openflare-nav.ts | 8 +- Wavelet/internal/apps/openflare/waf/logics.go | 33 +++ .../apps/openflare/waf/logics_test.go | 43 ++++ docs/changelog/index.md | 1 + .../web/features/waf/components/waf-page.tsx | 50 ++--- 10 files changed, 437 insertions(+), 52 deletions(-) create mode 100644 Wavelet/frontend/app/(main)/waf/components/ip-group-view-dialog.tsx diff --git a/Wavelet/frontend/app/(main)/waf/components/helpers.ts b/Wavelet/frontend/app/(main)/waf/components/helpers.ts index c8e21033..f8a8ebe8 100644 --- a/Wavelet/frontend/app/(main)/waf/components/helpers.ts +++ b/Wavelet/frontend/app/(main)/waf/components/helpers.ts @@ -1,4 +1,10 @@ -import type {ProxyRoutePoWConfig, WAFRuleGroup, WAFRuleGroupPayload,} from '@/lib/services/openflare'; +import type { + ProxyRoutePoWConfig, + WAFIPGroup, + WAFIPGroupPayload, + WAFRuleGroup, + WAFRuleGroupPayload, +} from '@/lib/services/openflare'; export type WAFTab = 'basic' | 'lists' | 'pow' | 'block'; export type RuleListType = 'whitelist' | 'blacklist'; @@ -266,3 +272,63 @@ export const automaticPresetRules = [ expr: 'ip_host_count > 50 && ip_host_ratio > 0.5', }, ]; + +export type IPGroupViewEntry = { + ip: string; + capturedAt?: string; + banRemaining?: string; +}; + +export function buildIPGroupPayloadFromGroup( + group: WAFIPGroup, + ipList: string[], +): WAFIPGroupPayload { + return { + name: group.name, + type: group.type, + enabled: group.enabled, + ip_list: ipList, + auto_config: group.auto_config ?? {}, + 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 ?? '', + }; +} + +export function formatIPGroupBanRemaining( + capturedAt: string, + ttlSeconds: number, + now = new Date(), +): string { + if (ttlSeconds <= 0) { + return '永久'; + } + const capturedDate = new Date(capturedAt); + if (Number.isNaN(capturedDate.getTime())) { + return '—'; + } + const expireDate = new Date(capturedDate.getTime() + ttlSeconds * 1000); + if (expireDate.getTime() <= now.getTime()) { + return '已过期'; + } + const diffMins = Math.round((expireDate.getTime() - now.getTime()) / (60 * 1000)); + if (diffMins < 60) { + return `${diffMins} 分钟后`; + } + return `${Math.round(diffMins / 60)} 小时后`; +} + +export function getIPGroupViewEntries(group: WAFIPGroup): IPGroupViewEntry[] { + if (group.type === 'automatic' && group.ext_ips && group.ext_ips.length > 0) { + const ttl = + typeof group.auto_config?.ttl === 'number' ? group.auto_config.ttl : -1; + return group.ext_ips.map((item) => ({ + ip: item.ip, + capturedAt: item.captured_at, + banRemaining: formatIPGroupBanRemaining(item.captured_at, ttl), + })); + } + return (group.ip_list ?? []).map((ip) => ({ ip })); +} diff --git a/Wavelet/frontend/app/(main)/waf/components/ip-group-view-dialog.tsx b/Wavelet/frontend/app/(main)/waf/components/ip-group-view-dialog.tsx new file mode 100644 index 00000000..ed48f26a --- /dev/null +++ b/Wavelet/frontend/app/(main)/waf/components/ip-group-view-dialog.tsx @@ -0,0 +1,189 @@ +'use client'; + +import {Loader2, Trash2} from 'lucide-react'; +import {useMemo, useState} from 'react'; + +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/components/ui/alert-dialog'; +import {Button} from '@/components/ui/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import {EmptyStateWithBorder} from '@/components/layout/empty'; +import {Table, TableBody, TableCell, TableHead, TableHeader, TableRow,} from '@/components/ui/table'; +import type {WAFIPGroup} from '@/lib/services/openflare'; +import {formatDateTime} from '@/lib/utils'; + +import {getIPGroupViewEntries, ipGroupTypeLabels, type IPGroupViewEntry} from './helpers'; + +interface IPGroupViewDialogProps { + open: boolean; + group: WAFIPGroup | null; + loading: boolean; + removingIp: string | null; + onOpenChange: (open: boolean) => void; + onRemoveIp: (ip: string) => Promise; +} + +export function IPGroupViewDialog({ + open, + group, + loading, + removingIp, + onOpenChange, + onRemoveIp, +}: IPGroupViewDialogProps) { + const [deleteTarget, setDeleteTarget] = useState(null); + + const entries = useMemo( + () => (group ? getIPGroupViewEntries(group) : []), + [group], + ); + + const showAutomaticMeta = group?.type === 'automatic'; + + return ( + <> + { + onOpenChange(nextOpen); + if (!nextOpen) { + setDeleteTarget(null); + } + }} + > + + + {group ? `查看 ${group.name}` : '查看 IP 组'} + + {group + ? `${ipGroupTypeLabels[group.type]} · 共 ${entries.length} 条 IP` + : '查看当前 IP 组中的 IP 列表,并可移除不需要的条目。'} + + + + {loading ? ( +
+ + 加载 IP 列表... +
+ ) : !group ? ( +

未选择 IP 组。

+ ) : entries.length === 0 ? ( + + ) : ( +
+ {group.type === 'subscription' ? ( +

+ 订阅类型在下次同步时可能重新拉取已删除的 IP。 +

+ ) : null} +
+ + + + IP 地址 + {showAutomaticMeta ? ( + <> + 抓取时间 + 封禁剩余 + + ) : null} + 操作 + + + + {entries.map((entry) => ( + + {entry.ip} + {showAutomaticMeta ? ( + <> + + {entry.capturedAt ? formatDateTime(entry.capturedAt) : '—'} + + {entry.banRemaining ?? '—'} + + ) : null} + + + + + ))} + +
+
+
+ )} + + + + +
+
+ + !nextOpen && setDeleteTarget(null)} + > + + + 确认删除 IP + + 确认从 IP 组「{group?.name}」中移除 {deleteTarget?.ip} 吗? + + + + 取消 + { + if (!deleteTarget) return; + await onRemoveIp(deleteTarget.ip); + setDeleteTarget(null); + }} + > + {removingIp ? '删除中...' : '确认删除'} + + + + + + ); +} \ No newline at end of file diff --git a/Wavelet/frontend/app/(main)/waf/components/ip-groups-table.tsx b/Wavelet/frontend/app/(main)/waf/components/ip-groups-table.tsx index 05c0b356..cbe2d461 100644 --- a/Wavelet/frontend/app/(main)/waf/components/ip-groups-table.tsx +++ b/Wavelet/frontend/app/(main)/waf/components/ip-groups-table.tsx @@ -1,6 +1,6 @@ 'use client'; -import {Download, MoreHorizontal, Pencil, Play, Trash2} from 'lucide-react'; +import {Download, Eye, MoreHorizontal, Pencil, Play, Trash2} from 'lucide-react'; import {Badge} from '@/components/ui/badge'; import {Button} from '@/components/ui/button'; @@ -20,6 +20,7 @@ import {ipGroupTypeLabels} from './helpers'; interface IPGroupsTableProps { groups: WAFIPGroup[]; syncingId: number | null; + onView: (group: WAFIPGroup) => void; onEdit: (group: WAFIPGroup) => void; onDelete: (group: WAFIPGroup) => void; onSync: (group: WAFIPGroup) => void; @@ -29,6 +30,7 @@ interface IPGroupsTableProps { export function IPGroupsTable({ groups, syncingId, + onView, onEdit, onDelete, onSync, @@ -78,6 +80,10 @@ export function IPGroupsTable({ + onView(group)}> + + 查看 + onEdit(group)}> 编辑 diff --git a/Wavelet/frontend/app/(main)/waf/components/rule-group-dialog.tsx b/Wavelet/frontend/app/(main)/waf/components/rule-group-dialog.tsx index 1654f9cd..40bd56ba 100644 --- a/Wavelet/frontend/app/(main)/waf/components/rule-group-dialog.tsx +++ b/Wavelet/frontend/app/(main)/waf/components/rule-group-dialog.tsx @@ -200,21 +200,6 @@ export function RuleGroupDialog({ )} /> - ( - -
- 启用规则组 - 关闭后保留配置,但不会参与匹配。 -
- - - -
- )} - /> + ( + +
+ 启用规则组 +
+ + + +
+ )} + /> diff --git a/Wavelet/frontend/app/(main)/waf/ip-groups/page.tsx b/Wavelet/frontend/app/(main)/waf/ip-groups/page.tsx index a8156dd5..63873d68 100644 --- a/Wavelet/frontend/app/(main)/waf/ip-groups/page.tsx +++ b/Wavelet/frontend/app/(main)/waf/ip-groups/page.tsx @@ -23,9 +23,10 @@ import {LoadingStateWithBorder} from '@/components/layout/loading'; import type {WAFIPGroup, WAFIPGroupAutoTestResult, WAFIPGroupPayload,} from '@/lib/services/openflare'; import {WafService} from '@/lib/services/openflare'; -import {getErrorMessage, parseAutomaticConfig} from '../components/helpers'; +import {buildIPGroupPayloadFromGroup, getErrorMessage, parseAutomaticConfig} from '../components/helpers'; import {IPGroupDialog} from '../components/ip-group-dialog'; import {IPGroupTestDialog} from '../components/ip-group-test-dialog'; +import {IPGroupViewDialog} from '../components/ip-group-view-dialog'; import {IPGroupsTable} from '../components/ip-groups-table'; const ipGroupsQueryKey = ['openflare', 'waf', 'ip-groups']; @@ -38,6 +39,9 @@ export default function WafIPGroupsPage() { const [testOpen, setTestOpen] = useState(false); const [testResult, setTestResult] = useState(null); const [syncingId, setSyncingId] = useState(null); + const [viewOpen, setViewOpen] = useState(false); + const [viewingGroup, setViewingGroup] = useState(null); + const [removingIp, setRemovingIp] = useState(null); const groupsQuery = useQuery({ queryKey: ipGroupsQueryKey, @@ -105,6 +109,33 @@ export default function WafIPGroupsPage() { }, }); + const viewGroupQuery = useQuery({ + queryKey: ['openflare', 'waf', 'ip-groups', viewingGroup?.id], + queryFn: () => WafService.getIPGroup(viewingGroup!.id), + enabled: viewOpen && viewingGroup !== null, + }); + + const removeIpMutation = useMutation({ + mutationFn: async ({ group, ip }: { group: WAFIPGroup; ip: string }) => { + const nextIpList = group.ip_list.filter((item) => item !== ip); + return WafService.updateIPGroup(group.id, buildIPGroupPayloadFromGroup(group, nextIpList)); + }, + onMutate: ({ ip }) => { + setRemovingIp(ip); + }, + onSuccess: async (updatedGroup) => { + toast.success('IP 已移除'); + setViewingGroup(updatedGroup); + await invalidate(); + }, + onError: (error) => { + toast.error(getErrorMessage(error)); + }, + onSettled: () => { + setRemovingIp(null); + }, + }); + const testMutation = useMutation({ mutationFn: (group: WAFIPGroup) => WafService.testIPGroup({ @@ -146,7 +177,19 @@ export default function WafIPGroupsPage() { testMutation.mutate(group); }; + const handleView = (group: WAFIPGroup) => { + setViewingGroup(group); + setViewOpen(true); + }; + + const handleRemoveIp = async (ip: string) => { + const group = viewGroupQuery.data ?? viewingGroup; + if (!group) return; + await removeIpMutation.mutateAsync({ group, ip }); + }; + const groups = groupsQuery.data ?? []; + const viewGroup = viewGroupQuery.data ?? viewingGroup; return (
@@ -205,6 +248,7 @@ export default function WafIPGroupsPage() { syncMutation.mutate(group.id)} @@ -234,6 +278,20 @@ export default function WafIPGroupsPage() { onOpenChange={setTestOpen} /> + { + setViewOpen(open); + if (!open) { + setViewingGroup(null); + } + }} + onRemoveIp={handleRemoveIp} + /> + !open && setDeleteTarget(null)} diff --git a/Wavelet/frontend/lib/navigation/openflare-nav.ts b/Wavelet/frontend/lib/navigation/openflare-nav.ts index 4fe48eca..0ee9eb2c 100644 --- a/Wavelet/frontend/lib/navigation/openflare-nav.ts +++ b/Wavelet/frontend/lib/navigation/openflare-nav.ts @@ -50,10 +50,10 @@ export const openflareWebsiteNavGroup: OpenFlareNavGroup = { title: '网站管理', icon: Globe, items: [ - {title: '网站', url: '/websites', childUrls: ['/websites/detail']}, - {title: '证书', url: '/websites/certificates'}, - {title: 'DNS', url: '/websites/dns-accounts'}, - {title: '源站', url: '/origins', childUrls: ['/origins/detail']}, + {title: '域名列表', url: '/websites', childUrls: ['/websites/detail']}, + {title: 'TLS证书', url: '/websites/certificates'}, + {title: 'DNS账号', url: '/websites/dns-accounts'}, + {title: '源站地址', url: '/origins', childUrls: ['/origins/detail']}, ], }; diff --git a/Wavelet/internal/apps/openflare/waf/logics.go b/Wavelet/internal/apps/openflare/waf/logics.go index 7f3ea1ab..3837d7b5 100644 --- a/Wavelet/internal/apps/openflare/waf/logics.go +++ b/Wavelet/internal/apps/openflare/waf/logics.go @@ -695,6 +695,11 @@ func buildIPGroup(group *model.OpenFlareWAFIPGroup, input IPGroupInput) (*model. group.Type = groupType group.Enabled = input.Enabled group.IPList = string(ipListJSON) + if groupType == wafIPGroupTypeAutomatic { + if err := pruneIPGroupExtIPs(group, normalizedIPs); err != nil { + return nil, err + } + } group.AutoConfig = autoConfig group.SubscriptionURL = subscriptionURL group.SubscriptionFormat = subscriptionFormat @@ -785,6 +790,34 @@ func loadIPGroupReferenceCounts(ctx context.Context) (map[uint]int, error) { return counts, nil } +func pruneIPGroupExtIPs(group *model.OpenFlareWAFIPGroup, ipList []string) error { + if group == nil { + return nil + } + allowed := make(map[string]struct{}, len(ipList)) + for _, ip := range ipList { + allowed[ip] = struct{}{} + } + var extIPs []ipGroupExtIP + if group.ExtIPs != "" && group.ExtIPs != "[]" { + if err := json.Unmarshal([]byte(group.ExtIPs), &extIPs); err != nil { + return err + } + } + pruned := make([]ipGroupExtIP, 0, len(extIPs)) + for _, extIP := range extIPs { + if _, ok := allowed[extIP.IP]; ok { + pruned = append(pruned, extIP) + } + } + extIPsJSON, err := json.Marshal(pruned) + if err != nil { + return err + } + group.ExtIPs = string(extIPsJSON) + return nil +} + func normalizeIPList(items []string) ([]string, error) { normalized := make([]string, 0, len(items)) for _, raw := range items { diff --git a/Wavelet/internal/apps/openflare/waf/logics_test.go b/Wavelet/internal/apps/openflare/waf/logics_test.go index 53fffb03..afd67117 100644 --- a/Wavelet/internal/apps/openflare/waf/logics_test.go +++ b/Wavelet/internal/apps/openflare/waf/logics_test.go @@ -65,3 +65,46 @@ func TestCreateRuleGroup(t *testing.T) { }) require.Error(t, err) } + +func TestPruneIPGroupExtIPs(t *testing.T) { + group := &model.OpenFlareWAFIPGroup{ + ExtIPs: `[{"ip":"203.0.113.10","captured_at":"2026-06-18T10:00:00Z"},{"ip":"203.0.113.11","captured_at":"2026-06-18T11:00:00Z"}]`, + } + err := pruneIPGroupExtIPs(group, []string{"203.0.113.10"}) + require.NoError(t, err) + assert.JSONEq(t, `[{"ip":"203.0.113.10","captured_at":"2026-06-18T10:00:00Z"}]`, group.ExtIPs) +} + +func TestUpdateIPGroupPrunesAutomaticExtIPs(t *testing.T) { + cleanup := setupWAFTestDB(t) + defer cleanup() + ctx := context.Background() + + created, err := CreateIPGroup(ctx, IPGroupInput{ + Name: "auto group", + Type: wafIPGroupTypeAutomatic, + Enabled: true, + AutoConfig: []byte(`{"lookback_minutes":60,"ttl":-1,"rules":[{"name":"scan","expr":"request_count > 1"}]}`), + }) + require.NoError(t, err) + + group, err := model.GetOpenFlareWAFIPGroupByID(ctx, created.ID) + require.NoError(t, err) + group.IPList = `["203.0.113.10","203.0.113.11"]` + group.ExtIPs = `[{"ip":"203.0.113.10","captured_at":"2026-06-18T10:00:00Z"},{"ip":"203.0.113.11","captured_at":"2026-06-18T11:00:00Z"}]` + require.NoError(t, model.UpdateOpenFlareWAFIPGroup(ctx, group)) + + updated, err := UpdateIPGroup(ctx, created.ID, IPGroupInput{ + Name: created.Name, + Type: created.Type, + Enabled: created.Enabled, + IPList: []string{"203.0.113.10"}, + AutoConfig: created.AutoConfig, + Remark: created.Remark, + }) + require.NoError(t, err) + require.Len(t, updated.IPList, 1) + assert.Equal(t, "203.0.113.10", updated.IPList[0]) + require.Len(t, updated.ExtIPs, 1) + assert.Equal(t, "203.0.113.10", updated.ExtIPs[0].IP) +} diff --git a/docs/changelog/index.md b/docs/changelog/index.md index e85cb281..7c12c9e9 100644 --- a/docs/changelog/index.md +++ b/docs/changelog/index.md @@ -65,6 +65,7 @@ sidebar: false - 移除 Wavelet 顶栏 OpenFlare 服务端版本入口按钮;版本升级能力保留在 Admin 设置 OpenFlare 运维 Tab。 - 将 Wavelet 默认上游仓库调整为 `Rain-kl/OpenFlare`,站点名称、邮件模板、前端默认标题与页脚品牌统一初始化为 OpenFlare;新增 goose 迁移回填既有环境的旧 Wavelet 默认值。 +- Wavelet WAF IP 组列表新增「查看」操作,支持在查看弹窗中浏览当前 IP 并移除单条 IP;自动类型同步裁剪 `ext_ips` 以与 `ip_list` 保持一致。 ## [v2.3.4] - 2026-06-17 diff --git a/openflare-server/web/features/waf/components/waf-page.tsx b/openflare-server/web/features/waf/components/waf-page.tsx index 7df5c084..de433e3a 100644 --- a/openflare-server/web/features/waf/components/waf-page.tsx +++ b/openflare-server/web/features/waf/components/waf-page.tsx @@ -1,18 +1,18 @@ 'use client'; -import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; -import type { ReactNode } from 'react'; -import { useEffect, useMemo, useState } from 'react'; -import { useRouter } from 'next/navigation'; -import { Globe2, Network, Plus, Save, ShieldCheck, Trash2 } from 'lucide-react'; +import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'; +import type {ReactNode} from 'react'; +import {useEffect, useMemo, useState} from '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'; -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 { getProxyRoutes } from '@/features/proxy-routes/api/proxy-routes'; +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 {getProxyRoutes} from '@/features/proxy-routes/api/proxy-routes'; import { DangerButton, PrimaryButton, @@ -30,18 +30,14 @@ import { replaceWAFRuleGroupSites, updateWAFRuleGroup, } from '@/features/waf/api/waf'; -import type { - WAFIPGroup, - WAFRuleGroup, - WAFRuleGroupPayload, -} from '@/features/waf/types'; -import { cn } from '@/lib/utils/cn'; +import type {WAFIPGroup, WAFRuleGroup, WAFRuleGroupPayload,} from '@/features/waf/types'; +import {cn} from '@/lib/utils/cn'; -import { RuleEntryModal } from './rule-entry-modal'; -import { SiteApplyDrawer } from './site-apply-drawer'; -import { PowTabPanel } from './pow-tab-panel'; -import { RuleListSection } from './rule-list-section'; -import { TabButton } from './tab-button'; +import {RuleEntryModal} from './rule-entry-modal'; +import {SiteApplyDrawer} from './site-apply-drawer'; +import {PowTabPanel} from './pow-tab-panel'; +import {RuleListSection} from './rule-list-section'; +import {TabButton} from './tab-button'; import { buildCountryOptions, buildDraft, @@ -56,12 +52,7 @@ 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(); @@ -444,7 +435,6 @@ export function WAFPage() { setDraft((current) => ({