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,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'
|
||||
Reference in New Issue
Block a user