Files
OpenGFW/web/frontend/src/views/DashboardView.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

242 lines
8.3 KiB
Vue

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