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

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>