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:
@@ -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>
|
||||
Generated
+2501
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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 |
@@ -0,0 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import { RouterView } from 'vue-router'
|
||||
|
||||
import Toaster from '@/components/Toaster.vue'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<RouterView />
|
||||
<Toaster />
|
||||
</template>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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'
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
Vendored
+7
@@ -0,0 +1,7 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
declare module '*.vue' {
|
||||
import type { DefineComponent } from 'vue'
|
||||
const component: DefineComponent<{}, {}, any>
|
||||
export default component
|
||||
}
|
||||
@@ -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',
|
||||
},
|
||||
}
|
||||
@@ -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'
|
||||
@@ -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: '错误',
|
||||
},
|
||||
}
|
||||
@@ -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`
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from 'clsx'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
@@ -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')
|
||||
@@ -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
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user