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:
mei
2026-07-27 08:09:11 +08:00
co-authored by Claude Opus 5
parent 393c29bd2d
commit 5a7722d1d2
94 changed files with 8534 additions and 23 deletions
+451
View File
@@ -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>