feat: add embedded web UI
Adds an optional web dashboard served by OpenGFW itself, enabled with a new `web` section in the config file. Backend (web package, decoupled from engine/io so it builds on any OS): - hub.go collects statistics off the engine logger callbacks: atomic counters, two ring-buffered time series (10s and 1min buckets), top N hosts/blocked destinations/rules/analyzers, and a 512 entry event buffer fanned out to connected clients over SSE. Slow clients drop frames instead of blocking the engine. - api.go exposes /api/v1 for info, meta, metrics, events, the SSE stream and ruleset read/validate/replace. - auth.go implements password login with in-memory session tokens and login rate limiting. Mutating endpoints require the bearer token (the session cookie is only accepted for GET), which makes them CSRF-safe. - cmd/web.go implements the rule manager: rules are compiled before anything is written, the file is replaced atomically and the engine is hot reloaded. The SIGHUP handler now shares that same path. - web/devserver serves the UI with synthetic traffic for frontend work on machines where the engine itself cannot be built. Frontend (web/frontend, Vue 3 + Vite + Tailwind CSS v4 + Reka UI): dashboard, live event feed with analyzer property inspection, visual and YAML rule editors, analyzer overview and settings. Responsive down to phone sizes with a bottom tab bar and bottom-sheet dialogs, plus light/dark themes and English/Chinese translations. The built UI in web/dist is committed and embedded with go:embed so that `go build` works without Node; CI builds the frontend and checks that the committed output is up to date. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,451 @@
|
||||
<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>
|
||||
Reference in New Issue
Block a user