feat(web): visual rule builder with geo, CIDR and wildcard pickers

The rule editor no longer requires writing expr by hand. Conditions are rows of
field + operator + values joined with AND or OR, each row negatable, and the
generated expression is shown live and validated by the engine before a rule is
accepted. Raw expression and YAML editing are still available.

Fields cover the connection (protocol, source/destination IP and port), domains
(TLS SNI, QUIC SNI, DNS query name), HTTP (host, path, method, User-Agent) and
protocol detection. Operators cover CIDR membership, GeoIP countries, GeoSite
categories, port ranges, wildcards, substrings and regular expressions.
Multiple values in a row are OR-ed, so one row holds a whole domain or country
list.

Wildcards compile to the cheapest expression that matches them: *.x.com
becomes endsWith, x.* startsWith, *ad* contains, and only a star in the
middle falls back to a regular expression. Values are validated as they are
typed, including a hint when a star is used with an operator that would match
it literally.

Country and category pickers are backed by the databases the engine actually
loaded, via a new GET /api/v1/geo endpoint (cached, loaded on demand) built on
new listing methods in the geo package. Country names and flags come from
Intl.DisplayNames, so no name table is shipped. Note that the v2geo format has
no AS numbers; the provider groups it does contain (cloudflare, google,
telegram, ...) are listed alongside the countries.

