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>
86 lines
2.8 KiB
Vue
86 lines
2.8 KiB
Vue
<script setup lang="ts">
|
|
import { Loader2, LockKeyhole, Moon, Sun } from 'lucide-vue-next'
|
|
import { ref } from 'vue'
|
|
import { useRoute, useRouter } from 'vue-router'
|
|
|
|
import { Button, Card, Input, Label } from '@/components/ui'
|
|
import { useTheme } from '@/composables/useTheme'
|
|
import { t, useI18n } from '@/i18n'
|
|
import { api, ApiError } from '@/lib/api'
|
|
|
|
const router = useRouter()
|
|
const route = useRoute()
|
|
const { lang, setLang } = useI18n()
|
|
const { isDark, toggle } = useTheme()
|
|
|
|
const password = ref('')
|
|
const loading = ref(false)
|
|
const error = ref('')
|
|
|
|
async function submit() {
|
|
if (loading.value) return
|
|
loading.value = true
|
|
error.value = ''
|
|
try {
|
|
await api.login(password.value)
|
|
const redirect = route.query.redirect
|
|
await router.replace(typeof redirect === 'string' ? redirect : '/')
|
|
} catch (e) {
|
|
error.value = e instanceof ApiError ? e.message : t('login.failed')
|
|
} finally {
|
|
loading.value = false
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<div class="bg-background flex min-h-dvh flex-col">
|
|
<div class="flex items-center justify-end gap-1 p-3">
|
|
<Button variant="ghost" size="sm" @click="setLang(lang === 'zh' ? 'en' : 'zh')">
|
|
{{ lang === 'zh' ? 'EN' : '中文' }}
|
|
</Button>
|
|
<Button variant="ghost" size="icon-sm" :aria-label="t('settings.theme')" @click="toggle">
|
|
<Moon v-if="isDark" class="size-4" />
|
|
<Sun v-else class="size-4" />
|
|
</Button>
|
|
</div>
|
|
|
|
<div class="flex flex-1 items-center justify-center px-4 pb-16">
|
|
<Card class="animate-slide-up w-full max-w-sm p-6">
|
|
<div class="mb-6 flex flex-col items-center text-center">
|
|
<div
|
|
class="bg-primary text-primary-foreground mb-3 flex size-11 items-center justify-center rounded-xl"
|
|
>
|
|
<LockKeyhole class="size-5" />
|
|
</div>
|
|
<h1 class="text-lg font-semibold tracking-tight">{{ t('app.name') }}</h1>
|
|
<p class="text-muted-foreground mt-1 text-sm">{{ t('login.subtitle') }}</p>
|
|
</div>
|
|
|
|
<form class="flex flex-col gap-3" @submit.prevent="submit">
|
|
<div class="flex flex-col gap-2">
|
|
<Label for="password">{{ t('login.password') }}</Label>
|
|
<Input
|
|
id="password"
|
|
v-model="password"
|
|
type="password"
|
|
autocomplete="current-password"
|
|
autofocus
|
|
:disabled="loading"
|
|
/>
|
|
</div>
|
|
|
|
<p v-if="error" class="text-destructive text-sm">{{ error }}</p>
|
|
|
|
<Button type="submit" class="mt-1 w-full" :disabled="loading || !password">
|
|
<Loader2 v-if="loading" class="size-4 animate-spin" />
|
|
{{ t('login.submit') }}
|
|
</Button>
|
|
</form>
|
|
|
|
<p class="text-muted-foreground mt-4 text-center text-xs">{{ t('login.hint') }}</p>
|
|
</Card>
|
|
</div>
|
|
</div>
|
|
</template>
|