2026-07-27 08:09:11 +08:00
|
|
|
<script setup lang="ts">
|
|
|
|
|
import {
|
|
|
|
|
ArrowDown,
|
|
|
|
|
ArrowUp,
|
|
|
|
|
CheckCircle2,
|
|
|
|
|
FileCode2,
|
|
|
|
|
Loader2,
|
|
|
|
|
Pencil,
|
|
|
|
|
Plus,
|
|
|
|
|
RotateCcw,
|
|
|
|
|
Save,
|
|
|
|
|
Trash2,
|
2026-07-27 08:48:56 +08:00
|
|
|
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'
|
2026-07-27 08:48:56 +08:00
|
|
|
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'
|
2026-07-27 08:48:56 +08:00
|
|
|
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)
|
|
|
|
|
|
2026-07-27 08:48:56 +08:00
|
|
|
// 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 = '{}'
|
2026-07-27 08:48:56 +08:00
|
|
|
builder.value = emptyBuilderState()
|
|
|
|
|
builderUnavailable.value = false
|
|
|
|
|
editorTab.value = 'builder'
|
|
|
|
|
editError.value = ''
|
2026-07-27 08:09:11 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function startEdit(index: number) {
|
2026-07-27 08:48:56 +08:00
|
|
|
const rule = rules.value[index]
|
2026-07-27 08:09:11 +08:00
|
|
|
editingIndex.value = index
|
2026-07-27 08:48:56 +08:00
|
|
|
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('')
|
|
|
|
|
|
2026-07-27 08:48:56 +08:00
|
|
|
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
|
2026-07-27 08:48:56 +08:00
|
|
|
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
|
|
|
|
|
}
|
2026-07-27 08:48:56 +08:00
|
|
|
const expr = draftExpr.value.trim()
|
|
|
|
|
if (!expr) {
|
2026-07-27 08:09:11 +08:00
|
|
|
editError.value = t('rules.exprRequired')
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-07-27 08:48:56 +08:00
|
|
|
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 {
|
2026-07-27 08:48:56 +08:00
|
|
|
editError.value = t('rules.invalidJson')
|
2026-07-27 08:09:11 +08:00
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
draft.modifier = null
|
|
|
|
|
}
|
2026-07-27 08:48:56 +08:00
|
|
|
|
|
|
|
|
// 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')"
|
2026-07-27 08:48:56 +08:00
|
|
|
class="sm:max-w-2xl"
|
2026-07-27 08:09:11 +08:00
|
|
|
@update:open="!$event && ((editing = null), (editError = ''))"
|
|
|
|
|
>
|
2026-07-27 08:48:56 +08:00
|
|
|
<!-- 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">
|
2026-07-27 08:48:56 +08:00
|
|
|
<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
|
|
|
/>
|
2026-07-27 08:48:56 +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>
|
|
|
|
|
|
2026-07-27 08:48:56 +08:00
|
|
|
<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>
|
2026-07-27 08:48:56 +08:00
|
|
|
<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>
|