feat: Wormhole 内网穿透与反向代理网关
CI / docker (push) Successful in 1m58s

Go + Vue 管理台:Agent 隧道、域名反代、IP 安全、访客验证与监控大盘。
Gitea Actions CI 多架构镜像;纯 Go SQLite、无 CGO Docker 构建。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-15 04:46:59 +00:00
committed by renjue
commit 0d84b07f68
111 changed files with 15770 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
registry=https://registry.npmmirror.com
+10
View File
@@ -0,0 +1,10 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<title>Wormhole</title>
</head>
<body>
<p>Wormhole API running. Build web UI: <code>make web</code></p>
</body>
</html>
+6
View File
@@ -0,0 +1,6 @@
package web
import "embed"
//go:embed dist/*
var Dist embed.FS
+13
View File
@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<title>Wormhole</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>
+1846
View File
File diff suppressed because it is too large Load Diff
+24
View File
@@ -0,0 +1,24 @@
{
"name": "wormhole-web",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@element-plus/icons-vue": "^2.3.2",
"axios": "^1.7.7",
"echarts": "^5.5.1",
"element-plus": "^2.8.4",
"vue": "^3.5.11",
"vue-i18n": "^9.14.4",
"vue-router": "^4.4.5"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.1.4",
"vite": "^5.4.8"
}
}
+10
View File
@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" fill="none">
<rect width="32" height="32" rx="8" fill="#0f7a62"/>
<g transform="translate(4 4)" stroke="#fafafa" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<ellipse cx="7.5" cy="12" rx="3.25" ry="5"/>
<ellipse cx="16.5" cy="12" rx="3.25" ry="5"/>
<path d="M10.25 8.25C11.35 9.35 12.65 9.35 13.75 8.25"/>
<path d="M10.25 15.75C11.35 14.65 12.65 14.65 13.75 15.75"/>
<circle cx="12" cy="12" r="1.5" fill="#fafafa" stroke="none"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 548 B

