mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-08 08:36:37 +08:00
refactor(frontend): order waf rule bindings
This commit is contained in:
@@ -1,16 +1,43 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import {useEffect, useMemo, useState} from 'react';
|
import {useEffect, useMemo, useState} from 'react';
|
||||||
|
import {
|
||||||
|
closestCenter,
|
||||||
|
DndContext,
|
||||||
|
KeyboardSensor,
|
||||||
|
PointerSensor,
|
||||||
|
useSensor,
|
||||||
|
useSensors,
|
||||||
|
type DragEndEvent,
|
||||||
|
} from '@dnd-kit/core';
|
||||||
|
import {
|
||||||
|
arrayMove,
|
||||||
|
SortableContext,
|
||||||
|
sortableKeyboardCoordinates,
|
||||||
|
useSortable,
|
||||||
|
verticalListSortingStrategy,
|
||||||
|
} from '@dnd-kit/sortable';
|
||||||
|
import {CSS} from '@dnd-kit/utilities';
|
||||||
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query';
|
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query';
|
||||||
|
import {ArrowDown, ArrowUp, GripVertical} from 'lucide-react';
|
||||||
import {toast} from 'sonner';
|
import {toast} from 'sonner';
|
||||||
|
|
||||||
import {Badge} from '@/components/ui/badge';
|
import {Badge} from '@/components/ui/badge';
|
||||||
|
import {Button} from '@/components/ui/button';
|
||||||
|
import {Checkbox} from '@/components/ui/checkbox';
|
||||||
|
import {
|
||||||
|
Field,
|
||||||
|
FieldDescription,
|
||||||
|
FieldGroup,
|
||||||
|
FieldLabel,
|
||||||
|
FieldLegend,
|
||||||
|
FieldSet,
|
||||||
|
} from '@/components/ui/field';
|
||||||
import {EmptyStateWithBorder} from '@/components/layout/empty';
|
import {EmptyStateWithBorder} from '@/components/layout/empty';
|
||||||
import {ErrorInline} from '@/components/layout/error';
|
import {ErrorInline} from '@/components/layout/error';
|
||||||
import {LoadingStateWithBorder} from '@/components/layout/loading';
|
import {LoadingStateWithBorder} from '@/components/layout/loading';
|
||||||
import type {ProxyRouteItem} from '@/lib/services/openflare';
|
import type {ProxyRouteItem} from '@/lib/services/openflare';
|
||||||
import {WafService} from '@/lib/services/openflare';
|
import {WafService} from '@/lib/services/openflare';
|
||||||
import {cn} from '@/lib/utils';
|
|
||||||
|
|
||||||
import {getErrorMessage} from '../../components/helpers';
|
import {getErrorMessage} from '../../components/helpers';
|
||||||
import {proxyRouteFormIds} from '../helpers';
|
import {proxyRouteFormIds} from '../helpers';
|
||||||
@@ -21,9 +48,70 @@ interface WafSectionProps {
|
|||||||
onSavingChange?: (saving: boolean) => void;
|
onSavingChange?: (saving: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface SortableRuleProps {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
index: number;
|
||||||
|
total: number;
|
||||||
|
onMove: (from: number, to: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function reorderRuleIDs(ids: number[], activeID: number, overID: number) {
|
||||||
|
const from = ids.indexOf(activeID);
|
||||||
|
const to = ids.indexOf(overID);
|
||||||
|
return from < 0 || to < 0 || from === to ? ids : arrayMove(ids, from, to);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SortableRule({id, name, index, total, onMove}: SortableRuleProps) {
|
||||||
|
const {attributes, listeners, setNodeRef, transform, transition} = useSortable({id});
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={setNodeRef}
|
||||||
|
style={{transform: CSS.Transform.toString(transform), transition}}
|
||||||
|
className="flex items-center gap-2 rounded-lg border p-3"
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
aria-label={`拖动${name}`}
|
||||||
|
{...attributes}
|
||||||
|
{...listeners}
|
||||||
|
>
|
||||||
|
<GripVertical data-icon="inline-start" />
|
||||||
|
</Button>
|
||||||
|
<span className="min-w-0 flex-1 truncate text-sm font-medium">{name}</span>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
aria-label={`上移${name}`}
|
||||||
|
disabled={index === 0}
|
||||||
|
onClick={() => onMove(index, index - 1)}
|
||||||
|
>
|
||||||
|
<ArrowUp data-icon="inline-start" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
aria-label={`下移${name}`}
|
||||||
|
disabled={index === total - 1}
|
||||||
|
onClick={() => onMove(index, index + 1)}
|
||||||
|
>
|
||||||
|
<ArrowDown data-icon="inline-start" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function WafSection({ route, onSavingChange }: WafSectionProps) {
|
export function WafSection({ route, onSavingChange }: WafSectionProps) {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [selectedIDs, setSelectedIDs] = useState<number[]>([]);
|
const [selectedIDs, setSelectedIDs] = useState<number[]>([]);
|
||||||
|
const sensors = useSensors(
|
||||||
|
useSensor(PointerSensor),
|
||||||
|
useSensor(KeyboardSensor, {coordinateGetter: sortableKeyboardCoordinates}),
|
||||||
|
);
|
||||||
|
|
||||||
const wafQuery = useQuery({
|
const wafQuery = useQuery({
|
||||||
queryKey: ['openflare', 'waf', 'site-rule-groups', route.id],
|
queryKey: ['openflare', 'waf', 'site-rule-groups', route.id],
|
||||||
@@ -61,6 +149,22 @@ export function WafSection({ route, onSavingChange }: WafSectionProps) {
|
|||||||
}, [wafQuery.data]);
|
}, [wafQuery.data]);
|
||||||
|
|
||||||
const selectedSet = useMemo(() => new Set(selectedIDs), [selectedIDs]);
|
const selectedSet = useMemo(() => new Set(selectedIDs), [selectedIDs]);
|
||||||
|
const ruleMap = useMemo(
|
||||||
|
() => new Map((wafQuery.data?.rule_groups ?? []).map((rule) => [rule.id, rule])),
|
||||||
|
[wafQuery.data?.rule_groups],
|
||||||
|
);
|
||||||
|
|
||||||
|
const moveRule = (from: number, to: number) => {
|
||||||
|
if (to < 0 || to >= selectedIDs.length) return;
|
||||||
|
setSelectedIDs((current) => arrayMove(current, from, to));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragEnd = ({active, over}: DragEndEvent) => {
|
||||||
|
if (!over || active.id === over.id) return;
|
||||||
|
setSelectedIDs((current) =>
|
||||||
|
reorderRuleIDs(current, Number(active.id), Number(over.id)),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SectionShell
|
<SectionShell
|
||||||
@@ -79,14 +183,14 @@ export function WafSection({ route, onSavingChange }: WafSectionProps) {
|
|||||||
) : (
|
) : (
|
||||||
<form
|
<form
|
||||||
id={proxyRouteFormIds.waf}
|
id={proxyRouteFormIds.waf}
|
||||||
className="space-y-5"
|
className="flex flex-col gap-5"
|
||||||
onSubmit={(event) => {
|
onSubmit={(event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
wafMutation.mutate(selectedIDs);
|
wafMutation.mutate(selectedIDs);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{wafQuery.data?.global_rule_group ? (
|
{wafQuery.data?.global_rule_group ? (
|
||||||
<div className="rounded-lg border bg-muted/30 p-4">
|
<div className="rounded-lg border bg-muted/30 p-4" data-testid="global-waf-rule">
|
||||||
<div className="flex items-center justify-between gap-3">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
|
<p className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||||
@@ -101,38 +205,77 @@ export function WafSection({ route, onSavingChange }: WafSectionProps) {
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<div className="grid gap-3 md:grid-cols-2">
|
<FieldSet>
|
||||||
{(wafQuery.data?.rule_groups ?? []).map((group) => (
|
<FieldLegend variant="label">选择自定义规则</FieldLegend>
|
||||||
<label
|
<FieldDescription>选中的规则会按下方执行顺序依次运行。</FieldDescription>
|
||||||
key={group.id}
|
<FieldGroup data-slot="checkbox-group" className="grid gap-3 md:grid-cols-2">
|
||||||
className={cn(
|
{(wafQuery.data?.rule_groups ?? []).map((group) => {
|
||||||
'flex cursor-pointer items-start gap-3 rounded-lg border p-4 transition',
|
const checkboxID = `waf-rule-${group.id}`;
|
||||||
selectedSet.has(group.id) && 'border-primary bg-muted/40',
|
return (
|
||||||
)}
|
<Field
|
||||||
|
key={group.id}
|
||||||
|
orientation="horizontal"
|
||||||
|
className="rounded-lg border p-4"
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
id={checkboxID}
|
||||||
|
aria-label={`选择${group.name}`}
|
||||||
|
checked={selectedSet.has(group.id)}
|
||||||
|
onCheckedChange={(checked) => {
|
||||||
|
setSelectedIDs((current) =>
|
||||||
|
checked
|
||||||
|
? [...current, group.id]
|
||||||
|
: current.filter((id) => id !== group.id),
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<FieldLabel
|
||||||
|
htmlFor={checkboxID}
|
||||||
|
className="min-w-0 cursor-pointer flex-col items-start gap-1"
|
||||||
|
>
|
||||||
|
<span className="truncate text-sm font-semibold">{group.name}</span>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{group.enabled ? '启用中' : '已停用'} ·{' '}
|
||||||
|
{group.graph.nodes.length} 个节点
|
||||||
|
</span>
|
||||||
|
</FieldLabel>
|
||||||
|
</Field>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</FieldGroup>
|
||||||
|
</FieldSet>
|
||||||
|
|
||||||
|
{selectedIDs.length > 0 ? (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<p className="text-sm font-medium">执行顺序</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
自定义规则按此顺序执行,可拖动或使用上下移动按钮调整。
|
||||||
|
</p>
|
||||||
|
<DndContext
|
||||||
|
sensors={sensors}
|
||||||
|
collisionDetection={closestCenter}
|
||||||
|
onDragEnd={handleDragEnd}
|
||||||
>
|
>
|
||||||
<input
|
<SortableContext items={selectedIDs} strategy={verticalListSortingStrategy}>
|
||||||
type="checkbox"
|
<div className="flex flex-col gap-2">
|
||||||
checked={selectedSet.has(group.id)}
|
{selectedIDs.map((id, index) => {
|
||||||
onChange={(event) => {
|
const rule = ruleMap.get(id);
|
||||||
const checked = event.target.checked;
|
return rule ? (
|
||||||
setSelectedIDs((current) =>
|
<SortableRule
|
||||||
checked
|
key={id}
|
||||||
? [...current, group.id].sort((left, right) => left - right)
|
id={id}
|
||||||
: current.filter((id) => id !== group.id),
|
name={rule.name}
|
||||||
);
|
index={index}
|
||||||
}}
|
total={selectedIDs.length}
|
||||||
className="mt-1 size-4 rounded border accent-primary"
|
onMove={moveRule}
|
||||||
/>
|
/>
|
||||||
<span className="min-w-0">
|
) : null;
|
||||||
<span className="block text-sm font-semibold">{group.name}</span>
|
})}
|
||||||
<span className="mt-1 block text-xs text-muted-foreground">
|
</div>
|
||||||
{group.enabled ? '启用中' : '已停用'} ·{' '}
|
</SortableContext>
|
||||||
{group.graph.nodes.length} 个节点
|
</DndContext>
|
||||||
</span>
|
</div>
|
||||||
</span>
|
) : null}
|
||||||
</label>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{(wafQuery.data?.rule_groups ?? []).length === 0 ? (
|
{(wafQuery.data?.rule_groups ?? []).length === 0 ? (
|
||||||
<EmptyStateWithBorder description="暂无自定义 WAF 规则组" />
|
<EmptyStateWithBorder description="暂无自定义 WAF 规则组" />
|
||||||
|
|||||||
@@ -1,115 +1,12 @@
|
|||||||
import type {
|
import type {
|
||||||
ProxyRoutePoWConfig,
|
|
||||||
WAFIPGroup,
|
WAFIPGroup,
|
||||||
WAFIPGroupPayload,
|
WAFIPGroupPayload,
|
||||||
WAFRuleGroup,
|
|
||||||
WAFRuleGroupPayload,
|
|
||||||
} from '@/lib/services/openflare';
|
} from '@/lib/services/openflare';
|
||||||
|
|
||||||
export type WAFTab = 'basic' | 'lists' | 'pow' | 'block';
|
|
||||||
export type RuleListType = 'whitelist' | 'blacklist';
|
|
||||||
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';
|
|
||||||
|
|
||||||
export type CountryOption = {
|
|
||||||
code: string;
|
|
||||||
zhName: string;
|
|
||||||
label: string;
|
|
||||||
searchText: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type RuleModalState = {
|
|
||||||
open: boolean;
|
|
||||||
listType: RuleListType;
|
|
||||||
dimension: RuleDimension;
|
|
||||||
ipValue: string;
|
|
||||||
ipGroupIDs: number[];
|
|
||||||
countryValues: string[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export type RuleListRenderable = Pick<
|
|
||||||
WAFRuleGroupPayload,
|
|
||||||
| 'ip_whitelist'
|
|
||||||
| 'ip_blacklist'
|
|
||||||
| 'ip_whitelist_group_ids'
|
|
||||||
| 'ip_blacklist_group_ids'
|
|
||||||
| 'country_whitelist'
|
|
||||||
| 'country_blacklist'
|
|
||||||
| 'region_whitelist'
|
|
||||||
| 'region_blacklist'
|
|
||||||
>;
|
|
||||||
|
|
||||||
export const defaultPowConfig: ProxyRoutePoWConfig = {
|
|
||||||
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: [],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export const emptyRuleGroupDraft: WAFRuleGroupPayload = {
|
|
||||||
name: '',
|
|
||||||
enabled: 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: defaultPowConfig,
|
|
||||||
};
|
|
||||||
|
|
||||||
export const defaultRuleModalState: RuleModalState = {
|
|
||||||
open: false,
|
|
||||||
listType: 'blacklist',
|
|
||||||
dimension: 'ip',
|
|
||||||
ipValue: '',
|
|
||||||
ipGroupIDs: [],
|
|
||||||
countryValues: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
export const wafTabItems: Array<{ id: WAFTab; label: string }> = [
|
|
||||||
{ id: 'basic', label: '基本信息' },
|
|
||||||
{ id: 'lists', label: '黑白名单' },
|
|
||||||
{ id: 'pow', label: 'PoW' },
|
|
||||||
{ id: 'block', label: '拦截返回' },
|
|
||||||
];
|
|
||||||
|
|
||||||
export function getErrorMessage(error: unknown) {
|
export function getErrorMessage(error: unknown) {
|
||||||
return error instanceof Error ? error.message : '操作失败';
|
return error instanceof Error ? error.message : '操作失败';
|
||||||
}
|
}
|
||||||
|
|
||||||
export function textToList(text: string) {
|
|
||||||
return text
|
|
||||||
.split(/[\n,,\s]+/)
|
|
||||||
.map((item) => item.trim())
|
|
||||||
.filter(Boolean);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function listToText(items: string[] | undefined) {
|
export function listToText(items: string[] | undefined) {
|
||||||
return (items ?? []).join('\n');
|
return (items ?? []).join('\n');
|
||||||
}
|
}
|
||||||
@@ -121,130 +18,6 @@ export function parseTextareaList(text: string) {
|
|||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function normalizeItems(items: string[]) {
|
|
||||||
return Array.from(
|
|
||||||
new Set(items.map((item) => item.trim()).filter(Boolean)),
|
|
||||||
).sort((left, right) => left.localeCompare(right));
|
|
||||||
}
|
|
||||||
|
|
||||||
export function buildRuleGroupDraft(group: WAFRuleGroup | null): WAFRuleGroupPayload {
|
|
||||||
if (!group) {
|
|
||||||
return { ...emptyRuleGroupDraft };
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
name: group.name,
|
|
||||||
enabled: group.enabled,
|
|
||||||
block_status_code: group.block_status_code || 418,
|
|
||||||
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 ?? [],
|
|
||||||
region_blacklist: group.region_blacklist ?? [],
|
|
||||||
pow_enabled: group.pow_enabled ?? false,
|
|
||||||
pow_config: group.pow_config ?? defaultPowConfig,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
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 +
|
|
||||||
group.region_blacklist.length
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function buildCountryOptions() {
|
|
||||||
const zhDisplayNames = new Intl.DisplayNames(['zh-CN'], { type: 'region' });
|
|
||||||
const enDisplayNames = new Intl.DisplayNames(['en'], { type: 'region' });
|
|
||||||
const options: CountryOption[] = [];
|
|
||||||
|
|
||||||
for (let first = 65; first <= 90; first += 1) {
|
|
||||||
for (let second = 65; second <= 90; second += 1) {
|
|
||||||
const code = String.fromCharCode(first, second);
|
|
||||||
const zhName = zhDisplayNames.of(code);
|
|
||||||
const enName = enDisplayNames.of(code);
|
|
||||||
|
|
||||||
if (
|
|
||||||
!zhName ||
|
|
||||||
zhName === code ||
|
|
||||||
/未知/.test(zhName) ||
|
|
||||||
!enName ||
|
|
||||||
enName === code ||
|
|
||||||
/Unknown/.test(enName)
|
|
||||||
) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
options.push({
|
|
||||||
code,
|
|
||||||
zhName,
|
|
||||||
label: `${code} ${zhName}`,
|
|
||||||
searchText: `${code} ${zhName} ${enName}`.toLowerCase(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return options.sort((left, right) => left.code.localeCompare(right.code));
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getListFieldKey(
|
|
||||||
listType: RuleListType,
|
|
||||||
dimension: RuleDimension,
|
|
||||||
): ListFieldKey {
|
|
||||||
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';
|
|
||||||
}
|
|
||||||
|
|
||||||
export function updateDraftList(
|
|
||||||
draft: WAFRuleGroupPayload,
|
|
||||||
key: ListFieldKey,
|
|
||||||
updater: (items: string[]) => string[],
|
|
||||||
) {
|
|
||||||
switch (key) {
|
|
||||||
case 'ip_whitelist':
|
|
||||||
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':
|
|
||||||
return { ...draft, country_blacklist: updater(draft.country_blacklist) };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function formatCountryItem(code: string, labelMap: Map<string, string>) {
|
|
||||||
return labelMap.get(code) ?? code;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function parseAutomaticConfig(text: string): Record<string, unknown> {
|
export function parseAutomaticConfig(text: string): Record<string, unknown> {
|
||||||
const parsed = JSON.parse(text || '{}') as unknown;
|
const parsed = JSON.parse(text || '{}') as unknown;
|
||||||
|
|||||||
@@ -1,244 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import {useEffect, useState} from 'react';
|
|
||||||
|
|
||||||
import {Input} from '@/components/ui/input';
|
|
||||||
import {Label} from '@/components/ui/label';
|
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from '@/components/ui/select';
|
|
||||||
import {Switch} from '@/components/ui/switch';
|
|
||||||
import {Textarea} from '@/components/ui/textarea';
|
|
||||||
import type {ProxyRoutePoWConfig} from '@/lib/services/openflare';
|
|
||||||
|
|
||||||
import {listToText, parseTextareaList} from './helpers';
|
|
||||||
|
|
||||||
interface PowConfigPanelProps {
|
|
||||||
enabled: boolean;
|
|
||||||
config: ProxyRoutePoWConfig;
|
|
||||||
onChange: (enabled: boolean, config: ProxyRoutePoWConfig) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
type PowListDraft = {
|
|
||||||
whitelist: Record<keyof ProxyRoutePoWConfig['whitelist'], string>;
|
|
||||||
blacklist: Record<keyof ProxyRoutePoWConfig['blacklist'], string>;
|
|
||||||
};
|
|
||||||
|
|
||||||
function buildDraft(config: ProxyRoutePoWConfig): PowListDraft {
|
|
||||||
return {
|
|
||||||
whitelist: {
|
|
||||||
ips: listToText(config.whitelist?.ips),
|
|
||||||
ip_cidrs: listToText(config.whitelist?.ip_cidrs),
|
|
||||||
paths: listToText(config.whitelist?.paths),
|
|
||||||
path_regexes: listToText(config.whitelist?.path_regexes),
|
|
||||||
user_agents: listToText(config.whitelist?.user_agents),
|
|
||||||
},
|
|
||||||
blacklist: {
|
|
||||||
ips: listToText(config.blacklist?.ips),
|
|
||||||
ip_cidrs: listToText(config.blacklist?.ip_cidrs),
|
|
||||||
paths: listToText(config.blacklist?.paths),
|
|
||||||
path_regexes: listToText(config.blacklist?.path_regexes),
|
|
||||||
user_agents: listToText(config.blacklist?.user_agents),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function applyDraft(
|
|
||||||
config: ProxyRoutePoWConfig,
|
|
||||||
draft: PowListDraft,
|
|
||||||
): ProxyRoutePoWConfig {
|
|
||||||
return {
|
|
||||||
...config,
|
|
||||||
whitelist: {
|
|
||||||
ips: parseTextareaList(draft.whitelist.ips),
|
|
||||||
ip_cidrs: parseTextareaList(draft.whitelist.ip_cidrs),
|
|
||||||
paths: parseTextareaList(draft.whitelist.paths),
|
|
||||||
path_regexes: parseTextareaList(draft.whitelist.path_regexes),
|
|
||||||
user_agents: parseTextareaList(draft.whitelist.user_agents),
|
|
||||||
},
|
|
||||||
blacklist: {
|
|
||||||
ips: parseTextareaList(draft.blacklist.ips),
|
|
||||||
ip_cidrs: parseTextareaList(draft.blacklist.ip_cidrs),
|
|
||||||
paths: parseTextareaList(draft.blacklist.paths),
|
|
||||||
path_regexes: parseTextareaList(draft.blacklist.path_regexes),
|
|
||||||
user_agents: parseTextareaList(draft.blacklist.user_agents),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function PowListCard({
|
|
||||||
title,
|
|
||||||
scope,
|
|
||||||
draft,
|
|
||||||
onUpdate,
|
|
||||||
}: {
|
|
||||||
title: string;
|
|
||||||
scope: keyof PowListDraft;
|
|
||||||
draft: PowListDraft;
|
|
||||||
onUpdate: (
|
|
||||||
scope: keyof PowListDraft,
|
|
||||||
key: keyof ProxyRoutePoWConfig['whitelist'],
|
|
||||||
value: string,
|
|
||||||
) => void;
|
|
||||||
}) {
|
|
||||||
const fields: Array<{
|
|
||||||
key: keyof ProxyRoutePoWConfig['whitelist'];
|
|
||||||
label: string;
|
|
||||||
placeholder: string;
|
|
||||||
}> = [
|
|
||||||
{key: 'ips', label: 'IP', placeholder: '每行一个 IP'},
|
|
||||||
{key: 'ip_cidrs', label: 'IP CIDR', placeholder: '每行一个网段,如 10.0.0.0/8'},
|
|
||||||
{key: 'paths', label: '路径', placeholder: '每行一个路径前缀'},
|
|
||||||
{key: 'path_regexes', label: '路径正则', placeholder: '每行一个正则表达式'},
|
|
||||||
{key: 'user_agents', label: 'User-Agent', placeholder: '每行一个 UA 关键词或正则'},
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-3 rounded-lg border border-dashed p-4">
|
|
||||||
<p className="text-sm font-medium">{title}</p>
|
|
||||||
<div className="space-y-3">
|
|
||||||
{fields.map((field) => (
|
|
||||||
<div key={field.key} className="space-y-1.5">
|
|
||||||
<Label className="text-xs text-muted-foreground">{field.label}</Label>
|
|
||||||
<Textarea
|
|
||||||
value={draft[scope][field.key]}
|
|
||||||
onChange={(event) => onUpdate(scope, field.key, event.target.value)}
|
|
||||||
placeholder={field.placeholder}
|
|
||||||
className="min-h-16 resize-y font-mono text-xs"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function PowConfigPanel({enabled, config, onChange}: PowConfigPanelProps) {
|
|
||||||
const [draft, setDraft] = useState(() => buildDraft(config));
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setDraft(buildDraft(config));
|
|
||||||
}, [config]);
|
|
||||||
|
|
||||||
const emitChange = (
|
|
||||||
nextEnabled: boolean,
|
|
||||||
nextConfig: Partial<ProxyRoutePoWConfig>,
|
|
||||||
nextDraft?: PowListDraft,
|
|
||||||
) => {
|
|
||||||
const merged = {
|
|
||||||
...config,
|
|
||||||
...nextConfig,
|
|
||||||
};
|
|
||||||
onChange(nextEnabled, nextDraft ? applyDraft(merged, nextDraft) : merged);
|
|
||||||
};
|
|
||||||
|
|
||||||
const updateList = (
|
|
||||||
scope: keyof PowListDraft,
|
|
||||||
key: keyof ProxyRoutePoWConfig['whitelist'],
|
|
||||||
value: string,
|
|
||||||
) => {
|
|
||||||
const nextDraft: PowListDraft = {
|
|
||||||
...draft,
|
|
||||||
[scope]: {
|
|
||||||
...draft[scope],
|
|
||||||
[key]: value,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
setDraft(nextDraft);
|
|
||||||
emitChange(enabled, {}, nextDraft);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-5">
|
|
||||||
<div className="flex items-center justify-between rounded-lg border border-dashed p-4">
|
|
||||||
<div className="space-y-1 pr-4">
|
|
||||||
<Label htmlFor="pow-enabled">启用 PoW 防护</Label>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
启用后,命中该规则组的请求需要完成浏览器计算挑战。
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Switch
|
|
||||||
id="pow-enabled"
|
|
||||||
checked={enabled}
|
|
||||||
onCheckedChange={(checked) => emitChange(checked, {})}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<Label className="text-xs text-muted-foreground">算法</Label>
|
|
||||||
<Select
|
|
||||||
value={config.algorithm}
|
|
||||||
onValueChange={(value: 'fast' | 'slow') =>
|
|
||||||
emitChange(enabled, {algorithm: value})
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="h-9">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="fast">Fast</SelectItem>
|
|
||||||
<SelectItem value="slow">Slow</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<Label className="text-xs text-muted-foreground">难度</Label>
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
min={1}
|
|
||||||
max={16}
|
|
||||||
value={config.difficulty}
|
|
||||||
onChange={(event) =>
|
|
||||||
emitChange(enabled, {difficulty: Number(event.target.value) || 1})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<Label className="text-xs text-muted-foreground">会话 TTL (秒)</Label>
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
min={60}
|
|
||||||
value={config.session_ttl}
|
|
||||||
onChange={(event) =>
|
|
||||||
emitChange(enabled, {session_ttl: Number(event.target.value) || 60})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<Label className="text-xs text-muted-foreground">挑战 TTL (秒)</Label>
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
min={30}
|
|
||||||
value={config.challenge_ttl}
|
|
||||||
onChange={(event) =>
|
|
||||||
emitChange(enabled, {challenge_ttl: Number(event.target.value) || 30})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid gap-4 xl:grid-cols-2">
|
|
||||||
<PowListCard
|
|
||||||
title="白名单(跳过 PoW)"
|
|
||||||
scope="whitelist"
|
|
||||||
draft={draft}
|
|
||||||
onUpdate={updateList}
|
|
||||||
/>
|
|
||||||
<PowListCard
|
|
||||||
title="黑名单(必须 PoW)"
|
|
||||||
scope="blacklist"
|
|
||||||
draft={draft}
|
|
||||||
onUpdate={updateList}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,367 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import {useEffect, useMemo, useState} from 'react';
|
|
||||||
import {zodResolver} from '@hookform/resolvers/zod';
|
|
||||||
import {Search} from 'lucide-react';
|
|
||||||
import {useForm} from 'react-hook-form';
|
|
||||||
import {z} from 'zod';
|
|
||||||
|
|
||||||
import {Button} from '@/components/ui/button';
|
|
||||||
import {Checkbox} from '@/components/ui/checkbox';
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from '@/components/ui/dialog';
|
|
||||||
import {Input} from '@/components/ui/input';
|
|
||||||
import {Label} from '@/components/ui/label';
|
|
||||||
import {Textarea} from '@/components/ui/textarea';
|
|
||||||
import {cn} from '@/lib/utils';
|
|
||||||
import type {WAFIPGroup} from '@/lib/services/openflare';
|
|
||||||
|
|
||||||
import {type CountryOption, normalizeItems, type RuleDimension, type RuleListType, textToList,} from './helpers';
|
|
||||||
|
|
||||||
const ruleEntrySchema = z
|
|
||||||
.object({
|
|
||||||
listType: z.enum(['whitelist', 'blacklist']),
|
|
||||||
dimension: z.enum(['ip', 'ip_group', 'country']),
|
|
||||||
ipValue: z.string(),
|
|
||||||
ipGroupIDs: z.array(z.number()),
|
|
||||||
countryValues: z.array(z.string()),
|
|
||||||
})
|
|
||||||
.superRefine((value, context) => {
|
|
||||||
if (value.dimension === 'ip') {
|
|
||||||
if (textToList(value.ipValue).length === 0) {
|
|
||||||
context.addIssue({
|
|
||||||
code: z.ZodIssueCode.custom,
|
|
||||||
path: ['ipValue'],
|
|
||||||
message: '请输入至少一个 IP 或 IP 段',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (value.dimension === 'ip_group') {
|
|
||||||
if (value.ipGroupIDs.length === 0) {
|
|
||||||
context.addIssue({
|
|
||||||
code: z.ZodIssueCode.custom,
|
|
||||||
path: ['ipGroupIDs'],
|
|
||||||
message: '请选择至少一个 IP 组',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (normalizeItems(value.countryValues).length === 0) {
|
|
||||||
context.addIssue({
|
|
||||||
code: z.ZodIssueCode.custom,
|
|
||||||
path: ['countryValues'],
|
|
||||||
message: '请选择至少一个国家/地区',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
export type RuleEntryFormValues = z.infer<typeof ruleEntrySchema>;
|
|
||||||
|
|
||||||
const defaultRuleEntryValues: RuleEntryFormValues = {
|
|
||||||
listType: 'blacklist',
|
|
||||||
dimension: 'ip',
|
|
||||||
ipValue: '',
|
|
||||||
ipGroupIDs: [],
|
|
||||||
countryValues: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
interface RuleEntryDialogProps {
|
|
||||||
open: boolean;
|
|
||||||
countryOptions: CountryOption[];
|
|
||||||
ipGroups: WAFIPGroup[];
|
|
||||||
onOpenChange: (open: boolean) => void;
|
|
||||||
onSubmit: (values: RuleEntryFormValues) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function RuleEntryDialog({
|
|
||||||
open,
|
|
||||||
countryOptions,
|
|
||||||
ipGroups,
|
|
||||||
onOpenChange,
|
|
||||||
onSubmit,
|
|
||||||
}: RuleEntryDialogProps) {
|
|
||||||
const [keyword, setKeyword] = useState('');
|
|
||||||
const form = useForm<RuleEntryFormValues>({
|
|
||||||
resolver: zodResolver(ruleEntrySchema),
|
|
||||||
defaultValues: defaultRuleEntryValues,
|
|
||||||
});
|
|
||||||
|
|
||||||
const listType = form.watch('listType');
|
|
||||||
const dimension = form.watch('dimension');
|
|
||||||
const ipValue = form.watch('ipValue');
|
|
||||||
const ipGroupIDs = form.watch('ipGroupIDs');
|
|
||||||
const countryValues = form.watch('countryValues');
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!open) return;
|
|
||||||
form.reset(defaultRuleEntryValues);
|
|
||||||
setKeyword('');
|
|
||||||
}, [form, open]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!open) return;
|
|
||||||
setKeyword('');
|
|
||||||
}, [dimension, open]);
|
|
||||||
|
|
||||||
const selectedCountrySet = useMemo(
|
|
||||||
() => new Set(countryValues),
|
|
||||||
[countryValues],
|
|
||||||
);
|
|
||||||
const selectedIPGroupSet = useMemo(
|
|
||||||
() => new Set(ipGroupIDs),
|
|
||||||
[ipGroupIDs],
|
|
||||||
);
|
|
||||||
|
|
||||||
const filteredCountries = useMemo(() => {
|
|
||||||
const normalized = keyword.trim().toLowerCase();
|
|
||||||
return countryOptions
|
|
||||||
.filter((option) => !normalized || option.searchText.includes(normalized))
|
|
||||||
.sort((left, right) => {
|
|
||||||
const leftSelected = selectedCountrySet.has(left.code) ? 1 : 0;
|
|
||||||
const rightSelected = selectedCountrySet.has(right.code) ? 1 : 0;
|
|
||||||
return rightSelected - leftSelected || left.code.localeCompare(right.code);
|
|
||||||
});
|
|
||||||
}, [countryOptions, keyword, selectedCountrySet]);
|
|
||||||
|
|
||||||
const toggleCountry = (code: string) => {
|
|
||||||
const values = selectedCountrySet.has(code)
|
|
||||||
? countryValues.filter((item) => item !== code)
|
|
||||||
: normalizeItems([...countryValues, code]);
|
|
||||||
form.setValue('countryValues', values, { shouldValidate: true });
|
|
||||||
};
|
|
||||||
|
|
||||||
const toggleIPGroup = (id: number) => {
|
|
||||||
const values = selectedIPGroupSet.has(id)
|
|
||||||
? ipGroupIDs.filter((item) => item !== id)
|
|
||||||
: [...ipGroupIDs, id].sort((left, right) => left - right);
|
|
||||||
form.setValue('ipGroupIDs', values, { shouldValidate: true });
|
|
||||||
};
|
|
||||||
|
|
||||||
const typeLabel = listType === 'blacklist' ? '黑名单' : '白名单';
|
|
||||||
const dimensionLabel =
|
|
||||||
dimension === 'ip'
|
|
||||||
? 'IP'
|
|
||||||
: dimension === 'ip_group'
|
|
||||||
? 'IP 组'
|
|
||||||
: '地域';
|
|
||||||
|
|
||||||
const handleSubmit = form.handleSubmit((values) => {
|
|
||||||
onSubmit(values);
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
||||||
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>添加{typeLabel}规则</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
当前准备新增 {dimensionLabel} 维度的{typeLabel}项。
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<div className="space-y-5">
|
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>类型</Label>
|
|
||||||
<div className="grid grid-cols-2 gap-2">
|
|
||||||
{[
|
|
||||||
{ value: 'blacklist', label: '黑名单' },
|
|
||||||
{ value: 'whitelist', label: '白名单' },
|
|
||||||
].map((option) => (
|
|
||||||
<Button
|
|
||||||
key={option.value}
|
|
||||||
type="button"
|
|
||||||
variant={listType === option.value ? 'default' : 'outline'}
|
|
||||||
onClick={() =>
|
|
||||||
form.setValue('listType', option.value as RuleListType)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{option.label}
|
|
||||||
</Button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>维度</Label>
|
|
||||||
<div className="grid grid-cols-3 gap-2">
|
|
||||||
{[
|
|
||||||
{ value: 'ip', label: 'IP' },
|
|
||||||
{ value: 'ip_group', label: 'IP 组' },
|
|
||||||
{ value: 'country', label: '地域' },
|
|
||||||
].map((option) => (
|
|
||||||
<Button
|
|
||||||
key={option.value}
|
|
||||||
type="button"
|
|
||||||
size="sm"
|
|
||||||
variant={dimension === option.value ? 'default' : 'outline'}
|
|
||||||
onClick={() =>
|
|
||||||
form.setValue('dimension', option.value as RuleDimension)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{option.label}
|
|
||||||
</Button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{dimension === 'ip' ? (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>IP / IP 段</Label>
|
|
||||||
<Textarea
|
|
||||||
value={ipValue}
|
|
||||||
placeholder="例如 1.1.1.1 或 192.168.0.0/24"
|
|
||||||
onChange={(event) =>
|
|
||||||
form.setValue('ipValue', event.target.value, { shouldValidate: true })
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
支持单个 IP、CIDR,或使用换行/逗号一次添加多个。
|
|
||||||
</p>
|
|
||||||
{form.formState.errors.ipValue ? (
|
|
||||||
<p className="text-xs text-destructive">
|
|
||||||
{form.formState.errors.ipValue.message}
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{dimension === 'ip_group' ? (
|
|
||||||
<div className="space-y-3 rounded-lg border border-dashed p-4">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<p className="text-sm font-medium">选择 IP 组</p>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
发布版本只保存引用 ID,IP 组成员由 Agent 按 checksum 差异同步。
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<span className="text-xs text-muted-foreground">
|
|
||||||
已选 {ipGroupIDs.length}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="max-h-64 space-y-2 overflow-y-auto">
|
|
||||||
{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-md border px-3 py-2',
|
|
||||||
selected && 'border-primary bg-muted/50',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Checkbox
|
|
||||||
checked={selected}
|
|
||||||
onCheckedChange={() => toggleIPGroup(group.id)}
|
|
||||||
/>
|
|
||||||
<span className="min-w-0 flex-1">
|
|
||||||
<span className="block text-sm font-medium truncate">
|
|
||||||
{group.name}
|
|
||||||
</span>
|
|
||||||
<span className="block text-xs text-muted-foreground">
|
|
||||||
{group.type} · {group.ip_list.length} 条 ·{' '}
|
|
||||||
{group.enabled ? '启用' : '停用'}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
);
|
|
||||||
})
|
|
||||||
) : (
|
|
||||||
<p className="text-sm text-muted-foreground">暂无 IP 组,请先创建。</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{form.formState.errors.ipGroupIDs ? (
|
|
||||||
<p className="text-xs text-destructive">
|
|
||||||
{form.formState.errors.ipGroupIDs.message}
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{dimension === 'country' ? (
|
|
||||||
<div className="space-y-3">
|
|
||||||
<div className="flex items-center gap-2 rounded-md border px-3 py-2">
|
|
||||||
<Search className="size-4 text-muted-foreground" />
|
|
||||||
<Input
|
|
||||||
value={keyword}
|
|
||||||
placeholder="搜索国家代码或中文名"
|
|
||||||
className="border-0 shadow-none focus-visible:ring-0"
|
|
||||||
onChange={(event) => setKeyword(event.target.value)}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() =>
|
|
||||||
form.setValue(
|
|
||||||
'countryValues',
|
|
||||||
normalizeItems([
|
|
||||||
...countryValues,
|
|
||||||
...filteredCountries.map((option) => option.code),
|
|
||||||
]),
|
|
||||||
{ shouldValidate: true },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
全选当前
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() =>
|
|
||||||
form.setValue('countryValues', [], { shouldValidate: true })
|
|
||||||
}
|
|
||||||
>
|
|
||||||
清空
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<div className="max-h-64 space-y-2 overflow-y-auto rounded-lg border border-dashed p-3">
|
|
||||||
{filteredCountries.map((option) => {
|
|
||||||
const selected = selectedCountrySet.has(option.code);
|
|
||||||
return (
|
|
||||||
<label
|
|
||||||
key={option.code}
|
|
||||||
className={cn(
|
|
||||||
'flex cursor-pointer items-center gap-3 rounded-md border px-3 py-2',
|
|
||||||
selected && 'border-primary bg-muted/50',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Checkbox
|
|
||||||
checked={selected}
|
|
||||||
onCheckedChange={() => toggleCountry(option.code)}
|
|
||||||
/>
|
|
||||||
<span className="text-sm">{option.label}</span>
|
|
||||||
</label>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
{form.formState.errors.countryValues ? (
|
|
||||||
<p className="text-xs text-destructive">
|
|
||||||
{form.formState.errors.countryValues.message}
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<DialogFooter>
|
|
||||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
|
||||||
取消
|
|
||||||
</Button>
|
|
||||||
<Button type="button" onClick={() => void handleSubmit()}>
|
|
||||||
添加
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,78 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import {X} from 'lucide-react';
|
|
||||||
|
|
||||||
import {Badge} from '@/components/ui/badge';
|
|
||||||
import {Button} from '@/components/ui/button';
|
|
||||||
|
|
||||||
interface RuleListSectionProps {
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function RuleListSection({
|
|
||||||
title,
|
|
||||||
description,
|
|
||||||
items,
|
|
||||||
groupItems = [],
|
|
||||||
tone,
|
|
||||||
emptyText,
|
|
||||||
onRemove,
|
|
||||||
onRemoveGroup,
|
|
||||||
}: RuleListSectionProps) {
|
|
||||||
const total = items.length + groupItems.length;
|
|
||||||
const badgeVariant = tone === 'whitelist' ? 'secondary' : 'destructive';
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="rounded-lg border border-dashed p-4 space-y-3">
|
|
||||||
<div className="flex items-start justify-between gap-3">
|
|
||||||
<div>
|
|
||||||
<h4 className="text-sm font-semibold">{title}</h4>
|
|
||||||
<p className="text-xs text-muted-foreground mt-1">{description}</p>
|
|
||||||
</div>
|
|
||||||
<Badge variant="outline">{total}</Badge>
|
|
||||||
</div>
|
|
||||||
{total > 0 ? (
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{groupItems.map((group) => (
|
|
||||||
<Badge key={`group-${group.id}`} variant={badgeVariant} className="gap-1 pr-1">
|
|
||||||
IP组: {group.name}
|
|
||||||
{!group.enabled ? ' (停用)' : ''}
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="size-4 hover:bg-transparent"
|
|
||||||
onClick={() => onRemoveGroup?.(group.id)}
|
|
||||||
>
|
|
||||||
<X className="size-3" />
|
|
||||||
</Button>
|
|
||||||
</Badge>
|
|
||||||
))}
|
|
||||||
{items.map((item) => (
|
|
||||||
<Badge key={item} variant={badgeVariant} className="gap-1 pr-1 font-mono text-xs">
|
|
||||||
{item}
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="size-4 hover:bg-transparent"
|
|
||||||
onClick={() => onRemove(item)}
|
|
||||||
>
|
|
||||||
<X className="size-3" />
|
|
||||||
</Button>
|
|
||||||
</Badge>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<p className="text-sm text-muted-foreground">{emptyText}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -54,14 +54,14 @@ export function SiteBindingSheet({
|
|||||||
setSelectedIDs((current) =>
|
setSelectedIDs((current) =>
|
||||||
current.includes(id)
|
current.includes(id)
|
||||||
? current.filter((item) => item !== id)
|
? current.filter((item) => item !== id)
|
||||||
: [...current, id].sort((left, right) => left - right),
|
: [...current, id],
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const selectFiltered = () => {
|
const selectFiltered = () => {
|
||||||
const next = new Set(selectedIDs);
|
const next = new Set(selectedIDs);
|
||||||
filteredRoutes.forEach((route) => next.add(route.id));
|
filteredRoutes.forEach((route) => next.add(route.id));
|
||||||
setSelectedIDs([...next].sort((left, right) => left - right));
|
setSelectedIDs([...next]);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import {QueryClient, QueryClientProvider} from '@tanstack/react-query';
|
||||||
|
import {fireEvent, render, screen, waitFor} from '@testing-library/react';
|
||||||
|
import {createElement} from 'react';
|
||||||
|
import {beforeEach, describe, expect, it, vi} from 'vitest';
|
||||||
|
|
||||||
|
import {
|
||||||
|
reorderRuleIDs,
|
||||||
|
WafSection,
|
||||||
|
} from '@/app/(main)/proxy-routes/detail/components/waf-section';
|
||||||
|
import type {ProxyRouteItem, WAFRule} from '@/lib/services/openflare';
|
||||||
|
import {WafService} from '@/lib/services/openflare';
|
||||||
|
|
||||||
|
vi.mock('@/lib/services/openflare', async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import('@/lib/services/openflare')>();
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
WafService: {
|
||||||
|
listSiteRuleGroups: vi.fn(),
|
||||||
|
updateSiteRuleGroups: vi.fn(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const graph = {
|
||||||
|
schema_version: 1,
|
||||||
|
nodes: [
|
||||||
|
{id: 'start', type: 'start' as const, position: {x: 0, y: 0}, config: {}},
|
||||||
|
{id: 'allow', type: 'allow' as const, position: {x: 100, y: 0}, config: {}},
|
||||||
|
],
|
||||||
|
edges: [{id: 'edge', source: 'start', source_handle: 'next', target: 'allow'}],
|
||||||
|
};
|
||||||
|
|
||||||
|
function rule(id: number, name: string, isGlobal = false): WAFRule {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
enabled: true,
|
||||||
|
is_global: isGlobal,
|
||||||
|
graph,
|
||||||
|
revision: 1,
|
||||||
|
applied_site_ids: [],
|
||||||
|
applied_site_count: 0,
|
||||||
|
created_at: '',
|
||||||
|
updated_at: '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('WAF route binding order', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.stubGlobal(
|
||||||
|
'ResizeObserver',
|
||||||
|
class {
|
||||||
|
observe() {}
|
||||||
|
unobserve() {}
|
||||||
|
disconnect() {}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
vi.mocked(WafService.listSiteRuleGroups).mockReset();
|
||||||
|
vi.mocked(WafService.updateSiteRuleGroups).mockReset();
|
||||||
|
vi.mocked(WafService.listSiteRuleGroups).mockResolvedValue({
|
||||||
|
route_id: 9,
|
||||||
|
global_rule_group: rule(99, '全局规则', true),
|
||||||
|
rule_groups: [rule(1, '规则 A'), rule(2, '规则 B'), rule(3, '规则 C')],
|
||||||
|
applied_rule_groups: [rule(1, '规则 A'), rule(2, '规则 B')],
|
||||||
|
applied_ids: [1, 2],
|
||||||
|
});
|
||||||
|
vi.mocked(WafService.updateSiteRuleGroups).mockImplementation(async (_routeID, ids) => ({
|
||||||
|
route_id: 9,
|
||||||
|
global_rule_group: rule(99, '全局规则', true),
|
||||||
|
rule_groups: [rule(1, '规则 A'), rule(2, '规则 B'), rule(3, '规则 C')],
|
||||||
|
applied_rule_groups: ids.map((id) => rule(id, `规则 ${id}`)),
|
||||||
|
applied_ids: ids,
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reorders by the active and target IDs reported by drag end', () => {
|
||||||
|
expect(reorderRuleIDs([1, 2, 3], 3, 1)).toEqual([3, 1, 2]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the global rule fixed and submits custom rules in UI order', async () => {
|
||||||
|
const client = new QueryClient({defaultOptions: {queries: {retry: false}}});
|
||||||
|
render(
|
||||||
|
createElement(
|
||||||
|
QueryClientProvider,
|
||||||
|
{client},
|
||||||
|
createElement(WafSection, {route: {id: 9} as ProxyRouteItem}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(await screen.findByText('全局规则')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('始终生效')).toBeInTheDocument();
|
||||||
|
expect(screen.queryByRole('button', {name: '上移全局规则'})).not.toBeInTheDocument();
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('checkbox', {name: '选择规则 C'}));
|
||||||
|
fireEvent.click(screen.getByRole('button', {name: '上移规则 C'}));
|
||||||
|
fireEvent.click(screen.getByRole('button', {name: '上移规则 C'}));
|
||||||
|
fireEvent.click(screen.getByRole('button', {name: '保存'}));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(WafService.updateSiteRuleGroups).toHaveBeenCalledWith(9, [3, 1, 2]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user