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,241 @@
|
||||
<script setup lang="ts">
|
||||
import { ArrowRight, Ban, Clock, Cpu, ShieldCheck, TriangleAlert, Waves } from 'lucide-vue-next'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import BarList from '@/components/BarList.vue'
|
||||
import EventItem from '@/components/EventItem.vue'
|
||||
import PageHeader from '@/components/PageHeader.vue'
|
||||
import StatCard from '@/components/StatCard.vue'
|
||||
import TrafficChart from '@/components/TrafficChart.vue'
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
Skeleton,
|
||||
Tabs,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
} from '@/components/ui'
|
||||
import { useLive } from '@/composables/useLive'
|
||||
import { t } from '@/i18n'
|
||||
import { compact, duration, percent } from '@/lib/format'
|
||||
|
||||
const router = useRouter()
|
||||
const { metrics, events } = useLive()
|
||||
|
||||
const range = ref('short')
|
||||
const buckets = computed(
|
||||
() => (range.value === 'short' ? metrics.value?.short : metrics.value?.long) ?? [],
|
||||
)
|
||||
|
||||
const recent = computed(() => events.value.slice(0, 8))
|
||||
|
||||
const verdicts = computed(() => {
|
||||
const a = metrics.value?.actions
|
||||
if (!a) return []
|
||||
const total = a.allow + a.block + a.drop + a.modify || 1
|
||||
return [
|
||||
{ key: 'allow', label: t('action.allow'), value: a.allow, color: 'bg-allow', total },
|
||||
{ key: 'block', label: t('action.block'), value: a.block, color: 'bg-block', total },
|
||||
{ key: 'drop', label: t('action.drop'), value: a.drop, color: 'bg-drop', total },
|
||||
{ key: 'modify', label: t('action.modify'), value: a.modify, color: 'bg-modify', total },
|
||||
].filter((v) => v.value > 0 || v.key === 'allow' || v.key === 'block')
|
||||
})
|
||||
|
||||
const protocols = computed(() => {
|
||||
const s = metrics.value?.streams
|
||||
if (!s) return []
|
||||
const total = s.total || 1
|
||||
return [
|
||||
{ key: 'tcp', label: t('dashboard.tcp'), value: s.tcp, color: 'bg-tcp', total },
|
||||
{ key: 'udp', label: t('dashboard.udp'), value: s.udp, color: 'bg-udp', total },
|
||||
]
|
||||
})
|
||||
|
||||
const blockedTotal = computed(() => {
|
||||
const a = metrics.value?.actions
|
||||
return a ? a.block + a.drop : 0
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<PageHeader :title="t('dashboard.title')" :description="t('dashboard.subtitle')" />
|
||||
|
||||
<div v-if="!metrics" class="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-6">
|
||||
<Skeleton v-for="i in 6" :key="i" class="h-20" />
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<div class="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-6">
|
||||
<StatCard
|
||||
:label="t('dashboard.streams')"
|
||||
:value="compact(metrics.streams.total)"
|
||||
:hint="`${compact(metrics.streams.tcp)} / ${compact(metrics.streams.udp)}`"
|
||||
:icon="Waves"
|
||||
/>
|
||||
<StatCard
|
||||
:label="t('dashboard.blocked')"
|
||||
:value="compact(blockedTotal)"
|
||||
:hint="`${percent(blockedTotal, metrics.streams.total)}%`"
|
||||
:icon="Ban"
|
||||
tone="block"
|
||||
/>
|
||||
<StatCard
|
||||
:label="t('dashboard.allowed')"
|
||||
:value="compact(metrics.actions.allow)"
|
||||
:icon="ShieldCheck"
|
||||
tone="allow"
|
||||
/>
|
||||
<StatCard
|
||||
:label="t('dashboard.modified')"
|
||||
:value="compact(metrics.actions.modify)"
|
||||
:icon="Waves"
|
||||
tone="modify"
|
||||
/>
|
||||
<StatCard
|
||||
:label="t('dashboard.uptime')"
|
||||
:value="duration(metrics.uptimeSeconds)"
|
||||
:icon="Clock"
|
||||
/>
|
||||
<StatCard
|
||||
:label="metrics.errors > 0 ? t('dashboard.errors') : t('dashboard.workers')"
|
||||
:value="metrics.errors > 0 ? compact(metrics.errors) : String(metrics.workers)"
|
||||
:icon="metrics.errors > 0 ? TriangleAlert : Cpu"
|
||||
:tone="metrics.errors > 0 ? 'drop' : 'default'"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="mt-3 grid gap-3 lg:grid-cols-3">
|
||||
<Card class="lg:col-span-2">
|
||||
<CardHeader class="flex-row items-start justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<CardTitle>{{ t('dashboard.traffic') }}</CardTitle>
|
||||
<CardDescription>{{ t('dashboard.trafficDesc') }}</CardDescription>
|
||||
</div>
|
||||
<Tabs v-model="range" class="shrink-0">
|
||||
<TabsList class="h-8">
|
||||
<TabsTrigger value="short" class="text-xs">{{ t('dashboard.range10m') }}</TabsTrigger>
|
||||
<TabsTrigger value="long" class="text-xs">{{ t('dashboard.range1h') }}</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<TrafficChart :buckets="buckets" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{{ t('dashboard.verdicts') }}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent class="flex flex-col gap-4">
|
||||
<div>
|
||||
<div class="bg-muted flex h-2 overflow-hidden rounded-full">
|
||||
<div
|
||||
v-for="v in verdicts"
|
||||
:key="v.key"
|
||||
:class="v.color"
|
||||
:style="{ width: `${(v.value / v.total) * 100}%` }"
|
||||
/>
|
||||
</div>
|
||||
<div class="mt-2.5 flex flex-col gap-1.5">
|
||||
<div
|
||||
v-for="v in verdicts"
|
||||
:key="v.key"
|
||||
class="flex items-center gap-2 text-xs sm:text-[13px]"
|
||||
>
|
||||
<span :class="['size-2 rounded-full', v.color]" />
|
||||
<span class="text-muted-foreground">{{ v.label }}</span>
|
||||
<span class="ml-auto font-medium tabular-nums">{{ compact(v.value) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p class="text-muted-foreground mb-2 text-xs font-medium">
|
||||
{{ t('dashboard.protocols') }}
|
||||
</p>
|
||||
<div class="bg-muted flex h-2 overflow-hidden rounded-full">
|
||||
<div
|
||||
v-for="p in protocols"
|
||||
:key="p.key"
|
||||
:class="p.color"
|
||||
:style="{ width: `${(p.value / p.total) * 100}%` }"
|
||||
/>
|
||||
</div>
|
||||
<div class="mt-2.5 flex flex-col gap-1.5">
|
||||
<div
|
||||
v-for="p in protocols"
|
||||
:key="p.key"
|
||||
class="flex items-center gap-2 text-xs sm:text-[13px]"
|
||||
>
|
||||
<span :class="['size-2 rounded-full', p.color]" />
|
||||
<span class="text-muted-foreground">{{ p.label }}</span>
|
||||
<span class="ml-auto font-medium tabular-nums">{{ compact(p.value) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div class="mt-3 grid gap-3 md:grid-cols-2 xl:grid-cols-3">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{{ t('dashboard.topHosts') }}</CardTitle>
|
||||
<CardDescription>{{ t('dashboard.topHostsDesc') }}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<BarList :items="metrics.topHosts" :empty="t('common.empty')" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{{ t('dashboard.topBlocked') }}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<BarList :items="metrics.topBlockedIPs" tone="block" :empty="t('common.empty')" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card class="md:col-span-2 xl:col-span-1">
|
||||
<CardHeader>
|
||||
<CardTitle>{{ t('dashboard.topRules') }}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<BarList :items="metrics.topRules" tone="allow" :empty="t('common.empty')" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card class="mt-3">
|
||||
<CardHeader class="flex-row items-center justify-between">
|
||||
<CardTitle>{{ t('dashboard.recent') }}</CardTitle>
|
||||
<Button variant="ghost" size="sm" @click="router.push('/events')">
|
||||
{{ t('dashboard.viewAll') }}
|
||||
<ArrowRight class="size-3.5" />
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent class="px-1.5 sm:px-2.5">
|
||||
<div v-if="recent.length" class="flex flex-col">
|
||||
<EventItem
|
||||
v-for="event in recent"
|
||||
:key="event.seq"
|
||||
:event="event"
|
||||
@select="router.push('/events')"
|
||||
/>
|
||||
</div>
|
||||
<p v-else class="text-muted-foreground py-6 text-center text-xs">
|
||||
{{ t('events.empty') }}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
Reference in New Issue
Block a user