+15
View File
@@ -0,0 +1,15 @@
<template>
<el-config-provider :locale="epLocale">
<router-view />
</el-config-provider>
</template>
<script setup>
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import zhCn from 'element-plus/dist/locale/zh-cn.mjs'
import enLocale from 'element-plus/dist/locale/en.mjs'
const { locale } = useI18n()
const epLocale = computed(() => (locale.value === 'en' ? enLocale : zhCn))
</script>
+27
View File
@@ -0,0 +1,27 @@
import axios from 'axios'
const api = axios.create({ baseURL: '/api' })
api.interceptors.request.use((config) => {
const token = localStorage.getItem('token')
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
})
api.interceptors.response.use(
(res) => res,
(err) => {
if (err.response?.status === 401) {
localStorage.removeItem('token')
localStorage.removeItem('user')
if (!window.location.pathname.includes('/login')) {
window.location.href = '/login'
}
}
return Promise.reject(err)
}
)
export default api
+19
View File
@@ -0,0 +1,19 @@
<template>
<div class="app-card">
<div v-if="title || $slots.header" class="app-card__header">
<slot name="header">
<h3 class="app-card__title">{{ title }}</h3>
</slot>
</div>
<div :class="padded ? 'app-card__body--padded' : ''">
<slot />
</div>
</div>
</template>
<script setup>
defineProps({
title: { type: String, default: '' },
padded: { type: Boolean, default: false },
})
</script>
+33
View File
@@ -0,0 +1,33 @@
<template>
<svg
:width="size"
:height="size"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
>
<!-- Wormhole: twin portals linked by a tunnel (outlined, currentColor) -->
<ellipse cx="7.5" cy="12" rx="3.25" ry="5" stroke="currentColor" stroke-width="2" />
<ellipse cx="16.5" cy="12" rx="3.25" ry="5" stroke="currentColor" stroke-width="2" />
<path
d="M10.25 8.25C11.35 9.35 12.65 9.35 13.75 8.25"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
/>
<path
d="M10.25 15.75C11.35 14.65 12.65 14.65 13.75 15.75"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
/>
<circle cx="12" cy="12" r="1.5" fill="currentColor" />
</svg>
</template>
<script setup>
defineProps({
size: { type: [Number, String], default: 20 },
})
</script>
+37
View File
@@ -0,0 +1,37 @@
<template>
<div ref="root" class="chart-box" />
</template>
<script setup>
import { ref, onMounted, onUnmounted, watch } from 'vue'
import * as echarts from 'echarts'
const props = defineProps({
option: { type: Object, default: null },
})
const root = ref(null)
let chart
function resize() {
chart?.resize()
}
function render() {
if (!chart || !props.option) return
chart.setOption(props.option, { notMerge: true })
}
onMounted(() => {
chart = echarts.init(root.value)
render()
window.addEventListener('resize', resize)
})
onUnmounted(() => {
window.removeEventListener('resize', resize)
chart?.dispose()
})
watch(() => props.option, render, { deep: true })
</script>
+36
View File
@@ -0,0 +1,36 @@
<template>
<el-select
:model-value="modelValue"
:disabled="disabled"
filterable
:placeholder="t('clientSelect.placeholder')"
class="w-full"
@update:model-value="$emit('update:modelValue', $event)"
>
<el-option
v-for="c in clients"
:key="c.id"
:label="clientLabel(c)"
:value="c.id"
/>
</el-select>
</template>
<script setup>
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
defineProps({
modelValue: { type: Number, default: null },
clients: { type: Array, default: () => [] },
disabled: { type: Boolean, default: false },
})
defineEmits(['update:modelValue'])
function clientLabel(c) {
const status = c.online ? t('common.online') : t('common.offline')
return t('clientSelect.label', { name: c.name, id: c.id, status })
}
</script>
+109
View File
@@ -0,0 +1,109 @@
<template>
<el-button @click="handleExport" :loading="exporting">{{ t('common.export') }}</el-button>
<el-button @click="openImport">{{ t('common.import') }}</el-button>
<input
ref="fileInput"
type="file"
accept="application/json,.json"
class="hidden-input"
@change="onFileChange"
/>
<el-dialog v-model="importVisible" :title="t('importExport.importTitle')" width="480px" destroy-on-close>
<el-form label-width="100px">
<el-form-item :label="t('importExport.importMode')">
<el-select v-model="importMode">
<el-option :label="t('importExport.upsert')" value="upsert" />
<el-option :label="t('importExport.skip')" value="skip" />
<el-option :label="t('importExport.create')" value="create" />
</el-select>
</el-form-item>
<el-form-item :label="t('importExport.pending')">
<span>{{ t('importExport.pendingCount', { n: pendingItems.length }) }}</span>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="importVisible = false">{{ t('common.cancel') }}</el-button>
<el-button type="primary" :loading="importing" @click="confirmImport">{{ t('importExport.startImport') }}</el-button>
</template>
</el-dialog>
</template>
<script setup>
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { ElMessage } from 'element-plus'
import { exportResource, importResource, parseImportFile } from '@/utils/importExport'
const { t } = useI18n()
const props = defineProps({
resource: { type: String, required: true },
exportFilename: { type: String, required: true },
})
const emit = defineEmits(['done'])
const exporting = ref(false)
const importing = ref(false)
const importVisible = ref(false)
const importMode = ref('upsert')
const pendingItems = ref([])
const fileInput = ref(null)
async function handleExport() {
exporting.value = true
try {
const data = await exportResource(`/${props.resource}`, props.exportFilename)
ElMessage.success(t('importExport.exportSuccess', { n: data.items?.length ?? 0 }))
} catch (e) {
ElMessage.error(e.response?.data?.error || t('message.exportFailed'))
} finally {
exporting.value = false
}
}
function openImport() {
fileInput.value?.click()
}
async function onFileChange(e) {
const file = e.target.files?.[0]
e.target.value = ''
if (!file) return
try {
pendingItems.value = await parseImportFile(file)
importMode.value = 'upsert'
importVisible.value = true
} catch (err) {
ElMessage.error(err.message || t('importExport.parseFailed'))
}
}
async function confirmImport() {
importing.value = true
try {
const result = await importResource(`/${props.resource}`, importMode.value, pendingItems.value)
const msg = t('importExport.importDone', {
created: result.created,
updated: result.updated,
skipped: result.skipped,
failed: result.failed,
})
if (result.failed > 0) {
ElMessage.warning(msg)
if (result.errors?.length) {
console.warn('import errors:', result.errors)
}
} else {
ElMessage.success(msg)
}
importVisible.value = false
emit('done')
} catch (e) {
ElMessage.error(e.response?.data?.error || t('message.importFailed'))
} finally {
importing.value = false
}
}
</script>
+18
View File
@@ -0,0 +1,18 @@
<template>
<div class="page-header">
<div>
<h1 class="page-title">{{ title }}</h1>
<p v-if="description" class="page-desc">{{ description }}</p>
</div>
<div v-if="$slots.actions" class="page-actions">
<slot name="actions" />
</div>
</div>
</template>
<script setup>
defineProps({
title: { type: String, required: true },
description: { type: String, default: '' },
})
</script>
+104
View File
@@ -0,0 +1,104 @@
<template>
<div class="proxy-headers-fields">
<el-form-item :label="t('ipForward.ipForward')">
<el-radio-group v-model="ipMode">
<el-radio label="replace">{{ ipForwardModeLabel('replace', t) }}</el-radio>
<el-radio label="append">{{ ipForwardModeLabel('append', t) }}</el-radio>
</el-radio-group>
<div class="form-hint">{{ modeHint }}</div>
</el-form-item>
<el-form-item :label="t('ipForward.customHeaders')">
<div class="header-list">
<div v-for="(row, idx) in headerRows" :key="idx" class="header-row">
<el-input v-model="row.key" :placeholder="t('ipForward.headerName')" @input="emitHeaders" />
<el-input v-model="row.value" :placeholder="t('ipForward.headerValue')" @input="emitHeaders" />
<el-button link type="danger" @click="removeRow(idx)">{{ t('common.delete') }}</el-button>
</div>
<el-button size="small" @click="addRow">{{ t('ipForward.addHeader') }}</el-button>
</div>
</el-form-item>
</div>
</template>
<script setup>
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { ipForwardModeHint, ipForwardModeLabel, normalizeIPForwardMode } from '@/utils/ipForward'
const { t } = useI18n()
const props = defineProps({
ipForwardMode: { type: String, default: 'replace' },
customHeaders: { type: String, default: '' },
})
const emit = defineEmits(['update:ipForwardMode', 'update:customHeaders'])
const headerRows = ref([])
function parseHeaders(raw) {
if (!raw || raw === '{}') return []
try {
const obj = JSON.parse(raw)
return Object.entries(obj).map(([key, value]) => ({ key, value: String(value) }))
} catch {
return []
}
}
function serializeHeaders(rows) {
const obj = {}
for (const row of rows) {
const key = row.key?.trim()
if (!key) continue
obj[key] = row.value ?? ''
}
return Object.keys(obj).length ? JSON.stringify(obj) : ''
}
function syncFromProps() {
headerRows.value = parseHeaders(props.customHeaders)
}
watch(() => props.customHeaders, syncFromProps, { immediate: true })
const ipMode = computed({
get() {
return normalizeIPForwardMode(props.ipForwardMode)
},
set(v) {
emit('update:ipForwardMode', v)
},
})
const modeHint = computed(() => ipForwardModeHint(ipMode.value, t))
function emitHeaders() {
emit('update:customHeaders', serializeHeaders(headerRows.value))
}
function addRow() {
headerRows.value.push({ key: '', value: '' })
emitHeaders()
}
function removeRow(idx) {
headerRows.value.splice(idx, 1)
emitHeaders()
}
</script>
<style scoped>
.header-list {
width: 100%;
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.header-row {
display: grid;
grid-template-columns: 1fr 1fr auto;
gap: var(--space-2);
align-items: center;
}
</style>
+74
View File
@@ -0,0 +1,74 @@
<template>
<el-dialog v-model="visible" :title="displayTitle" width="720px" destroy-on-close @open="load">
<el-table :data="items" v-loading="loading" size="small" class="table-scroll--lg">
<template v-if="type === 'tunnels'">
<el-table-column prop="name" :label="t('common.name')" />
<el-table-column prop="listen_port" :label="t('dashboard.listenPort')" width="80" />
<el-table-column :label="t('common.traffic')">
<template #default="{ row }">{{ formatBytes((row.inlet_flow || 0) + (row.export_flow || 0)) }}</template>
</el-table-column>
</template>
<template v-else-if="type === 'hosts'">
<el-table-column prop="host" :label="t('dashboard.host')" />
<el-table-column prop="target_addr" :label="t('common.target')" />
<el-table-column :label="t('common.traffic')">
<template #default="{ row }">{{ formatBytes((row.inlet_flow || 0) + (row.export_flow || 0)) }}</template>
</el-table-column>
</template>
<template v-else>
<el-table-column prop="ip" :label="t('dashboard.visitorIp')" min-width="120" />
<el-table-column prop="resource_label" :label="t('common.resource')" min-width="160" show-overflow-tooltip />
<el-table-column prop="hit_count" :label="t('dashboard.hitCount')" width="80" />
</template>
</el-table>
<div class="card-pager">
<el-pagination
v-model:current-page="page"
v-model:page-size="pageSize"
:total="total"
layout="total, prev, pager, next"
@current-change="load"
/>
</div>
</el-dialog>
</template>
<script setup>
import { ref, watch, computed } from 'vue'
import { useI18n } from 'vue-i18n'
import api from '@/api'
import { formatBytes } from '@/utils/format'
const { t } = useI18n()
const props = defineProps({
modelValue: Boolean,
type: { type: String, required: true },
title: { type: String, default: '' },
})
const emit = defineEmits(['update:modelValue'])
const visible = ref(false)
const items = ref([])
const total = ref(0)
const page = ref(1)
const pageSize = ref(50)
const loading = ref(false)
const displayTitle = computed(() => props.title || t('rank.defaultTitle'))
watch(() => props.modelValue, (v) => { visible.value = v })
watch(visible, (v) => emit('update:modelValue', v))
async function load() {
loading.value = true
try {
const { data } = await api.get('/dashboard/rankings', {
params: { type: props.type, page: page.value, page_size: pageSize.value },
})
items.value = data.items || []
total.value = data.total || 0
} finally {
loading.value = false
}
}
</script>
+13
View File
@@ -0,0 +1,13 @@
<template>
<div class="stat-card">
<div class="stat-card__label">{{ label }}</div>
<div class="stat-card__value">{{ value }}</div>
</div>
</template>
<script setup>
defineProps({
label: { type: String, required: true },
value: { type: [String, Number], required: true },
})
</script>
+73
View File
@@ -0,0 +1,73 @@
<template>
<div class="visitor-auth">
<el-divider content-position="left">{{ t('visitorAuth.title') }}</el-divider>
<el-form-item :label="t('visitorAuth.enable')">
<el-switch v-model="enabled" />
</el-form-item>
<template v-if="enabled">
<el-form-item :label="t('visitorAuth.allowVisitors')">
<el-radio-group v-model="bindMode">
<el-radio value="all">{{ t('visitorAuth.allVisitors') }}</el-radio>
<el-radio value="selected">{{ t('visitorAuth.selectedVisitors') }}</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item v-if="bindMode === 'selected'" :label="t('visitorAuth.selectVisitors')">
<el-select v-model="visitorIds" multiple filterable :placeholder="t('visitorAuth.selectVisitors')">
<el-option v-for="v in visitors" :key="v.id" :label="v.username" :value="v.id" />
</el-select>
</el-form-item>
<el-form-item :label="t('visitorAuth.ttl')">
<el-input-number v-model="ttlSeconds" :min="300" :max="604800" :step="3600" class="w-full" />
<div class="form-hint">{{ t('visitorAuth.ttlHint') }}</div>
</el-form-item>
<el-alert
v-if="tunnelHint"
type="info"
:closable="false"
show-icon
:title="t('visitorAuth.tunnelHint')"
class="u-mb-4"
/>
</template>
</div>
</template>
<script setup>
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
const props = defineProps({
visitorAuthEnabled: Boolean,
visitorBindMode: { type: String, default: 'all' },
visitorTtlSeconds: { type: Number, default: 7200 },
visitorIds: { type: Array, default: () => [] },
visitors: { type: Array, default: () => [] },
tunnelHint: Boolean,
})
const emit = defineEmits([
'update:visitorAuthEnabled',
'update:visitorBindMode',
'update:visitorTtlSeconds',
'update:visitorIds',
])
const enabled = computed({
get: () => props.visitorAuthEnabled,
set: (v) => emit('update:visitorAuthEnabled', v),
})
const bindMode = computed({
get: () => props.visitorBindMode || 'all',
set: (v) => emit('update:visitorBindMode', v),
})
const ttlSeconds = computed({
get: () => props.visitorTtlSeconds || 7200,
set: (v) => emit('update:visitorTtlSeconds', v),
})
const visitorIds = computed({
get: () => props.visitorIds || [],
set: (v) => emit('update:visitorIds', v),
})
</script>
+26
View File
@@ -0,0 +1,26 @@
import { ref } from 'vue'
import { formatChartTime } from '@/utils/datetime'
const MAX_POINTS = 36
export function useMetricHistory() {
const labels = ref([])
const cpu = ref([])
const memory = ref([])
const inlet = ref([])
const exportFlow = ref([])
function trim(arr) {
return arr.length > MAX_POINTS ? arr.slice(-MAX_POINTS) : arr
}
function record(snapshot) {
labels.value = trim([...labels.value, formatChartTime()])
cpu.value = trim([...cpu.value, Number(snapshot.cpu_percent?.toFixed?.(1) ?? 0)])
memory.value = trim([...memory.value, Number(snapshot.mem_percent?.toFixed?.(1) ?? 0)])
inlet.value = trim([...inlet.value, snapshot.inlet_flow ?? 0])
exportFlow.value = trim([...exportFlow.value, snapshot.export_flow ?? 0])
}
return { labels, cpu, memory, inlet, exportFlow, record }
}
+29
View File
@@ -0,0 +1,29 @@
import { createI18n } from 'vue-i18n'
import zhCN from '@/locales/zh-CN'
import en from '@/locales/en'
const LOCALE_KEY = 'wormhole-locale'
function detectLocale() {
const saved = localStorage.getItem(LOCALE_KEY)
if (saved === 'zh-CN' || saved === 'en') return saved
const lang = navigator.language || ''
return lang.startsWith('zh') ? 'zh-CN' : 'en'
}
export const i18n = createI18n({
legacy: false,
locale: detectLocale(),
fallbackLocale: 'zh-CN',
messages: { 'zh-CN': zhCN, en },
})
export function setLocale(locale) {
i18n.global.locale.value = locale
localStorage.setItem(LOCALE_KEY, locale)
document.documentElement.lang = locale === 'zh-CN' ? 'zh-CN' : 'en'
}
setLocale(i18n.global.locale.value)
export default i18n
+136
View File
@@ -0,0 +1,136 @@
<template>
<div class="layout">
<aside class="sidebar custom-scrollbar">
<div class="sidebar-brand">
<div class="brand-icon">
<AppLogo :size="20" />
</div>
<span class="brand-name">{{ t('app.name') }}</span>
</div>
<template v-for="group in navGroups" :key="group.labelKey">
<template v-if="group.visible">
<div class="nav-group-label">{{ t(group.labelKey) }}</div>
<router-link
v-for="item in group.items"
:key="item.path"
:to="item.path"
class="nav-item"
:class="{ active: route.path === item.path }"
>
<el-icon :size="16"><component :is="item.icon" /></el-icon>
{{ t(item.labelKey) }}
</router-link>
</template>
</template>
</aside>
<div class="main-area">
<header class="topbar">
<span class="topbar-title">{{ pageTitle }}</span>
<div class="topbar-user">
<el-select
:model-value="locale"
class="toolbar-select"
size="small"
@change="onLocaleChange"
>
<el-option :label="t('common.zh')" value="zh-CN" />
<el-option :label="t('common.en')" value="en" />
</el-select>
<span>{{ user?.username }}</span>
<el-button text @click="logout">{{ t('common.logout') }}</el-button>
</div>
</header>
<main class="main-content custom-scrollbar">
<router-view />
</main>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRoute, useRouter } from 'vue-router'
import {
Odometer,
Connection,
Share,
Monitor,
Lock,
User,
Setting,
} from '@element-plus/icons-vue'
import AppLogo from '@/components/AppLogo.vue'
import { setLocale } from '@/i18n'
const { t, locale } = useI18n()
const route = useRoute()
const router = useRouter()
const topbarKeys = {
'/dashboard': 'topbar.dashboard',
'/clients': 'topbar.clients',
'/tunnels': 'topbar.tunnels',
'/hosts': 'topbar.hosts',
'/security/ip-rules': 'topbar.ipRules',
'/security/request-ips': 'topbar.requestIPs',
'/users': 'topbar.users',
'/settings': 'topbar.settings',
}
const user = computed(() => {
try {
return JSON.parse(localStorage.getItem('user') || '{}')
} catch {
return {}
}
})
const isAdmin = computed(() => user.value?.role === 'admin')
const pageTitle = computed(() => t(topbarKeys[route.path] || 'topbar.fallback'))
const navGroups = computed(() => [
{
labelKey: 'nav.groups.overview',
visible: true,
items: [{ path: '/dashboard', labelKey: 'nav.dashboard', icon: Odometer }],
},
{
labelKey: 'nav.groups.tunnel',
visible: true,
items: [
{ path: '/clients', labelKey: 'nav.clients', icon: Connection },
{ path: '/tunnels', labelKey: 'nav.tunnels', icon: Share },
{ path: '/hosts', labelKey: 'nav.hosts', icon: Monitor },
],
},
{
labelKey: 'nav.groups.security',
visible: true,
items: [
{ path: '/security/ip-rules', labelKey: 'nav.ipRules', icon: Lock },
{ path: '/security/request-ips', labelKey: 'nav.requestIPs', icon: Monitor },
],
},
{
labelKey: 'nav.groups.system',
visible: isAdmin.value,
items: [
{ path: '/users', labelKey: 'nav.users', icon: User },
{ path: '/settings', labelKey: 'nav.settings', icon: Setting },
],
},
])
function onLocaleChange(value) {
setLocale(value)
}
function logout() {
localStorage.removeItem('token')
localStorage.removeItem('user')
router.push('/login')
}
</script>
+305
View File
@@ -0,0 +1,305 @@
export default {
app: {
name: 'Wormhole',
tagline: 'Intranet tunneling + reverse proxy gateway',
},
common: {
refresh: 'Refresh',
save: 'Save',
cancel: 'Cancel',
close: 'Close',
search: 'Search',
delete: 'Delete',
edit: 'Edit',
create: 'Create',
confirm: 'Confirm',
actions: 'Actions',
enabled: 'Enabled',
disabled: 'Disabled',
yes: 'Yes',
no: 'No',
name: 'Name',
status: 'Status',
id: 'ID',
optional: 'Optional',
loading: 'Loading…',
viewAll: 'View all',
logout: 'Sign out',
language: 'Language',
zh: '中文',
en: 'English',
hint: 'Notice',
export: 'Export',
import: 'Import',
start: 'Start',
stop: 'Stop',
pause: 'Pause',
resume: 'Resume',
block: 'Block',
traffic: 'Traffic',
target: 'Target',
client: 'Client',
mode: 'Mode',
port: 'Port',
prefix: 'Prefix',
resource: 'Resource',
method: 'Method',
path: 'Path',
time: 'Time',
type: 'Type',
running: 'Running',
stopped: 'Stopped',
paused: 'Paused',
on: 'On',
off: 'Off',
online: 'Online',
offline: 'Offline',
selectClient: 'Please select a client',
dash: '-',
},
nav: {
groups: {
overview: 'Overview',
tunnel: 'Tunnel',
security: 'Security',
system: 'System',
},
dashboard: 'Dashboard',
clients: 'Clients',
tunnels: 'Tunnels',
hosts: 'Hosts',
ipRules: 'IP rules',
requestIPs: 'Request IPs',
users: 'Visitors',
settings: 'Settings',
},
topbar: {
dashboard: 'Dashboard',
clients: 'Client management',
tunnels: 'Tunnel management',
hosts: 'Host routing',
ipRules: 'IP rules',
requestIPs: 'Request IP monitor',
users: 'Visitor users',
settings: 'System settings',
fallback: 'Wormhole',
},
login: {
title: 'Sign in',
username: 'Username',
password: 'Password',
submit: 'Sign in',
hint: 'Default account admin / admin',
lockDefault: 'Too many login attempts. Please try again later.',
lockMinutes: 'Too many login attempts. Try again in {n} minute(s).',
lockSeconds: 'Too many login attempts. Try again in {n} second(s).',
},
dashboard: {
title: 'Dashboard',
description: 'System status, traffic and access monitoring',
openapi: 'OpenAPI',
statClientTotal: 'Total clients',
statClientOnline: 'Online clients',
statTunnelRunning: 'Running tunnels',
statHostTotal: 'Total hosts',
systemResources: 'System resources',
trafficOverview: 'Traffic overview',
cpu: 'CPU',
memory: 'Memory',
load: 'Load',
inlet: 'Inbound',
export: 'Outbound',
resourceTrend: 'Resource trend',
trafficTrend: 'Traffic trend',
tunnelTop10: 'Top 10 tunnels by traffic',
hostTop10: 'Top 10 hosts by traffic',
ipTop10: 'Top 10 request IPs',
rankTunnels: 'Tunnel traffic ranking',
rankHosts: 'Host traffic ranking',
rankIps: 'Request IP ranking',
visitorIp: 'Visitor IP',
directIp: 'Direct IP',
hitCount: 'Hits',
host: 'Host',
listenPort: 'Port',
},
clients: {
title: 'Client management',
description: 'Manage Agent connections and verify_key',
add: 'Add client',
edit: 'Edit client',
verifyKey: 'Verify Key',
rateLimit: 'Rate limit (KB/s)',
maxConn: 'Max connections',
confirmDelete: 'Delete this client?',
},
tunnels: {
title: 'Tunnel management',
description: 'Configure TCP/UDP port forwarding tunnels',
add: 'Add tunnel',
edit: 'Edit tunnel',
searchPlaceholder: 'Search name, port, target',
listenPort: 'Listen port',
targetAddr: 'Target address',
visitorAuth: 'Visitor auth',
confirmDelete: 'Delete this tunnel?',
tcp: 'TCP',
udp: 'UDP',
},
hosts: {
title: 'Host routing',
description: 'HTTP/HTTPS reverse proxy and Host routing',
add: 'Add host',
edit: 'Edit host',
searchPlaceholder: 'Search host, target',
host: 'Host',
location: 'Path prefix',
autoHttps: 'Auto HTTPS',
visitorAuth: 'Visitor auth',
confirmDelete: 'Delete this host?',
},
ipRules: {
title: 'IP rules',
description: 'Allow / deny lists and CIDR access control',
add: 'Add rule',
edit: 'Edit rule',
pattern: 'Pattern',
patternPlaceholder: 'IP / CIDR / regex',
ruleType: 'Type',
allow: 'Allow',
deny: 'Deny',
resourceType: 'Resource type',
global: 'Global',
tunnel: 'Tunnel',
host: 'Host',
resource: 'Resource',
resourceId: 'Resource ID',
selectResource: 'Select a resource',
noResource: 'No resources available. Create one first.',
resourceRequired: 'Please select a resource',
confirmDelete: 'Delete this rule?',
},
requestIPs: {
title: 'Request IP monitor',
description: 'Visitor IP (X-Forwarded-For preferred) and direct IP',
searchPlaceholder: 'Search IP',
resourceType: 'Resource type',
resourceId: 'Resource ID',
hitCount: 'Hit count',
lastSeen: 'Last seen',
confirmBlock: 'Add this IP to the block list?',
},
users: {
title: 'Visitor users',
description: 'Manage visitor accounts for protected hosts/tunnels',
add: 'Add visitor',
edit: 'Edit visitor',
username: 'Username',
password: 'Password',
passwordPlaceholderEdit: 'Leave blank to keep unchanged',
accessLogs: 'Access logs',
accessLogsTitle: 'Access logs · {name}',
logsHint: 'Latest {n} entries (max 200, static assets excluded)',
login: 'Login',
visit: 'Visit',
confirmDelete: 'Delete this visitor?',
},
settings: {
title: 'System settings',
description: 'Server runtime parameters (some changes require restart)',
listenPorts: 'Listen ports',
bridgeAddr: 'Bridge address',
httpAddr: 'Admin API / Web',
httpProxyPort: 'HTTP proxy port',
httpsProxyPort: 'HTTPS proxy port',
tlsCert: 'TLS certificate',
tlsKey: 'TLS private key',
tlsOptional: 'Optional, leave blank to disable',
authSecurity: 'Auth & security',
jwtSecret: 'JWT secret',
jwtSecretPlaceholder: 'Leave blank to keep unchanged',
tokenTtl: 'Token TTL (hours)',
loginMaxAttempts: 'Max login attempts',
loginWindow: 'Attempt window (minutes)',
loginLockout: 'Lockout duration (minutes)',
adminAccount: 'Admin account',
currentPassword: 'Current password',
newPassword: 'New password',
confirmPassword: 'Confirm password',
changePassword: 'Change admin password',
metrics: 'Metrics',
flushInterval: 'Flush interval (seconds)',
apiKey: 'API Key',
apiKeyHint: 'For programmatic API access (port 8529), use header {header}. Docs: {doc}',
createApiKey: 'Create API Key',
createApiKeyTitle: 'Create API Key',
apiKeyNamePlaceholder: 'e.g. CI/CD',
apiKeySave: 'Save this key: {key}',
fillPassword: 'Please enter current and new password',
passwordMismatch: 'New passwords do not match',
enterApiKeyName: 'Please enter a name',
},
ipForward: {
replace: {
label: 'Replace real IP',
hint: 'Overwrite X-Forwarded-For and X-Real-IP with visitor IP, no proxy chain preserved',
},
append: {
label: 'Standard forward headers',
hint: 'Append X-Forwarded-For and set X-Real-IP per reverse-proxy convention (like Nginx proxy_add_x_forwarded_for)',
},
ipForward: 'IP forwarding',
customHeaders: 'Custom headers',
headerName: 'Header name',
headerValue: 'Header value',
addHeader: 'Add header',
},
visitorAuth: {
title: 'Visitor auth',
enable: 'Enable auth',
allowVisitors: 'Allowed visitors',
allVisitors: 'All visitors',
selectedVisitors: 'Selected visitors',
selectVisitors: 'Select visitor users',
ttl: 'Credential TTL',
ttlHint: 'Seconds; browser stores credential after successful login',
tunnelHint: 'With visitor auth, the port serves HTTP (for web apps). For SSH/TCP use host reverse proxy.',
},
importExport: {
importTitle: 'Bulk import',
importMode: 'Import mode',
upsert: 'Upsert (update if exists)',
skip: 'Skip existing (new only)',
create: 'Create all (skip conflicts)',
pending: 'Pending import',
pendingCount: '{n} item(s)',
startImport: 'Start import',
exportSuccess: 'Exported {n} item(s)',
importDone: 'Import done: created {created}, updated {updated}, skipped {skipped}, failed {failed}',
parseFailed: 'Failed to parse file',
},
clientSelect: {
placeholder: 'Select client',
label: '{name} (#{id}) · {status}',
},
rank: {
defaultTitle: 'Details',
},
message: {
loginSuccess: 'Signed in',
loginFailed: 'Sign in failed',
loginRateLimit: 'Too many login attempts',
saveSuccess: 'Saved',
saveFailed: 'Save failed',
deleteSuccess: 'Deleted',
exportFailed: 'Export failed',
importFailed: 'Import failed',
loadFailed: 'Load failed',
saved: 'Saved',
savedRestart: 'Saved. Restart wormhole server for listen port changes to take effect.',
passwordUpdated: 'Admin password updated',
passwordChangeFailed: 'Password change failed',
apiKeyCreated: 'Created. Copy and save the key.',
blockSuccess: 'Added to block list',
},
}
+305
View File
@@ -0,0 +1,305 @@
export default {
app: {
name: 'Wormhole',
tagline: '内网穿透 + 反向代理网关',
},
common: {
refresh: '刷新',
save: '保存',
cancel: '取消',
close: '关闭',
search: '搜索',
delete: '删除',
edit: '编辑',
create: '创建',
confirm: '确认',
actions: '操作',
enabled: '启用',
disabled: '禁用',
yes: '是',
no: '否',
name: '名称',
status: '状态',
id: 'ID',
optional: '可选',
loading: '加载中…',
viewAll: '查看全部',
logout: '退出',
language: '语言',
zh: '中文',
en: 'English',
hint: '提示',
export: '导出',
import: '导入',
start: '启动',
stop: '停止',
pause: '暂停',
resume: '恢复',
block: '拉黑',
traffic: '流量',
target: '目标',
client: '客户端',
mode: '模式',
port: '端口',
prefix: '前缀',
resource: '资源',
method: '方法',
path: '路径',
time: '时间',
type: '类型',
running: '运行中',
stopped: '已停止',
paused: '已暂停',
on: '已开启',
off: '未开启',
online: '在线',
offline: '离线',
selectClient: '请选择客户端',
dash: '-',
},
nav: {
groups: {
overview: '概览',
tunnel: '穿透',
security: '安全',
system: '系统',
},
dashboard: '仪表盘',
clients: '客户端',
tunnels: '隧道',
hosts: '域名',
ipRules: 'IP 规则',
requestIPs: '请求 IP',
users: '访客用户',
settings: '系统设置',
},
topbar: {
dashboard: '仪表盘',
clients: '客户端管理',
tunnels: '隧道管理',
hosts: '域名解析',
ipRules: 'IP 规则',
requestIPs: '请求 IP 监控',
users: '访客用户',
settings: '系统设置',
fallback: 'Wormhole',
},
login: {
title: '登录',
username: '用户名',
password: '密码',
submit: '登录',
hint: '默认账号 admin / admin',
lockDefault: '登录尝试次数过多,请稍后再试',
lockMinutes: '登录尝试次数过多,请 {n} 分钟后再试',
lockSeconds: '登录尝试次数过多,请 {n} 秒后再试',
},
dashboard: {
title: '仪表盘',
description: '系统运行状态、流量与访问监控概览',
openapi: 'OpenAPI',
statClientTotal: '客户端总数',
statClientOnline: '在线客户端',
statTunnelRunning: '运行隧道',
statHostTotal: '域名数量',
systemResources: '系统资源',
trafficOverview: '流量概览',
cpu: 'CPU',
memory: '内存',
load: '负载',
inlet: '入站',
export: '出站',
resourceTrend: '系统资源趋势',
trafficTrend: '流量趋势',
tunnelTop10: '隧道流量 Top10',
hostTop10: '域名流量 Top10',
ipTop10: '请求 IP Top10',
rankTunnels: '隧道流量排行',
rankHosts: '域名流量排行',
rankIps: '请求 IP 排行',
visitorIp: '访客 IP',
directIp: '直连 IP',
hitCount: '次数',
host: '域名',
listenPort: '端口',
},
clients: {
title: '客户端管理',
description: '管理 Agent 连接与 verify_key',
add: '新增客户端',
edit: '编辑客户端',
verifyKey: 'Verify Key',
rateLimit: '限速(KB/s)',
maxConn: '最大连接',
confirmDelete: '确认删除该客户端?',
},
tunnels: {
title: '隧道管理',
description: '配置 TCP/UDP 端口转发隧道',
add: '新增隧道',
edit: '编辑隧道',
searchPlaceholder: '搜索名称、端口、目标',
listenPort: '监听端口',
targetAddr: '目标地址',
visitorAuth: '访客登录',
confirmDelete: '确认删除该隧道?',
tcp: 'TCP',
udp: 'UDP',
},
hosts: {
title: '域名解析',
description: 'HTTP/HTTPS 反向代理与 Host 路由',
add: '新增域名',
edit: '编辑域名',
searchPlaceholder: '搜索域名、目标',
host: '域名',
location: '路径前缀',
autoHttps: '自动 HTTPS',
visitorAuth: '访客登录',
confirmDelete: '确认删除该域名?',
},
ipRules: {
title: 'IP 规则',
description: '白名单 / 黑名单 / CIDR 访问控制',
add: '新增规则',
edit: '编辑规则',
pattern: '匹配模式',
patternPlaceholder: 'IP / CIDR / 正则',
ruleType: '类型',
allow: '白名单',
deny: '黑名单',
resourceType: '资源类型',
global: '全局',
tunnel: '隧道',
host: '域名',
resource: '关联资源',
resourceId: '资源ID',
selectResource: '请选择资源',
noResource: '暂无可用资源,请先创建',
resourceRequired: '请选择关联资源',
confirmDelete: '确认删除该规则?',
},
requestIPs: {
title: '请求 IP 监控',
description: '访客真实 IP(优先 X-Forwarded-For)与直连 IP',
searchPlaceholder: '搜索 IP',
resourceType: '资源类型',
resourceId: '资源 ID',
hitCount: '访问次数',
lastSeen: '最后访问',
confirmBlock: '确认将该 IP 加入黑名单?',
},
users: {
title: '访客用户',
description: '管理可访问受保护域名/隧道的访客账号',
add: '新增访客',
edit: '编辑访客',
username: '用户名',
password: '密码',
passwordPlaceholderEdit: '留空不修改',
accessLogs: '访问记录',
accessLogsTitle: '访问记录 · {name}',
logsHint: '最近 {n} 条(最多保留 200 条,不含静态资源请求)',
login: '登录',
visit: '访问',
confirmDelete: '确认删除该访客?',
},
settings: {
title: '系统设置',
description: '服务端运行参数(保存后部分项需重启服务生效)',
listenPorts: '监听端口',
bridgeAddr: 'Bridge 地址',
httpAddr: '管理 API / Web',
httpProxyPort: 'HTTP 反代端口',
httpsProxyPort: 'HTTPS 反代端口',
tlsCert: 'TLS 证书',
tlsKey: 'TLS 私钥',
tlsOptional: '可选,留空不使用',
authSecurity: '认证与安全',
jwtSecret: 'JWT 密钥',
jwtSecretPlaceholder: '留空则不修改',
tokenTtl: 'Token 有效期(h)',
loginMaxAttempts: '登录最大尝试',
loginWindow: '统计窗口(分钟)',
loginLockout: '锁定时长(分钟)',
adminAccount: '管理员账号',
currentPassword: '当前密码',
newPassword: '新密码',
confirmPassword: '确认新密码',
changePassword: '修改管理员密码',
metrics: '指标',
flushInterval: '刷新间隔(秒)',
apiKey: 'API Key',
apiKeyHint: '用于程序化访问 API(端口 8529),请求头携带 {header}。文档:{doc}',
createApiKey: '创建 API Key',
createApiKeyTitle: '创建 API Key',
apiKeyNamePlaceholder: '如 CI/CD',
apiKeySave: '请保存:{key}',
fillPassword: '请填写当前密码和新密码',
passwordMismatch: '两次输入的新密码不一致',
enterApiKeyName: '请输入名称',
},
ipForward: {
replace: {
label: '覆盖真实 IP',
hint: '将 X-Forwarded-For、X-Real-IP 覆盖为访客真实 IP,不保留已有代理链',
},
append: {
label: '标准转发头',
hint: '按反向代理规范追加 X-Forwarded-For,并设置 X-Real-IP(等同 Nginx proxy_add_x_forwarded_for',
},
ipForward: 'IP 转发',
customHeaders: '自定义请求头',
headerName: 'Header 名称',
headerValue: 'Header 值',
addHeader: '添加请求头',
},
visitorAuth: {
title: '访客登录',
enable: '开启验证',
allowVisitors: '允许访客',
allVisitors: '全部访客',
selectedVisitors: '指定访客',
selectVisitors: '选择访客用户',
ttl: '凭证有效期',
ttlHint: '单位:秒,登录成功后浏览器将保存凭证',
tunnelHint: '开启访客登录后,该端口将以 HTTP 方式提供服务(适用于 Web 应用)。SSH 等 TCP 协议请使用域名反代。',
},
importExport: {
importTitle: '批量导入',
importMode: '导入模式',
upsert: '覆盖更新(已存在则更新)',
skip: '跳过已存在(仅导入新配置)',
create: '全部新建(冲突则跳过)',
pending: '待导入',
pendingCount: '{n} 条配置',
startImport: '开始导入',
exportSuccess: '已导出 {n} 条配置',
importDone: '导入完成:新增 {created},更新 {updated},跳过 {skipped},失败 {failed}',
parseFailed: '解析文件失败',
},
clientSelect: {
placeholder: '选择客户端',
label: '{name} (#{id}) · {status}',
},
rank: {
defaultTitle: '详情',
},
message: {
loginSuccess: '登录成功',
loginFailed: '登录失败',
loginRateLimit: '登录尝试次数过多',
saveSuccess: '保存成功',
saveFailed: '保存失败',
deleteSuccess: '已删除',
exportFailed: '导出失败',
importFailed: '导入失败',
loadFailed: '加载失败',
saved: '已保存',
savedRestart: '已保存,监听端口变更需重启 wormhole server 后生效',
passwordUpdated: '管理员密码已更新',
passwordChangeFailed: '修改失败',
apiKeyCreated: '已创建,请复制保存',
blockSuccess: '已加入黑名单',
},
}
+17
View File
@@ -0,0 +1,17 @@
import { createApp } from 'vue'
import ElementPlus from 'element-plus'
import zhCn from 'element-plus/dist/locale/zh-cn.mjs'
import enLocale from 'element-plus/dist/locale/en.mjs'
import 'element-plus/dist/index.css'
import '@/styles/theme.css'
import App from './App.vue'
import router from './router'
import i18n from '@/i18n'
const epLocales = { 'zh-CN': zhCn, en: enLocale }
createApp(App)
.use(ElementPlus, { locale: epLocales[i18n.global.locale.value] })
.use(i18n)
.use(router)
.mount('#app')
+48
View File
@@ -0,0 +1,48 @@
import { createRouter, createWebHistory } from 'vue-router'
import MainLayout from '@/layouts/MainLayout.vue'
import Login from '@/views/Login.vue'
import Dashboard from '@/views/Dashboard.vue'
import Clients from '@/views/Clients.vue'
import Tunnels from '@/views/Tunnels.vue'
import Hosts from '@/views/Hosts.vue'
import IPRules from '@/views/IPRules.vue'
import RequestIPs from '@/views/RequestIPs.vue'
import Users from '@/views/Users.vue'
import Settings from '@/views/Settings.vue'
const routes = [
{ path: '/login', component: Login },
{
path: '/',
component: MainLayout,
redirect: '/dashboard',
children: [
{ path: 'dashboard', component: Dashboard },
{ path: 'clients', component: Clients },
{ path: 'tunnels', component: Tunnels },
{ path: 'hosts', component: Hosts },
{ path: 'security/ip-rules', component: IPRules },
{ path: 'security/request-ips', component: RequestIPs },
{ path: 'users', component: Users },
{ path: 'settings', component: Settings },
],
},
]
const router = createRouter({
history: createWebHistory(),
routes,
})
router.beforeEach((to, from, next) => {
const token = localStorage.getItem('token')
if (to.path !== '/login' && !token) {
next('/login')
} else if (to.path === '/login' && token) {
next('/dashboard')
} else {
next()
}
})
export default router
+557
View File
@@ -0,0 +1,557 @@
:root {
--background: #f4f4f5;
--foreground: #18181b;
--card: #ffffff;
--card-foreground: #18181b;
--primary: #0f7a62;
--primary-hover: #0d6b56;
--primary-foreground: #fafafa;
--muted: #f4f4f5;
--muted-foreground: #71717a;
--border: #e4e4e7;
--input: #e4e4e7;
--destructive: #dc2626;
--success: #047857;
--warning: #b45309;
--danger: #dc2626;
--info: #a1a1aa;
--sidebar: rgba(250, 250, 250, 0.85);
--sidebar-foreground: #18181b;
--sidebar-accent: #f4f4f5;
--sidebar-border: #e4e4e7;
--sidebar-width: 15rem;
--radius: 0.5rem;
--space-1: 4px;
--space-2: 8px;
--space-3: 12px;
--space-4: 16px;
--space-5: 20px;
--space-6: 24px;
--space-8: 32px;
--el-color-primary: #0f7a62;
--el-color-primary-light-3: #4da08c;
--el-color-primary-light-5: #76b8a8;
--el-color-primary-light-7: #a0d0c4;
--el-color-primary-light-8: #b8dcd2;
--el-color-primary-light-9: #d0e8e1;
--el-color-primary-dark-2: #0c624e;
--el-color-success: #047857;
--el-color-warning: #b45309;
--el-color-danger: #dc2626;
--el-color-info: #a1a1aa;
--el-border-radius-base: 0.5rem;
--el-border-color: #e4e4e7;
--el-bg-color-page: #f4f4f5;
--el-text-color-primary: #18181b;
--el-text-color-regular: #3f3f46;
--el-text-color-secondary: #71717a;
--el-font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, "PingFang SC", "Microsoft YaHei", "Noto Sans SC", sans-serif;
}
* { box-sizing: border-box; }
html, body, #app {
margin: 0;
padding: 0;
height: 100%;
font-size: 14px;
font-family: var(--el-font-family);
background: var(--background);
color: var(--foreground);
-webkit-font-smoothing: antialiased;
}
.layout {
display: flex;
height: 100vh;
overflow: hidden;
}
.sidebar {
width: var(--sidebar-width);
flex-shrink: 0;
background: var(--sidebar);
border-right: 1px solid var(--sidebar-border);
backdrop-filter: blur(8px);
display: flex;
flex-direction: column;
overflow-y: auto;
}
.sidebar-brand {
display: flex;
align-items: center;
gap: var(--space-3);
padding: var(--space-5) var(--space-4);
}
.brand-icon {
width: 32px;
height: 32px;
border-radius: var(--radius);
background: var(--primary);
color: var(--primary-foreground);
display: flex;
align-items: center;
justify-content: center;
}
.brand-name {
font-size: 1.125rem;
font-weight: 600;
}
.nav-group-label {
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--muted-foreground);
padding: var(--space-4) var(--space-4) var(--space-2);
}
.nav-item {
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-2) var(--space-3);
margin: 0 var(--space-2);
border-radius: var(--radius);
color: var(--muted-foreground);
text-decoration: none;
font-size: 0.875rem;
font-weight: 500;
}
.nav-item:hover { background: var(--sidebar-accent); }
.nav-item.active {
background: var(--sidebar-accent);
color: var(--primary);
font-weight: 600;
}
.main-area {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
overflow: hidden;
}
.topbar {
height: 52px;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 var(--space-6);
background: var(--card);
border-bottom: 1px solid var(--border);
}
.topbar-title {
font-size: 0.875rem;
color: var(--muted-foreground);
}
.topbar-user {
display: flex;
align-items: center;
gap: var(--space-3);
font-size: 0.875rem;
color: var(--muted-foreground);
}
.main-content {
flex: 1;
overflow-y: auto;
padding: var(--space-6);
background: var(--background);
}
.page {
max-width: 1400px;
}
.page-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
margin-bottom: var(--space-6);
gap: var(--space-4);
}
.page-actions {
display: flex;
align-items: center;
gap: var(--space-2);
flex-shrink: 0;
flex-wrap: wrap;
}
.page-title {
font-size: 1.5rem;
font-weight: 600;
letter-spacing: -0.02em;
margin: 0 0 var(--space-1);
}
.page-desc {
font-size: 0.875rem;
color: var(--muted-foreground);
margin: 0;
}
.stat-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: var(--space-4);
margin-bottom: var(--space-6);
}
.stat-card {
background: var(--card);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: var(--space-5);
}
.stat-card__label {
font-size: 0.875rem;
color: var(--muted-foreground);
margin-bottom: var(--space-2);
}
.stat-card__value {
font-size: 1.75rem;
font-weight: 600;
}
.app-card {
background: var(--card);
border: 1px solid var(--border);
border-radius: var(--radius);
margin-bottom: var(--space-4);
}
.page > .app-card:last-child,
.page > .content-grid:last-child {
margin-bottom: 0;
}
.app-card__header {
padding: var(--space-4) var(--space-5);
border-bottom: 1px solid var(--border);
}
.app-card__title {
font-size: 0.9375rem;
font-weight: 600;
margin: 0;
}
.app-card__body--padded { padding: var(--space-5); }
.content-grid {
display: grid;
gap: var(--space-4);
margin-bottom: var(--space-6);
}
.content-grid--2-1 { grid-template-columns: 1.4fr 1fr; }
.content-grid--2 { grid-template-columns: 1fr 1fr; }
.content-grid--3 { grid-template-columns: repeat(3, 1fr); }
.card-toolbar {
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-4) var(--space-5) 0;
margin-bottom: var(--space-4);
flex-wrap: wrap;
}
.card-toolbar--desc {
align-items: flex-start;
}
.card-pager {
padding: var(--space-4) var(--space-5);
display: flex;
justify-content: flex-end;
}
.card-footer-hint {
padding: 0 var(--space-5) var(--space-3);
margin: 0;
}
.card-footer-hint + .card-pager {
padding-top: var(--space-3);
}
.card-inset-block {
margin: 0 var(--space-5) var(--space-4);
padding: var(--space-3);
background: var(--muted);
border-radius: var(--radius);
font-size: 0.875rem;
}
.tabs-bar {
padding: 0 var(--space-5);
border-bottom: 1px solid var(--border);
}
.tabs-bar .el-tabs__header { margin-bottom: 0; }
.page-toolbar {
display: flex;
align-items: center;
gap: var(--space-2);
margin-bottom: var(--space-4);
flex-wrap: wrap;
}
.inline-actions {
display: flex;
align-items: center;
gap: var(--space-2);
flex-wrap: wrap;
}
.card-header-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-4);
}
.form-hint {
margin-top: 6px;
font-size: 0.75rem;
color: var(--muted-foreground);
}
.form-hint--flush { margin-top: 0; }
.u-mt-3 { margin-top: var(--space-3); }
.u-mt-4 { margin-top: var(--space-4); }
.u-mb-3 { margin-bottom: var(--space-3); }
.u-mb-4 { margin-bottom: var(--space-4); }
.u-mb-6 { margin-bottom: var(--space-6); }
.text-center { text-align: center; }
.w-full { width: 100%; }
code.inline-code {
font-size: 0.75rem;
padding: 0 var(--space-1);
background: var(--muted);
border-radius: var(--radius);
}
.section-title {
font-size: 0.875rem;
font-weight: 600;
margin-bottom: var(--space-4);
}
.section-title:not(:first-child) { margin-top: var(--space-6); }
.login-page {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: var(--background);
padding: var(--space-6);
}
.login-wrap { max-width: 400px; width: 100%; text-align: center; }
.login-brand-icon {
width: 48px;
height: 48px;
border-radius: 12px;
background: var(--primary);
color: var(--primary-foreground);
display: inline-flex;
align-items: center;
justify-content: center;
margin-bottom: var(--space-4);
}
.login-card {
background: var(--card);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: var(--space-8);
margin-top: var(--space-6);
text-align: left;
}
.status-good { color: #047857; }
.status-warn { color: #b45309; }
.status-bad { color: #dc2626; }
.status-muted { color: var(--muted-foreground); }
.toolbar-select { width: 120px; }
.toolbar-select--country { width: 160px; }
.toolbar-select--wide { width: 280px; }
.toolbar-input { width: 200px; }
.toolbar-input--wide { width: 260px; }
.toolbar-input--grow { flex: 1; min-width: 200px; }
.table-cell-select { width: 100%; min-width: 200px; }
.el-form .el-select { width: 100%; }
.el-form .el-input-number { width: 100%; }
.app-card .el-table {
--el-table-header-bg-color: var(--muted);
font-size: 0.875rem;
}
.app-card .el-table--small .el-table__cell {
padding: var(--space-2) var(--space-3);
}
.app-card .el-table--small th.el-table__cell {
font-size: 0.75rem;
font-weight: 600;
color: var(--muted-foreground);
}
.app-card .el-table--small td.el-table__cell {
color: var(--el-text-color-regular);
}
.app-card .el-table--small .el-table__row {
height: 40px;
}
.app-card .el-table .el-button.is-link {
padding: 0;
height: auto;
}
.app-card .el-table .cell {
line-height: 1.4;
}
.table-scroll--sm { max-height: 320px; }
.table-scroll--md { max-height: 360px; }
.table-scroll--lg { max-height: 480px; }
.card-toolbar .toolbar-input--grow {
flex: 1;
min-width: 200px;
}
.card-pager .el-pagination {
font-size: 0.875rem;
}
.kv-block p {
margin: 0 0 var(--space-2);
}
.kv-block p:last-child {
margin-bottom: 0;
}
.kv-block .label {
display: inline-block;
width: 5rem;
color: var(--muted-foreground);
}
.kv-block code {
font-size: 0.8125rem;
}
.cell-stack {
display: flex;
flex-direction: column;
gap: var(--space-2);
width: 100%;
}
.meta-list {
margin: 0;
padding: 0;
list-style: none;
font-size: 0.8125rem;
color: var(--muted-foreground);
}
.meta-list li {
display: flex;
justify-content: space-between;
gap: var(--space-3);
padding: var(--space-1) 0;
}
.meta-list__mono {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
}
.meta-list__ok { color: var(--el-color-success); }
.meta-list__warn { color: var(--el-color-warning); }
.chart-box {
height: 280px;
}
.custom-scrollbar {
scrollbar-width: thin;
scrollbar-color: #e4e4e7 transparent;
}
.custom-scrollbar::-webkit-scrollbar {
width: 6px;
height: 6px;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background: #e4e4e7;
border-radius: 3px;
}
.custom-scrollbar::-webkit-scrollbar-track {
background: transparent;
}
.settings-form {
max-width: 640px;
}
.card-actions {
margin-bottom: var(--space-3);
}
.hidden-input {
display: none;
}
.el-card {
border: 1px solid var(--border);
box-shadow: none !important;
}
.el-dialog {
border-radius: var(--radius);
}
@media (max-width: 1024px) {
.stat-grid { grid-template-columns: repeat(2, 1fr); }
.content-grid--2-1,
.content-grid--2,
.content-grid--3 { grid-template-columns: 1fr; }
}
@media (max-width: 640px) {
.stat-grid { grid-template-columns: 1fr; }
.main-content { padding: var(--space-4); }
.page-header { flex-direction: column; }
}
+41
View File
@@ -0,0 +1,41 @@
/** §6.16 ECharts 品牌色与默认 grid */
export const CHART_COLORS = ['#0f7a62', '#71717a', '#4da08c', '#76b8a8', '#a0d0c4']
const AXIS_LABEL = { color: '#71717a', fontSize: 12 }
const AXIS_LINE = { lineStyle: { color: '#e4e4e7' } }
const SPLIT_LINE = { lineStyle: { color: '#e4e4e7' } }
export function buildLineChartOption({ labels, series, legend = true, yAxisFormatter }) {
return {
color: CHART_COLORS,
grid: { left: 40, right: 16, top: legend ? 36 : 16, bottom: 24 },
legend: legend
? { top: 0, textStyle: { fontSize: 12, color: '#71717a' } }
: undefined,
tooltip: { trigger: 'axis' },
xAxis: {
type: 'category',
data: labels,
axisLabel: AXIS_LABEL,
axisLine: AXIS_LINE,
},
yAxis: {
type: 'value',
axisLabel: {
...AXIS_LABEL,
formatter: yAxisFormatter,
},
axisLine: AXIS_LINE,
splitLine: SPLIT_LINE,
},
series: series.map((item, index) => ({
name: item.name,
type: 'line',
smooth: true,
data: item.data,
color: CHART_COLORS[index % CHART_COLORS.length],
lineStyle: { width: 2 },
showSymbol: false,
})),
}
}
+22
View File
@@ -0,0 +1,22 @@
/** §7.7: YYYY-MM-DD HH:mm:ss */
export function formatDateTime(value) {
if (!value) return '-'
let iso
if (value instanceof Date) {
if (Number.isNaN(value.getTime())) return '-'
iso = value.toISOString()
} else {
const d = new Date(value)
if (Number.isNaN(d.getTime())) return String(value)
iso = d.toISOString()
}
return iso.replace('T', ' ').slice(0, 19)
}
/** 图表 X 轴短标签 */
export function formatChartTime(date = new Date()) {
const h = String(date.getHours()).padStart(2, '0')
const m = String(date.getMinutes()).padStart(2, '0')
const s = String(date.getSeconds()).padStart(2, '0')
return `${h}:${m}:${s}`
}
+11
View File
@@ -0,0 +1,11 @@
export function formatBytes(n) {
if (!n) return '0 B'
const units = ['B', 'KB', 'MB', 'GB', 'TB']
let i = 0
let val = n
while (val >= 1024 && i < units.length - 1) {
val /= 1024
i++
}
return `${val.toFixed(1)} ${units[i]}`
}
+52
View File
@@ -0,0 +1,52 @@
import api from '@/api'
export function downloadJSON(filename, data) {
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = filename
a.click()
URL.revokeObjectURL(url)
}
export function parseImportFile(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => {
try {
const data = JSON.parse(reader.result)
const items = Array.isArray(data) ? data : data?.items
if (!Array.isArray(items) || items.length === 0) {
reject(new Error('文件中没有可导入的配置项'))
return
}
resolve(items)
} catch {
reject(new Error('JSON 格式无效'))
}
}
reader.onerror = () => reject(new Error('读取文件失败'))
reader.readAsText(file)
})
}
export async function exportResource(path, filename) {
const { data } = await api.get(`${path}/export`)
downloadJSON(filename, data)
return data
}
export async function importResource(path, mode, items) {
const { data } = await api.post(`${path}/import`, { mode, items })
return data
}
export function importModeLabel(mode) {
const map = {
upsert: '覆盖更新(已存在则更新)',
skip: '跳过已存在(仅导入新配置)',
create: '全部新建(冲突则跳过)',
}
return map[mode] || mode
}
+19
View File
@@ -0,0 +1,19 @@
/** @param {string} mode */
export function normalizeIPForwardMode(mode) {
if (mode === 'append' || mode === 'replace') return mode
if (mode === 'proxy') return 'append'
if (mode === 'real') return 'replace'
return 'replace'
}
/** @param {string} mode @param {(key: string) => string} t */
export function ipForwardModeLabel(mode, t) {
const key = normalizeIPForwardMode(mode)
return t(`ipForward.${key}.label`)
}
/** @param {string} mode @param {(key: string) => string} t */
export function ipForwardModeHint(mode, t) {
const key = normalizeIPForwardMode(mode)
return t(`ipForward.${key}.hint`)
}
+43
View File
@@ -0,0 +1,43 @@
/** 将 target_addr 转为可打开的 URL */
export function targetHref(addr) {
if (!addr) return ''
const trimmed = String(addr).trim()
if (/^https?:\/\//i.test(trimmed)) return trimmed
return `http://${trimmed}`
}
/** 域名解析:点击域名打开对外访问地址 */
export function hostHref(row) {
const domain = row?.host?.trim()
if (!domain) return ''
if (/^https?:\/\//i.test(domain)) return domain
const useHttps = row.auto_https || row.scheme === 'https'
const scheme = useHttps ? 'https' : 'http'
let loc = (row.location || '/').trim()
if (!loc.startsWith('/')) loc = `/${loc}`
const suffix = loc === '/' ? '' : loc
return `${scheme}://${domain}${suffix}`
}
/** 隧道:点击监听端口打开服务端对外入口 */
export function tunnelHref(row) {
const port = row?.listen_port
if (!port) return ''
const host = window.location.hostname || 'localhost'
return `http://${host}:${port}`
}
export function openTarget(addr) {
const url = targetHref(addr)
if (url) window.open(url, '_blank', 'noopener,noreferrer')
}
export function openHost(row) {
const url = hostHref(row)
if (url) window.open(url, '_blank', 'noopener,noreferrer')
}
export function openTunnel(row) {
const url = tunnelHref(row)
if (url) window.open(url, '_blank', 'noopener,noreferrer')
}
+116
View File
@@ -0,0 +1,116 @@
<template>
<div class="page">
<PageHeader :title="t('clients.title')" :description="t('clients.description')">
<template #actions>
<el-button @click="load">{{ t('common.refresh') }}</el-button>
<el-button type="primary" @click="openDialog()">{{ t('clients.add') }}</el-button>
</template>
</PageHeader>
<AppCard>
<el-table :data="list" v-loading="loading" size="small">
<el-table-column prop="id" :label="t('common.id')" width="60" />
<el-table-column prop="name" :label="t('common.name')" min-width="120" />
<el-table-column prop="verify_key" :label="t('clients.verifyKey')" min-width="200" show-overflow-tooltip />
<el-table-column :label="t('common.status')" width="90">
<template #default="{ row }">
<el-tag :type="row.online ? 'success' : 'info'" size="small">
{{ row.online ? t('common.online') : t('common.offline') }}
</el-tag>
</template>
</el-table-column>
<el-table-column :label="t('common.traffic')" width="120">
<template #default="{ row }">
{{ formatBytes((row.inlet_flow || 0) + (row.export_flow || 0)) }}
</template>
</el-table-column>
<el-table-column :label="t('common.actions')" width="140" fixed="right">
<template #default="{ row }">
<el-button size="small" link type="primary" @click="openDialog(row)">{{ t('common.edit') }}</el-button>
<el-button size="small" link type="danger" @click="remove(row)">{{ t('common.delete') }}</el-button>
</template>
</el-table-column>
</el-table>
</AppCard>
<el-dialog
v-model="dialogVisible"
:title="form.id ? t('clients.edit') : t('clients.add')"
width="560px"
destroy-on-close
>
<el-form :model="form" label-width="100px">
<el-form-item :label="t('common.name')"><el-input v-model="form.name" /></el-form-item>
<el-form-item :label="t('clients.rateLimit')"><el-input-number v-model="form.rate_limit" :min="0" class="w-full" /></el-form-item>
<el-form-item :label="t('clients.maxConn')"><el-input-number v-model="form.max_conn" :min="0" class="w-full" /></el-form-item>
<ProxyHeadersFields
v-model:ip-forward-mode="form.ip_forward_mode"
v-model:custom-headers="form.custom_headers"
/>
<el-form-item :label="t('common.enabled')"><el-switch v-model="form.status" /></el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">{{ t('common.cancel') }}</el-button>
<el-button type="primary" @click="save">{{ t('common.save') }}</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { ElMessage, ElMessageBox } from 'element-plus'
import PageHeader from '@/components/PageHeader.vue'
import AppCard from '@/components/AppCard.vue'
import ProxyHeadersFields from '@/components/ProxyHeadersFields.vue'
import api from '@/api'
import { formatBytes } from '@/utils/format'
const { t } = useI18n()
const list = ref([])
const loading = ref(false)
const dialogVisible = ref(false)
const form = reactive({
id: null,
name: '',
rate_limit: 0,
max_conn: 0,
ip_forward_mode: 'replace',
custom_headers: '',
status: true,
})
async function load() {
loading.value = true
try {
const { data } = await api.get('/clients')
list.value = data
} finally {
loading.value = false
}
}
function openDialog(row) {
if (row) Object.assign(form, { ...row })
else Object.assign(form, { id: null, name: '', rate_limit: 0, max_conn: 0, ip_forward_mode: 'replace', custom_headers: '', status: true })
dialogVisible.value = true
}
async function save() {
if (form.id) await api.put(`/clients/${form.id}`, form)
else await api.post('/clients', form)
ElMessage.success(t('message.saveSuccess'))
dialogVisible.value = false
load()
}
async function remove(row) {
await ElMessageBox.confirm(t('clients.confirmDelete'), t('common.hint'), { type: 'warning' })
await api.delete(`/clients/${row.id}`)
ElMessage.success(t('message.deleteSuccess'))
load()
}
onMounted(load)
</script>
+155
View File
@@ -0,0 +1,155 @@
<template>
<div class="page">
<PageHeader :title="t('dashboard.title')" :description="t('dashboard.description')">
<template #actions>
<el-button @click="load">{{ t('common.refresh') }}</el-button>
<el-button link type="primary" @click="openApiDoc">{{ t('dashboard.openapi') }}</el-button>
</template>
</PageHeader>
<div class="stat-grid">
<StatCard v-for="item in statCards" :key="item.labelKey" :label="t(item.labelKey)" :value="item.value" />
</div>
<div class="content-grid content-grid--2">
<AppCard :title="t('dashboard.systemResources')" padded>
<div class="kv-block">
<p><span class="label">{{ t('dashboard.cpu') }}</span><span>{{ data.cpu_percent?.toFixed?.(1) ?? 0 }}%</span></p>
<p><span class="label">{{ t('dashboard.memory') }}</span><span>{{ data.mem_percent?.toFixed?.(1) ?? 0 }}%</span></p>
<p><span class="label">{{ t('dashboard.load') }}</span><span>{{ data.load1?.toFixed?.(2) ?? 0 }}</span></p>
</div>
</AppCard>
<AppCard :title="t('dashboard.trafficOverview')" padded>
<div class="kv-block">
<p><span class="label">{{ t('dashboard.inlet') }}</span><span>{{ formatBytes(data.inlet_flow) }}</span></p>
<p><span class="label">{{ t('dashboard.export') }}</span><span>{{ formatBytes(data.export_flow) }}</span></p>
</div>
</AppCard>
</div>
<div class="content-grid content-grid--2">
<AppCard :title="t('dashboard.resourceTrend')" padded>
<ChartBox :option="resourceChartOption" />
</AppCard>
<AppCard :title="t('dashboard.trafficTrend')" padded>
<ChartBox :option="trafficChartOption" />
</AppCard>
</div>
<div class="content-grid content-grid--3">
<AppCard>
<template #header>
<div class="card-header-row">
<h3 class="app-card__title">{{ t('dashboard.tunnelTop10') }}</h3>
<el-button link type="primary" @click="openRank('tunnels', t('dashboard.rankTunnels'))">{{ t('common.viewAll') }}</el-button>
</div>
</template>
<el-table :data="data.top_tunnels || []" size="small">
<el-table-column prop="name" :label="t('common.name')" />
<el-table-column :label="t('common.traffic')">
<template #default="{ row }">{{ formatBytes((row.inlet_flow || 0) + (row.export_flow || 0)) }}</template>
</el-table-column>
</el-table>
</AppCard>
<AppCard>
<template #header>
<div class="card-header-row">
<h3 class="app-card__title">{{ t('dashboard.hostTop10') }}</h3>
<el-button link type="primary" @click="openRank('hosts', t('dashboard.rankHosts'))">{{ t('common.viewAll') }}</el-button>
</div>
</template>
<el-table :data="data.top_hosts || []" size="small">
<el-table-column prop="host" :label="t('dashboard.host')" />
<el-table-column :label="t('common.traffic')">
<template #default="{ row }">{{ formatBytes((row.inlet_flow || 0) + (row.export_flow || 0)) }}</template>
</el-table-column>
</el-table>
</AppCard>
<AppCard>
<template #header>
<div class="card-header-row">
<h3 class="app-card__title">{{ t('dashboard.ipTop10') }}</h3>
<el-button link type="primary" @click="openRank('ips', t('dashboard.rankIps'))">{{ t('common.viewAll') }}</el-button>
</div>
</template>
<el-table :data="data.top_ips || []" size="small">
<el-table-column prop="ip" :label="t('dashboard.visitorIp')" />
<el-table-column prop="resource_label" :label="t('common.resource')" min-width="140" show-overflow-tooltip />
<el-table-column prop="hit_count" :label="t('dashboard.hitCount')" width="72" />
</el-table>
</AppCard>
</div>
<RankDialog v-model="rankVisible" :type="rankType" :title="rankTitle" />
</div>
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { useI18n } from 'vue-i18n'
import PageHeader from '@/components/PageHeader.vue'
import StatCard from '@/components/StatCard.vue'
import AppCard from '@/components/AppCard.vue'
import ChartBox from '@/components/ChartBox.vue'
import RankDialog from '@/components/RankDialog.vue'
import api from '@/api'
import { formatBytes } from '@/utils/format'
import { buildLineChartOption } from '@/utils/chartTheme'
import { useMetricHistory } from '@/composables/useMetricHistory'
const { t } = useI18n()
const data = ref({})
let timer
const rankVisible = ref(false)
const rankType = ref('tunnels')
const rankTitle = ref('')
const history = useMetricHistory()
const statCards = computed(() => [
{ labelKey: 'dashboard.statClientTotal', value: data.value.client_total ?? 0 },
{ labelKey: 'dashboard.statClientOnline', value: data.value.client_online ?? 0 },
{ labelKey: 'dashboard.statTunnelRunning', value: data.value.tunnel_running ?? 0 },
{ labelKey: 'dashboard.statHostTotal', value: data.value.host_total ?? 0 },
])
const resourceChartOption = computed(() => buildLineChartOption({
labels: history.labels.value,
series: [
{ name: t('dashboard.cpu'), data: history.cpu.value },
{ name: t('dashboard.memory'), data: history.memory.value },
],
yAxisFormatter: (v) => `${v}%`,
}))
const trafficChartOption = computed(() => buildLineChartOption({
labels: history.labels.value,
series: [
{ name: t('dashboard.inlet'), data: history.inlet.value },
{ name: t('dashboard.export'), data: history.exportFlow.value },
],
yAxisFormatter: (v) => formatBytes(v),
}))
async function load() {
const { data: d } = await api.get('/dashboard')
data.value = d
history.record(d)
}
function openRank(type, title) {
rankType.value = type
rankTitle.value = title
rankVisible.value = true
}
function openApiDoc() {
window.open('/api/openapi.json', '_blank')
}
onMounted(() => {
load()
timer = setInterval(load, 5000)
})
onUnmounted(() => clearInterval(timer))
</script>
+201
View File
@@ -0,0 +1,201 @@
<template>
<div class="page">
<PageHeader :title="t('hosts.title')" :description="t('hosts.description')">
<template #actions>
<el-button @click="load">{{ t('common.refresh') }}</el-button>
<ImportExportActions resource="hosts" export-filename="wormhole-hosts.json" @done="load" />
<el-button type="primary" @click="openDialog()">{{ t('hosts.add') }}</el-button>
</template>
</PageHeader>
<AppCard>
<div class="card-toolbar">
<el-input v-model="query" :placeholder="t('hosts.searchPlaceholder')" clearable class="toolbar-input--wide" @keyup.enter="search" @clear="search" />
<el-button @click="search">{{ t('common.search') }}</el-button>
</div>
<el-table :data="list" v-loading="loading" size="small">
<el-table-column prop="id" :label="t('common.id')" width="60" />
<el-table-column :label="t('common.client')" width="120">
<template #default="{ row }">{{ clientName(row.client_id) }}</template>
</el-table-column>
<el-table-column :label="t('hosts.host')" min-width="160">
<template #default="{ row }">
<el-link type="primary" :underline="false" @click="openHost(row)">{{ row.host }}</el-link>
</template>
</el-table-column>
<el-table-column prop="target_addr" :label="t('common.target')" min-width="140" />
<el-table-column :label="t('hosts.visitorAuth')" width="88">
<template #default="{ row }">
<el-tag :type="row.visitor_auth_enabled ? 'warning' : 'info'" size="small">
{{ row.visitor_auth_enabled ? t('common.on') : t('common.off') }}
</el-tag>
</template>
</el-table-column>
<el-table-column :label="t('common.status')" width="88">
<template #default="{ row }">
<el-tag :type="row.status ? 'success' : 'info'" size="small">{{ row.status ? t('common.enabled') : t('common.paused') }}</el-tag>
</template>
</el-table-column>
<el-table-column :label="t('common.actions')" width="220" fixed="right">
<template #default="{ row }">
<el-button v-if="row.status" size="small" link @click="pause(row)">{{ t('common.pause') }}</el-button>
<el-button v-else size="small" link type="primary" @click="resume(row)">{{ t('common.resume') }}</el-button>
<el-button size="small" link type="primary" @click="openDialog(row)">{{ t('common.edit') }}</el-button>
<el-button size="small" link type="danger" @click="remove(row)">{{ t('common.delete') }}</el-button>
</template>
</el-table-column>
</el-table>
<div class="card-pager">
<el-pagination
v-model:current-page="page"
v-model:page-size="pageSize"
:total="total"
:page-sizes="[10, 20, 50]"
layout="total, sizes, prev, pager, next"
@current-change="load"
@size-change="search"
/>
</div>
</AppCard>
<el-dialog v-model="dialogVisible" :title="form.id ? t('hosts.edit') : t('hosts.add')" width="600px" destroy-on-close>
<el-form :model="form" label-width="100px">
<el-form-item :label="t('common.client')" required>
<ClientSelect v-model="form.client_id" :clients="clients" :disabled="!!form.id" />
</el-form-item>
<el-form-item :label="t('hosts.host')"><el-input v-model="form.host" placeholder="app.example.com" /></el-form-item>
<el-form-item :label="t('hosts.location')"><el-input v-model="form.location" placeholder="/" /></el-form-item>
<el-form-item :label="t('common.target')"><el-input v-model="form.target_addr" placeholder="127.0.0.1:8080" /></el-form-item>
<el-form-item :label="t('hosts.autoHttps')"><el-switch v-model="form.auto_https" /></el-form-item>
<ProxyHeadersFields v-model:ip-forward-mode="form.ip_forward_mode" v-model:custom-headers="form.custom_headers" />
<VisitorAuthFields
v-model:visitor-auth-enabled="form.visitor_auth_enabled"
v-model:visitor-bind-mode="form.visitor_bind_mode"
v-model:visitor-ttl-seconds="form.visitor_ttl_seconds"
v-model:visitor-ids="form.visitor_ids"
:visitors="visitors"
/>
<el-form-item :label="t('common.enabled')"><el-switch v-model="form.status" /></el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">{{ t('common.cancel') }}</el-button>
<el-button type="primary" @click="save">{{ t('common.save') }}</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { ElMessage, ElMessageBox } from 'element-plus'
import PageHeader from '@/components/PageHeader.vue'
import AppCard from '@/components/AppCard.vue'
import ClientSelect from '@/components/ClientSelect.vue'
import ProxyHeadersFields from '@/components/ProxyHeadersFields.vue'
import VisitorAuthFields from '@/components/VisitorAuthFields.vue'
import ImportExportActions from '@/components/ImportExportActions.vue'
import { openHost } from '@/utils/targetLink'
import api from '@/api'
const { t } = useI18n()
const list = ref([])
const clients = ref([])
const visitors = ref([])
const loading = ref(false)
const dialogVisible = ref(false)
const page = ref(1)
const pageSize = ref(20)
const total = ref(0)
const query = ref('')
const defaultForm = () => ({
id: null, client_id: null, host: '', location: '/', target_addr: '127.0.0.1:80',
auto_https: false, ip_forward_mode: 'replace', custom_headers: '', status: true,
visitor_auth_enabled: false, visitor_bind_mode: 'all', visitor_ttl_seconds: 7200, visitor_ids: [],
})
const form = reactive(defaultForm())
function clientName(id) {
const c = clients.value.find((item) => item.id === id)
return c ? `${c.name} (#${id})` : `#${id}`
}
async function loadClients() {
const { data } = await api.get('/clients')
clients.value = data
}
async function loadVisitors() {
try {
const { data } = await api.get('/users')
visitors.value = data
} catch {
visitors.value = []
}
}
async function load() {
loading.value = true
try {
const { data } = await api.get('/hosts', { params: { page: page.value, page_size: pageSize.value, q: query.value || undefined } })
list.value = data.items || []
total.value = data.total || 0
await loadClients()
await loadVisitors()
} finally {
loading.value = false
}
}
function search() {
page.value = 1
load()
}
function openDialog(row) {
if (row) {
Object.assign(form, {
...row,
visitor_ids: row.visitor_ids || [],
visitor_bind_mode: row.visitor_bind_mode || 'all',
visitor_ttl_seconds: row.visitor_ttl_seconds || 7200,
})
} else {
Object.assign(form, defaultForm())
if (clients.value.length) form.client_id = clients.value[0].id
}
dialogVisible.value = true
}
async function save() {
if (!form.client_id) { ElMessage.warning(t('common.selectClient')); return }
try {
if (form.id) await api.put(`/hosts/${form.id}`, form)
else await api.post('/hosts', form)
ElMessage.success(t('message.saveSuccess'))
dialogVisible.value = false
load()
} catch (e) {
ElMessage.error(e.response?.data?.error || t('message.saveFailed'))
}
}
async function pause(row) {
await api.post(`/hosts/${row.id}/pause`)
load()
}
async function resume(row) {
await api.post(`/hosts/${row.id}/resume`)
load()
}
async function remove(row) {
await ElMessageBox.confirm(t('hosts.confirmDelete'), t('common.hint'), { type: 'warning' })
await api.delete(`/hosts/${row.id}`)
ElMessage.success(t('message.deleteSuccess'))
load()
}
onMounted(load)
</script>
+253
View File
@@ -0,0 +1,253 @@
<template>
<div class="page">
<PageHeader :title="t('ipRules.title')" :description="t('ipRules.description')">
<template #actions>
<el-button @click="load">{{ t('common.refresh') }}</el-button>
<el-button type="primary" @click="openDialog()">{{ t('ipRules.add') }}</el-button>
</template>
</PageHeader>
<AppCard>
<el-table :data="list" v-loading="loading" size="small">
<el-table-column prop="id" :label="t('common.id')" width="60" />
<el-table-column prop="pattern" :label="t('ipRules.pattern')" min-width="160" />
<el-table-column :label="t('ipRules.ruleType')" width="100">
<template #default="{ row }">
<el-tag :type="ruleTypeTag(row)" size="small">{{ ruleTypeLabel(row) }}</el-tag>
</template>
</el-table-column>
<el-table-column :label="t('ipRules.resourceType')" width="100">
<template #default="{ row }">{{ resourceTypeLabel(row) }}</template>
</el-table-column>
<el-table-column :label="t('ipRules.resource')" min-width="160" show-overflow-tooltip>
<template #default="{ row }">{{ resourceLabel(row) }}</template>
</el-table-column>
<el-table-column :label="t('common.actions')" width="140" fixed="right">
<template #default="{ row }">
<el-button size="small" link type="primary" @click="openDialog(row)">{{ t('common.edit') }}</el-button>
<el-button size="small" link type="danger" @click="remove(row)">{{ t('common.delete') }}</el-button>
</template>
</el-table-column>
</el-table>
</AppCard>
<el-dialog
v-model="dialogVisible"
:title="form.id ? t('ipRules.edit') : t('ipRules.add')"
width="520px"
destroy-on-close
>
<el-form :model="form" label-width="100px">
<el-form-item :label="t('ipRules.pattern')"><el-input v-model="form.pattern" :placeholder="t('ipRules.patternPlaceholder')" /></el-form-item>
<el-form-item :label="t('ipRules.ruleType')">
<el-select v-model="form.rule_type">
<el-option :label="t('ipRules.allow')" value="allow" />
<el-option :label="t('ipRules.deny')" value="deny" />
</el-select>
</el-form-item>
<el-form-item :label="t('ipRules.resourceType')">
<el-select v-model="form.resource_type" class="w-full">
<el-option :label="t('ipRules.global')" value="global" />
<el-option :label="t('ipRules.tunnel')" value="tunnel" />
<el-option :label="t('ipRules.host')" value="host" />
</el-select>
</el-form-item>
<el-form-item v-if="form.resource_type !== 'global'" :label="t('ipRules.resource')" required>
<el-select
v-model="form.resource_id"
filterable
class="w-full"
:placeholder="t('ipRules.selectResource')"
:loading="resourcesLoading"
:no-data-text="t('ipRules.noResource')"
>
<el-option
v-for="item in resourceOptions"
:key="item.id"
:label="item.label"
:value="item.id"
/>
</el-select>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">{{ t('common.cancel') }}</el-button>
<el-button type="primary" @click="save">{{ t('common.save') }}</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, reactive, computed, watch, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { ElMessage, ElMessageBox } from 'element-plus'
import PageHeader from '@/components/PageHeader.vue'
import AppCard from '@/components/AppCard.vue'
import api from '@/api'
const { t } = useI18n()
const list = ref([])
const tunnels = ref([])
const hosts = ref([])
const loading = ref(false)
const resourcesLoading = ref(false)
const dialogVisible = ref(false)
const form = reactive({
id: null,
pattern: '',
rule_type: 'deny',
resource_type: 'global',
resource_id: null,
})
const resourceOptions = computed(() => {
if (form.resource_type === 'tunnel') {
return tunnels.value.map((item) => ({
id: item.id,
label: `${item.name} (#${item.id}) · :${item.listen_port}`,
}))
}
if (form.resource_type === 'host') {
return hosts.value.map((item) => ({
id: item.id,
label: `${item.host} (#${item.id})`,
}))
}
return []
})
watch(
() => form.resource_type,
(type) => {
if (type === 'global') {
form.resource_id = null
return
}
const options = resourceOptions.value
if (!options.some((item) => item.id === form.resource_id)) {
form.resource_id = options[0]?.id ?? null
}
},
)
function ruleScope(row) {
return row.scope || row.resource_type || 'global'
}
function ruleTypeLabel(row) {
const type = row.type || row.rule_type
if (type === 'allow') return t('ipRules.allow')
if (type === 'deny') return t('ipRules.deny')
return type || '—'
}
function ruleTypeTag(row) {
const type = row.type || row.rule_type
if (type === 'allow') return 'success'
if (type === 'deny') return 'danger'
return 'info'
}
function resourceTypeLabel(row) {
const scope = ruleScope(row)
if (scope === 'global') return t('ipRules.global')
if (scope === 'tunnel') return t('ipRules.tunnel')
if (scope === 'host') return t('ipRules.host')
return scope
}
function resourceLabel(row) {
const scope = ruleScope(row)
const id = row.resource_id
if (scope === 'global' || !id) return '—'
if (scope === 'tunnel') {
const item = tunnels.value.find((tunnel) => tunnel.id === id)
return item ? `${item.name} (#${id})` : `#${id}`
}
if (scope === 'host') {
const item = hosts.value.find((host) => host.id === id)
return item ? `${item.host} (#${id})` : `#${id}`
}
return `#${id}`
}
function rowToForm(row) {
return {
id: row.id,
pattern: row.pattern,
rule_type: row.type || row.rule_type || 'deny',
resource_type: ruleScope(row),
resource_id: row.resource_id || null,
}
}
function formToPayload() {
return {
pattern: form.pattern,
scope: form.resource_type,
type: form.rule_type,
resource_id: form.resource_type === 'global' ? 0 : form.resource_id,
}
}
async function loadResources() {
resourcesLoading.value = true
try {
const [tunnelsRes, hostsRes] = await Promise.all([
api.get('/tunnels', { params: { page: 1, page_size: 1000 } }),
api.get('/hosts', { params: { page: 1, page_size: 1000 } }),
])
tunnels.value = tunnelsRes.data.items || []
hosts.value = hostsRes.data.items || []
} finally {
resourcesLoading.value = false
}
}
async function load() {
loading.value = true
try {
const [rulesRes] = await Promise.all([api.get('/security/ip-rules'), loadResources()])
list.value = rulesRes.data
} finally {
loading.value = false
}
}
function openDialog(row) {
if (row) Object.assign(form, rowToForm(row))
else {
Object.assign(form, {
id: null,
pattern: '',
rule_type: 'deny',
resource_type: 'global',
resource_id: null,
})
}
dialogVisible.value = true
}
async function save() {
if (form.resource_type !== 'global' && !form.resource_id) {
ElMessage.warning(t('ipRules.resourceRequired'))
return
}
const payload = formToPayload()
if (form.id) await api.put(`/security/ip-rules/${form.id}`, payload)
else await api.post('/security/ip-rules', payload)
ElMessage.success(t('message.saveSuccess'))
dialogVisible.value = false
load()
}
async function remove(row) {
await ElMessageBox.confirm(t('ipRules.confirmDelete'), t('common.hint'), { type: 'warning' })
await api.delete(`/security/ip-rules/${row.id}`)
ElMessage.success(t('message.deleteSuccess'))
load()
}
onMounted(load)
</script>
+98
View File
@@ -0,0 +1,98 @@
<template>
<div class="login-page">
<div class="login-wrap">
<div class="login-brand-icon">
<AppLogo :size="24" />
</div>
<h1 class="page-title">{{ t('app.name') }}</h1>
<p class="page-desc">{{ t('app.tagline') }}</p>
<div class="login-card">
<h3 class="app-card__title u-mb-6">{{ t('login.title') }}</h3>
<el-form size="large" @submit.prevent="onLogin">
<el-form-item>
<el-input v-model="form.username" :placeholder="t('login.username')" :disabled="locked" />
</el-form-item>
<el-form-item>
<el-input v-model="form.password" type="password" :placeholder="t('login.password')" show-password :disabled="locked" />
</el-form-item>
<el-alert
v-if="locked"
type="warning"
:closable="false"
show-icon
:title="lockMessage"
class="u-mb-4"
/>
<el-button type="primary" native-type="submit" :loading="loading" :disabled="locked" class="w-full">
{{ t('login.submit') }}
</el-button>
</el-form>
<p class="form-hint u-mt-4 text-center">{{ t('login.hint') }}</p>
</div>
</div>
</div>
</template>
<script setup>
import { computed, onUnmounted, reactive, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import AppLogo from '@/components/AppLogo.vue'
import api from '@/api'
const { t } = useI18n()
const router = useRouter()
const loading = ref(false)
const locked = ref(false)
const lockSeconds = ref(0)
const form = reactive({ username: 'admin', password: 'admin' })
let lockTimer
const lockMessage = computed(() => {
if (lockSeconds.value <= 0) return t('login.lockDefault')
if (lockSeconds.value >= 60) {
return t('login.lockMinutes', { n: Math.ceil(lockSeconds.value / 60) })
}
return t('login.lockSeconds', { n: lockSeconds.value })
})
function startLockCountdown(seconds) {
clearInterval(lockTimer)
lockSeconds.value = seconds
locked.value = true
lockTimer = setInterval(() => {
lockSeconds.value -= 1
if (lockSeconds.value <= 0) {
locked.value = false
clearInterval(lockTimer)
}
}, 1000)
}
async function onLogin() {
if (locked.value) return
loading.value = true
try {
const { data } = await api.post('/auth/login', form)
localStorage.setItem('token', data.token)
localStorage.setItem('user', JSON.stringify(data.user))
ElMessage.success(t('message.loginSuccess'))
router.push('/dashboard')
} catch (e) {
const status = e.response?.status
const retryAfter = e.response?.data?.retry_after
if (status === 429 && retryAfter > 0) {
startLockCountdown(retryAfter)
ElMessage.warning(e.response?.data?.error || t('message.loginRateLimit'))
} else {
ElMessage.error(e.response?.data?.error || t('message.loginFailed'))
}
} finally {
loading.value = false
}
}
onUnmounted(() => clearInterval(lockTimer))
</script>
+86
View File
@@ -0,0 +1,86 @@
<template>
<div class="page">
<PageHeader :title="t('requestIPs.title')" :description="t('requestIPs.description')">
<template #actions>
<el-button @click="load">{{ t('common.refresh') }}</el-button>
</template>
</PageHeader>
<AppCard>
<div class="card-toolbar">
<el-input v-model="query" :placeholder="t('requestIPs.searchPlaceholder')" clearable class="toolbar-input" @keyup.enter="search" @clear="search" />
<el-button @click="search">{{ t('common.search') }}</el-button>
</div>
<el-table :data="list" v-loading="loading" size="small">
<el-table-column prop="ip" :label="t('dashboard.visitorIp')" min-width="140" />
<el-table-column prop="resource_label" :label="t('common.resource')" min-width="160" show-overflow-tooltip />
<el-table-column prop="hit_count" :label="t('requestIPs.hitCount')" width="100" />
<el-table-column :label="t('requestIPs.lastSeen')" min-width="160">
<template #default="{ row }">{{ row.last_seen_at }}</template>
</el-table-column>
<el-table-column :label="t('common.actions')" width="120" fixed="right">
<template #default="{ row }">
<el-button size="small" link type="danger" @click="block(row)">{{ t('common.block') }}</el-button>
</template>
</el-table-column>
</el-table>
<div class="card-pager">
<el-pagination
v-model:current-page="page"
v-model:page-size="pageSize"
:total="total"
layout="total, prev, pager, next"
@current-change="load"
/>
</div>
</AppCard>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { ElMessage, ElMessageBox } from 'element-plus'
import PageHeader from '@/components/PageHeader.vue'
import AppCard from '@/components/AppCard.vue'
import api from '@/api'
const { t } = useI18n()
const list = ref([])
const loading = ref(false)
const page = ref(1)
const pageSize = ref(50)
const total = ref(0)
const query = ref('')
async function load() {
loading.value = true
try {
const { data } = await api.get('/security/request-ips', {
params: { limit: pageSize.value, offset: (page.value - 1) * pageSize.value, q: query.value || undefined },
})
list.value = data.items || []
total.value = data.total || 0
} finally {
loading.value = false
}
}
function search() {
page.value = 1
load()
}
async function block(row) {
await ElMessageBox.confirm(t('requestIPs.confirmBlock'), t('common.hint'), { type: 'warning' })
await api.post('/security/block-ip', {
ip: row.ip,
scope: row.resource_type,
resource_id: row.resource_id,
})
ElMessage.success(t('message.blockSuccess'))
load()
}
onMounted(load)
</script>
+229
View File
@@ -0,0 +1,229 @@
<template>
<div class="page">
<PageHeader :title="t('settings.title')" :description="t('settings.description')">
<template #actions>
<el-button @click="load">{{ t('common.refresh') }}</el-button>
<el-button type="primary" :loading="saving" @click="save">{{ t('common.save') }}</el-button>
</template>
</PageHeader>
<AppCard v-loading="loading" padded>
<el-alert v-if="note" :title="note" type="info" show-icon :closable="false" class="u-mb-4" />
<el-form :model="form" label-width="120px" class="settings-form">
<div class="section-title">{{ t('settings.listenPorts') }}</div>
<el-form-item :label="t('settings.bridgeAddr')">
<el-input v-model="form.server.bridge_addr" placeholder=":8528" />
</el-form-item>
<el-form-item :label="t('settings.httpAddr')">
<el-input v-model="form.server.http_addr" placeholder=":8529" />
</el-form-item>
<el-form-item :label="t('settings.httpProxyPort')">
<el-input-number v-model="form.server.http_proxy_port" :min="1" :max="65535" />
</el-form-item>
<el-form-item :label="t('settings.httpsProxyPort')">
<el-input-number v-model="form.server.https_proxy_port" :min="1" :max="65535" />
</el-form-item>
<el-form-item :label="t('settings.tlsCert')">
<el-input v-model="form.server.tls_cert_file" :placeholder="t('settings.tlsOptional')" />
</el-form-item>
<el-form-item :label="t('settings.tlsKey')">
<el-input v-model="form.server.tls_key_file" :placeholder="t('settings.tlsOptional')" />
</el-form-item>
<div class="section-title">{{ t('settings.authSecurity') }}</div>
<el-form-item :label="t('settings.jwtSecret')">
<el-input v-model="form.auth.jwt_secret" type="password" show-password :placeholder="t('settings.jwtSecretPlaceholder')" />
</el-form-item>
<el-form-item :label="t('settings.tokenTtl')">
<el-input-number v-model="form.auth.token_ttl_hours" :min="1" :max="720" />
</el-form-item>
<el-form-item :label="t('settings.loginMaxAttempts')">
<el-input-number v-model="form.auth.login_max_attempts" :min="1" :max="100" />
</el-form-item>
<el-form-item :label="t('settings.loginWindow')">
<el-input-number v-model="form.auth.login_window_min" :min="1" :max="1440" />
</el-form-item>
<el-form-item :label="t('settings.loginLockout')">
<el-input-number v-model="form.auth.login_lockout_min" :min="1" :max="1440" />
</el-form-item>
<div class="section-title">{{ t('settings.adminAccount') }}</div>
<el-form-item :label="t('settings.currentPassword')">
<el-input v-model="pwdForm.current" type="password" show-password autocomplete="current-password" />
</el-form-item>
<el-form-item :label="t('settings.newPassword')">
<el-input v-model="pwdForm.newPwd" type="password" show-password autocomplete="new-password" />
</el-form-item>
<el-form-item :label="t('settings.confirmPassword')">
<el-input v-model="pwdForm.confirm" type="password" show-password autocomplete="new-password" />
</el-form-item>
<el-form-item>
<el-button type="primary" plain :loading="pwdSaving" @click="changePassword">{{ t('settings.changePassword') }}</el-button>
</el-form-item>
<div class="section-title">{{ t('settings.metrics') }}</div>
<el-form-item :label="t('settings.flushInterval')">
<el-input-number v-model="metricsFlushSec" :min="5" :max="3600" />
</el-form-item>
</el-form>
</AppCard>
<AppCard :title="t('settings.apiKey')" padded>
<p class="form-hint form-hint--flush card-actions">{{ t('settings.apiKeyHint', { header: 'X-API-Key', doc: 'OpenAPI' }) }}</p>
<el-button type="primary" size="small" @click="createKeyDialog = true">{{ t('settings.createApiKey') }}</el-button>
<el-table :data="apiKeys" size="small" class="u-mt-3">
<el-table-column prop="id" :label="t('common.id')" width="60" />
<el-table-column prop="name" :label="t('common.name')" />
<el-table-column prop="key_prefix" :label="t('common.prefix')" width="140" />
<el-table-column :label="t('common.actions')" width="100">
<template #default="{ row }">
<el-button link type="danger" @click="removeKey(row)">{{ t('common.delete') }}</el-button>
</template>
</el-table-column>
</el-table>
</AppCard>
<el-dialog v-model="createKeyDialog" :title="t('settings.createApiKeyTitle')" width="480px">
<el-form label-width="80px">
<el-form-item :label="t('common.name')"><el-input v-model="newKeyName" :placeholder="t('settings.apiKeyNamePlaceholder')" /></el-form-item>
</el-form>
<el-alert v-if="createdKey" :title="t('settings.apiKeySave', { key: createdKey })" type="warning" show-icon :closable="false" />
<template #footer>
<el-button @click="createKeyDialog = false">{{ t('common.close') }}</el-button>
<el-button type="primary" :disabled="!!createdKey" @click="createKey">{{ t('common.create') }}</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { ElMessage } from 'element-plus'
import PageHeader from '@/components/PageHeader.vue'
import AppCard from '@/components/AppCard.vue'
import api from '@/api'
const { t } = useI18n()
const loading = ref(false)
const saving = ref(false)
const note = ref('')
const metricsFlushSec = ref(30)
const apiKeys = ref([])
const createKeyDialog = ref(false)
const newKeyName = ref('')
const createdKey = ref('')
const pwdSaving = ref(false)
const pwdForm = reactive({ current: '', newPwd: '', confirm: '' })
const form = reactive({
server: {
bridge_addr: ':8528',
http_addr: ':8529',
http_proxy_port: 8081,
https_proxy_port: 8443,
tls_cert_file: '',
tls_key_file: '',
},
auth: {
jwt_secret: '',
token_ttl_hours: 24,
login_max_attempts: 5,
login_window_min: 5,
login_lockout_min: 15,
},
})
async function load() {
loading.value = true
try {
const { data } = await api.get('/settings')
note.value = data.note || ''
Object.assign(form.server, data.settings.server)
Object.assign(form.auth, data.settings.auth)
form.auth.jwt_secret = ''
const ms = data.settings.metrics?.flush_interval_sec
metricsFlushSec.value = ms > 0 ? ms : 30
await loadApiKeys()
} finally {
loading.value = false
}
}
async function save() {
saving.value = true
try {
const payload = {
server: { ...form.server },
auth: { ...form.auth },
metrics: { flush_interval_sec: metricsFlushSec.value },
}
const { data } = await api.put('/settings', payload)
if (data.restart_required) {
ElMessage.warning(t('message.savedRestart'))
} else {
ElMessage.success(t('message.saved'))
}
await load()
} catch (e) {
ElMessage.error(e.response?.data?.error || t('message.saveFailed'))
} finally {
saving.value = false
}
}
async function changePassword() {
if (!pwdForm.current || !pwdForm.newPwd) {
ElMessage.warning(t('settings.fillPassword'))
return
}
if (pwdForm.newPwd !== pwdForm.confirm) {
ElMessage.warning(t('settings.passwordMismatch'))
return
}
pwdSaving.value = true
try {
await api.put('/auth/me/password', {
current_password: pwdForm.current,
new_password: pwdForm.newPwd,
})
ElMessage.success(t('message.passwordUpdated'))
pwdForm.current = ''
pwdForm.newPwd = ''
pwdForm.confirm = ''
} catch (e) {
ElMessage.error(e.response?.data?.error || t('message.passwordChangeFailed'))
} finally {
pwdSaving.value = false
}
}
async function loadApiKeys() {
try {
const { data } = await api.get('/api-keys')
apiKeys.value = data
} catch {
apiKeys.value = []
}
}
async function createKey() {
if (!newKeyName.value) {
ElMessage.warning(t('settings.enterApiKeyName'))
return
}
const { data } = await api.post('/api-keys', { name: newKeyName.value })
createdKey.value = data.api_key
ElMessage.success(t('message.apiKeyCreated'))
loadApiKeys()
}
async function removeKey(row) {
await api.delete(`/api-keys/${row.id}`)
ElMessage.success(t('message.deleteSuccess'))
loadApiKeys()
}
onMounted(load)
</script>
+218
View File
@@ -0,0 +1,218 @@
<template>
<div class="page">
<PageHeader :title="t('tunnels.title')" :description="t('tunnels.description')">
<template #actions>
<el-button @click="load">{{ t('common.refresh') }}</el-button>
<ImportExportActions resource="tunnels" export-filename="wormhole-tunnels.json" @done="load" />
<el-button type="primary" @click="openDialog()">{{ t('tunnels.add') }}</el-button>
</template>
</PageHeader>
<AppCard>
<div class="card-toolbar">
<el-input v-model="query" :placeholder="t('tunnels.searchPlaceholder')" clearable class="toolbar-input--wide" @keyup.enter="search" @clear="search" />
<el-button @click="search">{{ t('common.search') }}</el-button>
</div>
<el-table :data="list" v-loading="loading" size="small">
<el-table-column prop="id" :label="t('common.id')" width="60" />
<el-table-column prop="name" :label="t('common.name')" min-width="100" />
<el-table-column :label="t('common.client')" width="120">
<template #default="{ row }">{{ clientName(row.client_id) }}</template>
</el-table-column>
<el-table-column prop="mode" :label="t('common.mode')" width="72" />
<el-table-column :label="t('tunnels.listenPort')" width="100">
<template #default="{ row }">
<el-link type="primary" :underline="false" @click="openTunnel(row)">{{ row.listen_port }}</el-link>
</template>
</el-table-column>
<el-table-column prop="target_addr" :label="t('common.target')" min-width="140" />
<el-table-column :label="t('tunnels.visitorAuth')" width="88">
<template #default="{ row }">
<el-tag :type="row.visitor_auth_enabled ? 'warning' : 'info'" size="small">
{{ row.visitor_auth_enabled ? t('common.on') : t('common.off') }}
</el-tag>
</template>
</el-table-column>
<el-table-column :label="t('common.status')" width="88">
<template #default="{ row }">
<el-tag :type="row.run_status ? 'success' : 'info'" size="small">
{{ row.run_status ? t('common.running') : row.status ? t('common.stopped') : t('common.paused') }}
</el-tag>
</template>
</el-table-column>
<el-table-column :label="t('common.actions')" width="260" fixed="right">
<template #default="{ row }">
<el-button v-if="row.status && !row.run_status" size="small" link @click="resume(row)">{{ t('common.start') }}</el-button>
<el-button v-if="row.run_status" size="small" link @click="stop(row)">{{ t('common.stop') }}</el-button>
<el-button v-if="row.status" size="small" link @click="pause(row)">{{ t('common.pause') }}</el-button>
<el-button v-if="!row.status" size="small" link type="primary" @click="resume(row)">{{ t('common.resume') }}</el-button>
<el-button size="small" link type="primary" @click="openDialog(row)">{{ t('common.edit') }}</el-button>
<el-button size="small" link type="danger" @click="remove(row)">{{ t('common.delete') }}</el-button>
</template>
</el-table-column>
</el-table>
<div class="card-pager">
<el-pagination
v-model:current-page="page"
v-model:page-size="pageSize"
:total="total"
:page-sizes="[10, 20, 50]"
layout="total, sizes, prev, pager, next"
@current-change="load"
@size-change="search"
/>
</div>
</AppCard>
<el-dialog v-model="dialogVisible" :title="form.id ? t('tunnels.edit') : t('tunnels.add')" width="600px" destroy-on-close>
<el-form :model="form" label-width="100px">
<el-form-item :label="t('common.client')" required>
<ClientSelect v-model="form.client_id" :clients="clients" :disabled="!!form.id" />
</el-form-item>
<el-form-item :label="t('common.name')"><el-input v-model="form.name" /></el-form-item>
<el-form-item :label="t('common.mode')">
<el-select v-model="form.mode">
<el-option :label="t('tunnels.tcp')" value="tcp" />
<el-option :label="t('tunnels.udp')" value="udp" />
</el-select>
</el-form-item>
<el-form-item :label="t('tunnels.listenPort')"><el-input-number v-model="form.listen_port" :min="1" :max="65535" class="w-full" /></el-form-item>
<el-form-item :label="t('tunnels.targetAddr')"><el-input v-model="form.target_addr" placeholder="127.0.0.1:8080" /></el-form-item>
<ProxyHeadersFields v-model:ip-forward-mode="form.ip_forward_mode" v-model:custom-headers="form.custom_headers" />
<VisitorAuthFields
v-model:visitor-auth-enabled="form.visitor_auth_enabled"
v-model:visitor-bind-mode="form.visitor_bind_mode"
v-model:visitor-ttl-seconds="form.visitor_ttl_seconds"
v-model:visitor-ids="form.visitor_ids"
:visitors="visitors"
tunnel-hint
/>
<el-form-item :label="t('common.enabled')"><el-switch v-model="form.status" /></el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">{{ t('common.cancel') }}</el-button>
<el-button type="primary" @click="save">{{ t('common.save') }}</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { ElMessage, ElMessageBox } from 'element-plus'
import PageHeader from '@/components/PageHeader.vue'
import AppCard from '@/components/AppCard.vue'
import ClientSelect from '@/components/ClientSelect.vue'
import ProxyHeadersFields from '@/components/ProxyHeadersFields.vue'
import VisitorAuthFields from '@/components/VisitorAuthFields.vue'
import ImportExportActions from '@/components/ImportExportActions.vue'
import { openTunnel } from '@/utils/targetLink'
import api from '@/api'
const { t } = useI18n()
const list = ref([])
const clients = ref([])
const visitors = ref([])
const loading = ref(false)
const dialogVisible = ref(false)
const page = ref(1)
const pageSize = ref(20)
const total = ref(0)
const query = ref('')
const defaultForm = () => ({
id: null, client_id: null, name: '', mode: 'tcp', listen_port: 9000,
target_addr: '127.0.0.1:80', ip_forward_mode: 'replace', custom_headers: '', status: true,
visitor_auth_enabled: false, visitor_bind_mode: 'all', visitor_ttl_seconds: 7200, visitor_ids: [],
})
const form = reactive(defaultForm())
function clientName(id) {
const c = clients.value.find((item) => item.id === id)
return c ? `${c.name} (#${id})` : `#${id}`
}
async function loadClients() {
const { data } = await api.get('/clients')
clients.value = data
}
async function loadVisitors() {
try {
const { data } = await api.get('/users')
visitors.value = data
} catch {
visitors.value = []
}
}
async function load() {
loading.value = true
try {
const { data } = await api.get('/tunnels', { params: { page: page.value, page_size: pageSize.value, q: query.value || undefined } })
list.value = data.items || []
total.value = data.total || 0
await loadClients()
await loadVisitors()
} finally {
loading.value = false
}
}
function search() {
page.value = 1
load()
}
function openDialog(row) {
if (row) {
Object.assign(form, {
...row,
visitor_ids: row.visitor_ids || [],
visitor_bind_mode: row.visitor_bind_mode || 'all',
visitor_ttl_seconds: row.visitor_ttl_seconds || 7200,
})
} else {
Object.assign(form, defaultForm())
if (clients.value.length) form.client_id = clients.value[0].id
}
dialogVisible.value = true
}
async function save() {
if (!form.client_id) { ElMessage.warning(t('common.selectClient')); return }
try {
if (form.id) await api.put(`/tunnels/${form.id}`, form)
else await api.post('/tunnels', form)
ElMessage.success(t('message.saveSuccess'))
dialogVisible.value = false
load()
} catch (e) {
ElMessage.error(e.response?.data?.error || t('message.saveFailed'))
}
}
async function stop(row) {
await api.post(`/tunnels/${row.id}/stop`)
load()
}
async function pause(row) {
await api.post(`/tunnels/${row.id}/pause`)
load()
}
async function resume(row) {
await api.post(`/tunnels/${row.id}/resume`)
load()
}
async function remove(row) {
await ElMessageBox.confirm(t('tunnels.confirmDelete'), t('common.hint'), { type: 'warning' })
await api.delete(`/tunnels/${row.id}`)
ElMessage.success(t('message.deleteSuccess'))
load()
}
onMounted(load)
</script>
+148
View File
@@ -0,0 +1,148 @@
<template>
<div class="page">
<PageHeader :title="t('users.title')" :description="t('users.description')">
<template #actions>
<el-button @click="load">{{ t('common.refresh') }}</el-button>
<el-button type="primary" @click="openDialog()">{{ t('users.add') }}</el-button>
</template>
</PageHeader>
<AppCard>
<el-table :data="list" v-loading="loading" size="small">
<el-table-column prop="id" :label="t('common.id')" width="60" />
<el-table-column prop="username" :label="t('users.username')" min-width="120" />
<el-table-column :label="t('common.status')" width="88">
<template #default="{ row }">
<el-tag :type="row.status ? 'success' : 'info'" size="small">{{ row.status ? t('common.enabled') : t('common.disabled') }}</el-tag>
</template>
</el-table-column>
<el-table-column :label="t('common.actions')" width="220" fixed="right">
<template #default="{ row }">
<el-button size="small" link type="primary" @click="openLogs(row)">{{ t('users.accessLogs') }}</el-button>
<el-button size="small" link type="primary" @click="openDialog(row)">{{ t('common.edit') }}</el-button>
<el-button size="small" link type="danger" @click="remove(row)">{{ t('common.delete') }}</el-button>
</template>
</el-table-column>
</el-table>
</AppCard>
<el-dialog
v-model="dialogVisible"
:title="form.id ? t('users.edit') : t('users.add')"
width="480px"
destroy-on-close
>
<el-form :model="form" label-width="80px">
<el-form-item :label="t('users.username')"><el-input v-model="form.username" :disabled="!!form.id" /></el-form-item>
<el-form-item :label="t('users.password')"><el-input v-model="form.password" type="password" show-password :placeholder="form.id ? t('users.passwordPlaceholderEdit') : ''" /></el-form-item>
<el-form-item v-if="form.id" :label="t('common.status')">
<el-switch v-model="form.status" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">{{ t('common.cancel') }}</el-button>
<el-button type="primary" @click="save">{{ t('common.save') }}</el-button>
</template>
</el-dialog>
<el-dialog
v-model="logsVisible"
:title="t('users.accessLogsTitle', { name: logsUser?.username || '' })"
width="720px"
destroy-on-close
>
<p class="form-hint form-hint--flush u-mb-4">{{ t('users.logsHint', { n: logs.length }) }}</p>
<el-table :data="logs" v-loading="logsLoading" size="small" class="table-scroll--lg">
<el-table-column :label="t('common.time')" width="168">
<template #default="{ row }">{{ formatDateTime(row.created_at) }}</template>
</el-table-column>
<el-table-column :label="t('common.type')" width="72">
<template #default="{ row }">
<el-tag :type="row.action === 'login' ? 'success' : 'info'" size="small">
{{ row.action === 'login' ? t('users.login') : t('users.visit') }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="resource_label" :label="t('common.resource')" min-width="140" show-overflow-tooltip />
<el-table-column prop="method" :label="t('common.method')" width="72" />
<el-table-column prop="path" :label="t('common.path')" min-width="160" show-overflow-tooltip />
<el-table-column prop="ip" label="IP" width="120" />
<el-table-column prop="user_agent" label="UA" min-width="140" show-overflow-tooltip />
</el-table>
</el-dialog>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { ElMessage, ElMessageBox } from 'element-plus'
import PageHeader from '@/components/PageHeader.vue'
import AppCard from '@/components/AppCard.vue'
import api from '@/api'
import { formatDateTime } from '@/utils/datetime'
const { t } = useI18n()
const list = ref([])
const loading = ref(false)
const dialogVisible = ref(false)
const logsVisible = ref(false)
const logsLoading = ref(false)
const logs = ref([])
const logsUser = ref(null)
const form = reactive({ id: null, username: '', password: '', status: true })
async function load() {
loading.value = true
try {
const { data } = await api.get('/users')
list.value = data
} finally {
loading.value = false
}
}
function openDialog(row) {
if (row) Object.assign(form, { id: row.id, username: row.username, password: '', status: row.status })
else Object.assign(form, { id: null, username: '', password: '', status: true })
dialogVisible.value = true
}
async function openLogs(row) {
logsUser.value = row
logsVisible.value = true
logsLoading.value = true
logs.value = []
try {
const { data } = await api.get(`/users/${row.id}/access-logs`, { params: { limit: 200 } })
logs.value = data.items || []
} catch (e) {
ElMessage.error(e.response?.data?.error || t('message.loadFailed'))
} finally {
logsLoading.value = false
}
}
async function save() {
const payload = { username: form.username, password: form.password, role: 'visitor' }
if (form.id) {
const body = { password: form.password, role: 'visitor', status: form.status }
await api.put(`/users/${form.id}`, body)
} else {
await api.post('/users', payload)
}
ElMessage.success(t('message.saveSuccess'))
dialogVisible.value = false
load()
}
async function remove(row) {
await ElMessageBox.confirm(t('users.confirmDelete'), t('common.hint'), { type: 'warning' })
await api.delete(`/users/${row.id}`)
ElMessage.success(t('message.deleteSuccess'))
load()
}
onMounted(load)
</script>
+25
View File
@@ -0,0 +1,25 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { fileURLToPath, URL } from 'node:url'
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
},
},
build: {
outDir: 'dist',
emptyOutDir: true,
},
server: {
port: 5173,
proxy: {
'/api': {
target: 'http://127.0.0.1:8080',
changeOrigin: true,
},
},
},
})