Files
OpenGFW/web/frontend/src/views/AnalyzersView.vue
T
meiandClaude Opus 5 5a7722d1d2 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>
2026-07-27 08:09:11 +08:00

104 lines
3.3 KiB
Vue

<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>