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>
242 lines
8.3 KiB
Vue
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>
|