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

217 lines
7.3 KiB
Vue

<script setup lang="ts">
import { Copy, Pause, Play, Search, Trash2 } from 'lucide-vue-next'
import { computed, ref, watch } from 'vue'
import ActionBadge from '@/components/ActionBadge.vue'
import EventItem from '@/components/EventItem.vue'
import PageHeader from '@/components/PageHeader.vue'
import { Badge, Button, Card, Input, Modal, Select } from '@/components/ui'
import { clearEvents, useLive } from '@/composables/useLive'
import { toast } from '@/composables/useToast'
import { t } from '@/i18n'
import { dateTime, endpoint } from '@/lib/format'
import type { LiveEvent } from '@/lib/types'
const { events } = useLive()
const paused = ref(false)
const frozen = ref<LiveEvent[]>([])
const query = ref('')
const action = ref('all')
const proto = ref('all')
const selected = ref<LiveEvent | null>(null)
watch(paused, (value) => {
frozen.value = value ? events.value : []
})
const source = computed(() => (paused.value ? frozen.value : events.value))
const pending = computed(() =>
paused.value ? Math.max(0, events.value.length - frozen.value.length) : 0,
)
const filtered = computed(() => {
const q = query.value.trim().toLowerCase()
return source.value.filter((e) => {
if (action.value !== 'all') {
if (action.value === 'log' || action.value === 'error') {
if (e.kind !== action.value) return false
} else if (e.kind !== 'action' || e.action !== action.value) {
return false
}
}
if (proto.value !== 'all' && e.proto !== proto.value) return false
if (!q) return true
return (
e.srcIP.toLowerCase().includes(q) ||
e.dstIP.toLowerCase().includes(q) ||
(e.host ?? '').toLowerCase().includes(q) ||
(e.rule ?? '').toLowerCase().includes(q)
)
})
})
const actionOptions = computed(() => [
{ value: 'all', label: t('common.all') },
{ value: 'block', label: t('action.block') },
{ value: 'drop', label: t('action.drop') },
{ value: 'allow', label: t('action.allow') },
{ value: 'modify', label: t('action.modify') },
{ value: 'log', label: t('action.log') },
{ value: 'error', label: t('action.error') },
])
const protoOptions = computed(() => [
{ value: 'all', label: t('common.all') },
{ value: 'tcp', label: 'TCP' },
{ value: 'udp', label: 'UDP' },
])
const propEntries = computed(() => Object.entries(selected.value?.props ?? {}))
async function copyDetail() {
if (!selected.value) return
try {
await navigator.clipboard.writeText(JSON.stringify(selected.value, null, 2))
toast.success(t('common.copied'))
} catch {
toast.error(t('common.error'))
}
}
function clear() {
clearEvents()
frozen.value = []
}
</script>
<template>
<div>
<PageHeader :title="t('events.title')" :description="t('events.subtitle')">
<template #actions>
<Button variant="outline" size="sm" @click="paused = !paused">
<Play v-if="paused" class="size-3.5" />
<Pause v-else class="size-3.5" />
<span class="hidden sm:inline">{{ paused ? t('events.resume') : t('events.pause') }}</span>
</Button>
<Button variant="outline" size="sm" @click="clear">
<Trash2 class="size-3.5" />
<span class="hidden sm:inline">{{ t('events.clear') }}</span>
</Button>
</template>
</PageHeader>
<div class="mb-3 flex flex-col gap-2 sm:flex-row">
<div class="relative flex-1">
<Search class="text-muted-foreground absolute top-1/2 left-3 size-4 -translate-y-1/2" />
<Input
v-model="query"
:placeholder="t('events.searchPlaceholder')"
class="pl-9"
type="search"
/>
</div>
<div class="grid grid-cols-2 gap-2 sm:flex">
<Select
v-model="action"
:options="actionOptions"
:aria-label="t('events.filterAction')"
class="sm:w-32"
/>
<Select
v-model="proto"
:options="protoOptions"
:aria-label="t('events.filterProto')"
class="sm:w-28"
/>
</div>
</div>
<div
v-if="paused"
class="text-muted-foreground mb-2 flex items-center justify-between gap-2 text-xs"
>
<Badge variant="drop">{{ t('events.paused') }}</Badge>
<span v-if="pending > 0">{{ t('events.buffered', { n: pending }) }}</span>
</div>
<Card class="px-1.5 py-1.5 sm:px-2.5">
<div v-if="filtered.length" class="flex flex-col">
<EventItem
v-for="event in filtered"
:key="event.seq"
:event="event"
@select="selected = $event"
/>
</div>
<p v-else class="text-muted-foreground py-12 text-center text-sm">
{{ source.length ? t('events.emptyFiltered') : t('events.empty') }}
</p>
</Card>
<Modal
:open="selected !== null"
:title="t('events.detail')"
@update:open="!$event && (selected = null)"
>
<div v-if="selected" class="flex flex-col gap-3 text-sm">
<div class="flex flex-wrap items-center gap-2">
<ActionBadge :event="selected" />
<Badge :variant="selected.proto === 'udp' ? 'udp' : 'tcp'" class="uppercase">
{{ selected.proto }}
</Badge>
<span class="text-muted-foreground text-xs">{{ dateTime(selected.time) }}</span>
</div>
<dl class="grid grid-cols-[auto_1fr] gap-x-4 gap-y-2 text-xs sm:text-sm">
<dt class="text-muted-foreground">{{ t('events.stream') }}</dt>
<dd class="font-mono break-all">{{ selected.id }}</dd>
<dt class="text-muted-foreground">{{ t('events.source') }}</dt>
<dd class="font-mono break-all">{{ endpoint(selected.srcIP, selected.srcPort) }}</dd>
<dt class="text-muted-foreground">{{ t('events.destination') }}</dt>
<dd class="font-mono break-all">{{ endpoint(selected.dstIP, selected.dstPort) }}</dd>
<template v-if="selected.host">
<dt class="text-muted-foreground">{{ t('events.host') }}</dt>
<dd class="font-mono break-all">{{ selected.host }}</dd>
</template>
<template v-if="selected.rule">
<dt class="text-muted-foreground">{{ t('events.rule') }}</dt>
<dd class="break-all">{{ selected.rule }}</dd>
</template>
<template v-if="selected.message">
<dt class="text-muted-foreground">{{ t('common.error') }}</dt>
<dd class="text-destructive break-all">{{ selected.message }}</dd>
</template>
</dl>
<div>
<p class="text-muted-foreground mb-1.5 text-xs font-medium">{{ t('events.props') }}</p>
<div v-if="propEntries.length" class="flex flex-col gap-2">
<div v-for="[name, value] in propEntries" :key="name" class="rounded-lg border">
<div class="border-b px-2.5 py-1.5 text-xs font-medium">{{ name }}</div>
<pre
class="overflow-x-auto px-2.5 py-2 font-mono text-[11px] leading-relaxed"
>{{ JSON.stringify(value, null, 2) }}</pre>
</div>
</div>
<p v-else class="text-muted-foreground text-xs">{{ t('events.noProps') }}</p>
</div>
</div>
<template #footer>
<Button variant="outline" @click="copyDetail">
<Copy class="size-4" />
{{ t('events.copyJson') }}
</Button>
<Button @click="selected = null">{{ t('common.close') }}</Button>
</template>
</Modal>
</div>
</template>