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>
This commit is contained in:
mei
2026-07-27 08:09:11 +08:00
co-authored by Claude Opus 5
parent 393c29bd2d
commit 5a7722d1d2
94 changed files with 8534 additions and 23 deletions
+32
View File
@@ -0,0 +1,32 @@
<!doctype html>
<html lang="en" class="dark">
<head>
<meta charset="UTF-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=1, viewport-fit=cover, maximum-scale=5"
/>
<meta name="theme-color" content="#09090b" />
<meta name="color-scheme" content="light dark" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="mobile-web-app-capable" content="yes" />
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<title>OpenGFW</title>
<script>
// Apply the stored theme before first paint to avoid a flash.
;(function () {
try {
var t = localStorage.getItem('opengfw.theme') || 'system'
var dark =
t === 'dark' ||
(t === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches)
document.documentElement.classList.toggle('dark', dark)
} catch (e) {}
})()
</script>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+2501
View File
File diff suppressed because it is too large Load Diff
+31
View File
@@ -0,0 +1,31 @@
{
"name": "opengfw-web",
"version": "1.0.0",
"private": true,
"type": "module",
"description": "Web UI for OpenGFW",
"scripts": {
"dev": "vite",
"build": "vue-tsc -b && vite build",
"preview": "vite preview",
"typecheck": "vue-tsc -b --noEmit"
},
"dependencies": {
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-vue-next": "^0.469.0",
"reka-ui": "^2.5.0",
"tailwind-merge": "^3.3.1",
"vue": "^3.5.13",
"vue-router": "^4.5.0"
},
"devDependencies": {
"@tailwindcss/vite": "^4.1.11",
"@types/node": "^22.10.5",
"@vitejs/plugin-vue": "^5.2.1",
"tailwindcss": "^4.1.11",
"typescript": "~5.7.3",
"vite": "^6.0.7",
"vue-tsc": "^2.2.0"
}
}
+11
View File
@@ -0,0 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect width="32" height="32" rx="8" fill="#18181b" />
<path
d="M16 5l9 3.5v7.2c0 5.3-3.6 9.9-9 11.3-5.4-1.4-9-6-9-11.3V8.5L16 5z"
fill="none"
stroke="#a1a1aa"
stroke-width="2"
stroke-linejoin="round"
/>
<path d="M11 16h10M16 11v10" stroke="#ef4444" stroke-width="2" stroke-linecap="round" />
</svg>

After

Width:  |  Height:  |  Size: 388 B

