452 lines
14 KiB
Vue
452 lines
14 KiB
Vue
<script setup lang="ts">
|
|||
|
|
import {
|
||
|
|
ArrowDown,
|
||
|
|
ArrowUp,
|
||
|
|
CheckCircle2,
|
||
|
|
FileCode2,
|
||
|
|
Loader2,
|
||
|
|
Pencil,
|
||
|
|
Plus,
|
||
|
|
RotateCcw,
|
||
|
|
Save,
|
||
|
|
Trash2,
|
||
|
|
} from 'lucide-vue-next'
|
||
|
|
import { computed, onMounted, ref } from 'vue'
|
||
|
|
|
||
|
|
import PageHeader from '@/components/PageHeader.vue'
|
||
|
|
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 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)
|
||
|
|
|
||
|
|
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 = '{}'
|
||
|
|
}
|
||
|
|
|
||
|
|
function startEdit(index: number) {
|
||
|
|
editingIndex.value = index
|
||
|
|
editing.value = { ...rules.value[index] }
|
||
|
|
modifierArgs.value = JSON.stringify(rules.value[index].modifier?.args ?? {}, null, 2)
|
||
|
|
}
|
||
|
|
|
||
|
|
const modifierArgs = ref('{}')
|
||
|
|
const editError = ref('')
|
||
|
|
|
||
|
|
function commitEdit() {
|
||
|
|
const draft = editing.value
|
||
|
|
if (!draft) return
|
||
|
|
if (!draft.name.trim()) {
|
||
|
|
editError.value = t('rules.nameRequired')
|
||
|
|
return
|
||
|
|
}
|
||
|
|
if (!draft.expr.trim()) {
|
||
|
|
editError.value = t('rules.exprRequired')
|
||
|
|
return
|
||
|
|
}
|
||
|
|
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'
|
||
|
|
return
|
||
|
|
}
|
||
|
|
} else {
|
||
|
|
draft.modifier = null
|
||
|
|
}
|
||
|
|
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) {
|
||
|
|
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')"
|
||
|
|
@update:open="!$event && ((editing = null), (editError = ''))"
|
||
|
|
>
|
||
|
|
<form v-if="editing" class="flex flex-col gap-4" @submit.prevent="commitEdit">
|
||
|
|
<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">
|
||
|
|
<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>
|
||
|
|
|
||
|
|
<p v-if="editError" class="text-destructive text-sm">{{ editError }}</p>
|
||
|
|
</form>
|
||
|
|
|
||
|
|
<template #footer>
|
||
|
|
<Button variant="outline" @click="((editing = null), (editError = ''))">
|
||
|
|
{{ t('common.cancel') }}
|
||
|
|
</Button>
|
||
|
|
<Button @click="commitEdit">{{ t('common.save') }}</Button>
|
||
|
|
</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>
|