前端优化

This commit is contained in:
ryan
2026-06-18 20:20:34 +08:00
parent 676899cab0
commit 056c75a853
10 changed files with 437 additions and 52 deletions
@@ -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 }));
}
@@ -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<void>;
}
export function IPGroupViewDialog({
open,
group,
loading,
removingIp,
onOpenChange,
onRemoveIp,
}: IPGroupViewDialogProps) {
const [deleteTarget, setDeleteTarget] = useState<IPGroupViewEntry | null>(null);
const entries = useMemo(
() => (group ? getIPGroupViewEntries(group) : []),
[group],
);
const showAutomaticMeta = group?.type === 'automatic';
return (
<>
<Dialog
open={open}
onOpenChange={(nextOpen) => {
onOpenChange(nextOpen);
if (!nextOpen) {
setDeleteTarget(null);
}
}}
>
<DialogContent className="max-w-3xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>{group ? `查看 ${group.name}` : '查看 IP 组'}</DialogTitle>
<DialogDescription>
{group
? `${ipGroupTypeLabels[group.type]} · 共 ${entries.length} 条 IP`
: '查看当前 IP 组中的 IP 列表,并可移除不需要的条目。'}
</DialogDescription>
</DialogHeader>
{loading ? (
<div className="flex items-center justify-center gap-2 py-12 text-sm text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
加载 IP 列表...
</div>
) : !group ? (
<p className="py-8 text-center text-sm text-muted-foreground">未选择 IP 组。</p>
) : entries.length === 0 ? (
<EmptyStateWithBorder
description={
group.type === 'automatic'
? '该自动规则暂未抓取任何 IP,可点击「立即执行」手动触发抓取。'
: '当前 IP 组暂无 IP 条目。'
}
/>
) : (
<div className="space-y-3">
{group.type === 'subscription' ? (
<p className="text-xs text-muted-foreground">
订阅类型在下次同步时可能重新拉取已删除的 IP。
</p>
) : null}
<div className="rounded-lg border border-dashed">
<Table>
<TableHeader>
<TableRow>
<TableHead>IP 地址</TableHead>
{showAutomaticMeta ? (
<>
<TableHead>抓取时间</TableHead>
<TableHead>封禁剩余</TableHead>
</>
) : null}
<TableHead className="w-[80px] text-right">操作</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{entries.map((entry) => (
<TableRow key={entry.ip}>
<TableCell className="font-mono text-sm">{entry.ip}</TableCell>
{showAutomaticMeta ? (
<>
<TableCell className="text-sm text-muted-foreground">
{entry.capturedAt ? formatDateTime(entry.capturedAt) : '—'}
</TableCell>
<TableCell className="text-sm">{entry.banRemaining ?? '—'}</TableCell>
</>
) : null}
<TableCell className="text-right">
<Button
type="button"
variant="ghost"
size="icon"
className="size-8 text-destructive hover:text-destructive"
disabled={removingIp === entry.ip}
onClick={() => setDeleteTarget(entry)}
>
{removingIp === entry.ip ? (
<Loader2 className="size-4 animate-spin" />
) : (
<Trash2 className="size-4" />
)}
<span className="sr-only">删除 {entry.ip}</span>
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
)}
<DialogFooter>
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
关闭
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<AlertDialog
open={Boolean(deleteTarget)}
onOpenChange={(nextOpen) => !nextOpen && setDeleteTarget(null)}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>确认删除 IP</AlertDialogTitle>
<AlertDialogDescription>
确认从 IP 组「{group?.name}」中移除 {deleteTarget?.ip} 吗?
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={Boolean(removingIp)}>取消</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-white hover:bg-destructive/90"
disabled={Boolean(removingIp)}
onClick={async () => {
if (!deleteTarget) return;
await onRemoveIp(deleteTarget.ip);
setDeleteTarget(null);
}}
>
{removingIp ? '删除中...' : '确认删除'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}
@@ -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({
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => onView(group)}>
<Eye className="size-4 mr-2" />
查看
</DropdownMenuItem>
<DropdownMenuItem onClick={() => onEdit(group)}>
<Pencil className="size-4 mr-2" />
编辑
@@ -200,21 +200,6 @@ export function RuleGroupDialog({
</FormItem>
)}
/>
<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="remark"
@@ -229,6 +214,20 @@ export function RuleGroupDialog({
)}
/>
</div>
<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>
</div>
<FormControl>
<Switch checked={field.value} onCheckedChange={field.onChange} />
</FormControl>
</FormItem>
)}
/>
</TabsContent>
<TabsContent value="lists" className="space-y-4 mt-4">
@@ -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<WAFIPGroupAutoTestResult | null>(null);
const [syncingId, setSyncingId] = useState<number | null>(null);
const [viewOpen, setViewOpen] = useState(false);
const [viewingGroup, setViewingGroup] = useState<WAFIPGroup | null>(null);
const [removingIp, setRemovingIp] = useState<string | null>(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 (
<div className="py-6 px-1 space-y-6">
@@ -205,6 +248,7 @@ export default function WafIPGroupsPage() {
<IPGroupsTable
groups={groups}
syncingId={syncingId}
onView={handleView}
onEdit={handleEdit}
onDelete={setDeleteTarget}
onSync={(group) => syncMutation.mutate(group.id)}
@@ -234,6 +278,20 @@ export default function WafIPGroupsPage() {
onOpenChange={setTestOpen}
/>
<IPGroupViewDialog
open={viewOpen}
group={viewGroup}
loading={viewGroupQuery.isFetching && !viewGroupQuery.data}
removingIp={removingIp}
onOpenChange={(open) => {
setViewOpen(open);
if (!open) {
setViewingGroup(null);
}
}}
onRemoveIp={handleRemoveIp}
/>
<AlertDialog
open={Boolean(deleteTarget)}
onOpenChange={(open) => !open && setDeleteTarget(null)}
@@ -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']},
],
};
@@ -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 {
@@ -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)
}
+1
View File
@@ -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
@@ -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() {
</ResourceField>
<ToggleField
label="启用规则组"
description="关闭后保留配置,但不会参与匹配。"
checked={draft.enabled}
onChange={(checked) =>
setDraft((current) => ({