Go + Vue 管理台:Agent 隧道、域名反代、IP 安全、访客验证与监控大盘。 Gitea Actions CI 多架构镜像;纯 Go SQLite、无 CGO Docker 构建。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1 @@
|
||||
registry=https://registry.npmmirror.com
|
||||
Vendored
+10
@@ -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>
|
||||
@@ -0,0 +1,6 @@
|
||||
package web
|
||||
|
||||
import "embed"
|
||||
|
||||
//go:embed dist/*
|
||||
var Dist embed.FS
|
||||
@@ -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>
|
||||
Generated
+1846
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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 |
@@ -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>
|
||||
@@ -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
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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
|
||||
@@ -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>
|
||||
@@ -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',
|
||||
},
|
||||
}
|
||||
@@ -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: '已加入黑名单',
|
||||
},
|
||||
}
|
||||
@@ -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')
|
||||
@@ -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
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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,
|
||||
})),
|
||||
}
|
||||
}
|
||||
@@ -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}`
|
||||
}
|
||||
@@ -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]}`
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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`)
|
||||
}
|
||||
@@ -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')
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user