Opening an existing rule parses its expression back into conditions. Anything
the builder cannot represent opens in the expression editor with a warning
rather than being rewritten. ruleset/expr_test.go pins the canonical
expressions the builder generates and compiles them with the real engine, and
the devserver now uses the real ruleset compiler so the same errors show up
during frontend work.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
mei
2026-07-27 08:48:56 +08:00
co-authored by Claude Opus 5
parent 5a7722d1d2
commit f7ad3aac95
52 changed files with 2191 additions and 216 deletions
@@ -0,0 +1,236 @@
<script setup lang="ts">
import { Trash2 } from 'lucide-vue-next'
import { computed } from 'vue'
import GeoPicker from '@/components/rule/GeoPicker.vue'
import { Button, Input, Select, TagsInput } from '@/components/ui'
import { t } from '@/i18n'
import { explainWildcard } from '@/lib/rule/compile'
import {
FIELDS,
FIELD_MAP,
PROTO_VALUES,
defaultOperator,
operatorsFor,
type OperatorId,
} from '@/lib/rule/fields'
import type { Condition } from '@/lib/rule/types'
import { validateValue } from '@/lib/rule/validate'
import { cn } from '@/lib/utils'
const props = defineProps<{
condition: Condition
/** Analyzer names offered by the "protocol detected" field. */
analyzers: string[]
removable: boolean
}>()
const emit = defineEmits<{
update: [condition: Condition]
remove: []
}>()
const field = computed(() => FIELD_MAP[props.condition.field] ?? FIELDS[0])
/** Field keys contain dots, which the translation lookup uses for nesting. */
const labelKey = (key: string) => `rules.field.${key.replace(/\./g, '_')}`
const fieldOptions = computed(() =>
FIELDS.map((f) => ({
value: f.key,
label: `${t(`rules.group.${f.group}`)} · ${t(labelKey(f.key))}`,
})),
)
const operatorOptions = computed(() =>
operatorsFor(field.value).map((op) => ({ value: op, label: t(`rules.op.${op}`) })),
)
function patch(changes: Partial<Condition>) {
emit('update', { ...props.condition, ...changes })
}
function changeField(key: string) {
const next = FIELD_MAP[key]
if (!next) return
const keepOperator = operatorsFor(next).includes(props.condition.op)
patch({
field: key,
op: keepOperator ? props.condition.op : defaultOperator(next.type),
values: keepOperator && next.type === field.value.type ? props.condition.values : [],
})
}
function changeOperator(op: string) {
patch({ op: op as OperatorId, values: [] })
}
function toggleValue(value: string) {
const values = props.condition.values.includes(value)
? props.condition.values.filter((v) => v !== value)
: [...props.condition.values, value]
patch({ values })
}
const validator = (value: string) => validateValue(field.value.type, props.condition.op, value)
const placeholder = computed(() => {
switch (props.condition.op) {
case 'cidr':
return '10.0.0.0/8, 2001:db8::/32'
case 'wildcard':
return '*.example.com'
case 'regex':
return '^/api/v\\d+/'
default:
break
}
switch (field.value.type) {
case 'ip':
return '1.2.3.4'
case 'port':
return '443'
case 'domain':
return 'example.com'
default:
return t('rules.valuePlaceholder')
}
})
/** Human readable explanation of each wildcard pattern. */
const wildcardHints = computed(() => {
if (props.condition.op !== 'wildcard') return []
return props.condition.values
.filter(Boolean)
.slice(0, 4)
.map((value) => {
const info = explainWildcard(value)
return `${value} → ${t(`rules.wildcard.${info.kind}`, { value: info.value })}`
})
})
</script>
<template>
<div class="bg-muted/40 flex flex-col gap-2 rounded-lg border p-2.5">
<div class="flex flex-wrap items-center gap-2">
<Select
:model-value="condition.field"
:options="fieldOptions"
:aria-label="t('rules.builder.field')"
class="h-8 min-w-0 flex-1 basis-45 text-xs sm:basis-56"
@update:model-value="changeField"
/>
<Select
:model-value="condition.op"
:options="operatorOptions"
:aria-label="t('rules.builder.operator')"
class="h-8 min-w-0 flex-1 basis-32 text-xs sm:basis-40 sm:flex-none"
@update:model-value="changeOperator"
/>
<button
type="button"
:class="
cn(
'h-8 shrink-0 rounded-md border px-2 text-xs font-medium transition-colors',
condition.negate
? 'border-block/40 bg-block/10 text-block'
: 'text-muted-foreground hover:bg-accent',
)
"
:aria-pressed="condition.negate"
@click="patch({ negate: !condition.negate })"
>
{{ t('rules.builder.not') }}
</button>
<Button
v-if="removable"
variant="ghost"
size="icon-sm"
class="text-muted-foreground hover:text-destructive shrink-0"
:aria-label="t('common.delete')"
@click="emit('remove')"
>
<Trash2 class="size-3.5" />
</Button>
</div>
<!-- Protocol: fixed choices -->
<div v-if="field.type === 'proto'" class="flex flex-wrap gap-1.5">
<button
v-for="value in PROTO_VALUES"
:key="value"
type="button"
:class="
cn(
'rounded-md border px-2.5 py-1 font-mono text-xs uppercase transition-colors',
condition.values.includes(value)
? 'border-primary/40 bg-primary/10 text-foreground'
: 'text-muted-foreground hover:bg-accent',
)
"
@click="toggleValue(value)"
>
{{ value }}
</button>
</div>
<!-- Protocol detection: analyzer chips -->
<div v-else-if="field.type === 'analyzer'" class="flex flex-wrap gap-1.5">
<button
v-for="name in analyzers"
:key="name"
type="button"
:class="
cn(
'rounded-md border px-2.5 py-1 font-mono text-xs transition-colors',
condition.values.includes(name)
? 'border-primary/40 bg-primary/10 text-foreground'
: 'text-muted-foreground hover:bg-accent',
)
"
@click="toggleValue(name)"
>
{{ name }}
</button>
</div>
<!-- Port range -->
<div v-else-if="condition.op === 'range'" class="flex items-center gap-2">
<Input
:model-value="condition.values[0] ?? ''"
inputmode="numeric"
placeholder="1024"
class="h-8 w-28 text-xs"
@update:model-value="patch({ values: [$event, condition.values[1] ?? ''] })"
/>
<span class="text-muted-foreground text-xs">–</span>
<Input
:model-value="condition.values[1] ?? ''"
inputmode="numeric"
placeholder="65535"
class="h-8 w-28 text-xs"
@update:model-value="patch({ values: [condition.values[0] ?? '', $event] })"
/>
</div>
<!-- GeoIP / GeoSite pickers -->
<GeoPicker
v-else-if="condition.op === 'geoip' || condition.op === 'geosite'"
:model-value="condition.values"
:kind="condition.op"
@update:model-value="patch({ values: $event })"
/>
<!-- Everything else: a list of values -->
<template v-else>
<TagsInput
:model-value="condition.values"
:placeholder="placeholder"
:validate="validator"
@update:model-value="patch({ values: $event })"
/>
<p v-for="hint in wildcardHints" :key="hint" class="text-muted-foreground font-mono text-[11px]">
{{ hint }}
</p>
</template>
</div>
</template>
@@ -0,0 +1,199 @@
<script setup lang="ts">
import { Check, ChevronsUpDown, Search, X } from 'lucide-vue-next'
import {
ComboboxAnchor,
ComboboxContent,
ComboboxEmpty,
ComboboxInput,
ComboboxItem,
ComboboxPortal,
ComboboxRoot,
ComboboxTrigger,
ComboboxViewport,
} from 'reka-ui'
import { computed, ref } from 'vue'
import { TagsInput } from '@/components/ui'
import { useGeo } from '@/composables/useGeo'
import { t, useI18n } from '@/i18n'
import { flagOf, regionName } from '@/lib/geo'
import { cn } from '@/lib/utils'
/**
* Multi-select for geoip() country codes and geosite() categories, backed by
* the databases the engine actually loaded. Falls back to a plain chip input
* when the databases are not available.
*/
const props = defineProps<{ modelValue: string[]; kind: 'geoip' | 'geosite' }>()
const emit = defineEmits<{ 'update:modelValue': [value: string[]] }>()
const { lang } = useI18n()
const { geo, error, loading, load } = useGeo()
const search = ref('')
interface Option {
value: string
label: string
hint: string
flag: string
}
const options = computed<Option[]>(() => {
const entries = (props.kind === 'geoip' ? geo.value?.ip : geo.value?.site) ?? []
const out: Option[] = []
for (const entry of entries) {
if (props.kind === 'geoip') {
const name = regionName(entry.code, lang.value)
out.push({
value: entry.code,
label: name ?? entry.code,
hint: `${entry.code.toUpperCase()} · ${entry.count}`,
flag: flagOf(entry.code),
})
} else {
out.push({
value: entry.code,
label: entry.code,
hint: String(entry.count),
flag: '',
})
for (const attr of entry.attributes ?? []) {
out.push({
value: `${entry.code}@${attr}`,
label: `${entry.code}@${attr}`,
hint: t('rules.geo.attribute'),
flag: '',
})
}
}
}
return out
})
const filtered = computed(() => {
const q = search.value.trim().toLowerCase()
const list = q
? options.value.filter((o) => o.value.includes(q) || o.label.toLowerCase().includes(q))
: options.value
return list.slice(0, 300)
})
const unavailable = computed(
() => !loading.value && options.value.length === 0,
)
const errorMessage = computed(() =>
props.kind === 'geoip' ? error.value.ip : error.value.site,
)
function remove(value: string) {
emit(
'update:modelValue',
props.modelValue.filter((v) => v !== value),
)
}
function labelOf(value: string): string {
const option = options.value.find((o) => o.value === value)
if (option) return `${option.flag} ${option.label}`.trim()
return value
}
void load()
</script>
<template>
<div class="flex flex-col gap-1.5">
<template v-if="unavailable">
<TagsInput
:model-value="props.modelValue"
:placeholder="props.kind === 'geoip' ? 'cn, us, private' : 'category-ads-all'"
@update:model-value="emit('update:modelValue', $event)"
/>
<p class="text-muted-foreground text-xs">
{{ t('rules.geo.unavailable') }}
<span v-if="errorMessage" class="font-mono">({{ errorMessage }})</span>
</p>
</template>
<template v-else>
<div v-if="props.modelValue.length" class="flex flex-wrap gap-1.5">
<span
v-for="value in props.modelValue"
:key="value"
class="bg-secondary text-secondary-foreground inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 text-xs"
>
{{ labelOf(value) }}
<button
type="button"
class="opacity-60 transition-opacity hover:opacity-100"
:aria-label="t('common.delete')"
@click="remove(value)"
>
<X class="size-3" />
</button>
</span>
</div>
<p v-if="loading" class="text-muted-foreground text-xs">{{ t('rules.geo.loading') }}</p>
<ComboboxRoot
:model-value="props.modelValue"
multiple
ignore-filter
:reset-search-term-on-blur="false"
@update:model-value="emit('update:modelValue', ($event as unknown as string[]) ?? [])"
>
<ComboboxAnchor
class="border-input bg-background focus-within:border-ring focus-within:ring-ring/40 flex h-9 w-full items-center gap-2 rounded-md border px-3 shadow-xs transition-[color,box-shadow] focus-within:ring-[3px]"
>
<Search class="text-muted-foreground size-4 shrink-0" />
<ComboboxInput
v-model="search"
:placeholder="
props.kind === 'geoip' ? t('rules.geo.searchCountry') : t('rules.geo.searchSite')
"
class="placeholder:text-muted-foreground w-full bg-transparent text-sm outline-none"
/>
<ComboboxTrigger class="text-muted-foreground shrink-0">
<ChevronsUpDown class="size-4" />
</ComboboxTrigger>
</ComboboxAnchor>
<ComboboxPortal>
<ComboboxContent
position="popper"
:side-offset="4"
class="bg-popover text-popover-foreground animate-fade-in z-50 max-h-72 w-[var(--reka-combobox-trigger-width)] overflow-hidden rounded-md border shadow-md"
>
<ComboboxViewport class="max-h-72 overflow-y-auto p-1">
<ComboboxEmpty class="text-muted-foreground px-2 py-4 text-center text-xs">
{{ t('common.empty') }}
</ComboboxEmpty>
<ComboboxItem
v-for="option in filtered"
:key="option.value"
:value="option.value"
:class="
cn(
'data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-none select-none',
)
"
>
<span v-if="option.flag" class="shrink-0">{{ option.flag }}</span>
<span class="truncate">{{ option.label }}</span>
<span class="text-muted-foreground ml-auto shrink-0 text-[11px] tabular-nums">
{{ option.hint }}
</span>
<Check
v-if="props.modelValue.includes(option.value)"
class="absolute right-2 size-4"
/>
</ComboboxItem>
</ComboboxViewport>
</ComboboxContent>
</ComboboxPortal>
</ComboboxRoot>
</template>
</div>
</template>
@@ -0,0 +1,96 @@
<script setup lang="ts">
import { Plus } from 'lucide-vue-next'
import { computed } from 'vue'
import ConditionRow from '@/components/rule/ConditionRow.vue'
import { Button } from '@/components/ui'
import { t } from '@/i18n'
import { compileRule } from '@/lib/rule/compile'
import { newCondition, type Condition, type RuleBuilderState } from '@/lib/rule/types'
import { cn } from '@/lib/utils'
const props = defineProps<{ modelValue: RuleBuilderState; analyzers: string[] }>()
const emit = defineEmits<{ 'update:modelValue': [value: RuleBuilderState] }>()
const expression = computed(() => compileRule(props.modelValue))
function patch(changes: Partial<RuleBuilderState>) {
emit('update:modelValue', { ...props.modelValue, ...changes })
}
function updateCondition(index: number, condition: Condition) {
const conditions = [...props.modelValue.conditions]
conditions[index] = condition
patch({ conditions })
}
function removeCondition(index: number) {
patch({ conditions: props.modelValue.conditions.filter((_, i) => i !== index) })
}
function addCondition() {
patch({ conditions: [...props.modelValue.conditions, newCondition()] })
}
</script>
<template>
<div class="flex flex-col gap-3">
<div class="flex flex-wrap items-center gap-2 text-sm">
<span class="text-muted-foreground">{{ t('rules.builder.match') }}</span>
<div class="bg-muted inline-flex rounded-md p-0.5">
<button
v-for="mode in ['and', 'or'] as const"
:key="mode"
type="button"
:class="
cn(
'rounded-[5px] px-2.5 py-1 text-xs font-medium transition-colors',
props.modelValue.mode === mode
? 'bg-background text-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground',
)
"
@click="patch({ mode })"
>
{{ t(`rules.builder.${mode}`) }}
</button>
</div>
<span class="text-muted-foreground">{{ t('rules.builder.matchSuffix') }}</span>
</div>
<div class="flex flex-col gap-2">
<template v-for="(condition, index) in props.modelValue.conditions" :key="condition.id">
<div v-if="index > 0" class="flex items-center gap-2">
<span class="bg-border h-px flex-1" />
<span class="text-muted-foreground text-[11px] font-medium uppercase">
{{ t(`rules.builder.${props.modelValue.mode}`) }}
</span>
<span class="bg-border h-px flex-1" />
</div>
<ConditionRow
:condition="condition"
:analyzers="props.analyzers"
:removable="props.modelValue.conditions.length > 1"
@update="updateCondition(index, $event)"
@remove="removeCondition(index)"
/>
</template>
</div>
<Button variant="outline" size="sm" class="w-full" @click="addCondition">
<Plus class="size-3.5" />
{{ t('rules.builder.addCondition') }}
</Button>
<div class="flex flex-col gap-1">
<span class="text-muted-foreground text-xs font-medium">
{{ t('rules.builder.preview') }}
</span>
<code
class="bg-muted/60 text-muted-foreground block rounded-md px-2.5 py-2 font-mono text-[11px] leading-relaxed break-all"
>
{{ expression || t('rules.builder.empty') }}
</code>
</div>
</div>
</template>
@@ -0,0 +1,73 @@
<script setup lang="ts">
import { X } from 'lucide-vue-next'
import { TagsInputInput, TagsInputItem, TagsInputItemDelete, TagsInputItemText, TagsInputRoot } from 'reka-ui'
import { computed } from 'vue'
import { cn } from '@/lib/utils'
/** Chip style input for a list of values, with per-value validation. */
const props = defineProps<{
modelValue: string[]
placeholder?: string
/** Returns an error message for an invalid value, or "" when it is fine. */
validate?: (value: string) => string
class?: string
}>()
const emit = defineEmits<{ 'update:modelValue': [value: string[]] }>()
const invalid = computed(() => {
if (!props.validate) return new Set<string>()
return new Set(props.modelValue.filter((v) => props.validate?.(v)))
})
const errors = computed(() => {
if (!props.validate) return [] as string[]
const messages = new Set<string>()
for (const value of props.modelValue) {
const message = props.validate(value)
if (message) messages.add(message)
}
return [...messages]
})
</script>
<template>
<div class="flex flex-col gap-1.5">
<TagsInputRoot
:model-value="props.modelValue"
:delimiter="','"
:add-on-paste="true"
:class="
cn(
'border-input bg-background focus-within:border-ring focus-within:ring-ring/40 flex min-h-9 w-full flex-wrap items-center gap-1.5 rounded-md border px-2 py-1.5 shadow-xs transition-[color,box-shadow] focus-within:ring-[3px]',
props.class,
)
"
@update:model-value="emit('update:modelValue', $event as string[])"
>
<TagsInputItem
v-for="value in props.modelValue"
:key="value"
:value="value"
:class="
cn(
'flex items-center gap-1 rounded-md border px-1.5 py-0.5 font-mono text-xs',
invalid.has(value)
? 'border-destructive/50 bg-destructive/10 text-destructive'
: 'bg-secondary text-secondary-foreground border-transparent',
)
"
>
<TagsInputItemText />
<TagsInputItemDelete class="hover:text-foreground opacity-60 transition-opacity hover:opacity-100">
<X class="size-3" />
</TagsInputItemDelete>
</TagsInputItem>
<TagsInputInput
:placeholder="props.modelValue.length ? '' : props.placeholder"
class="placeholder:text-muted-foreground min-w-24 flex-1 bg-transparent px-1 text-sm outline-none"
/>
</TagsInputRoot>
<p v-for="message in errors" :key="message" class="text-destructive text-xs">{{ message }}</p>
</div>
</template>
+1
View File
@@ -13,6 +13,7 @@ export { default as Separator } from './Separator.vue'
export { default as Skeleton } from './Skeleton.vue'
export { default as Switch } from './Switch.vue'
export { default as Tabs } from './Tabs.vue'
export { default as TagsInput } from './TagsInput.vue'
export { default as TabsContent } from './TabsContent.vue'
export { default as TabsList } from './TabsList.vue'
export { default as TabsTrigger } from './TabsTrigger.vue'
+35
View File
@@ -0,0 +1,35 @@
import { ref } from 'vue'
import { api } from '@/lib/api'
import type { GeoData } from '@/lib/types'
const geo = ref<GeoData | null>(null)
const error = ref<{ ip: string; site: string }>({ ip: '', site: '' })
const loading = ref(false)
let pending: Promise<void> | null = null
/** Loads the geo database listing once and shares it across components. */
async function load(force = false): Promise<void> {
if (geo.value && !force) return
if (pending) return pending
loading.value = true
pending = (async () => {
try {
const data = await api.geo(force)
geo.value = data
error.value = { ip: data.ipError ?? '', site: data.siteError ?? '' }
} catch (e) {
const message = e instanceof Error ? e.message : String(e)
error.value = { ip: message, site: message }
geo.value = { ip: [], site: [] }
} finally {
loading.value = false
pending = null
}
})()
return pending
}
export function useGeo() {
return { geo, error, loading, load }
}
+78 -1
View File
@@ -127,8 +127,85 @@ export default {
moveDown: 'Move down',
empty: 'No rules yet. Everything is accepted.',
nameRequired: 'A name is required',
exprRequired: 'An expression is required',
exprRequired: 'Add at least one condition',
hint: 'Saving writes the rule file and hot reloads the engine.',
conditions: 'Conditions',
exprHint: 'Any expr expression works here, the engine validates it when you save.',
invalidJson: 'Modifier arguments must be valid JSON',
invalidCidr: 'Not a valid CIDR, e.g. 10.0.0.0/8',
invalidRegex: 'Not a valid regular expression',
invalidGeo: 'Use letters, digits, - and @',
invalidIp: 'Not a valid IP address',
invalidPort: 'Port must be between 0 and 65535',
invalidDomain: 'Not a valid domain pattern',
starHint: 'Contains *: switch the operator to "matches wildcard"',
valuePlaceholder: 'Type a value and press Enter',
builder: {
tab: 'Builder',
match: 'Match',
matchSuffix: 'of the following conditions',
and: 'all',
or: 'any',
addCondition: 'Add condition',
preview: 'Generated expression',
empty: 'Fill in a condition to see the expression',
not: 'NOT',
field: 'Field',
operator: 'Operator',
notParsed:
'This rule uses an expression the builder cannot represent, so it is shown as raw expr. Editing it in the builder would change its meaning.',
},
group: {
basic: 'Connection',
domain: 'Domain',
http: 'HTTP',
protocol: 'Protocol',
},
field: {
proto: 'Transport protocol',
ip_dst: 'Destination IP',
ip_src: 'Source IP',
port_dst: 'Destination port',
port_src: 'Source port',
tls_sni: 'TLS SNI',
quic_sni: 'QUIC SNI',
dns_name: 'DNS query name',
http_host: 'HTTP Host',
http_path: 'HTTP path',
http_method: 'HTTP method',
http_ua: 'User-Agent',
analyzer: 'Detected protocol',
},
op: {
is: 'is',
subdomain: 'domain or subdomain of',
wildcard: 'matches wildcard',
contains: 'contains',
startsWith: 'starts with',
endsWith: 'ends with',
regex: 'matches regex',
geosite: 'in GeoSite category',
cidr: 'in CIDR',
geoip: 'in GeoIP country',
range: 'in range',
detected: 'is',
},
wildcard: {
exact: 'exact match',
suffix: 'ends with {value}',
prefix: 'starts with {value}',
contains: 'contains {value}',
regex: 'regex {value}',
any: 'any non-empty value',
},
geo: {
attribute: 'attribute',
loading: 'Loading the geo database, this can take a moment the first time…',
searchCountry: 'Search country or group…',
searchSite: 'Search category…',
unavailable:
'The geo database could not be read, enter the codes manually (e.g. cn, us).',
},
},
analyzers: {
title: 'Analyzers',
+76 -1
View File
@@ -127,8 +127,83 @@ export default {
moveDown: '下移',
empty: '暂无规则,所有流量都会放行。',
nameRequired: '请填写名称',
exprRequired: '请填写表达式',
exprRequired: '请至少添加一个条件',
hint: '保存后将写入规则文件并热加载引擎。',
conditions: '匹配条件',
exprHint: '这里可以写任意 expr 表达式,保存时由引擎校验。',
invalidJson: '修改器参数必须是合法 JSON',
invalidCidr: '不是合法的 CIDR,例如 10.0.0.0/8',
invalidRegex: '不是合法的正则表达式',
invalidGeo: '只能包含字母、数字、- 和 @',
invalidIp: '不是合法的 IP 地址',
invalidPort: '端口需在 0-65535 之间',
invalidDomain: '不是合法的域名或通配符',
starHint: '包含 *,如需通配符请把条件改成「通配符匹配」',
valuePlaceholder: '输入后按回车添加',
builder: {
tab: '可视化构建',
match: '满足以下条件的',
matchSuffix: '项',
and: '全部',
or: '任一',
addCondition: '添加条件',
preview: '生成的表达式',
empty: '填写条件后这里会显示表达式',
not: '取反',
field: '字段',
operator: '条件',
notParsed: '该规则的表达式无法用可视化构建器表示,已切换到表达式模式;在构建器中编辑会改变其语义。',
},
group: {
basic: '连接',
domain: '域名',
http: 'HTTP',
protocol: '协议',
},
field: {
proto: '传输层协议',
ip_dst: '目标 IP',
ip_src: '来源 IP',
port_dst: '目标端口',
port_src: '来源端口',
tls_sni: 'TLS SNI',
quic_sni: 'QUIC SNI',
dns_name: 'DNS 查询域名',
http_host: 'HTTP Host',
http_path: 'HTTP 路径',
http_method: 'HTTP 方法',
http_ua: 'User-Agent',
analyzer: '识别到的协议',
},
op: {
is: '等于',
subdomain: '是域名或其子域名',
wildcard: '通配符匹配',
contains: '包含',
startsWith: '开头是',
endsWith: '结尾是',
regex: '正则匹配',
geosite: '属于 GeoSite 分类',
cidr: '属于网段',
geoip: '属于 GeoIP 国家/地区',
range: '在区间内',
detected: '是',
},
wildcard: {
exact: '精确匹配',
suffix: '以 {value} 结尾',
prefix: '以 {value} 开头',
contains: '包含 {value}',
regex: '正则 {value}',
any: '任意非空值',
},
geo: {
attribute: '属性',
loading: '正在加载 geo 数据库,首次加载可能需要一点时间…',
searchCountry: '搜索国家/地区或分组…',
searchSite: '搜索分类…',
unavailable: '无法读取 geo 数据库,请手动输入代码(如 cn、us)。',
},
},
analyzers: {
title: '解析器',
+2
View File
@@ -1,4 +1,5 @@
import type {
GeoData,
Info,
LiveEvent,
MetaInfo,
@@ -96,6 +97,7 @@ export const api = {
metrics: () => request<Metrics>('/metrics'),
events: (limit = 200) => request<{ events: LiveEvent[] }>(`/events?limit=${limit}`),
rules: () => request<RulesResponse>('/rules'),
geo: (reload = false) => request<GeoData>(`/geo${reload ? '?reload=1' : ''}`),
saveRules: (payload: { raw?: string; rules?: Rule[] }) =>
request<RulesResponse>('/rules', { method: 'PUT', body: JSON.stringify(payload) }),
+41
View File
@@ -0,0 +1,41 @@
/** Helpers for displaying geo database keys. */
const displayNames = new Map<string, Intl.DisplayNames>()
function names(lang: string): Intl.DisplayNames | null {
const locale = lang === 'zh' ? 'zh-CN' : 'en'
if (!displayNames.has(locale)) {
try {
displayNames.set(locale, new Intl.DisplayNames([locale], { type: 'region' }))
} catch {
return null
}
}
return displayNames.get(locale) ?? null
}
/**
* Localized country name for a two letter code, or null when the code is not a
* country. GeoIP databases also contain provider groups such as "cloudflare".
*/
export function regionName(code: string, lang: string): string | null {
if (!/^[a-z]{2}$/i.test(code)) return null
const upper = code.toUpperCase()
try {
const name = names(lang)?.of(upper)
return name && name !== upper ? name : null
} catch {
return null
}
}
/** Flag emoji for a two letter country code, empty for anything else. */
export function flagOf(code: string): string {
if (!/^[a-z]{2}$/i.test(code)) return ''
const base = 0x1f1e6
const upper = code.toUpperCase()
return String.fromCodePoint(
base + upper.charCodeAt(0) - 65,
base + upper.charCodeAt(1) - 65,
)
}
+113
View File
@@ -0,0 +1,113 @@
import { FIELD_MAP, type FieldDef, type OperatorId } from './fields'
import type { Condition, RuleBuilderState } from './types'
/** Quotes a value as an expr string literal. */
export function quote(value: string): string {
return `"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`
}
const REGEX_SPECIALS = /[.+?^${}()|[\]\\]/g
/** Converts a `*.example.com` style pattern into an anchored regular expression. */
export function wildcardToRegex(pattern: string): string {
return `^${pattern.replace(REGEX_SPECIALS, '\\$&').replace(/\*/g, '.*')}$`
}
export interface WildcardExplain {
kind: 'exact' | 'suffix' | 'prefix' | 'contains' | 'regex' | 'any'
/** The meaningful part of the pattern, e.g. ".example.com" for "*.example.com". */
value: string
}
/** Describes how a wildcard pattern will be matched, for the inline hint. */
export function explainWildcard(pattern: string): WildcardExplain {
const p = pattern.trim()
if (p === '*') return { kind: 'any', value: '' }
if (!p.includes('*')) return { kind: 'exact', value: p }
const starts = p.startsWith('*')
const ends = p.endsWith('*')
const core = p.replace(/^\*/, '').replace(/\*$/, '')
if (core.includes('*')) return { kind: 'regex', value: wildcardToRegex(p) }
if (starts && ends) return { kind: 'contains', value: core }
if (starts) return { kind: 'suffix', value: core }
if (ends) return { kind: 'prefix', value: core }
return { kind: 'regex', value: wildcardToRegex(p) }
}
function wildcardTerm(subject: string, pattern: string): string {
const info = explainWildcard(pattern)
switch (info.kind) {
case 'any':
return `${subject} != ""`
case 'exact':
return `${subject} == ${quote(info.value)}`
case 'suffix':
return `${subject} endsWith ${quote(info.value)}`
case 'prefix':
return `${subject} startsWith ${quote(info.value)}`
case 'contains':
return `${subject} contains ${quote(info.value)}`
case 'regex':
return `${subject} matches ${quote(info.value)}`
}
}
/** Builds the expression for one value of a condition. */
function term(field: FieldDef, op: OperatorId, subject: string, value: string): string {
const v = value.trim()
switch (op) {
case 'is':
return field.type === 'port' ? `${subject} == ${v}` : `${subject} == ${quote(v)}`
case 'subdomain':
return `(${subject} == ${quote(v)} || ${subject} endsWith ${quote('.' + v)})`
case 'wildcard':
return wildcardTerm(subject, v)
case 'contains':
case 'startsWith':
case 'endsWith':
return `${subject} ${op} ${quote(v)}`
case 'regex':
return `${subject} matches ${quote(v)}`
case 'geosite':
return `geosite(${subject}, ${quote(v.toLowerCase())})`
case 'cidr':
return `cidr(${subject}, ${quote(v)})`
case 'geoip':
return `geoip(${subject}, ${quote(v.toLowerCase())})`
case 'detected':
return `${v} != nil`
case 'range':
return ''
}
}
/** Compiles a single condition into an expression, or "" when incomplete. */
export function compileCondition(condition: Condition): string {
const field = FIELD_MAP[condition.field]
if (!field) return ''
const values = condition.values.map((v) => v.trim()).filter(Boolean)
let body: string
if (condition.op === 'range') {
const [from, to] = condition.values
if (!from?.trim() || !to?.trim()) return ''
body = `(${field.expr} >= ${from.trim()} && ${field.expr} <= ${to.trim()})`
} else {
if (values.length === 0) return ''
const subject = field.iter ? (field.item as string) : (field.expr as string)
const terms = values.map((v) => term(field, condition.op, subject, v))
const joined = terms.length > 1 ? `(${terms.join(' || ')})` : terms[0]
body = field.iter ? `any(${field.iter}, {${joined}})` : joined
}
if (!condition.negate) return body
return body.startsWith('(') && body.endsWith(')') ? `!${body}` : `!(${body})`
}
/** Compiles the whole builder state into a rule expression. */
export function compileRule(state: RuleBuilderState): string {
const parts = state.conditions.map(compileCondition).filter(Boolean)
if (parts.length === 0) return ''
if (parts.length === 1) return parts[0]
return parts.join(state.mode === 'and' ? ' && ' : ' || ')
}
+126
View File
@@ -0,0 +1,126 @@
/**
* Catalog of the fields and operators the visual rule builder offers.
*
* Every field maps to a snippet of the expr expression language used by the
* engine. Optional chaining (`a?.b`) keeps rules safe when an analyzer did not
* produce any property for a stream, and `string(...)` turns a missing value
* into an empty string instead of an error.
*/
export type ValueType = 'proto' | 'ip' | 'port' | 'domain' | 'text' | 'analyzer'
export type OperatorId =
| 'is'
| 'subdomain'
| 'wildcard'
| 'contains'
| 'startsWith'
| 'endsWith'
| 'regex'
| 'geosite'
| 'cidr'
| 'geoip'
| 'range'
| 'detected'
export interface FieldDef {
key: string
/** Group used to organise the field picker. */
group: 'basic' | 'domain' | 'http' | 'protocol'
type: ValueType
/** Expression evaluating to the value, already coerced to a string. */
expr?: string
/** Analyzer this field depends on, shown as a hint in the UI. */
analyzer?: string
/**
* Set for fields that live in an array: `iter` is the list expression and
* `item` the expression of a single element inside the predicate.
*/
iter?: string
item?: string
}
export const FIELDS: FieldDef[] = [
{ key: 'proto', group: 'basic', type: 'proto', expr: 'proto' },
{ key: 'ip.dst', group: 'basic', type: 'ip', expr: 'ip.dst' },
{ key: 'ip.src', group: 'basic', type: 'ip', expr: 'ip.src' },
{ key: 'port.dst', group: 'basic', type: 'port', expr: 'port.dst' },
{ key: 'port.src', group: 'basic', type: 'port', expr: 'port.src' },
{ key: 'tls.sni', group: 'domain', type: 'domain', expr: 'string(tls?.req?.sni)', analyzer: 'tls' },
{
key: 'quic.sni',
group: 'domain',
type: 'domain',
expr: 'string(quic?.req?.sni)',
analyzer: 'quic',
},
{
key: 'dns.name',
group: 'domain',
type: 'domain',
analyzer: 'dns',
iter: 'dns?.questions ?? []',
item: 'string(.name)',
},
{
key: 'http.host',
group: 'http',
type: 'domain',
expr: 'string(http?.req?.headers?.host)',
analyzer: 'http',
},
{ key: 'http.path', group: 'http', type: 'text', expr: 'string(http?.req?.path)', analyzer: 'http' },
{
key: 'http.method',
group: 'http',
type: 'text',
expr: 'string(http?.req?.method)',
analyzer: 'http',
},
{
key: 'http.ua',
group: 'http',
type: 'text',
expr: 'string(get(http?.req?.headers, "user-agent"))',
analyzer: 'http',
},
{ key: 'analyzer', group: 'protocol', type: 'analyzer' },
]
export const FIELD_MAP: Record<string, FieldDef> = Object.fromEntries(
FIELDS.map((f) => [f.key, f]),
)
/** Operators offered for each value type, in menu order. */
export const OPERATORS: Record<ValueType, OperatorId[]> = {
proto: ['is'],
ip: ['cidr', 'geoip', 'is'],
port: ['is', 'range'],
domain: ['subdomain', 'wildcard', 'is', 'contains', 'endsWith', 'startsWith', 'regex', 'geosite'],
text: ['contains', 'is', 'startsWith', 'endsWith', 'regex'],
analyzer: ['detected'],
}
/** Operators whose value is picked from a list rather than typed. */
export const PICKER_OPERATORS: Partial<Record<OperatorId, 'geoip' | 'geosite'>> = {
geoip: 'geoip',
geosite: 'geosite',
}
export const PROTO_VALUES = ['tcp', 'udp']
/** `range` takes exactly two values (from and to); everything else is a list. */
export function isRangeOperator(op: OperatorId): boolean {
return op === 'range'
}
export function defaultOperator(type: ValueType): OperatorId {
return OPERATORS[type][0]
}
export function operatorsFor(field: FieldDef): OperatorId[] {
return OPERATORS[field.type]
}
+322
View File
@@ -0,0 +1,322 @@
import { FIELDS, type FieldDef, type OperatorId } from './fields'
import { newCondition } from './types'
import type { Condition, RuleBuilderState } from './types'
/**
* Best-effort conversion of an expression back into builder state.
*
* It understands the canonical shapes produced by compile.ts, which covers
* every rule created through the builder. Hand written expressions that use
* anything else simply return null, and the UI falls back to the expression
* editor instead of silently rewriting them.
*/
export function parseRule(expr: string): RuleBuilderState | null {
const source = expr.trim()
if (!source) return null
let mode: 'and' | 'or' = 'and'
let parts = splitTop(source, '&&')
if (parts.length === 1) {
const or = splitTop(source, '||')
if (or.length > 1) {
mode = 'or'
parts = or
}
}
const conditions: Condition[] = []
for (const part of parts) {
const condition = parseCondition(part)
if (!condition) return null
conditions.push(condition)
}
return { mode, conditions }
}
function parseCondition(input: string): Condition | null {
let source = input.trim()
let negate = false
if (source.startsWith('!')) {
negate = true
source = source.slice(1).trim()
}
// A single value, including the parenthesised "domain or subdomain" form.
const single = parseTerm(source)
if (single) {
return newCondition({ field: single.field.key, op: single.op, negate, values: [single.value] })
}
const body = unwrap(source)
// Array fields: any(<iter>, {<predicate>})
const anyMatch = matchCall(body, 'any')
if (anyMatch !== null) {
const [iter, closure] = splitTop(anyMatch, ',').map((s) => s.trim())
if (!closure?.startsWith('{') || !closure.endsWith('}')) return null
const field = FIELDS.find((f) => f.iter === iter)
if (!field) return null
return finish(field, closure.slice(1, -1).trim(), negate, field.item as string)
}
// Port range: (port.dst >= 1 && port.dst <= 2)
const range = splitTop(body, '&&')
if (range.length === 2) {
const from = /^(\S+)\s*>=\s*(\d+)$/.exec(range[0].trim())
const to = /^(\S+)\s*<=\s*(\d+)$/.exec(range[1].trim())
if (from && to && from[1] === to[1]) {
const field = FIELDS.find((f) => f.expr === from[1] && f.type === 'port')
if (!field) return null
return newCondition({ field: field.key, op: 'range', negate, values: [from[2], to[2]] })
}
return null
}
if (range.length > 1) return null
return finish(null, body, negate)
}
/**
* Parses the values of a condition, which are OR-ed together and must all use
* the same field and operator.
*/
function finish(
expected: FieldDef | null,
body: string,
negate: boolean,
subject?: string,
): Condition | null {
const single = parseTerm(body.trim(), subject)
if (single && (!expected || single.field.key === expected.key)) {
return newCondition({ field: single.field.key, op: single.op, negate, values: [single.value] })
}
const parts = splitTop(unwrap(body), '||')
if (parts.length < 2) return null
const terms: ParsedTerm[] = []
let field = expected
for (const part of parts) {
const parsed = parseTerm(part.trim(), subject)
if (!parsed) return null
if (!field) field = parsed.field
if (parsed.field.key !== field.key) return null
terms.push(parsed)
}
if (!field) return null
const ops = new Set(terms.map((t) => t.op))
if (ops.size === 1) {
return newCondition({
field: field.key,
op: terms[0].op,
negate,
values: terms.map((t) => t.value),
})
}
// Values of a wildcard condition compile to different operators depending on
// where the star sits, so fold mixed operators back into wildcard patterns.
const patterns = terms.map((t) => toWildcard(t.op, t.value))
if (patterns.some((p) => p === null)) return null
return newCondition({ field: field.key, op: 'wildcard', negate, values: patterns as string[] })
}
/** Inverse of the wildcard compilation, used when a condition mixes operators. */
function toWildcard(op: OperatorId, value: string): string | null {
switch (op) {
case 'wildcard':
return value
case 'is':
return value.includes('*') ? null : value
case 'endsWith':
return `*${value}`
case 'startsWith':
return `${value}*`
case 'contains':
return `*${value}*`
default:
return null
}
}
interface ParsedTerm {
field: FieldDef
subject: string
op: OperatorId
value: string
}
const CALL_OPS: Record<string, OperatorId> = {
geoip: 'geoip',
geosite: 'geosite',
cidr: 'cidr',
}
const COMPARE_OPS: Record<string, OperatorId> = {
'==': 'is',
contains: 'contains',
startsWith: 'startsWith',
endsWith: 'endsWith',
matches: 'regex',
}
/** All expressions that can appear on the left hand side of a comparison. */
function subjects(expected?: string): { subject: string; field: FieldDef }[] {
const out: { subject: string; field: FieldDef }[] = []
for (const field of FIELDS) {
if (field.item) out.push({ subject: field.item, field })
if (field.expr) out.push({ subject: field.expr, field })
}
// Longest match wins, but the subject already established for the condition
// is always tried first so that ambiguous fields stay stable.
return out.sort(
(a, b) =>
Number(b.subject === expected) - Number(a.subject === expected) ||
b.subject.length - a.subject.length,
)
}
function parseTerm(input: string, expected?: string): ParsedTerm | null {
const source = input.trim()
// Analyzer presence: `tls != nil`
const detected = /^([a-z][\w]*)\s*!=\s*nil$/.exec(source)
if (detected) {
const field = FIELDS.find((f) => f.type === 'analyzer')
if (field) return { field, subject: '', op: 'detected', value: detected[1] }
}
// Domain-or-subdomain: (S == "v" || S endsWith ".v")
if (source.startsWith('(') && isWrapped(source)) {
const inner = splitTop(unwrap(source), '||')
if (inner.length === 2) {
const a = parseTerm(inner[0].trim(), expected)
const b = parseTerm(inner[1].trim(), expected)
if (
a &&
b &&
a.op === 'is' &&
b.op === 'endsWith' &&
a.field.key === b.field.key &&
b.value === `.${a.value}`
) {
return { ...a, op: 'subdomain' }
}
}
return null
}
for (const [name, op] of Object.entries(CALL_OPS)) {
const args = matchCall(source, name)
if (args === null) continue
const parts = splitTop(args, ',').map((s) => s.trim())
if (parts.length !== 2) return null
const entry = subjects(expected).find((s) => s.subject === parts[0])
const value = unquote(parts[1])
if (!entry || value === null) return null
return { field: entry.field, subject: entry.subject, op, value }
}
for (const { subject, field } of subjects(expected)) {
if (!source.startsWith(subject)) continue
const rest = source.slice(subject.length).trim()
// Wildcard "*" compiles to `S != ""`.
if (rest === '!= ""') return { field, subject, op: 'wildcard', value: '*' }
for (const [token, op] of Object.entries(COMPARE_OPS)) {
if (!rest.startsWith(token)) continue
const literal = rest.slice(token.length).trim()
if (field.type === 'port' && /^\d+$/.test(literal) && op === 'is') {
return { field, subject, op, value: literal }
}
const value = unquote(literal)
if (value === null) continue
return { field, subject, op, value }
}
return null
}
return null
}
/** Returns the arguments of `name(...)` when the input is exactly that call. */
function matchCall(source: string, name: string): string | null {
const prefix = `${name}(`
if (!source.startsWith(prefix) || !source.endsWith(')')) return null
const args = source.slice(prefix.length, -1)
return balanced(args) ? args : null
}
function unquote(literal: string): string | null {
if (literal.length < 2 || literal[0] !== '"' || literal[literal.length - 1] !== '"') return null
const body = literal.slice(1, -1)
let out = ''
for (let i = 0; i < body.length; i++) {
if (body[i] === '\\' && i + 1 < body.length) {
i++
out += body[i]
} else if (body[i] === '"') {
return null
} else {
out += body[i]
}
}
return out
}
/** Splits on a top level operator, ignoring anything nested or quoted. */
export function splitTop(source: string, operator: string): string[] {
const parts: string[] = []
let depth = 0
let quoted = false
let start = 0
for (let i = 0; i < source.length; i++) {
const ch = source[i]
if (quoted) {
if (ch === '\\') i++
else if (ch === '"') quoted = false
continue
}
if (ch === '"') quoted = true
else if (ch === '(' || ch === '[' || ch === '{') depth++
else if (ch === ')' || ch === ']' || ch === '}') depth--
else if (depth === 0 && source.startsWith(operator, i)) {
parts.push(source.slice(start, i))
i += operator.length - 1
start = i + 1
}
}
parts.push(source.slice(start))
return parts.map((p) => p.trim()).filter((p) => p.length > 0)
}
function balanced(source: string): boolean {
let depth = 0
let quoted = false
for (let i = 0; i < source.length; i++) {
const ch = source[i]
if (quoted) {
if (ch === '\\') i++
else if (ch === '"') quoted = false
continue
}
if (ch === '"') quoted = true
else if (ch === '(' || ch === '[' || ch === '{') depth++
else if (ch === ')' || ch === ']' || ch === '}') {
depth--
if (depth < 0) return false
}
}
return depth === 0 && !quoted
}
/** True when the whole string is wrapped in one pair of parentheses. */
function isWrapped(source: string): boolean {
if (!source.startsWith('(') || !source.endsWith(')')) return false
return balanced(source.slice(1, -1))
}
function unwrap(source: string): string {
let out = source.trim()
while (isWrapped(out)) out = out.slice(1, -1).trim()
return out
}
+35
View File
@@ -0,0 +1,35 @@
import type { OperatorId } from './fields'
export type MatchMode = 'and' | 'or'
export interface Condition {
id: string
field: string
op: OperatorId
negate: boolean
/** One entry per value; the `range` operator uses exactly two. */
values: string[]
}
export interface RuleBuilderState {
mode: MatchMode
conditions: Condition[]
}
let counter = 0
export function newCondition(partial: Partial<Condition> = {}): Condition {
counter += 1
return {
id: `c${counter}`,
field: 'tls.sni',
op: 'subdomain',
negate: false,
values: [],
...partial,
}
}
export function emptyBuilderState(): RuleBuilderState {
return { mode: 'and', conditions: [newCondition()] }
}
+76
View File
@@ -0,0 +1,76 @@
import { t } from '@/i18n'
import type { OperatorId, ValueType } from './fields'
const IPV4 = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/
export function isIPv4(value: string): boolean {
const m = IPV4.exec(value)
return m ? m.slice(1).every((part) => Number(part) <= 255 && String(Number(part)) === part) : false
}
export function isIPv6(value: string): boolean {
if (!value.includes(':')) return false
// Reject obvious garbage; the engine does the authoritative parsing.
if (!/^[0-9a-fA-F:.]+$/.test(value)) return false
return (value.match(/::/g) ?? []).length <= 1 && value.split(':').length <= 9
}
export function isIP(value: string): boolean {
return isIPv4(value) || isIPv6(value)
}
export function isCIDR(value: string): boolean {
const slash = value.lastIndexOf('/')
if (slash < 0) return false
const ip = value.slice(0, slash)
const bits = value.slice(slash + 1)
if (!/^\d{1,3}$/.test(bits)) return false
const prefix = Number(bits)
if (isIPv4(ip)) return prefix <= 32
if (isIPv6(ip)) return prefix <= 128
return false
}
export function isPort(value: string): boolean {
return /^\d{1,5}$/.test(value) && Number(value) <= 65535
}
/** Returns an error message for a value, or "" when it is acceptable. */
export function validateValue(type: ValueType, op: OperatorId, value: string): string {
const v = value.trim()
if (!v) return ''
switch (op) {
case 'cidr':
return isCIDR(v) ? '' : t('rules.invalidCidr')
case 'regex':
try {
new RegExp(v)
return ''
} catch {
return t('rules.invalidRegex')
}
case 'geoip':
case 'geosite':
return /^[a-z0-9-]+(@[a-z0-9-]+)*$/i.test(v) ? '' : t('rules.invalidGeo')
case 'wildcard':
return /^[a-z0-9.*_-]+$/i.test(v) ? '' : t('rules.invalidDomain')
default:
break
}
// A star only means something with the wildcard operator; anywhere else it
// would be matched literally, which is never what the user meant.
if ((type === 'domain' || type === 'text') && v.includes('*')) {
return t('rules.starHint')
}
switch (type) {
case 'ip':
return isIP(v) ? '' : t('rules.invalidIp')
case 'port':
return isPort(v) ? '' : t('rules.invalidPort')
case 'domain':
return /^[a-z0-9.*_-]+$/i.test(v) ? '' : t('rules.invalidDomain')
default:
return ''
}
}
+13
View File
@@ -83,6 +83,19 @@ export interface MetaInfo {
functions: string[]
}
export interface GeoEntry {
code: string
count: number
attributes?: string[]
}
export interface GeoData {
ip: GeoEntry[]
site: GeoEntry[]
ipError?: string
siteError?: string
}
export interface RuleModifier {
name: string
args?: Record<string, unknown>
+116 -17
View File
@@ -10,10 +10,12 @@ import {
RotateCcw,
Save,
Trash2,
Wand2,
} from 'lucide-vue-next'
import { computed, onMounted, ref } from 'vue'
import PageHeader from '@/components/PageHeader.vue'
import RuleBuilder from '@/components/rule/RuleBuilder.vue'
import {
Badge,
Button,
@@ -32,6 +34,9 @@ import {
import { toast } from '@/composables/useToast'
import { t } from '@/i18n'
import { api, ApiError } from '@/lib/api'
import { compileRule } from '@/lib/rule/compile'
import { parseRule } from '@/lib/rule/parse'
import { emptyBuilderState, type RuleBuilderState } from '@/lib/rule/types'
import type { MetaInfo, Rule } from '@/lib/types'
const mode = ref('visual')
@@ -49,6 +54,14 @@ const editing = ref<Rule | null>(null)
const editingIndex = ref(-1)
const removing = ref<number>(-1)
// Rule editor state
const editorTab = ref('builder')
const builder = ref<RuleBuilderState>(emptyBuilderState())
const builderUnavailable = ref(false)
const checking = ref(false)
const analyzerNames = computed(() => (meta.value?.analyzers ?? []).map((a) => a.name))
const actionOptions = computed(() => [
{ value: '', label: t('rules.actionNone') },
...(meta.value?.actions ?? []).map((a) => ({ value: a, label: t(`action.${a}`) })),
@@ -175,46 +188,96 @@ function startAdd() {
editingIndex.value = -1
editing.value = { name: '', action: 'block', log: false, expr: '' }
modifierArgs.value = '{}'
builder.value = emptyBuilderState()
builderUnavailable.value = false
editorTab.value = 'builder'
editError.value = ''
}
function startEdit(index: number) {
const rule = rules.value[index]
editingIndex.value = index
editing.value = { ...rules.value[index] }
modifierArgs.value = JSON.stringify(rules.value[index].modifier?.args ?? {}, null, 2)
editing.value = { ...rule }
modifierArgs.value = JSON.stringify(rule.modifier?.args ?? {}, null, 2)
const parsed = parseRule(rule.expr)
builderUnavailable.value = parsed === null
builder.value = parsed ?? emptyBuilderState()
editorTab.value = parsed ? 'builder' : 'expr'
editError.value = ''
}
/**
* Switching tabs keeps both representations in sync: the builder is compiled
* into the expression, and an expression is parsed back when it uses the
* shapes the builder knows.
*/
function switchEditorTab(next: string) {
if (next === editorTab.value || !editing.value) return
if (next === 'expr') {
const expr = compileRule(builder.value)
if (expr) editing.value.expr = expr
} else {
const parsed = parseRule(editing.value.expr)
builderUnavailable.value = parsed === null
if (parsed) builder.value = parsed
}
editorTab.value = next
}
const modifierArgs = ref('{}')
const editError = ref('')
function commitEdit() {
const draftExpr = computed(() =>
editorTab.value === 'builder' ? compileRule(builder.value) : (editing.value?.expr ?? ''),
)
async function commitEdit() {
const draft = editing.value
if (!draft) return
if (!draft || checking.value) return
editError.value = ''
if (!draft.name.trim()) {
editError.value = t('rules.nameRequired')
return
}
if (!draft.expr.trim()) {
const expr = draftExpr.value.trim()
if (!expr) {
editError.value = t('rules.exprRequired')
return
}
draft.expr = expr
if (draft.action === 'modify') {
try {
const args = JSON.parse(modifierArgs.value || '{}') as Record<string, unknown>
draft.modifier = { name: draft.modifier?.name ?? modifierOptions.value[0]?.value ?? '', args }
} catch {
editError.value = 'JSON'
editError.value = t('rules.invalidJson')
return
}
} else {
draft.modifier = null
}
// Let the engine have the final word on the expression before accepting it.
checking.value = true
try {
const res = await api.validateRules({ rules: [{ ...draft, name: draft.name.trim() }] })
if (!res.valid) {
editError.value = res.error ?? t('rules.invalid')
return
}
} catch (e) {
editError.value = e instanceof ApiError ? e.message : String(e)
return
} finally {
checking.value = false
}
const next = [...rules.value]
if (editingIndex.value >= 0) next[editingIndex.value] = draft
else next.push(draft)
rules.value = next
dirty.value = true
editing.value = null
editError.value = ''
}
function remove(index: number) {
@@ -369,9 +432,11 @@ function remove(index: number) {
<Modal
:open="editing !== null"
:title="editingIndex >= 0 ? t('rules.editTitle') : t('rules.addTitle')"
class="sm:max-w-2xl"
@update:open="!$event && ((editing = null), (editError = ''))"
>
<form v-if="editing" class="flex flex-col gap-4" @submit.prevent="commitEdit">
<!-- Submission goes through the footer button: Enter belongs to the tag inputs. -->
<form v-if="editing" class="flex flex-col gap-4" @submit.prevent>
<div class="flex flex-col gap-2">
<Label for="rule-name">{{ t('rules.name') }}</Label>
<Input id="rule-name" v-model="editing.name" :placeholder="t('rules.namePlaceholder')" />
@@ -412,24 +477,58 @@ function remove(index: number) {
</div>
<div class="flex flex-col gap-2">
<Label for="rule-expr">{{ t('rules.expr') }}</Label>
<Textarea
id="rule-expr"
v-model="editing.expr"
spellcheck="false"
:placeholder="t('rules.exprPlaceholder')"
class="min-h-28 font-mono text-xs leading-relaxed"
<div class="flex flex-wrap items-center justify-between gap-2">
<Label>{{ t('rules.conditions') }}</Label>
<Tabs :model-value="editorTab" @update:model-value="switchEditorTab">
<TabsList class="h-8">
<TabsTrigger value="builder" class="text-xs">
<Wand2 />
{{ t('rules.builder.tab') }}
</TabsTrigger>
<TabsTrigger value="expr" class="text-xs">
<FileCode2 />
{{ t('rules.expr') }}
</TabsTrigger>
</TabsList>
</Tabs>
</div>
<p
v-if="builderUnavailable && editorTab === 'expr'"
class="border-drop/40 bg-drop/10 text-drop rounded-md border px-2.5 py-2 text-xs"
>
{{ t('rules.builder.notParsed') }}
</p>
<RuleBuilder
v-if="editorTab === 'builder'"
v-model="builder"
:analyzers="analyzerNames"
/>
<template v-else>
<Textarea
v-model="editing.expr"
spellcheck="false"
:placeholder="t('rules.exprPlaceholder')"
class="min-h-28 font-mono text-xs leading-relaxed"
/>
<p class="text-muted-foreground text-xs">{{ t('rules.exprHint') }}</p>
</template>
</div>
<p v-if="editError" class="text-destructive text-sm">{{ editError }}</p>
<p v-if="editError" class="text-destructive font-mono text-xs break-words">
{{ editError }}
</p>
</form>
<template #footer>
<Button variant="outline" @click="((editing = null), (editError = ''))">
{{ t('common.cancel') }}
</Button>
<Button @click="commitEdit">{{ t('common.save') }}</Button>
<Button :disabled="checking" @click="commitEdit">
<Loader2 v-if="checking" class="size-4 animate-spin" />
{{ t('common.save') }}
</Button>
</template>
</Modal>