+10
View File
@@ -0,0 +1,10 @@
<script setup lang="ts">
import { RouterView } from 'vue-router'
import Toaster from '@/components/Toaster.vue'
</script>
<template>
<RouterView />
<Toaster />
</template>
+208
View File
@@ -0,0 +1,208 @@
@import 'tailwindcss';
@custom-variant dark (&:is(.dark *));
:root {
--radius: 0.65rem;
--background: oklch(1 0 0);
--foreground: oklch(0.141 0.005 285.823);
--card: oklch(1 0 0);
--card-foreground: oklch(0.141 0.005 285.823);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.141 0.005 285.823);
--primary: oklch(0.21 0.006 285.885);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.967 0.001 286.375);
--secondary-foreground: oklch(0.21 0.006 285.885);
--muted: oklch(0.967 0.001 286.375);
--muted-foreground: oklch(0.552 0.016 285.938);
--accent: oklch(0.967 0.001 286.375);
--accent-foreground: oklch(0.21 0.006 285.885);
--destructive: oklch(0.577 0.245 27.325);
--destructive-foreground: oklch(0.985 0 0);
--border: oklch(0.92 0.004 286.32);
--input: oklch(0.92 0.004 286.32);
--ring: oklch(0.705 0.015 286.067);
/* Verdict colors, shared by badges, charts and the live feed. */
--allow: oklch(0.63 0.14 155);
--block: oklch(0.58 0.22 27);
--drop: oklch(0.72 0.16 70);
--modify: oklch(0.6 0.16 255);
--log: oklch(0.62 0.11 215);
--tcp: oklch(0.62 0.15 265);
--udp: oklch(0.68 0.13 190);
}
.dark {
--background: oklch(0.141 0.005 285.823);
--foreground: oklch(0.985 0 0);
--card: oklch(0.19 0.006 285.885);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.21 0.006 285.885);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.92 0.004 286.32);
--primary-foreground: oklch(0.21 0.006 285.885);
--secondary: oklch(0.274 0.006 286.033);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.274 0.006 286.033);
--muted-foreground: oklch(0.705 0.015 286.067);
--accent: oklch(0.274 0.006 286.033);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--destructive-foreground: oklch(0.985 0 0);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.552 0.016 285.938);
--allow: oklch(0.72 0.16 155);
--block: oklch(0.68 0.2 22);
--drop: oklch(0.79 0.15 75);
--modify: oklch(0.7 0.15 255);
--log: oklch(0.72 0.11 215);
--tcp: oklch(0.7 0.15 265);
--udp: oklch(0.76 0.12 190);
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-allow: var(--allow);
--color-block: var(--block);
--color-drop: var(--drop);
--color-modify: var(--modify);
--color-log: var(--log);
--color-tcp: var(--tcp);
--color-udp: var(--udp);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--font-mono:
ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, 'Liberation Mono', monospace;
--animate-fade-in: fade-in 0.18s ease-out;
--animate-slide-up: slide-up 0.22s cubic-bezier(0.32, 0.72, 0, 1);
--animate-slide-in-right: slide-in-right 0.24s cubic-bezier(0.32, 0.72, 0, 1);
--animate-slide-in-left: slide-in-left 0.24s cubic-bezier(0.32, 0.72, 0, 1);
--animate-pulse-dot: pulse-dot 1.8s ease-in-out infinite;
}
@keyframes fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes slide-up {
from {
opacity: 0;
transform: translateY(8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes slide-in-right {
from {
transform: translateX(100%);
}
to {
transform: translateX(0);
}
}
@keyframes slide-in-left {
from {
transform: translateX(-100%);
}
to {
transform: translateX(0);
}
}
@keyframes pulse-dot {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.35;
}
}
@layer base {
* {
border-color: var(--color-border);
}
html {
-webkit-text-size-adjust: 100%;
-webkit-tap-highlight-color: transparent;
}
body {
background-color: var(--color-background);
color: var(--color-foreground);
font-family:
ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue',
'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif;
font-feature-settings: 'cv02', 'cv03', 'cv04', 'cv11';
overscroll-behavior-y: none;
}
::-webkit-scrollbar {
width: 10px;
height: 10px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: color-mix(in oklab, var(--color-muted-foreground) 35%, transparent);
border: 3px solid transparent;
background-clip: content-box;
border-radius: 999px;
}
::-webkit-scrollbar-thumb:hover {
background: color-mix(in oklab, var(--color-muted-foreground) 55%, transparent);
background-clip: content-box;
}
}
@utility safe-bottom {
padding-bottom: max(env(safe-area-inset-bottom), 0px);
}
@utility safe-top {
padding-top: max(env(safe-area-inset-top), 0px);
}
@@ -0,0 +1,36 @@
<script setup lang="ts">
import { computed } from 'vue'
import { Badge } from '@/components/ui'
import { t } from '@/i18n'
import type { LiveEvent } from '@/lib/types'
const props = defineProps<{ event: LiveEvent }>()
const variant = computed(() => {
if (props.event.kind === 'error') return 'block'
if (props.event.kind === 'log') return 'log'
switch (props.event.action) {
case 'allow':
return 'allow'
case 'block':
return 'block'
case 'drop':
return 'drop'
case 'modify':
return 'modify'
default:
return 'muted'
}
})
const label = computed(() => {
if (props.event.kind === 'error') return t('action.error')
if (props.event.kind === 'log') return t('action.log')
return t(`action.${props.event.action ?? 'maybe'}`)
})
</script>
<template>
<Badge :variant="variant" class="uppercase">{{ label }}</Badge>
</template>
+117
View File
@@ -0,0 +1,117 @@
<script setup lang="ts">
import { Activity, LayoutDashboard, ListFilter, Radio, Settings2 } from 'lucide-vue-next'
import { computed, onMounted, onUnmounted } from 'vue'
import { RouterLink, RouterView, useRoute } from 'vue-router'
import StatusPill from '@/components/StatusPill.vue'
import { startLive, stopLive } from '@/composables/useLive'
import { useTheme } from '@/composables/useTheme'
import { t } from '@/i18n'
import { cn } from '@/lib/utils'
const route = useRoute()
useTheme()
const items = computed(() => [
{ to: '/', label: t('nav.dashboard'), icon: LayoutDashboard },
{ to: '/events', label: t('nav.events'), icon: Radio },
{ to: '/rules', label: t('nav.rules'), icon: ListFilter },
{ to: '/analyzers', label: t('nav.analyzers'), icon: Activity },
{ to: '/settings', label: t('nav.settings'), icon: Settings2 },
])
const title = computed(() => {
const item = items.value.find((i) => i.to === route.path)
return item?.label ?? t('app.name')
})
onMounted(startLive)
onUnmounted(stopLive)
</script>
<template>
<div class="bg-background min-h-dvh">
<!-- Desktop sidebar -->
<aside
class="bg-card/40 fixed inset-y-0 left-0 z-40 hidden w-56 flex-col border-r px-3 py-4 md:flex lg:w-60"
>
<div class="flex items-center gap-2 px-2 pb-4">
<div
class="bg-primary text-primary-foreground flex size-8 items-center justify-center rounded-lg font-bold"
>
G
</div>
<div class="min-w-0">
<p class="truncate text-sm font-semibold">{{ t('app.name') }}</p>
<p class="text-muted-foreground truncate text-[11px]">{{ t('app.tagline') }}</p>
</div>
</div>
<nav class="flex flex-1 flex-col gap-0.5">
<RouterLink
v-for="item in items"
:key="item.to"
:to="item.to"
:class="
cn(
'flex items-center gap-2.5 rounded-lg px-2.5 py-2 text-sm font-medium transition-colors',
route.path === item.to
? 'bg-accent text-accent-foreground'
: 'text-muted-foreground hover:bg-accent/50 hover:text-foreground',
)
"
>
<component :is="item.icon" class="size-4 shrink-0" />
{{ item.label }}
</RouterLink>
</nav>
<div class="px-2 pt-3">
<StatusPill />
</div>
</aside>
<div class="flex min-h-dvh flex-col md:pl-56 lg:pl-60">
<!-- Mobile top bar -->
<header
class="bg-background/85 safe-top sticky top-0 z-30 border-b backdrop-blur-md md:hidden"
>
<div class="flex h-12 items-center justify-between gap-3 px-4">
<span class="truncate text-sm font-semibold">{{ title }}</span>
<StatusPill />
</div>
</header>
<main class="flex-1 px-4 pt-4 pb-24 sm:px-6 sm:pt-6 md:pb-8">
<RouterView v-slot="{ Component }">
<component :is="Component" class="animate-fade-in" />
</RouterView>
</main>
</div>
<!-- Mobile bottom navigation -->
<nav
class="bg-background/90 safe-bottom fixed inset-x-0 bottom-0 z-40 border-t backdrop-blur-md md:hidden"
>
<div class="grid grid-cols-5">
<RouterLink
v-for="item in items"
:key="item.to"
:to="item.to"
:class="
cn(
'flex flex-col items-center gap-0.5 py-2 text-[10px] font-medium transition-colors',
route.path === item.to ? 'text-foreground' : 'text-muted-foreground',
)
"
>
<component
:is="item.icon"
:class="cn('size-5', route.path === item.to && 'text-primary')"
/>
<span class="truncate">{{ item.label }}</span>
</RouterLink>
</div>
</nav>
</div>
</template>
+42
View File
@@ -0,0 +1,42 @@
<script setup lang="ts">
import { computed } from 'vue'
import { compact } from '@/lib/format'
import type { NameCount } from '@/lib/types'
import { cn } from '@/lib/utils'
const props = withDefaults(
defineProps<{ items: NameCount[]; tone?: 'primary' | 'block' | 'allow'; empty?: string }>(),
{ tone: 'primary' },
)
const max = computed(() => Math.max(1, ...props.items.map((i) => i.count)))
const bars: Record<string, string> = {
primary: 'bg-primary/15',
block: 'bg-block/15',
allow: 'bg-allow/15',
}
</script>
<template>
<div v-if="props.items.length" class="flex flex-col gap-1">
<div
v-for="item in props.items"
:key="item.name"
class="relative flex items-center justify-between gap-3 overflow-hidden rounded-md px-2 py-1.5"
>
<div
:class="cn('absolute inset-y-0 left-0 rounded-md', bars[props.tone])"
:style="{ width: `${Math.max(4, (item.count / max) * 100)}%` }"
/>
<span class="relative truncate font-mono text-xs sm:text-[13px]">{{ item.name }}</span>
<span class="text-muted-foreground relative shrink-0 text-xs tabular-nums">{{
compact(item.count)
}}</span>
</div>
</div>
<p v-else class="text-muted-foreground py-6 text-center text-xs">
{{ props.empty ?? '—' }}
</p>
</template>
+46
View File
@@ -0,0 +1,46 @@
<script setup lang="ts">
import { ArrowRight } from 'lucide-vue-next'
import ActionBadge from '@/components/ActionBadge.vue'
import { Badge } from '@/components/ui'
import { endpoint, time } from '@/lib/format'
import type { LiveEvent } from '@/lib/types'
const props = defineProps<{ event: LiveEvent }>()
defineEmits<{ select: [event: LiveEvent] }>()
</script>
<template>
<button
type="button"
class="hover:bg-accent/60 focus-visible:ring-ring/40 flex w-full flex-col gap-1.5 rounded-lg px-2.5 py-2 text-left transition-colors outline-none focus-visible:ring-[3px] sm:flex-row sm:items-center sm:gap-3"
@click="$emit('select', props.event)"
>
<div class="flex items-center gap-2">
<span class="text-muted-foreground shrink-0 font-mono text-[11px] tabular-nums">
{{ time(props.event.time) }}
</span>
<ActionBadge :event="props.event" />
<Badge :variant="props.event.proto === 'udp' ? 'udp' : 'tcp'" class="uppercase">
{{ props.event.proto }}
</Badge>
<Badge v-if="props.event.rule" variant="outline" class="max-w-32 truncate sm:hidden">
{{ props.event.rule }}
</Badge>
</div>
<div class="flex min-w-0 flex-1 items-center gap-1.5 font-mono text-xs">
<span class="text-muted-foreground truncate">
{{ endpoint(props.event.srcIP, props.event.srcPort) }}
</span>
<ArrowRight class="text-muted-foreground/60 size-3 shrink-0" />
<span class="truncate">
{{ props.event.host || endpoint(props.event.dstIP, props.event.dstPort) }}
</span>
</div>
<Badge v-if="props.event.rule" variant="outline" class="hidden max-w-40 truncate sm:inline-flex">
{{ props.event.rule }}
</Badge>
</button>
</template>
@@ -0,0 +1,17 @@
<script setup lang="ts">
const props = defineProps<{ title: string; description?: string }>()
</script>
<template>
<div class="mb-4 flex flex-wrap items-end justify-between gap-3 sm:mb-6">
<div class="min-w-0">
<h1 class="text-lg font-semibold tracking-tight sm:text-2xl">{{ props.title }}</h1>
<p v-if="props.description" class="text-muted-foreground mt-0.5 text-xs sm:text-sm">
{{ props.description }}
</p>
</div>
<div v-if="$slots.actions" class="flex shrink-0 items-center gap-2">
<slot name="actions" />
</div>
</div>
</template>
+49
View File
@@ -0,0 +1,49 @@
<script setup lang="ts">
import type { Component } from 'vue'
import { Card } from '@/components/ui'
import { cn } from '@/lib/utils'
const props = defineProps<{
label: string
value: string
hint?: string
icon?: Component
tone?: 'default' | 'allow' | 'block' | 'drop' | 'modify'
}>()
const tones: Record<string, string> = {
default: 'text-muted-foreground',
allow: 'text-allow',
block: 'text-block',
drop: 'text-drop',
modify: 'text-modify',
}
</script>
<template>
<Card class="p-3.5 sm:p-4">
<div class="flex items-start justify-between gap-2">
<span class="text-muted-foreground text-xs font-medium sm:text-sm">{{ props.label }}</span>
<component
:is="props.icon"
v-if="props.icon"
:class="cn('size-4 shrink-0', tones[props.tone ?? 'default'])"
/>
</div>
<div class="mt-1.5 flex items-baseline gap-1.5">
<span
:class="
cn(
'text-xl font-semibold tracking-tight tabular-nums sm:text-2xl',
props.tone && props.tone !== 'default' ? tones[props.tone] : '',
)
"
>{{ props.value }}</span
>
<span v-if="props.hint" class="text-muted-foreground truncate text-xs">{{
props.hint
}}</span>
</div>
</Card>
</template>
@@ -0,0 +1,49 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useLive } from '@/composables/useLive'
import { t } from '@/i18n'
import { cn } from '@/lib/utils'
const { status } = useLive()
const label = computed(() => {
switch (status.value) {
case 'open':
return t('common.live')
case 'connecting':
return t('common.connecting')
default:
return t('common.offline')
}
})
</script>
<template>
<span
:class="
cn(
'inline-flex items-center gap-1.5 rounded-full border px-2 py-0.5 text-[11px] font-medium',
status === 'open'
? 'border-allow/30 bg-allow/10 text-allow'
: status === 'connecting'
? 'border-drop/30 bg-drop/10 text-drop'
: 'border-block/30 bg-block/10 text-block',
)
"
>
<span
:class="
cn(
'size-1.5 rounded-full',
status === 'open'
? 'bg-allow animate-pulse-dot'
: status === 'connecting'
? 'bg-drop animate-pulse-dot'
: 'bg-block',
)
"
/>
{{ label }}
</span>
</template>
+52
View File
@@ -0,0 +1,52 @@
<script setup lang="ts">
import { CheckCircle2, TriangleAlert, X } from 'lucide-vue-next'
import { dismiss, useToast } from '@/composables/useToast'
import { cn } from '@/lib/utils'
const { toasts } = useToast()
</script>
<template>
<div
class="pointer-events-none fixed inset-x-0 top-0 z-100 flex flex-col items-center gap-2 p-3 sm:top-auto sm:right-0 sm:bottom-0 sm:left-auto sm:items-end sm:p-4"
>
<TransitionGroup
enter-active-class="transition duration-200 ease-out"
enter-from-class="opacity-0 -translate-y-2 sm:translate-y-2"
leave-active-class="transition duration-150 ease-in absolute"
leave-to-class="opacity-0 scale-95"
>
<div
v-for="item in toasts"
:key="item.id"
:class="
cn(
'bg-popover text-popover-foreground pointer-events-auto flex w-full max-w-sm items-start gap-2.5 rounded-lg border p-3 shadow-lg',
item.variant === 'error' && 'border-block/40',
item.variant === 'success' && 'border-allow/40',
)
"
>
<CheckCircle2 v-if="item.variant === 'success'" class="text-allow mt-0.5 size-4 shrink-0" />
<TriangleAlert
v-else-if="item.variant === 'error'"
class="text-block mt-0.5 size-4 shrink-0"
/>
<div class="min-w-0 flex-1">
<p class="text-sm font-medium">{{ item.title }}</p>
<p v-if="item.description" class="text-muted-foreground mt-0.5 text-xs break-words">
{{ item.description }}
</p>
</div>
<button
type="button"
class="text-muted-foreground hover:text-foreground -m-1 p-1"
@click="dismiss(item.id)"
>
<X class="size-3.5" />
</button>
</div>
</TransitionGroup>
</div>
</template>
@@ -0,0 +1,168 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { t } from '@/i18n'
import { compact, time } from '@/lib/format'
import type { Bucket } from '@/lib/types'
const props = defineProps<{ buckets: Bucket[] }>()
const W = 600
const H = 160
const PAD_TOP = 8
const PAD_BOTTOM = 16
const hover = ref<number | null>(null)
const points = computed(() => props.buckets ?? [])
const max = computed(() => Math.max(1, ...points.value.map((b) => b.tcp + b.udp)))
const hasData = computed(() => points.value.some((b) => b.tcp + b.udp + b.blocked > 0))
function x(i: number): number {
const n = points.value.length
return n <= 1 ? 0 : (i / (n - 1)) * W
}
function y(value: number): number {
const usable = H - PAD_TOP - PAD_BOTTOM
return PAD_TOP + usable - (value / max.value) * usable
}
/** Builds a closed area path for a series of stacked values. */
function area(values: number[]): string {
if (values.length === 0) return ''
const top = values.map((v, i) => `${i === 0 ? 'M' : 'L'}${x(i).toFixed(1)},${y(v).toFixed(1)}`)
const base = `L${W},${y(0)} L0,${y(0)} Z`
return top.join(' ') + ' ' + base
}
function line(values: number[]): string {
return values
.map((v, i) => `${i === 0 ? 'M' : 'L'}${x(i).toFixed(1)},${y(v).toFixed(1)}`)
.join(' ')
}
const totalPath = computed(() => area(points.value.map((b) => b.tcp + b.udp)))
const tcpPath = computed(() => area(points.value.map((b) => b.tcp)))
const blockedPath = computed(() => line(points.value.map((b) => b.blocked)))
const active = computed(() => (hover.value === null ? null : points.value[hover.value]))
function onMove(event: PointerEvent) {
const rect = (event.currentTarget as HTMLElement).getBoundingClientRect()
const ratio = (event.clientX - rect.left) / rect.width
const index = Math.round(ratio * (points.value.length - 1))
hover.value = Math.min(points.value.length - 1, Math.max(0, index))
}
const legend = computed(() => {
const sum = (key: 'tcp' | 'udp' | 'blocked') =>
points.value.reduce((acc, b) => acc + b[key], 0)
return [
{ label: t('dashboard.tcp'), color: 'bg-tcp', value: sum('tcp') },
{ label: t('dashboard.udp'), color: 'bg-udp', value: sum('udp') },
{ label: t('dashboard.blocked'), color: 'bg-block', value: sum('blocked') },
]
})
</script>
<template>
<div>
<div
class="relative touch-pan-y"
@pointermove="onMove"
@pointerdown="onMove"
@pointerleave="hover = null"
>
<svg
:viewBox="`0 0 ${W} ${H}`"
preserveAspectRatio="none"
class="h-36 w-full sm:h-44"
role="img"
>
<line
v-for="frac in [0, 0.5, 1]"
:key="frac"
:x1="0"
:x2="W"
:y1="y(max * frac)"
:y2="y(max * frac)"
class="stroke-border"
stroke-width="1"
vector-effect="non-scaling-stroke"
stroke-dasharray="3 4"
/>
<path :d="totalPath" class="fill-udp/25" />
<path :d="tcpPath" class="fill-tcp/45" />
<path
:d="blockedPath"
class="stroke-block"
fill="none"
stroke-width="1.75"
stroke-linejoin="round"
vector-effect="non-scaling-stroke"
/>
<template v-if="hover !== null">
<line
:x1="x(hover)"
:x2="x(hover)"
:y1="PAD_TOP"
:y2="y(0)"
class="stroke-foreground/40"
stroke-width="1"
vector-effect="non-scaling-stroke"
/>
</template>
</svg>
<div
class="text-muted-foreground pointer-events-none absolute top-0 left-0 text-[10px] tabular-nums"
>
{{ compact(max) }}
</div>
<div
v-if="active"
class="bg-popover pointer-events-none absolute top-1 z-10 rounded-md border px-2 py-1.5 text-[11px] shadow-md"
:style="{
left: `${(hover! / Math.max(1, points.length - 1)) * 100}%`,
transform:
hover! / Math.max(1, points.length - 1) > 0.6
? 'translateX(-105%)'
: 'translateX(8px)',
}"
>
<div class="text-muted-foreground mb-0.5">{{ time(active.time) }}</div>
<div class="flex items-center gap-2 tabular-nums">
<span class="bg-tcp size-2 rounded-full" />{{ t('dashboard.tcp') }}
<span class="ml-auto font-medium">{{ active.tcp }}</span>
</div>
<div class="flex items-center gap-2 tabular-nums">
<span class="bg-udp size-2 rounded-full" />{{ t('dashboard.udp') }}
<span class="ml-auto font-medium">{{ active.udp }}</span>
</div>
<div class="flex items-center gap-2 tabular-nums">
<span class="bg-block size-2 rounded-full" />{{ t('dashboard.blocked') }}
<span class="ml-auto font-medium">{{ active.blocked }}</span>
</div>
</div>
<div
v-if="!hasData"
class="text-muted-foreground absolute inset-0 flex items-center justify-center text-xs"
>
{{ t('dashboard.noTraffic') }}
</div>
</div>
<div class="mt-3 flex flex-wrap items-center gap-x-4 gap-y-1">
<div v-for="item in legend" :key="item.label" class="flex items-center gap-1.5 text-xs">
<span :class="['size-2 rounded-full', item.color]" />
<span class="text-muted-foreground">{{ item.label }}</span>
<span class="font-medium tabular-nums">{{ compact(item.value) }}</span>
</div>
</div>
</div>
</template>
+39
View File
@@ -0,0 +1,39 @@
<script lang="ts">
import { cva, type VariantProps } from 'class-variance-authority'
export const badgeVariants = cva(
'inline-flex items-center justify-center gap-1 rounded-md border px-1.5 py-0.5 text-[11px] font-medium whitespace-nowrap tabular-nums',
{
variants: {
variant: {
default: 'border-transparent bg-primary text-primary-foreground',
secondary: 'border-transparent bg-secondary text-secondary-foreground',
outline: 'text-foreground',
muted: 'border-transparent bg-muted text-muted-foreground',
allow: 'border-allow/25 bg-allow/12 text-allow',
block: 'border-block/25 bg-block/12 text-block',
drop: 'border-drop/25 bg-drop/12 text-drop',
modify: 'border-modify/25 bg-modify/12 text-modify',
log: 'border-log/25 bg-log/12 text-log',
tcp: 'border-tcp/25 bg-tcp/12 text-tcp',
udp: 'border-udp/25 bg-udp/12 text-udp',
},
},
defaultVariants: { variant: 'default' },
},
)
export type BadgeVariants = VariantProps<typeof badgeVariants>
</script>
<script setup lang="ts">
import { cn } from '@/lib/utils'
const props = defineProps<{ variant?: BadgeVariants['variant']; class?: string }>()
</script>
<template>
<span :class="cn(badgeVariants({ variant: props.variant }), props.class)">
<slot />
</span>
</template>
+61
View File
@@ -0,0 +1,61 @@
<script lang="ts">
import { cva, type VariantProps } from 'class-variance-authority'
export const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-[color,box-shadow,background-color] disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 outline-none focus-visible:ring-[3px] focus-visible:ring-ring/40 focus-visible:border-ring active:scale-[0.98] select-none",
{
variants: {
variant: {
default: 'bg-primary text-primary-foreground shadow-xs hover:bg-primary/90',
destructive:
'bg-destructive text-destructive-foreground shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/30',
outline:
'border border-input bg-background shadow-xs hover:bg-accent hover:text-accent-foreground',
secondary: 'bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80',
ghost: 'hover:bg-accent hover:text-accent-foreground',
link: 'text-primary underline-offset-4 hover:underline',
},
size: {
default: 'h-9 px-4 py-2 has-[>svg]:px-3',
sm: 'h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5 text-[13px]',
lg: 'h-10 rounded-md px-6 has-[>svg]:px-4',
icon: 'size-9',
'icon-sm': 'size-8 rounded-md',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
},
)
export type ButtonVariants = VariantProps<typeof buttonVariants>
</script>
<script setup lang="ts">
import { Primitive, type PrimitiveProps } from 'reka-ui'
import { cn } from '@/lib/utils'
const props = withDefaults(
defineProps<
PrimitiveProps & {
variant?: ButtonVariants['variant']
size?: ButtonVariants['size']
class?: string
}
>(),
{ as: 'button' },
)
</script>
<template>
<Primitive
:as="props.as"
:as-child="props.asChild"
:class="cn(buttonVariants({ variant: props.variant, size: props.size }), props.class)"
>
<slot />
</Primitive>
</template>
+18
View File
@@ -0,0 +1,18 @@
<script setup lang="ts">
import { cn } from '@/lib/utils'
const props = defineProps<{ class?: string }>()
</script>
<template>
<div
:class="
cn(
'bg-card text-card-foreground rounded-xl border shadow-sm transition-colors',
props.class,
)
"
>
<slot />
</div>
</template>
@@ -0,0 +1,11 @@
<script setup lang="ts">
import { cn } from '@/lib/utils'
const props = defineProps<{ class?: string }>()
</script>
<template>
<div :class="cn('px-4 pb-4 sm:px-5 sm:pb-5', props.class)">
<slot />
</div>
</template>
@@ -0,0 +1,11 @@
<script setup lang="ts">
import { cn } from '@/lib/utils'
const props = defineProps<{ class?: string }>()
</script>
<template>
<p :class="cn('text-muted-foreground text-xs sm:text-sm', props.class)">
<slot />
</p>
</template>
@@ -0,0 +1,11 @@
<script setup lang="ts">
import { cn } from '@/lib/utils'
const props = defineProps<{ class?: string }>()
</script>
<template>
<div :class="cn('flex flex-col gap-1 px-4 pt-4 pb-3 sm:px-5 sm:pt-5', props.class)">
<slot />
</div>
</template>
@@ -0,0 +1,11 @@
<script setup lang="ts">
import { cn } from '@/lib/utils'
const props = defineProps<{ class?: string }>()
</script>
<template>
<h3 :class="cn('text-sm leading-none font-semibold tracking-tight sm:text-base', props.class)">
<slot />
</h3>
</template>
+22
View File
@@ -0,0 +1,22 @@
<script setup lang="ts">
import { cn } from '@/lib/utils'
const props = defineProps<{ class?: string; modelValue?: string | number }>()
const emit = defineEmits<{ 'update:modelValue': [value: string] }>()
</script>
<template>
<input
:value="props.modelValue"
:class="
cn(
'border-input bg-background flex h-9 w-full min-w-0 rounded-md border px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none sm:text-sm',
'placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground',
'focus-visible:border-ring focus-visible:ring-ring/40 focus-visible:ring-[3px]',
'disabled:cursor-not-allowed disabled:opacity-50',
props.class,
)
"
@input="emit('update:modelValue', ($event.target as HTMLInputElement).value)"
/>
</template>
+21
View File
@@ -0,0 +1,21 @@
<script setup lang="ts">
import { Label } from 'reka-ui'
import { cn } from '@/lib/utils'
const props = defineProps<{ class?: string; for?: string }>()
</script>
<template>
<Label
:for="props.for"
:class="
cn(
'flex items-center gap-2 text-sm leading-none font-medium select-none',
props.class,
)
"
>
<slot />
</Label>
</template>
+71
View File
@@ -0,0 +1,71 @@
<script setup lang="ts">
import { X } from 'lucide-vue-next'
import {
DialogClose,
DialogContent,
DialogDescription,
DialogOverlay,
DialogPortal,
DialogRoot,
DialogTitle,
} from 'reka-ui'
import { cn } from '@/lib/utils'
/**
* A dialog that turns into a bottom drawer on small screens, which is much
* easier to reach with a thumb than a centered modal.
*/
const props = defineProps<{
open: boolean
title: string
description?: string
class?: string
}>()
const emit = defineEmits<{ 'update:open': [value: boolean] }>()
</script>
<template>
<DialogRoot :open="props.open" @update:open="emit('update:open', $event)">
<DialogPortal>
<DialogOverlay
class="animate-fade-in fixed inset-0 z-50 bg-black/60 backdrop-blur-[2px]"
/>
<DialogContent
:class="
cn(
'bg-background fixed z-50 flex flex-col gap-4 border shadow-lg outline-none',
// Mobile: bottom drawer
'animate-slide-up inset-x-0 bottom-0 max-h-[92vh] rounded-t-2xl border-b-0 p-4 pb-[max(1rem,env(safe-area-inset-bottom))]',
// Desktop: centered dialog
'sm:top-1/2 sm:bottom-auto sm:left-1/2 sm:max-h-[85vh] sm:w-full sm:max-w-lg sm:-translate-x-1/2 sm:-translate-y-1/2 sm:rounded-xl sm:border-b sm:p-6',
props.class,
)
"
>
<div
class="bg-muted-foreground/25 mx-auto -mt-1 mb-1 h-1 w-10 shrink-0 rounded-full sm:hidden"
/>
<div class="flex items-start justify-between gap-4">
<div class="flex flex-col gap-1">
<DialogTitle class="text-base font-semibold">{{ props.title }}</DialogTitle>
<DialogDescription v-if="props.description" class="text-muted-foreground text-sm">
{{ props.description }}
</DialogDescription>
</div>
<DialogClose
class="text-muted-foreground hover:bg-accent hover:text-foreground focus-visible:ring-ring/40 -mt-1 -mr-1 rounded-md p-1.5 transition-colors outline-none focus-visible:ring-[3px]"
>
<X class="size-4" />
</DialogClose>
</div>
<div class="min-h-0 flex-1 overflow-y-auto">
<slot />
</div>
<div v-if="$slots.footer" class="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
<slot name="footer" />
</div>
</DialogContent>
</DialogPortal>
</DialogRoot>
</template>
+69
View File
@@ -0,0 +1,69 @@
<script setup lang="ts">
import { Check, ChevronDown } from 'lucide-vue-next'
import {
SelectContent,
SelectItem,
SelectItemIndicator,
SelectItemText,
SelectPortal,
SelectRoot,
SelectTrigger,
SelectValue,
SelectViewport,
} from 'reka-ui'
import type { SelectOption } from '@/lib/types'
import { cn } from '@/lib/utils'
const props = defineProps<{
modelValue: string
options: SelectOption[]
placeholder?: string
ariaLabel?: string
class?: string
}>()
const emit = defineEmits<{ 'update:modelValue': [value: string] }>()
</script>
<template>
<SelectRoot
:model-value="props.modelValue"
@update:model-value="emit('update:modelValue', String($event))"
>
<SelectTrigger
:aria-label="props.ariaLabel"
:class="
cn(
'border-input bg-background flex h-9 w-full items-center justify-between gap-2 rounded-md border px-3 py-1 text-sm shadow-xs transition-[color,box-shadow] outline-none',
'focus-visible:border-ring focus-visible:ring-ring/40 focus-visible:ring-[3px]',
'data-[placeholder]:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50',
props.class,
)
"
>
<SelectValue :placeholder="props.placeholder ?? ''" class="truncate" />
<ChevronDown class="size-4 shrink-0 opacity-50" />
</SelectTrigger>
<SelectPortal>
<SelectContent
position="popper"
:side-offset="4"
class="bg-popover text-popover-foreground animate-fade-in relative z-50 max-h-72 min-w-[var(--reka-select-trigger-width)] overflow-hidden rounded-md border shadow-md"
>
<SelectViewport class="p-1">
<SelectItem
v-for="option in props.options"
:key="option.value"
:value="option.value"
class="focus:bg-accent focus:text-accent-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-none select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50"
>
<SelectItemText>{{ option.label }}</SelectItemText>
<SelectItemIndicator class="absolute right-2 flex items-center">
<Check class="size-4" />
</SelectItemIndicator>
</SelectItem>
</SelectViewport>
</SelectContent>
</SelectPortal>
</SelectRoot>
</template>
@@ -0,0 +1,21 @@
<script setup lang="ts">
import { cn } from '@/lib/utils'
const props = withDefaults(
defineProps<{ class?: string; orientation?: 'horizontal' | 'vertical' }>(),
{ orientation: 'horizontal' },
)
</script>
<template>
<div
role="separator"
:class="
cn(
'bg-border shrink-0',
props.orientation === 'vertical' ? 'h-full w-px' : 'h-px w-full',
props.class,
)
"
/>
</template>
@@ -0,0 +1,9 @@
<script setup lang="ts">
import { cn } from '@/lib/utils'
const props = defineProps<{ class?: string }>()
</script>
<template>
<div :class="cn('bg-muted animate-pulse rounded-md', props.class)" />
</template>
+26
View File
@@ -0,0 +1,26 @@
<script setup lang="ts">
import { SwitchRoot, SwitchThumb } from 'reka-ui'
import { cn } from '@/lib/utils'
const props = defineProps<{ class?: string; modelValue?: boolean; id?: string }>()
const emit = defineEmits<{ 'update:modelValue': [value: boolean] }>()
</script>
<template>
<SwitchRoot
:id="props.id"
:model-value="props.modelValue"
:class="
cn(
'peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border border-transparent transition-colors outline-none focus-visible:ring-[3px] focus-visible:ring-ring/40 disabled:cursor-not-allowed disabled:opacity-50',
props.class,
)
"
@update:model-value="emit('update:modelValue', $event)"
>
<SwitchThumb
class="bg-background pointer-events-none block size-4 rounded-full shadow-sm ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0.5"
/>
</SwitchRoot>
</template>
+18
View File
@@ -0,0 +1,18 @@
<script setup lang="ts">
import { TabsRoot } from 'reka-ui'
import { cn } from '@/lib/utils'
const props = defineProps<{ class?: string; modelValue?: string }>()
const emit = defineEmits<{ 'update:modelValue': [value: string] }>()
</script>
<template>
<TabsRoot
:model-value="props.modelValue"
:class="cn('flex flex-col gap-3', props.class)"
@update:model-value="emit('update:modelValue', String($event))"
>
<slot />
</TabsRoot>
</template>
@@ -0,0 +1,13 @@
<script setup lang="ts">
import { TabsContent } from 'reka-ui'
import { cn } from '@/lib/utils'
const props = defineProps<{ class?: string; value: string }>()
</script>
<template>
<TabsContent :value="props.value" :class="cn('outline-none', props.class)">
<slot />
</TabsContent>
</template>
@@ -0,0 +1,20 @@
<script setup lang="ts">
import { TabsList } from 'reka-ui'
import { cn } from '@/lib/utils'
const props = defineProps<{ class?: string }>()
</script>
<template>
<TabsList
:class="
cn(
'bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-1',
props.class,
)
"
>
<slot />
</TabsList>
</template>
@@ -0,0 +1,24 @@
<script setup lang="ts">
import { TabsTrigger } from 'reka-ui'
import { cn } from '@/lib/utils'
const props = defineProps<{ class?: string; value: string }>()
</script>
<template>
<TabsTrigger
:value="props.value"
:class="
cn(
'inline-flex flex-1 items-center justify-center gap-1.5 rounded-md px-3 py-1 text-sm font-medium whitespace-nowrap transition-all outline-none',
'data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm',
'focus-visible:ring-ring/40 focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50',
'[&_svg]:size-3.5',
props.class,
)
"
>
<slot />
</TabsTrigger>
</template>
@@ -0,0 +1,22 @@
<script setup lang="ts">
import { cn } from '@/lib/utils'
const props = defineProps<{ class?: string; modelValue?: string }>()
const emit = defineEmits<{ 'update:modelValue': [value: string] }>()
</script>
<template>
<textarea
:value="props.modelValue"
:class="
cn(
'border-input bg-background flex min-h-20 w-full rounded-md border px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none sm:text-sm',
'placeholder:text-muted-foreground field-sizing-content',
'focus-visible:border-ring focus-visible:ring-ring/40 focus-visible:ring-[3px]',
'disabled:cursor-not-allowed disabled:opacity-50',
props.class,
)
"
@input="emit('update:modelValue', ($event.target as HTMLTextAreaElement).value)"
/>
</template>
+19
View File
@@ -0,0 +1,19 @@
export { default as Badge, badgeVariants } from './Badge.vue'
export { default as Button, buttonVariants } from './Button.vue'
export { default as Card } from './Card.vue'
export { default as CardContent } from './CardContent.vue'
export { default as CardDescription } from './CardDescription.vue'
export { default as CardHeader } from './CardHeader.vue'
export { default as CardTitle } from './CardTitle.vue'
export { default as Input } from './Input.vue'
export { default as Label } from './Label.vue'
export { default as Modal } from './Modal.vue'
export { default as Select } from './Select.vue'
export { default as Separator } from './Separator.vue'
export { default as Skeleton } from './Skeleton.vue'
export { default as Switch } from './Switch.vue'
export { default as Tabs } from './Tabs.vue'
export { default as TabsContent } from './TabsContent.vue'
export { default as TabsList } from './TabsList.vue'
export { default as TabsTrigger } from './TabsTrigger.vue'
export { default as Textarea } from './Textarea.vue'
+105
View File
@@ -0,0 +1,105 @@
import { computed, ref, shallowRef } from 'vue'
import { api, auth, liveUrl } from '@/lib/api'
import type { LiveEvent, Metrics } from '@/lib/types'
const MAX_EVENTS = 500
export type LiveStatus = 'closed' | 'connecting' | 'open'
const metrics = ref<Metrics | null>(null)
const events = shallowRef<LiveEvent[]>([])
const status = ref<LiveStatus>('closed')
let source: EventSource | null = null
let retryTimer: number | undefined
let retryDelay = 1000
function pushEvent(event: LiveEvent) {
const next = [event, ...events.value]
if (next.length > MAX_EVENTS) next.length = MAX_EVENTS
events.value = next
}
async function seed() {
try {
const [m, e] = await Promise.all([api.metrics(), api.events(MAX_EVENTS)])
metrics.value = m
if (events.value.length === 0) events.value = e.events ?? []
} catch {
// The SSE connection will report the problem, no need to duplicate it.
}
}
function connect() {
if (source || !auth.authenticated) return
status.value = 'connecting'
const es = new EventSource(liveUrl)
source = es
es.addEventListener('open', () => {
status.value = 'open'
retryDelay = 1000
})
es.addEventListener('metrics', (ev) => {
try {
metrics.value = JSON.parse((ev as MessageEvent<string>).data) as Metrics
status.value = 'open'
} catch {
/* ignore malformed frame */
}
})
es.addEventListener('event', (ev) => {
try {
pushEvent(JSON.parse((ev as MessageEvent<string>).data) as LiveEvent)
} catch {
/* ignore malformed frame */
}
})
es.addEventListener('error', () => {
es.close()
if (source === es) source = null
status.value = 'closed'
// The session may have expired; a plain fetch tells us for sure.
window.clearTimeout(retryTimer)
retryTimer = window.setTimeout(() => {
if (auth.authenticated) connect()
}, retryDelay)
retryDelay = Math.min(retryDelay * 2, 15000)
})
}
export function startLive() {
if (!auth.authenticated) return
void seed()
connect()
}
export function stopLive() {
window.clearTimeout(retryTimer)
source?.close()
source = null
status.value = 'closed'
metrics.value = null
events.value = []
}
export function clearEvents() {
events.value = []
}
export function useLive() {
return {
metrics,
events,
status,
connected: computed(() => status.value === 'open'),
startLive,
stopLive,
clearEvents,
refresh: seed,
}
}
+46
View File
@@ -0,0 +1,46 @@
import { computed, ref, watch } from 'vue'
export type Theme = 'light' | 'dark' | 'system'
const THEME_KEY = 'opengfw.theme'
const stored = localStorage.getItem(THEME_KEY)
const theme = ref<Theme>(stored === 'light' || stored === 'dark' ? stored : 'system')
const media = window.matchMedia('(prefers-color-scheme: dark)')
function resolve(value: Theme): boolean {
return value === 'dark' || (value === 'system' && media.matches)
}
const isDark = ref(resolve(theme.value))
function apply() {
isDark.value = resolve(theme.value)
document.documentElement.classList.toggle('dark', isDark.value)
document
.querySelector('meta[name="theme-color"]')
?.setAttribute('content', isDark.value ? '#09090b' : '#ffffff')
}
watch(theme, (value) => {
localStorage.setItem(THEME_KEY, value)
apply()
})
media.addEventListener('change', () => {
if (theme.value === 'system') apply()
})
apply()
export function useTheme() {
return {
theme,
isDark: computed(() => isDark.value),
setTheme: (value: Theme) => {
theme.value = value
},
toggle: () => {
theme.value = isDark.value ? 'light' : 'dark'
},
}
}
+33
View File
@@ -0,0 +1,33 @@
import { ref } from 'vue'
export type ToastVariant = 'default' | 'success' | 'error'
export interface Toast {
id: number
title: string
description?: string
variant: ToastVariant
}
const toasts = ref<Toast[]>([])
let nextId = 1
function push(title: string, variant: ToastVariant, description?: string) {
const id = nextId++
toasts.value = [...toasts.value, { id, title, description, variant }]
window.setTimeout(() => dismiss(id), variant === 'error' ? 6000 : 3500)
}
export function dismiss(id: number) {
toasts.value = toasts.value.filter((t) => t.id !== id)
}
export const toast = {
info: (title: string, description?: string) => push(title, 'default', description),
success: (title: string, description?: string) => push(title, 'success', description),
error: (title: string, description?: string) => push(title, 'error', description),
}
export function useToast() {
return { toasts, toast, dismiss }
}
+7
View File
@@ -0,0 +1,7 @@
/// <reference types="vite/client" />
declare module '*.vue' {
import type { DefineComponent } from 'vue'
const component: DefineComponent<{}, {}, any>
export default component
}
+182
View File
@@ -0,0 +1,182 @@
export default {
app: {
name: 'OpenGFW',
tagline: 'Network filtering and analysis',
},
nav: {
dashboard: 'Dashboard',
events: 'Events',
rules: 'Rules',
analyzers: 'Analyzers',
settings: 'Settings',
},
common: {
save: 'Save',
saving: 'Saving…',
cancel: 'Cancel',
close: 'Close',
refresh: 'Refresh',
search: 'Search',
loading: 'Loading…',
empty: 'Nothing here yet',
error: 'Error',
retry: 'Retry',
delete: 'Delete',
edit: 'Edit',
add: 'Add',
apply: 'Apply',
copy: 'Copy',
copied: 'Copied',
enabled: 'Enabled',
disabled: 'Disabled',
all: 'All',
unknown: 'Unknown',
total: 'Total',
live: 'Live',
offline: 'Disconnected',
connecting: 'Connecting…',
back: 'Back',
more: 'More',
},
login: {
title: 'Sign in',
subtitle: 'Enter the web UI password from your config file',
password: 'Password',
submit: 'Sign in',
failed: 'Sign in failed',
hint: 'Set it with web.secret in config.yaml',
},
dashboard: {
title: 'Dashboard',
subtitle: 'Live overview of the engine',
uptime: 'Uptime',
workers: 'Workers',
streams: 'Streams',
blocked: 'Blocked',
allowed: 'Allowed',
modified: 'Modified',
errors: 'Errors',
traffic: 'Traffic',
trafficDesc: 'New streams and verdicts over time',
range10m: '10 min',
range1h: '1 hour',
protocols: 'Protocols',
verdicts: 'Verdicts',
topHosts: 'Top hosts',
topHostsDesc: 'Seen in TLS SNI, HTTP Host and DNS queries',
topBlocked: 'Blocked destinations',
topRules: 'Triggered rules',
recent: 'Recent events',
viewAll: 'View all',
noTraffic: 'No traffic seen yet',
tcp: 'TCP',
udp: 'UDP',
},
events: {
title: 'Events',
subtitle: 'Live feed of verdicts, rule logs and errors',
pause: 'Pause',
resume: 'Resume',
paused: 'Paused',
clear: 'Clear',
filterAction: 'Verdict',
filterProto: 'Protocol',
filterKind: 'Type',
searchPlaceholder: 'Search IP, host or rule…',
empty: 'Waiting for events…',
emptyFiltered: 'No event matches the filter',
detail: 'Event detail',
stream: 'Stream',
source: 'Source',
destination: 'Destination',
host: 'Host',
rule: 'Rule',
props: 'Analyzer properties',
noProps: 'No analyzer properties',
copyJson: 'Copy JSON',
buffered: '{n} new',
},
rules: {
title: 'Rules',
subtitle: 'Rules are matched top to bottom; the first match wins',
count: '{n} rules',
file: 'Rule file',
add: 'New rule',
editTitle: 'Edit rule',
addTitle: 'New rule',
name: 'Name',
namePlaceholder: 'block-google-dns',
action: 'Action',
actionNone: 'None (log only)',
log: 'Log matches',
expr: 'Expression',
exprPlaceholder: 'dns != nil && dns.qr && any(dns.questions, {.name endsWith "example.com"})',
modifier: 'Modifier',
modifierArgs: 'Modifier arguments (JSON)',
visual: 'Visual',
yaml: 'YAML',
validate: 'Validate',
valid: 'Rules are valid',
invalid: 'Invalid rules',
save: 'Save & apply',
saved: 'Rules applied to the running engine',
unsaved: 'Unsaved changes',
discard: 'Discard',
deleteConfirm: 'Delete rule "{name}"?',
moveUp: 'Move up',
moveDown: 'Move down',
empty: 'No rules yet. Everything is accepted.',
nameRequired: 'A name is required',
exprRequired: 'An expression is required',
hint: 'Saving writes the rule file and hot reloads the engine.',
},
analyzers: {
title: 'Analyzers',
subtitle: 'Protocol analyzers compiled into this build',
hits: 'Streams analyzed',
modifiers: 'Modifiers',
modifiersDesc: 'Available for rules with the modify action',
functions: 'Rule functions',
functionsDesc: 'Built-in functions usable in expressions',
used: 'In use',
idle: 'Idle',
},
settings: {
title: 'Settings',
subtitle: 'Appearance and instance information',
appearance: 'Appearance',
theme: 'Theme',
themeLight: 'Light',
themeDark: 'Dark',
themeSystem: 'System',
language: 'Language',
instance: 'Instance',
version: 'Version',
platform: 'Platform',
goVersion: 'Go version',
hostname: 'Hostname',
ruleFile: 'Rule file',
engineConfig: 'Engine configuration',
ioQueueSize: 'NFQueue size',
ioLocal: 'Local mode',
ioRST: 'Send RST',
workers: 'Workers',
workerQueueSize: 'Worker queue size',
udpMaxStreams: 'Max UDP streams',
geoip: 'GeoIP database',
geosite: 'GeoSite database',
auto: 'Auto',
session: 'Session',
logout: 'Sign out',
docs: 'Documentation',
},
action: {
allow: 'Allow',
block: 'Block',
drop: 'Drop',
modify: 'Modify',
maybe: 'Maybe',
log: 'Log',
error: 'Error',
},
}
+46
View File
@@ -0,0 +1,46 @@
import { computed, ref } from 'vue'
import en from './en'
import zh from './zh'
export type Lang = 'en' | 'zh'
const LANG_KEY = 'opengfw.lang'
const dictionaries: Record<Lang, Record<string, unknown>> = { en, zh }
function detect(): Lang {
const stored = localStorage.getItem(LANG_KEY)
if (stored === 'en' || stored === 'zh') return stored
return navigator.language.toLowerCase().startsWith('zh') ? 'zh' : 'en'
}
const lang = ref<Lang>(detect())
function lookup(dict: Record<string, unknown>, key: string): string | undefined {
const value = key.split('.').reduce<unknown>((acc, part) => {
if (acc && typeof acc === 'object') return (acc as Record<string, unknown>)[part]
return undefined
}, dict)
return typeof value === 'string' ? value : undefined
}
/** Translates a dotted key, interpolating {placeholders}. */
export function t(key: string, params?: Record<string, string | number>): string {
const raw = lookup(dictionaries[lang.value], key) ?? lookup(dictionaries.en, key) ?? key
if (!params) return raw
return raw.replace(/\{(\w+)\}/g, (_, name: string) =>
params[name] === undefined ? `{${name}}` : String(params[name]),
)
}
export function setLang(value: Lang) {
lang.value = value
localStorage.setItem(LANG_KEY, value)
document.documentElement.lang = value === 'zh' ? 'zh-CN' : 'en'
}
export function useI18n() {
return { t, lang: computed(() => lang.value), setLang }
}
document.documentElement.lang = lang.value === 'zh' ? 'zh-CN' : 'en'
+182
View File
@@ -0,0 +1,182 @@
export default {
app: {
name: 'OpenGFW',
tagline: '网络过滤与分析',
},
nav: {
dashboard: '概览',
events: '事件',
rules: '规则',
analyzers: '解析器',
settings: '设置',
},
common: {
save: '保存',
saving: '保存中…',
cancel: '取消',
close: '关闭',
refresh: '刷新',
search: '搜索',
loading: '加载中…',
empty: '暂无数据',
error: '错误',
retry: '重试',
delete: '删除',
edit: '编辑',
add: '添加',
apply: '应用',
copy: '复制',
copied: '已复制',
enabled: '已启用',
disabled: '已禁用',
all: '全部',
unknown: '未知',
total: '总计',
live: '实时',
offline: '连接断开',
connecting: '连接中…',
back: '返回',
more: '更多',
},
login: {
title: '登录',
subtitle: '请输入配置文件中设置的 Web UI 密码',
password: '密码',
submit: '登录',
failed: '登录失败',
hint: '在 config.yaml 的 web.secret 中设置',
},
dashboard: {
title: '概览',
subtitle: '引擎实时运行状态',
uptime: '运行时长',
workers: '工作线程',
streams: '连接数',
blocked: '已拦截',
allowed: '已放行',
modified: '已修改',
errors: '错误',
traffic: '流量趋势',
trafficDesc: '新建连接与判定结果随时间变化',
range10m: '10 分钟',
range1h: '1 小时',
protocols: '协议分布',
verdicts: '判定分布',
topHosts: '热门域名',
topHostsDesc: '来自 TLS SNI、HTTP Host 与 DNS 查询',
topBlocked: '被拦截的目标',
topRules: '命中的规则',
recent: '最近事件',
viewAll: '查看全部',
noTraffic: '暂未捕获到流量',
tcp: 'TCP',
udp: 'UDP',
},
events: {
title: '事件',
subtitle: '判定结果、规则日志与错误的实时推送',
pause: '暂停',
resume: '继续',
paused: '已暂停',
clear: '清空',
filterAction: '判定',
filterProto: '协议',
filterKind: '类型',
searchPlaceholder: '搜索 IP、域名或规则…',
empty: '等待事件中…',
emptyFiltered: '没有符合筛选条件的事件',
detail: '事件详情',
stream: '连接',
source: '来源',
destination: '目标',
host: '域名',
rule: '规则',
props: '解析器属性',
noProps: '无解析器属性',
copyJson: '复制 JSON',
buffered: '{n} 条新事件',
},
rules: {
title: '规则',
subtitle: '规则自上而下匹配,命中第一条即生效',
count: '{n} 条规则',
file: '规则文件',
add: '新建规则',
editTitle: '编辑规则',
addTitle: '新建规则',
name: '名称',
namePlaceholder: 'block-google-dns',
action: '动作',
actionNone: '无(仅记录日志)',
log: '记录日志',
expr: '表达式',
exprPlaceholder: 'dns != nil && dns.qr && any(dns.questions, {.name endsWith "example.com"})',
modifier: '修改器',
modifierArgs: '修改器参数(JSON)',
visual: '可视化',
yaml: 'YAML',
validate: '校验',
valid: '规则校验通过',
invalid: '规则不合法',
save: '保存并应用',
saved: '规则已热加载到运行中的引擎',
unsaved: '有未保存的修改',
discard: '放弃修改',
deleteConfirm: '确定删除规则「{name}」?',
moveUp: '上移',
moveDown: '下移',
empty: '暂无规则,所有流量都会放行。',
nameRequired: '请填写名称',
exprRequired: '请填写表达式',
hint: '保存后将写入规则文件并热加载引擎。',
},
analyzers: {
title: '解析器',
subtitle: '当前版本内置的协议解析器',
hits: '已解析连接',
modifiers: '修改器',
modifiersDesc: '可用于 modify 动作的规则',
functions: '规则函数',
functionsDesc: '表达式中可用的内置函数',
used: '使用中',
idle: '空闲',
},
settings: {
title: '设置',
subtitle: '外观与实例信息',
appearance: '外观',
theme: '主题',
themeLight: '浅色',
themeDark: '深色',
themeSystem: '跟随系统',
language: '语言',
instance: '实例信息',
version: '版本',
platform: '平台',
goVersion: 'Go 版本',
hostname: '主机名',
ruleFile: '规则文件',
engineConfig: '引擎配置',
ioQueueSize: 'NFQueue 队列大小',
ioLocal: '本机模式',
ioRST: '发送 RST',
workers: '工作线程数',
workerQueueSize: '工作线程队列大小',
udpMaxStreams: 'UDP 最大连接数',
geoip: 'GeoIP 数据库',
geosite: 'GeoSite 数据库',
auto: '自动',
session: '会话',
logout: '退出登录',
docs: '文档',
},
action: {
allow: '放行',
block: '拦截',
drop: '丢弃',
modify: '修改',
maybe: '待定',
log: '日志',
error: '错误',
},
}
+110
View File
@@ -0,0 +1,110 @@
import type {
Info,
LiveEvent,
MetaInfo,
Metrics,
Rule,
RulesResponse,
ValidateResponse,
} from './types'
const BASE = '/api/v1'
const TOKEN_KEY = 'opengfw.token'
export class ApiError extends Error {
status: number
constructor(status: number, message: string) {
super(message)
this.status = status
}
}
let token = localStorage.getItem(TOKEN_KEY) ?? ''
const unauthorizedHandlers = new Set<() => void>()
export const auth = {
get token() {
return token
},
get authenticated() {
return token !== ''
},
set(value: string) {
token = value
localStorage.setItem(TOKEN_KEY, value)
},
clear() {
token = ''
localStorage.removeItem(TOKEN_KEY)
},
onUnauthorized(handler: () => void) {
unauthorizedHandlers.add(handler)
return () => unauthorizedHandlers.delete(handler)
},
}
async function request<T>(path: string, init: RequestInit = {}): Promise<T> {
const headers = new Headers(init.headers)
if (token) headers.set('Authorization', `Bearer ${token}`)
if (init.body) headers.set('Content-Type', 'application/json')
let res: Response
try {
res = await fetch(BASE + path, { ...init, headers, credentials: 'same-origin' })
} catch {
throw new ApiError(0, 'network error')
}
if (res.status === 401) {
auth.clear()
unauthorizedHandlers.forEach((h) => h())
throw new ApiError(401, 'unauthorized')
}
const text = await res.text()
const data = text ? (JSON.parse(text) as unknown) : null
if (!res.ok) {
const message =
data && typeof data === 'object' && 'error' in data
? String((data as { error: unknown }).error)
: `request failed (${res.status})`
throw new ApiError(res.status, message)
}
return data as T
}
export const api = {
async login(password: string): Promise<void> {
const res = await request<{ token: string }>('/login', {
method: 'POST',
body: JSON.stringify({ password }),
})
auth.set(res.token)
},
async logout(): Promise<void> {
try {
await request('/logout', { method: 'POST' })
} finally {
auth.clear()
}
},
info: () => request<Info>('/info'),
meta: () => request<MetaInfo>('/meta'),
metrics: () => request<Metrics>('/metrics'),
events: (limit = 200) => request<{ events: LiveEvent[] }>(`/events?limit=${limit}`),
rules: () => request<RulesResponse>('/rules'),
saveRules: (payload: { raw?: string; rules?: Rule[] }) =>
request<RulesResponse>('/rules', { method: 'PUT', body: JSON.stringify(payload) }),
validateRules: (payload: { raw?: string; rules?: Rule[] }) =>
request<ValidateResponse>('/rules/validate', {
method: 'POST',
body: JSON.stringify(payload),
}),
}
export const liveUrl = `${BASE}/live`
+48
View File
@@ -0,0 +1,48 @@
/** Compact number formatting: 1234 -> 1.2k */
export function compact(n: number): string {
if (!Number.isFinite(n)) return '0'
if (n < 1000) return String(n)
const units = ['k', 'M', 'G', 'T']
let value = n
let unit = -1
while (value >= 1000 && unit < units.length - 1) {
value /= 1000
unit++
}
return `${value >= 100 ? Math.round(value) : value.toFixed(1).replace(/\.0$/, '')}${units[unit]}`
}
export function percent(part: number, total: number): number {
if (!total) return 0
return Math.round((part / total) * 100)
}
/** 3725 -> "1h 2m" */
export function duration(seconds: number): string {
if (seconds < 60) return `${Math.max(0, Math.floor(seconds))}s`
const d = Math.floor(seconds / 86400)
const h = Math.floor((seconds % 86400) / 3600)
const m = Math.floor((seconds % 3600) / 60)
if (d > 0) return `${d}d ${h}h`
if (h > 0) return `${h}h ${m}m`
return `${m}m`
}
export function time(ts: number): string {
const d = new Date(ts)
return d.toLocaleTimeString(undefined, { hour12: false })
}
export function dateTime(ts: number): string {
const d = new Date(ts)
return `${d.toLocaleDateString()} ${d.toLocaleTimeString(undefined, { hour12: false })}`
}
/** Formats an address, wrapping IPv6 in brackets. */
export function endpoint(ip: string, port: number): string {
return ip.includes(':') ? `[${ip}]:${port}` : `${ip}:${port}`
}
export function truncate(s: string, max = 42): string {
return s.length > max ? `${s.slice(0, max - 1)}…` : s
}
+110
View File
@@ -0,0 +1,110 @@
export interface SelectOption {
value: string
label: string
}
export type ActionName = 'allow' | 'block' | 'drop' | 'modify' | 'maybe'
export type EventKind = 'action' | 'log' | 'error'
export interface LiveEvent {
seq: number
time: number
kind: EventKind
id: number
proto: string
srcIP: string
srcPort: number
dstIP: string
dstPort: number
action?: ActionName
rule?: string
host?: string
message?: string
props?: Record<string, Record<string, unknown>>
}
export interface Bucket {
time: number
tcp: number
udp: number
blocked: number
allowed: number
}
export interface NameCount {
name: string
count: number
}
export interface Metrics {
uptimeSeconds: number
workers: number
streams: { tcp: number; udp: number; total: number }
actions: { allow: number; block: number; drop: number; modify: number; maybe: number }
errors: number
short: Bucket[]
long: Bucket[]
topHosts: NameCount[]
topBlockedIPs: NameCount[]
topRules: NameCount[]
analyzers: NameCount[]
}
export interface ConfigDigest {
ioQueueSize: number
ioLocal: boolean
ioRST: boolean
workers: number
workerQueueSize: number
udpMaxStreams: number
geoip?: string
geosite?: string
}
export interface Info {
version: string
commit?: string
platform: string
goVersion: string
hostname: string
ruleFile: string
config: ConfigDigest
}
export interface AnalyzerInfo {
name: string
proto: string
}
export interface MetaInfo {
analyzers: AnalyzerInfo[]
modifiers: string[]
actions: string[]
functions: string[]
}
export interface RuleModifier {
name: string
args?: Record<string, unknown>
}
export interface Rule {
name: string
action?: string
log?: boolean
expr: string
modifier?: RuleModifier | null
}
export interface RulesResponse {
path: string
raw: string
rules: Rule[]
}
export interface ValidateResponse {
valid: boolean
error?: string
rules?: Rule[]
raw: string
}
+6
View File
@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from 'clsx'
import { twMerge } from 'tailwind-merge'
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
+14
View File
@@ -0,0 +1,14 @@
import { createApp } from 'vue'
import App from './App.vue'
import './assets/style.css'
import { stopLive } from './composables/useLive'
import { auth } from './lib/api'
import router from './router'
auth.onUnauthorized(() => {
stopLive()
void router.replace({ name: 'login' })
})
createApp(App).use(router).mount('#app')
+38
View File
@@ -0,0 +1,38 @@
import { createRouter, createWebHistory } from 'vue-router'
import AppShell from '@/components/AppShell.vue'
import { auth } from '@/lib/api'
import LoginView from '@/views/LoginView.vue'
const router = createRouter({
history: createWebHistory(),
routes: [
{ path: '/login', name: 'login', component: LoginView, meta: { public: true } },
{
path: '/',
component: AppShell,
children: [
{ path: '', name: 'dashboard', component: () => import('@/views/DashboardView.vue') },
{ path: 'events', name: 'events', component: () => import('@/views/EventsView.vue') },
{ path: 'rules', name: 'rules', component: () => import('@/views/RulesView.vue') },
{
path: 'analyzers',
name: 'analyzers',
component: () => import('@/views/AnalyzersView.vue'),
},
{ path: 'settings', name: 'settings', component: () => import('@/views/SettingsView.vue') },
],
},
{ path: '/:pathMatch(.*)*', redirect: '/' },
],
})
router.beforeEach((to) => {
if (!to.meta.public && !auth.authenticated) {
return { name: 'login', query: to.fullPath === '/' ? undefined : { redirect: to.fullPath } }
}
if (to.name === 'login' && auth.authenticated) return { name: 'dashboard' }
return true
})
export default router
+103
View File
@@ -0,0 +1,103 @@
<script setup lang="ts">
import { Boxes, FunctionSquare } from 'lucide-vue-next'
import { computed, onMounted, ref } from 'vue'
import PageHeader from '@/components/PageHeader.vue'
import {
Badge,
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
Skeleton,
} from '@/components/ui'
import { useLive } from '@/composables/useLive'
import { t } from '@/i18n'
import { api } from '@/lib/api'
import { compact } from '@/lib/format'
import type { MetaInfo } from '@/lib/types'
const { metrics } = useLive()
const meta = ref<MetaInfo | null>(null)
onMounted(async () => {
try {
meta.value = await api.meta()
} catch {
meta.value = { analyzers: [], modifiers: [], actions: [], functions: [] }
}
})
const hits = computed(() => {
const map = new Map<string, number>()
for (const item of metrics.value?.analyzers ?? []) map.set(item.name, item.count)
return map
})
const analyzers = computed(() =>
[...(meta.value?.analyzers ?? [])].sort(
(a, b) => (hits.value.get(b.name) ?? 0) - (hits.value.get(a.name) ?? 0),
),
)
</script>
<template>
<div>
<PageHeader :title="t('analyzers.title')" :description="t('analyzers.subtitle')" />
<div v-if="!meta" class="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4">
<Skeleton v-for="i in 8" :key="i" class="h-24" />
</div>
<template v-else>
<div class="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4">
<Card v-for="analyzer in analyzers" :key="analyzer.name" class="p-3.5 sm:p-4">
<div class="flex items-center justify-between gap-2">
<span class="truncate font-mono text-sm font-medium">{{ analyzer.name }}</span>
<Badge :variant="analyzer.proto === 'udp' ? 'udp' : 'tcp'" class="uppercase">
{{ analyzer.proto }}
</Badge>
</div>
<p class="mt-2 text-xl font-semibold tabular-nums">
{{ compact(hits.get(analyzer.name) ?? 0) }}
</p>
<p class="text-muted-foreground text-xs">{{ t('analyzers.hits') }}</p>
</Card>
</div>
<div class="mt-3 grid gap-3 md:grid-cols-2">
<Card>
<CardHeader>
<CardTitle class="flex items-center gap-2">
<Boxes class="size-4" />
{{ t('analyzers.modifiers') }}
</CardTitle>
<CardDescription>{{ t('analyzers.modifiersDesc') }}</CardDescription>
</CardHeader>
<CardContent class="flex flex-wrap gap-1.5">
<Badge v-for="name in meta.modifiers" :key="name" variant="secondary" class="font-mono">
{{ name }}
</Badge>
<span v-if="!meta.modifiers.length" class="text-muted-foreground text-xs">—</span>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle class="flex items-center gap-2">
<FunctionSquare class="size-4" />
{{ t('analyzers.functions') }}
</CardTitle>
<CardDescription>{{ t('analyzers.functionsDesc') }}</CardDescription>
</CardHeader>
<CardContent class="flex flex-wrap gap-1.5">
<Badge v-for="name in meta.functions" :key="name" variant="secondary" class="font-mono">
{{ name }}()
</Badge>
</CardContent>
</Card>
</div>
</template>
</div>
</template>
+241
View File
@@ -0,0 +1,241 @@
<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>
+216
View File
@@ -0,0 +1,216 @@
<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>
+85
View File
@@ -0,0 +1,85 @@
<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>
+451
View File
@@ -0,0 +1,451 @@
<script setup lang="ts">
import {
ArrowDown,
ArrowUp,
CheckCircle2,
FileCode2,
Loader2,
Pencil,
Plus,
RotateCcw,
Save,
Trash2,
} from 'lucide-vue-next'
import { computed, onMounted, ref } from 'vue'
import PageHeader from '@/components/PageHeader.vue'
import {
Badge,
Button,
Card,
Input,
Label,
Modal,
Select,
Switch,
Tabs,
TabsContent,
TabsList,
TabsTrigger,
Textarea,
} from '@/components/ui'
import { toast } from '@/composables/useToast'
import { t } from '@/i18n'
import { api, ApiError } from '@/lib/api'
import type { MetaInfo, Rule } from '@/lib/types'
const mode = ref('visual')
const rules = ref<Rule[]>([])
const raw = ref('')
const path = ref('')
const meta = ref<MetaInfo | null>(null)
const loading = ref(true)
const busy = ref(false)
const dirty = ref(false)
const problem = ref('')
const editing = ref<Rule | null>(null)
const editingIndex = ref(-1)
const removing = ref<number>(-1)
const actionOptions = computed(() => [
{ value: '', label: t('rules.actionNone') },
...(meta.value?.actions ?? []).map((a) => ({ value: a, label: t(`action.${a}`) })),
])
const modifierOptions = computed(() =>
(meta.value?.modifiers ?? []).map((m) => ({ value: m, label: m })),
)
const badgeVariant = (action?: string) => {
switch (action) {
case 'allow':
return 'allow'
case 'block':
return 'block'
case 'drop':
return 'drop'
case 'modify':
return 'modify'
default:
return 'muted'
}
}
onMounted(async () => {
try {
const [res, m] = await Promise.all([api.rules(), api.meta()])
rules.value = res.rules ?? []
raw.value = res.raw
path.value = res.path
meta.value = m
} catch (e) {
problem.value = e instanceof ApiError ? e.message : String(e)
} finally {
loading.value = false
}
})
/** The backend is the single source of truth for YAML <-> rules conversion. */
async function switchMode(next: string) {
if (next === mode.value || busy.value) return
busy.value = true
problem.value = ''
try {
const res = await api.validateRules(next === 'yaml' ? { rules: rules.value } : { raw: raw.value })
if (next === 'yaml') {
raw.value = res.raw
} else {
if (!res.valid) {
problem.value = res.error ?? t('rules.invalid')
return
}
rules.value = res.rules ?? []
}
mode.value = next
} catch (e) {
problem.value = e instanceof ApiError ? e.message : String(e)
} finally {
busy.value = false
}
}
function payload() {
return mode.value === 'yaml' ? { raw: raw.value } : { rules: rules.value }
}
async function validate() {
busy.value = true
problem.value = ''
try {
const res = await api.validateRules(payload())
if (res.valid) toast.success(t('rules.valid'))
else problem.value = res.error ?? t('rules.invalid')
} catch (e) {
problem.value = e instanceof ApiError ? e.message : String(e)
} finally {
busy.value = false
}
}
async function save() {
busy.value = true
problem.value = ''
try {
const res = await api.saveRules(payload())
rules.value = res.rules ?? []
raw.value = res.raw
dirty.value = false
toast.success(t('rules.saved'), t('rules.count', { n: rules.value.length }))
} catch (e) {
problem.value = e instanceof ApiError ? e.message : String(e)
toast.error(t('rules.invalid'), problem.value)
} finally {
busy.value = false
}
}
async function reload() {
busy.value = true
problem.value = ''
try {
const res = await api.rules()
rules.value = res.rules ?? []
raw.value = res.raw
dirty.value = false
} catch (e) {
problem.value = e instanceof ApiError ? e.message : String(e)
} finally {
busy.value = false
}
}
function move(index: number, delta: number) {
const target = index + delta
if (target < 0 || target >= rules.value.length) return
const next = [...rules.value]
const [item] = next.splice(index, 1)
next.splice(target, 0, item)
rules.value = next
dirty.value = true
}
function startAdd() {
editingIndex.value = -1
editing.value = { name: '', action: 'block', log: false, expr: '' }
modifierArgs.value = '{}'
}
function startEdit(index: number) {
editingIndex.value = index
editing.value = { ...rules.value[index] }
modifierArgs.value = JSON.stringify(rules.value[index].modifier?.args ?? {}, null, 2)
}
const modifierArgs = ref('{}')
const editError = ref('')
function commitEdit() {
const draft = editing.value
if (!draft) return
if (!draft.name.trim()) {
editError.value = t('rules.nameRequired')
return
}
if (!draft.expr.trim()) {
editError.value = t('rules.exprRequired')
return
}
if (draft.action === 'modify') {
try {
const args = JSON.parse(modifierArgs.value || '{}') as Record<string, unknown>
draft.modifier = { name: draft.modifier?.name ?? modifierOptions.value[0]?.value ?? '', args }
} catch {
editError.value = 'JSON'
return
}
} else {
draft.modifier = null
}
const next = [...rules.value]
if (editingIndex.value >= 0) next[editingIndex.value] = draft
else next.push(draft)
rules.value = next
dirty.value = true
editing.value = null
editError.value = ''
}
function remove(index: number) {
rules.value = rules.value.filter((_, i) => i !== index)
dirty.value = true
removing.value = -1
}
</script>
<template>
<div>
<PageHeader :title="t('rules.title')" :description="t('rules.subtitle')">
<template #actions>
<Button variant="ghost" size="sm" :disabled="busy" @click="reload">
<RotateCcw class="size-3.5" />
<span class="hidden sm:inline">{{ t('common.refresh') }}</span>
</Button>
<Button variant="outline" size="sm" :disabled="busy" @click="validate">
<CheckCircle2 class="size-3.5" />
<span class="hidden sm:inline">{{ t('rules.validate') }}</span>
</Button>
<Button size="sm" :disabled="busy" @click="save">
<Loader2 v-if="busy" class="size-3.5 animate-spin" />
<Save v-else class="size-3.5" />
{{ t('rules.save') }}
</Button>
</template>
</PageHeader>
<div class="mb-3 flex flex-wrap items-center gap-2">
<Tabs :model-value="mode" @update:model-value="switchMode">
<TabsList>
<TabsTrigger value="visual">{{ t('rules.visual') }}</TabsTrigger>
<TabsTrigger value="yaml">
<FileCode2 />
{{ t('rules.yaml') }}
</TabsTrigger>
</TabsList>
</Tabs>
<Badge variant="secondary">{{ t('rules.count', { n: rules.length }) }}</Badge>
<Badge v-if="dirty" variant="drop">{{ t('rules.unsaved') }}</Badge>
<span
v-if="path"
class="text-muted-foreground ml-auto hidden truncate font-mono text-[11px] sm:inline"
>{{ path }}</span
>
</div>
<p
v-if="problem"
class="border-destructive/40 bg-destructive/10 text-destructive mb-3 rounded-lg border px-3 py-2 font-mono text-xs break-words whitespace-pre-wrap"
>
{{ problem }}
</p>
<Tabs :model-value="mode" @update:model-value="switchMode">
<TabsContent value="visual">
<div v-if="loading" class="text-muted-foreground py-12 text-center text-sm">
{{ t('common.loading') }}
</div>
<div v-else class="flex flex-col gap-2">
<Card
v-for="(rule, index) in rules"
:key="`${rule.name}-${index}`"
class="p-3 sm:p-4"
>
<div class="flex items-start justify-between gap-3">
<div class="flex min-w-0 flex-col gap-1.5">
<div class="flex flex-wrap items-center gap-1.5">
<span class="text-muted-foreground font-mono text-[11px] tabular-nums"
>#{{ index + 1 }}</span
>
<span class="truncate text-sm font-medium">{{ rule.name }}</span>
<Badge v-if="rule.action" :variant="badgeVariant(rule.action)" class="uppercase">
{{ t(`action.${rule.action}`) }}
</Badge>
<Badge v-if="rule.log" variant="log">{{ t('action.log') }}</Badge>
<Badge v-if="rule.modifier?.name" variant="outline">
{{ rule.modifier.name }}
</Badge>
</div>
<code
class="text-muted-foreground bg-muted/60 rounded-md px-2 py-1.5 font-mono text-[11px] leading-relaxed break-all sm:text-xs"
>{{ rule.expr }}</code
>
</div>
<div class="flex shrink-0 flex-col gap-1 sm:flex-row">
<Button
variant="ghost"
size="icon-sm"
:aria-label="t('rules.moveUp')"
:disabled="index === 0"
@click="move(index, -1)"
>
<ArrowUp class="size-3.5" />
</Button>
<Button
variant="ghost"
size="icon-sm"
:aria-label="t('rules.moveDown')"
:disabled="index === rules.length - 1"
@click="move(index, 1)"
>
<ArrowDown class="size-3.5" />
</Button>
<Button
variant="ghost"
size="icon-sm"
:aria-label="t('common.edit')"
@click="startEdit(index)"
>
<Pencil class="size-3.5" />
</Button>
<Button
variant="ghost"
size="icon-sm"
class="text-destructive"
:aria-label="t('common.delete')"
@click="removing = index"
>
<Trash2 class="size-3.5" />
</Button>
</div>
</div>
</Card>
<p v-if="!rules.length" class="text-muted-foreground py-10 text-center text-sm">
{{ t('rules.empty') }}
</p>
<Button variant="outline" class="mt-1 w-full" @click="startAdd">
<Plus class="size-4" />
{{ t('rules.add') }}
</Button>
</div>
</TabsContent>
<TabsContent value="yaml">
<Textarea
v-model="raw"
spellcheck="false"
class="h-[60vh] resize-y font-mono text-xs leading-relaxed"
@update:model-value="dirty = true"
/>
<p class="text-muted-foreground mt-2 text-xs">{{ t('rules.hint') }}</p>
</TabsContent>
</Tabs>
<!-- Rule editor -->
<Modal
:open="editing !== null"
:title="editingIndex >= 0 ? t('rules.editTitle') : t('rules.addTitle')"
@update:open="!$event && ((editing = null), (editError = ''))"
>
<form v-if="editing" class="flex flex-col gap-4" @submit.prevent="commitEdit">
<div class="flex flex-col gap-2">
<Label for="rule-name">{{ t('rules.name') }}</Label>
<Input id="rule-name" v-model="editing.name" :placeholder="t('rules.namePlaceholder')" />
</div>
<div class="grid gap-4 sm:grid-cols-2">
<div class="flex flex-col gap-2">
<Label>{{ t('rules.action') }}</Label>
<Select
:model-value="editing.action ?? ''"
:options="actionOptions"
@update:model-value="editing.action = $event"
/>
</div>
<div class="flex flex-col justify-end gap-2">
<Label for="rule-log">{{ t('rules.log') }}</Label>
<div class="flex h-9 items-center">
<Switch id="rule-log" v-model="editing.log" />
</div>
</div>
</div>
<div v-if="editing.action === 'modify'" class="flex flex-col gap-4">
<div class="flex flex-col gap-2">
<Label>{{ t('rules.modifier') }}</Label>
<Select
:model-value="editing.modifier?.name ?? ''"
:options="modifierOptions"
@update:model-value="
editing.modifier = { name: $event, args: editing.modifier?.args }
"
/>
</div>
<div class="flex flex-col gap-2">
<Label for="rule-args">{{ t('rules.modifierArgs') }}</Label>
<Textarea id="rule-args" v-model="modifierArgs" class="font-mono text-xs" rows="4" />
</div>
</div>
<div class="flex flex-col gap-2">
<Label for="rule-expr">{{ t('rules.expr') }}</Label>
<Textarea
id="rule-expr"
v-model="editing.expr"
spellcheck="false"
:placeholder="t('rules.exprPlaceholder')"
class="min-h-28 font-mono text-xs leading-relaxed"
/>
</div>
<p v-if="editError" class="text-destructive text-sm">{{ editError }}</p>
</form>
<template #footer>
<Button variant="outline" @click="((editing = null), (editError = ''))">
{{ t('common.cancel') }}
</Button>
<Button @click="commitEdit">{{ t('common.save') }}</Button>
</template>
</Modal>
<!-- Delete confirmation -->
<Modal
:open="removing >= 0"
:title="t('common.delete')"
:description="
removing >= 0 ? t('rules.deleteConfirm', { name: rules[removing]?.name ?? '' }) : ''
"
@update:open="!$event && (removing = -1)"
>
<template #footer>
<Button variant="outline" @click="removing = -1">{{ t('common.cancel') }}</Button>
<Button variant="destructive" @click="remove(removing)">{{ t('common.delete') }}</Button>
</template>
</Modal>
</div>
</template>
+183
View File
@@ -0,0 +1,183 @@
<script setup lang="ts">
import { BookOpen, LogOut, Monitor, Moon, Sun } from 'lucide-vue-next'
import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import PageHeader from '@/components/PageHeader.vue'
import {
Button,
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
Select,
Separator,
} from '@/components/ui'
import { stopLive } from '@/composables/useLive'
import { useTheme, type Theme } from '@/composables/useTheme'
import { t, useI18n } from '@/i18n'
import { api } from '@/lib/api'
import type { Info } from '@/lib/types'
const router = useRouter()
const { theme, setTheme } = useTheme()
const { lang, setLang } = useI18n()
const info = ref<Info | null>(null)
onMounted(async () => {
try {
info.value = await api.info()
} catch {
/* the shell already surfaces connection problems */
}
})
const themes = computed(() => [
{ value: 'light', label: t('settings.themeLight'), icon: Sun },
{ value: 'dark', label: t('settings.themeDark'), icon: Moon },
{ value: 'system', label: t('settings.themeSystem'), icon: Monitor },
])
const langOptions = [
{ value: 'en', label: 'English' },
{ value: 'zh', label: '中文' },
]
const rows = computed(() => {
const i = info.value
if (!i) return []
return [
{ label: t('settings.version'), value: i.commit ? `${i.version} (${i.commit})` : i.version },
{ label: t('settings.platform'), value: i.platform },
{ label: t('settings.goVersion'), value: i.goVersion },
{ label: t('settings.hostname'), value: i.hostname },
{ label: t('settings.ruleFile'), value: i.ruleFile },
]
})
const engineRows = computed(() => {
const c = info.value?.config
if (!c) return []
const rows = [
{ label: t('settings.workers'), value: c.workers > 0 ? String(c.workers) : t('settings.auto') },
{
label: t('settings.workerQueueSize'),
value: c.workerQueueSize > 0 ? String(c.workerQueueSize) : t('settings.auto'),
},
{
label: t('settings.ioQueueSize'),
value: c.ioQueueSize > 0 ? String(c.ioQueueSize) : t('settings.auto'),
},
{ label: t('settings.ioLocal'), value: c.ioLocal ? t('common.enabled') : t('common.disabled') },
{ label: t('settings.ioRST'), value: c.ioRST ? t('common.enabled') : t('common.disabled') },
{
label: t('settings.udpMaxStreams'),
value: c.udpMaxStreams > 0 ? String(c.udpMaxStreams) : t('settings.auto'),
},
]
if (c.geoip) rows.push({ label: t('settings.geoip'), value: c.geoip })
if (c.geosite) rows.push({ label: t('settings.geosite'), value: c.geosite })
return rows
})
async function logout() {
stopLive()
await api.logout()
await router.replace({ name: 'login' })
}
</script>
<template>
<div class="max-w-3xl">
<PageHeader :title="t('settings.title')" :description="t('settings.subtitle')" />
<Card>
<CardHeader>
<CardTitle>{{ t('settings.appearance') }}</CardTitle>
</CardHeader>
<CardContent class="flex flex-col gap-4">
<div class="flex flex-col gap-2">
<span class="text-sm font-medium">{{ t('settings.theme') }}</span>
<div class="bg-muted grid grid-cols-3 gap-1 rounded-lg p-1">
<button
v-for="item in themes"
:key="item.value"
type="button"
class="flex items-center justify-center gap-1.5 rounded-md px-2 py-1.5 text-xs font-medium transition-colors sm:text-sm"
:class="
theme === item.value
? 'bg-background text-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground'
"
@click="setTheme(item.value as Theme)"
>
<component :is="item.icon" class="size-3.5" />
{{ item.label }}
</button>
</div>
</div>
<div class="flex items-center justify-between gap-4">
<span class="text-sm font-medium">{{ t('settings.language') }}</span>
<Select
:model-value="lang"
:options="langOptions"
class="w-36"
:aria-label="t('settings.language')"
@update:model-value="setLang($event as 'en' | 'zh')"
/>
</div>
</CardContent>
</Card>
<Card class="mt-3">
<CardHeader>
<CardTitle>{{ t('settings.instance') }}</CardTitle>
<CardDescription>{{ t('settings.engineConfig') }}</CardDescription>
</CardHeader>
<CardContent>
<dl class="flex flex-col">
<div
v-for="row in rows"
:key="row.label"
class="flex items-start justify-between gap-4 py-1.5 text-sm"
>
<dt class="text-muted-foreground shrink-0">{{ row.label }}</dt>
<dd class="truncate font-mono text-xs sm:text-[13px]">{{ row.value }}</dd>
</div>
</dl>
<Separator class="my-3" />
<dl class="flex flex-col">
<div
v-for="row in engineRows"
:key="row.label"
class="flex items-start justify-between gap-4 py-1.5 text-sm"
>
<dt class="text-muted-foreground shrink-0">{{ row.label }}</dt>
<dd class="truncate font-mono text-xs sm:text-[13px]">{{ row.value }}</dd>
</div>
</dl>
</CardContent>
</Card>
<Card class="mt-3">
<CardHeader>
<CardTitle>{{ t('settings.session') }}</CardTitle>
</CardHeader>
<CardContent class="flex flex-wrap gap-2">
<Button variant="destructive" @click="logout">
<LogOut class="size-4" />
{{ t('settings.logout') }}
</Button>
<Button variant="outline" as="a" href="https://gfw.dev/" target="_blank" rel="noreferrer">
<BookOpen class="size-4" />
{{ t('settings.docs') }}
</Button>
</CardContent>
</Card>
</div>
</template>
+25
View File
@@ -0,0 +1,25 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "preserve",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.vue"]
}
+4
View File
@@ -0,0 +1,4 @@
{
"files": [],
"references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }]
}
+16
View File
@@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"strict": true,
"types": ["node"]
},
"include": ["vite.config.ts"]
}
+29
View File
@@ -0,0 +1,29 @@
import { fileURLToPath, URL } from 'node:url'
import tailwindcss from '@tailwindcss/vite'
import vue from '@vitejs/plugin-vue'
import { defineConfig } from 'vite'
// The production build lands in ../dist, which is embedded into the Go binary.
export default defineConfig({
plugins: [vue(), tailwindcss()],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
},
},
build: {
outDir: '../dist',
emptyOutDir: true,
chunkSizeWarningLimit: 900,
},
server: {
port: 5173,
proxy: {
'/api': {
target: process.env.OPENGFW_API ?? 'http://127.0.0.1:8080',
changeOrigin: true,
},
},
},
})