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,103 @@
|
||||
<script setup lang="ts">
|
||||
import { Boxes, FunctionSquare } from 'lucide-vue-next'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
|
||||
import PageHeader from '@/components/PageHeader.vue'
|
||||
import {
|
||||
Badge,
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
Skeleton,
|
||||
} from '@/components/ui'
|
||||
import { useLive } from '@/composables/useLive'
|
||||
import { t } from '@/i18n'
|
||||
import { api } from '@/lib/api'
|
||||
import { compact } from '@/lib/format'
|
||||
import type { MetaInfo } from '@/lib/types'
|
||||
|
||||
const { metrics } = useLive()
|
||||
const meta = ref<MetaInfo | null>(null)
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
meta.value = await api.meta()
|
||||
} catch {
|
||||
meta.value = { analyzers: [], modifiers: [], actions: [], functions: [] }
|
||||
}
|
||||
})
|
||||
|
||||
const hits = computed(() => {
|
||||
const map = new Map<string, number>()
|
||||
for (const item of metrics.value?.analyzers ?? []) map.set(item.name, item.count)
|
||||
return map
|
||||
})
|
||||
|
||||
const analyzers = computed(() =>
|
||||
[...(meta.value?.analyzers ?? [])].sort(
|
||||
(a, b) => (hits.value.get(b.name) ?? 0) - (hits.value.get(a.name) ?? 0),
|
||||
),
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<PageHeader :title="t('analyzers.title')" :description="t('analyzers.subtitle')" />
|
||||
|
||||
<div v-if="!meta" class="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4">
|
||||
<Skeleton v-for="i in 8" :key="i" class="h-24" />
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<div class="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4">
|
||||
<Card v-for="analyzer in analyzers" :key="analyzer.name" class="p-3.5 sm:p-4">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<span class="truncate font-mono text-sm font-medium">{{ analyzer.name }}</span>
|
||||
<Badge :variant="analyzer.proto === 'udp' ? 'udp' : 'tcp'" class="uppercase">
|
||||
{{ analyzer.proto }}
|
||||
</Badge>
|
||||
</div>
|
||||
<p class="mt-2 text-xl font-semibold tabular-nums">
|
||||
{{ compact(hits.get(analyzer.name) ?? 0) }}
|
||||
</p>
|
||||
<p class="text-muted-foreground text-xs">{{ t('analyzers.hits') }}</p>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div class="mt-3 grid gap-3 md:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle class="flex items-center gap-2">
|
||||
<Boxes class="size-4" />
|
||||
{{ t('analyzers.modifiers') }}
|
||||
</CardTitle>
|
||||
<CardDescription>{{ t('analyzers.modifiersDesc') }}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent class="flex flex-wrap gap-1.5">
|
||||
<Badge v-for="name in meta.modifiers" :key="name" variant="secondary" class="font-mono">
|
||||
{{ name }}
|
||||
</Badge>
|
||||
<span v-if="!meta.modifiers.length" class="text-muted-foreground text-xs">—</span>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle class="flex items-center gap-2">
|
||||
<FunctionSquare class="size-4" />
|
||||
{{ t('analyzers.functions') }}
|
||||
</CardTitle>
|
||||
<CardDescription>{{ t('analyzers.functionsDesc') }}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent class="flex flex-wrap gap-1.5">
|
||||
<Badge v-for="name in meta.functions" :key="name" variant="secondary" class="font-mono">
|
||||
{{ name }}()
|
||||
</Badge>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
Reference in New Issue
Block a user