Files
OpenGFW/web/frontend/src/views/RulesView.vue
T

551 lines
17 KiB
Vue
Raw Normal View History

2026-07-27 08:09:11 +08:00
<script setup lang="ts">
import {
ArrowDown,
ArrowUp,
CheckCircle2,
FileCode2,
Loader2,
Pencil,
Plus,
RotateCcw,
Save,
Trash2,
Wand2,
2026-07-27 08:09:11 +08:00
} from 'lucide-vue-next'
import { computed, onMounted, ref } from 'vue'
import PageHeader from '@/components/PageHeader.vue'
import RuleBuilder from '@/components/rule/RuleBuilder.vue'
2026-07-27 08:09:11 +08:00
import {
Badge,
Button,
Card,
Input,
Label,
Modal,
Select,
Switch,
Tabs,
TabsContent,
TabsList,
TabsTrigger,
Textarea,
} from '@/components/ui'
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'
2026-07-27 08:09:11 +08:00
import type { MetaInfo, Rule } from '@/lib/types'
const mode = ref('visual')
const rules = ref<Rule[]>([])
const raw = ref('')
const path = ref('')
const meta = ref<MetaInfo | null>(null)
const loading = ref(true)
const busy = ref(false)
const dirty = ref(false)
const problem = ref('')
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))
2026-07-27 08:09:11 +08:00
const actionOptions = computed(() => [
{ value: '', label: t('rules.actionNone') },
...(meta.value?.actions ?? []).map((a) => ({ value: a, label: t(`action.${a}`) })),
])
const modifierOptions = computed(() =>
(meta.value?.modifiers ?? []).map((m) => ({ value: m, label: m })),
)
const badgeVariant = (action?: string) => {
switch (action) {
case 'allow':
return 'allow'
case 'block':
return 'block'
case 'drop':
return 'drop'
case 'modify':
return 'modify'
default:
return 'muted'
}
}
onMounted(async () => {
try {
const [res, m] = await Promise.all([api.rules(), api.meta()])
rules.value = res.rules ?? []
raw.value = res.raw
path.value = res.path
meta.value = m
} catch (e) {
problem.value = e instanceof ApiError ? e.message : String(e)
} finally {
loading.value = false
}
})
/** The backend is the single source of truth for YAML <-> rules conversion. */
async function switchMode(next: string) {
if (next === mode.value || busy.value) return
busy.value = true
problem.value = ''
try {
const res = await api.validateRules(next === 'yaml' ? { rules: rules.value } : { raw: raw.value })
if (next === 'yaml') {
raw.value = res.raw
} else {
if (!res.valid) {
problem.value = res.error ?? t('rules.invalid')
return
}
rules.value = res.rules ?? []
}
mode.value = next
} catch (e) {
problem.value = e instanceof ApiError ? e.message : String(e)
} finally {
busy.value = false
}
}
function payload() {
return mode.value === 'yaml' ? { raw: raw.value } : { rules: rules.value }
}
async function validate() {
busy.value = true
problem.value = ''
try {
const res = await api.validateRules(payload())
if (res.valid) toast.success(t('rules.valid'))
else problem.value = res.error ?? t('rules.invalid')
} catch (e) {
problem.value = e instanceof ApiError ? e.message : String(e)
} finally {
busy.value = false
}
}
async function save() {
busy.value = true
problem.value = ''
try {
const res = await api.saveRules(payload())
rules.value = res.rules ?? []
raw.value = res.raw
dirty.value = false
toast.success(t('rules.saved'), t('rules.count', { n: rules.value.length }))
} catch (e) {
problem.value = e instanceof ApiError ? e.message : String(e)
toast.error(t('rules.invalid'), problem.value)
} finally {
busy.value = false
}
}
async function reload() {
busy.value = true
problem.value = ''
try {
const res = await api.rules()
rules.value = res.rules ?? []
raw.value = res.raw
dirty.value = false
} catch (e) {
problem.value = e instanceof ApiError ? e.message : String(e)
} finally {
busy.value = false
}
}
function move(index: number, delta: number) {
const target = index + delta
if (target < 0 || target >= rules.value.length) return
const next = [...rules.value]
const [item] = next.splice(index, 1)
next.splice(target, 0, item)
rules.value = next
dirty.value = true
}
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 = ''
2026-07-27 08:09:11 +08:00
}
function startEdit(index: number) {
const rule = rules.value[index]
2026-07-27 08:09:11 +08:00
editingIndex.value = index
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
2026-07-27 08:09:11 +08:00
}
const modifierArgs = ref('{}')
const editError = ref('')
const draftExpr = computed(() =>
editorTab.value === 'builder' ? compileRule(builder.value) : (editing.value?.expr ?? ''),
)
async function commitEdit() {
2026-07-27 08:09:11 +08:00
const draft = editing.value
if (!draft || checking.value) return
editError.value = ''
2026-07-27 08:09:11 +08:00
if (!draft.name.trim()) {
editError.value = t('rules.nameRequired')
return
}
const expr = draftExpr.value.trim()
if (!expr) {
2026-07-27 08:09:11 +08:00
editError.value = t('rules.exprRequired')
return
}
draft.expr = expr
2026-07-27 08:09:11 +08:00
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 = t('rules.invalidJson')
2026-07-27 08:09:11 +08:00
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
}
2026-07-27 08:09:11 +08:00
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
}
function remove(index: number) {
rules.value = rules.value.filter((_, i) => i !== index)
dirty.value = true
removing.value = -1
}
</script>
<template>
<div>
<PageHeader :title="t('rules.title')" :description="t('rules.subtitle')">
<template #actions>
<Button variant="ghost" size="sm" :disabled="busy" @click="reload">
<RotateCcw class="size-3.5" />
<span class="hidden sm:inline">{{ t('common.refresh') }}</span>
</Button>
<Button variant="outline" size="sm" :disabled="busy" @click="validate">
<CheckCircle2 class="size-3.5" />
<span class="hidden sm:inline">{{ t('rules.validate') }}</span>
</Button>
<Button size="sm" :disabled="busy" @click="save">
<Loader2 v-if="busy" class="size-3.5 animate-spin" />
<Save v-else class="size-3.5" />
{{ t('rules.save') }}
</Button>
</template>
</PageHeader>
<div class="mb-3 flex flex-wrap items-center gap-2">
<Tabs :model-value="mode" @update:model-value="switchMode">
<TabsList>
<TabsTrigger value="visual">{{ t('rules.visual') }}</TabsTrigger>
<TabsTrigger value="yaml">
<FileCode2 />
{{ t('rules.yaml') }}
</TabsTrigger>
</TabsList>
</Tabs>
<Badge variant="secondary">{{ t('rules.count', { n: rules.length }) }}</Badge>
<Badge v-if="dirty" variant="drop">{{ t('rules.unsaved') }}</Badge>
<span
v-if="path"
class="text-muted-foreground ml-auto hidden truncate font-mono text-[11px] sm:inline"
>{{ path }}</span
>
</div>
<p
v-if="problem"
class="border-destructive/40 bg-destructive/10 text-destructive mb-3 rounded-lg border px-3 py-2 font-mono text-xs break-words whitespace-pre-wrap"
>
{{ problem }}
</p>
<Tabs :model-value="mode" @update:model-value="switchMode">
<TabsContent value="visual">
<div v-if="loading" class="text-muted-foreground py-12 text-center text-sm">
{{ t('common.loading') }}
</div>
<div v-else class="flex flex-col gap-2">
<Card
v-for="(rule, index) in rules"
:key="`${rule.name}-${index}`"
class="p-3 sm:p-4"
>
<div class="flex items-start justify-between gap-3">
<div class="flex min-w-0 flex-col gap-1.5">
<div class="flex flex-wrap items-center gap-1.5">
<span class="text-muted-foreground font-mono text-[11px] tabular-nums"
>#{{ index + 1 }}</span
>
<span class="truncate text-sm font-medium">{{ rule.name }}</span>
<Badge v-if="rule.action" :variant="badgeVariant(rule.action)" class="uppercase">
{{ t(`action.${rule.action}`) }}
</Badge>
<Badge v-if="rule.log" variant="log">{{ t('action.log') }}</Badge>
<Badge v-if="rule.modifier?.name" variant="outline">
{{ rule.modifier.name }}
</Badge>
</div>
<code
class="text-muted-foreground bg-muted/60 rounded-md px-2 py-1.5 font-mono text-[11px] leading-relaxed break-all sm:text-xs"
>{{ rule.expr }}</code
>
</div>
<div class="flex shrink-0 flex-col gap-1 sm:flex-row">
<Button
variant="ghost"
size="icon-sm"
:aria-label="t('rules.moveUp')"
:disabled="index === 0"
@click="move(index, -1)"
>
<ArrowUp class="size-3.5" />
</Button>
<Button
variant="ghost"
size="icon-sm"
:aria-label="t('rules.moveDown')"
:disabled="index === rules.length - 1"
@click="move(index, 1)"
>
<ArrowDown class="size-3.5" />
</Button>
<Button
variant="ghost"
size="icon-sm"
:aria-label="t('common.edit')"
@click="startEdit(index)"
>
<Pencil class="size-3.5" />
</Button>
<Button
variant="ghost"
size="icon-sm"
class="text-destructive"
:aria-label="t('common.delete')"
@click="removing = index"
>
<Trash2 class="size-3.5" />
</Button>
</div>
</div>
</Card>
<p v-if="!rules.length" class="text-muted-foreground py-10 text-center text-sm">
{{ t('rules.empty') }}
</p>
<Button variant="outline" class="mt-1 w-full" @click="startAdd">
<Plus class="size-4" />
{{ t('rules.add') }}
</Button>
</div>
</TabsContent>
<TabsContent value="yaml">
<Textarea
v-model="raw"
spellcheck="false"
class="h-[60vh] resize-y font-mono text-xs leading-relaxed"
@update:model-value="dirty = true"
/>
<p class="text-muted-foreground mt-2 text-xs">{{ t('rules.hint') }}</p>
</TabsContent>
</Tabs>
<!-- Rule editor -->
<Modal
:open="editing !== null"
:title="editingIndex >= 0 ? t('rules.editTitle') : t('rules.addTitle')"
class="sm:max-w-2xl"
2026-07-27 08:09:11 +08:00
@update:open="!$event && ((editing = null), (editError = ''))"
>
<!-- Submission goes through the footer button: Enter belongs to the tag inputs. -->
<form v-if="editing" class="flex flex-col gap-4" @submit.prevent>
2026-07-27 08:09:11 +08:00
<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')" />
</div>
<div class="grid gap-4 sm:grid-cols-2">
<div class="flex flex-col gap-2">
<Label>{{ t('rules.action') }}</Label>
<Select
:model-value="editing.action ?? ''"
:options="actionOptions"
@update:model-value="editing.action = $event"
/>
</div>
<div class="flex flex-col justify-end gap-2">
<Label for="rule-log">{{ t('rules.log') }}</Label>
<div class="flex h-9 items-center">
<Switch id="rule-log" v-model="editing.log" />
</div>
</div>
</div>
<div v-if="editing.action === 'modify'" class="flex flex-col gap-4">
<div class="flex flex-col gap-2">
<Label>{{ t('rules.modifier') }}</Label>
<Select
:model-value="editing.modifier?.name ?? ''"
:options="modifierOptions"
@update:model-value="
editing.modifier = { name: $event, args: editing.modifier?.args }
"
/>
</div>
<div class="flex flex-col gap-2">
<Label for="rule-args">{{ t('rules.modifierArgs') }}</Label>
<Textarea id="rule-args" v-model="modifierArgs" class="font-mono text-xs" rows="4" />
</div>
</div>
<div class="flex flex-col gap-2">
<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"
2026-07-27 08:09:11 +08:00
/>
<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>
2026-07-27 08:09:11 +08:00
</div>
<p v-if="editError" class="text-destructive font-mono text-xs break-words">
{{ editError }}
</p>
2026-07-27 08:09:11 +08:00
</form>
<template #footer>
<Button variant="outline" @click="((editing = null), (editError = ''))">
{{ t('common.cancel') }}
</Button>
<Button :disabled="checking" @click="commitEdit">
<Loader2 v-if="checking" class="size-4 animate-spin" />
{{ t('common.save') }}
</Button>
2026-07-27 08:09:11 +08:00
</template>
</Modal>
<!-- Delete confirmation -->
<Modal
:open="removing >= 0"
:title="t('common.delete')"
:description="
removing >= 0 ? t('rules.deleteConfirm', { name: rules[removing]?.name ?? '' }) : ''
"
@update:open="!$event && (removing = -1)"
>
<template #footer>
<Button variant="outline" @click="removing = -1">{{ t('common.cancel') }}</Button>
<Button variant="destructive" @click="remove(removing)">{{ t('common.delete') }}</Button>
</template>
</Modal>
</div>
</template>