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>
217 lines
7.3 KiB
Vue
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>
|