Files
opsx-vpn/frontend/src/views/Tunnels.vue
T

661 lines
29 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<el-card class="page-card" shadow="never">
<template #header>
<div class="tunnel-toolbar">
<div class="tunnel-filters">
<el-input v-model="keyword" :placeholder="$t('tunnels.searchPlaceholder')" clearable class="tunnel-search" @change="load" />
<el-select v-model="protocolFilter" :placeholder="$t('tunnels.allProtocols')" clearable class="tunnel-filter" @change="load">
<el-option v-for="p in protocolOptions" :key="p" :label="$t(protocolDisplay(p))" :value="p" />
</el-select>
<el-select v-model="stateFilter" :placeholder="$t('tunnels.allStates')" clearable class="tunnel-filter" @change="load">
<el-option :label="$t('tunnels.stateRunning')" value="running" />
<el-option :label="$t('tunnels.stateStopped')" value="stopped" />
<el-option :label="$t('tunnels.stateError')" value="error" />
</el-select>
</div>
<div class="tunnel-actions">
<el-tooltip :content="$t('common.refresh')" placement="top">
<el-button :icon="Refresh" circle :loading="loading" :aria-label="$t('common.refresh')" @click="load" />
</el-tooltip>
<el-button type="primary" :icon="Plus" @click="$router.push('/tunnels/create')">{{ $t('tunnels.createTunnel') }}</el-button>
</div>
</div>
</template>
<el-alert v-if="loadError" type="error" :closable="false" :title="loadError" class="tunnel-error">
<el-button size="small" @click="load">{{ $t('common.refresh') }}</el-button>
</el-alert>
<!-- 批量操作栏 -->
<div v-if="selectedRows.length > 0" class="batch-bar">
<span class="batch-info">{{ $t('tunnels.selected', { count: selectedRows.length }) }}</span>
<el-button type="success" size="small" @click="batchAction('start')">{{ $t('tunnels.batchStart') }}</el-button>
<el-button type="warning" size="small" @click="batchAction('stop')">{{ $t('tunnels.batchStop') }}</el-button>
<el-button type="danger" size="small" @click="batchAction('delete')">{{ $t('tunnels.batchDelete') }}</el-button>
</div>
<el-table :data="list" v-loading="loading" size="default" @selection-change="handleSelectionChange">
<el-table-column type="selection" width="45" />
<el-table-column prop="name" :label="$t('common.name')" min-width="140">
<template #default="{ row }">
<b>{{ row.name }}</b>
<div class="remark" v-if="row.remark">{{ row.remark }}</div>
</template>
</el-table-column>
<el-table-column :label="$t('common.protocol')" width="150">
<template #default="{ row }">
<span class="proto-cell">
<ProtocolIcon :protocol="row.protocol" :size="16" :style="{ color: protocolColor(row.protocol) }" />
{{ $t(protocolDisplay(row.protocol)) }}
</span>
</template>
</el-table-column>
<el-table-column :label="$t('tunnels.authMethod')" min-width="170">
<template #default="{ row }">
<el-tooltip v-if="row.protocol === 'wireguard'" :content="$t('tunnels.wgAuthHint')" placement="top">
<span class="auth-cell">{{ authMethod(row) }}</span>
</el-tooltip>
<span v-else class="auth-cell">{{ authMethod(row) }}</span>
</template>
</el-table-column>
<el-table-column :label="$t('common.status')" width="110">
<template #default="{ row }">
<span :style="{ color: stateColor(row) }">{{ stateText(row) }}</span>
<el-tooltip v-if="row.runtime?.detail" :content="row.runtime.detail" placement="top">
<el-icon style="margin-left:4px;color:#909399;cursor:help"><InfoFilled /></el-icon>
</el-tooltip>
</template>
</el-table-column>
<el-table-column :label="$t('common.traffic')" width="160" align="right" header-align="right">
<template #default="{ row }">
<span style="font-size:12px">{{ fmtBytes(row.rx_bytes) }} / {{ fmtBytes(row.tx_bytes) }}</span>
</template>
</el-table-column>
<el-table-column :label="$t('tunnels.expireTime')" width="150">
<template #default="{ row }">
<span :style="{ color: expiringSoon(row) ? '#e6a23c' : '' }">{{ row.expire_at ? fmtTime(row.expire_at) : $t('common.permanent') }}</span>
</template>
</el-table-column>
<el-table-column :label="$t('tunnels.node')" width="110">
<template #default="{ row }">{{ row.node?.name || $t('tunnels.localNode') }}</template>
</el-table-column>
<el-table-column :label="$t('common.operation')" width="205" fixed="right">
<template #default="{ row }">
<el-button v-if="stateOf(row) !== 'running'" type="success" size="small" plain :loading="changingIds.has(row.id)" :disabled="changingIds.has(row.id)" @click="changeState(row, 'start')">{{ $t('tunnels.start') }}</el-button>
<el-button v-else type="warning" size="small" plain :loading="changingIds.has(row.id)" :disabled="changingIds.has(row.id)" @click="changeState(row, 'stop')">{{ $t('tunnels.stop') }}</el-button>
<el-button size="small" link @click="$router.push(`/tunnels/${row.id}/edit`)">{{ $t('common.edit') }}</el-button>
<el-dropdown trigger="click" @command="cmd => onMoreCommand(cmd, row)" style="margin-left:8px">
<el-button size="small" link>{{ $t('tunnels.more') }}<el-icon class="el-icon--right"><ArrowDown /></el-icon></el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="detail">{{ $t('tunnels.detail') }}</el-dropdown-item>
<el-dropdown-item command="logs">{{ $t('tunnels.viewLogs') }}</el-dropdown-item>
<el-dropdown-item command="download">{{ $t('tunnels.downloadConfig') }}</el-dropdown-item>
<el-dropdown-item v-if="row.protocol === 'wireguard'" command="qrcode">{{ $t('tunnels.showQrCode') }}</el-dropdown-item>
<el-dropdown-item v-if="row.protocol === 'ipsec'" command="ipsec-peer">{{ $t('tunnels.ipsecTest') }}</el-dropdown-item>
<el-dropdown-item command="delete" divided>{{ $t('common.delete') }}</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</template>
</el-table-column>
<template #empty>
<el-empty :description="$t('tunnels.noTunnels')">
<el-button type="primary" :icon="Plus" @click="$router.push('/tunnels/create')">{{ $t('tunnels.createFirst') }}</el-button>
</el-empty>
</template>
</el-table>
<!-- 详情抽屉 -->
<el-drawer v-model="detailVisible" :title="detail?.tunnel?.name" size="560px">
<template v-if="detail">
<el-descriptions :column="1" border size="small">
<el-descriptions-item :label="$t('common.protocol')">{{ $t(protocolDisplay(detail.tunnel.protocol)) }}</el-descriptions-item>
<el-descriptions-item :label="$t('tunnels.authMethod')">{{ authMethod(detail.tunnel) }}</el-descriptions-item>
<el-descriptions-item :label="$t('common.status')">{{ stateText(detail.tunnel) }}</el-descriptions-item>
<el-descriptions-item :label="$t('tunnels.createTime')">{{ fmtTime(detail.tunnel.created_at) }}</el-descriptions-item>
<el-descriptions-item :label="$t('tunnels.autoStart')">{{ detail.tunnel.auto_start ? $t('common.yes') : $t('common.no') }}</el-descriptions-item>
</el-descriptions>
<h4>{{ $t('tunnels.runningParams') }}</h4>
<pre class="mono conf-box">{{ prettyConfig(detail.tunnel.config) }}</pre>
<h4>{{ $t('tunnels.clientConfig') }}</h4>
<pre class="mono conf-box">{{ detail.client_config || $t('tunnels.noClientConfig') }}</pre>
<el-button type="primary" style="width:100%;margin-bottom:8px" @click="download(detail.tunnel)">{{ $t('tunnels.downloadClientConfig') }}</el-button>
<el-button v-if="detail.tunnel.protocol === 'ipsec'" type="warning" plain style="width:100%;margin-bottom:16px"
@click="$router.push('/ipsec-peer')">{{ $t('tunnels.ipsecParamCheck') }}</el-button>
<!-- 流量策略 -->
<h4>{{ $t('tunnels.trafficPolicy') }}</h4>
<el-form label-width="90px" size="small" v-loading="policyLoading">
<el-form-item :label="$t('tunnels.quotaGB')">
<el-input-number v-model="policy.quota_gb" :min="0" :precision="1" :step="10" style="width:100%" />
<span style="font-size:12px;color:var(--text-secondary);margin-left:8px">{{ $t('tunnels.quotaUnlimited', { used: fmtBytes(policy.used_bytes || 0) }) }}</span>
</el-form-item>
<el-form-item :label="$t('tunnels.rateLimitMbps')">
<el-input-number v-model="policy.rate_limit_mbps" :min="0" :max="10000" style="width:100%" />
<span style="font-size:12px;color:var(--text-secondary);margin-left:8px">{{ $t('tunnels.rateUnlimited') }}</span>
</el-form-item>
<el-form-item :label="$t('tunnels.exceedAction')">
<el-select v-model="policy.action_on_exceed" style="width:100%">
<el-option value="warn" :label="$t('tunnels.actionWarn')" />
<el-option value="block" :label="$t('tunnels.actionBlock')" />
<el-option value="throttle" :label="$t('tunnels.actionThrottle')" />
</el-select>
</el-form-item>
<el-form-item :label="$t('tunnels.resetCycle')">
<el-select v-model="policy.reset_cycle" style="width:100%">
<el-option value="none" :label="$t('tunnels.resetNone')" />
<el-option value="daily" :label="$t('tunnels.resetDaily')" />
<el-option value="monthly" :label="$t('tunnels.resetMonthly')" />
</el-select>
</el-form-item>
<el-button type="primary" size="small" :loading="policySaving" @click="savePolicy(detail.tunnel.id)">{{ $t('tunnels.savePolicy') }}</el-button>
</el-form>
<!-- WireGuard Peer 管理 -->
<template v-if="detail.tunnel.protocol === 'wireguard'">
<div style="display:flex;justify-content:space-between;align-items:center;margin:8px 0">
<h4 style="margin:0">{{ $t('tunnels.peerList') }}</h4>
<el-button type="primary" size="small" @click="openAddPeer">{{ $t('tunnels.addPeer') }}</el-button>
</div>
<el-table :data="wgPeers" size="small" v-loading="peersLoading">
<el-table-column prop="id" label="ID" width="100" />
<el-table-column prop="name" :label="$t('common.name')" width="100" />
<el-table-column prop="client_address" :label="$t('tunnels.address')" min-width="110" />
<el-table-column :label="$t('common.status')" width="80">
<template #default="{ row }">
<el-tag size="small" :type="peerOnline(row) ? 'success' : 'info'">
{{ peerOnline(row) ? $t('tunnels.peerOnline') : $t('tunnels.peerOffline') }}
</el-tag>
</template>
</el-table-column>
<el-table-column :label="$t('common.operation')" width="150">
<template #default="{ row }">
<el-button v-if="row.managed !== false" link size="small" @click="downloadPeer(row)">{{ $t('tunnels.config') }}</el-button>
<el-button v-if="row.managed !== false" link size="small" @click="qrPeer(row)">{{ $t('tunnels.qrCode') }}</el-button>
<el-button type="danger" link size="small" @click="removePeer(row)">{{ $t('common.delete') }}</el-button>
</template>
</el-table-column>
</el-table>
</template>
<!-- 流量趋势图 -->
<TrafficChart v-if="detail.tunnel.id" :tunnel-id="detail.tunnel.id" />
</template>
</el-drawer>
<!-- WireGuard 二维码 -->
<el-dialog v-model="qrVisible" :title="$t('tunnels.scanQrTitle')" width="360px" align-center>
<div style="text-align:center">
<canvas ref="qrCanvas"></canvas>
<p style="color:#909399;font-size:12px">{{ $t('tunnels.scanQrHint') }}</p>
</div>
</el-dialog>
<!-- 添加 Peer -->
<el-dialog v-model="peerDialog" :title="$t('tunnels.addPeerTitle')" width="440px">
<el-form label-width="110px">
<el-form-item :label="$t('tunnels.peerId')">
<el-input v-model="peerForm.id" :placeholder="$t('tunnels.autoGenId')" />
</el-form-item>
<el-form-item :label="$t('common.name')">
<el-input v-model="peerForm.name" :placeholder="$t('tunnels.peerNamePlaceholder')" />
</el-form-item>
<el-form-item :label="$t('tunnels.clientAddress')" required>
<el-input v-model="peerForm.client_address" placeholder="10.8.0.3/32" />
</el-form-item>
<el-form-item label="AllowedIPs">
<el-input v-model="peerForm.allowed_ips" :placeholder="$t('tunnels.defaultAllowedIPs')" />
</el-form-item>
<el-form-item :label="$t('tunnels.externalPublicKey')">
<el-input v-model="peerForm.public_key" type="textarea" :rows="2"
:placeholder="$t('tunnels.externalKeyHint')" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="peerDialog = false">{{ $t('common.cancel') }}</el-button>
<el-button type="primary" :loading="peerSaving" @click="addPeer">{{ $t('common.add') }}</el-button>
</template>
</el-dialog>
</el-card>
<!-- 日志实时查看弹窗 -->
<el-dialog v-model="logVisible" :title="$t('tunnels.logTitle', { name: logTunnel?.name || '' })" width="780px" align-center
@close="closeLogStream">
<div class="log-toolbar">
<el-tag size="small" :type="logStreaming ? 'success' : 'info'">
{{ logStreaming ? $t('tunnels.liveOutput') : $t('tunnels.logStopped') }}
</el-tag>
<el-button v-if="logStreaming" size="small" @click="stopLogStream">{{ $t('tunnels.pause') }}</el-button>
<el-button v-else size="small" type="primary" @click="resumeLogStream">{{ $t('tunnels.resume') }}</el-button>
<el-button size="small" @click="logContent = ''">{{ $t('tunnels.clearScreen') }}</el-button>
</div>
<div class="log-view mono" ref="logView">{{ logContent || $t('tunnels.waitingLog') }}</div>
</el-dialog>
<!-- 批量任务进度 -->
<TaskProgress ref="taskProgressRef" />
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { useI18n } from 'vue-i18n'
import { Plus, InfoFilled, ArrowDown, Refresh } from '@element-plus/icons-vue'
import QRCode from 'qrcode'
import { useRouter } from 'vue-router'
import api from '../api'
import { fmtBytes } from '../utils/format'
import ProtocolIcon from '../components/ProtocolIcon.vue'
import TaskProgress from '../components/TaskProgress.vue'
import TrafficChart from '../components/TrafficChart.vue'
import { protocolDisplay, protocolColor } from '../protocols'
const { t } = useI18n()
const router = useRouter()
const list = ref([])
const loading = ref(false)
const loadError = ref('')
const changingIds = ref(new Set())
let loadRevision = 0
const keyword = ref('')
const protocolFilter = ref('')
const stateFilter = ref('')
const protocolOptions = ref([])
const detailVisible = ref(false)
const detail = ref(null)
const qrVisible = ref(false)
// 流量策略
const policy = ref({ quota_gb: 0, rate_limit_mbps: 0, action_on_exceed: 'block', reset_cycle: 'none', used_bytes: 0 })
const policyLoading = ref(false)
const policySaving = ref(false)
const qrCanvas = ref(null)
const wgPeers = ref([])
const runtimePeers = ref([])
const peersLoading = ref(false)
const peerDialog = ref(false)
const peerSaving = ref(false)
const peerForm = ref({ id: '', name: '', client_address: '', allowed_ips: '', public_key: '' })
// 批量操作
const selectedRows = ref([])
const taskProgressRef = ref(null)
function handleSelectionChange(rows) {
selectedRows.value = rows
}
const selectedIds = computed(() => selectedRows.value.map(r => r.id))
async function batchAction(action) {
const ids = selectedIds.value
if (ids.length === 0) return
const confirmKey = `tunnels.batch${action.charAt(0).toUpperCase() + action.slice(1)}Confirm`
try {
await ElMessageBox.confirm(t(confirmKey, { count: ids.length }), t('common.confirm'), { type: 'warning' })
} catch { return }
try {
const { data } = await api.post(`/tunnels/batch/${action}`, { tunnel_ids: ids })
ElMessage.success(t('tunnels.batchStarted'))
selectedRows.value = []
if (data.task_id && taskProgressRef.value) {
taskProgressRef.value.open(data.task_id)
}
load()
} catch (e) {
ElMessage.error(e.response?.data?.error || t('common.operationFailed'))
}
}
function fmtTime(time) { return time ? new Date(time).toLocaleString() : '-' }
function expiringSoon(row) {
return row.expire_at && new Date(row.expire_at) - Date.now() < 7 * 864e5
}
function stateText(row) {
return { running: t('tunnels.stateRunning'), stopped: t('tunnels.stateStopped'), error: t('tunnels.stateError'), undeployed: t('tunnels.stateUndeployed') }[stateOf(row)] || stateOf(row)
}
function stateColor(row) {
return { running: '#67c23a', stopped: '#909399', error: '#f56c6c', undeployed: '#e6a23c' }[stateOf(row)]
}
function stateOf(row) { return row.runtime?.state || row.state }
function authMethod(row) {
let config = {}
try { config = typeof row.config === 'string' ? JSON.parse(row.config) : (row.config || {}) } catch { /* old malformed config */ }
if (row.protocol === 'wireguard') return !config.auth_mode || config.auth_mode === 'password' ? t('tunnels.wgAuthPassword') : t('tunnels.authUnknown')
if (row.protocol === 'openvpn') {
const mode = config.auth_mode || 'password'
const key = `protocols.openvpn.fields.auth_mode.options.${mode}`
return ['certificate', 'password', 'certificate+password', 'oidc'].includes(mode) ? t(key) : t('tunnels.authUnknown')
}
return t('tunnels.authUnknown')
}
function prettyConfig(c) {
try { return JSON.stringify(JSON.parse(c), null, 2) } catch { return c }
}
function peerOnline(row) {
const rt = runtimePeers.value.find(p => p.id === row.id || p.public_key === row.public_key)
return !!rt?.connected
}
async function load() {
const revision = ++loadRevision
loading.value = true
try {
const { data } = await api.get('/tunnels', { params: { keyword: keyword.value, protocol: protocolFilter.value } })
let result = data || []
if (stateFilter.value) {
result = result.filter(r => stateOf(r) === stateFilter.value)
}
if (revision === loadRevision) { list.value = result; loadError.value = '' }
} catch (e) {
if (revision === loadRevision) loadError.value = e.response?.data?.error || t('common.loadFailed')
} finally { if (revision === loadRevision) loading.value = false }
}
// 更多操作下拉命令分发
function onMoreCommand(cmd, row) {
if (cmd === 'detail') showDetail(row)
else if (cmd === 'delete') remove(row)
else if (cmd === 'download') download(row)
else if (cmd === 'qrcode') showQr(row)
else if (cmd === 'ipsec-peer') router.push('/ipsec-peer')
else if (cmd === 'logs') openLogStream(row)
}
// ---- 实时日志流(SSE via fetch + ReadableStream,避免 token 暴露在 URL)----
const logVisible = ref(false)
const logTunnel = ref(null)
const logContent = ref('')
const logStreaming = ref(false)
const logView = ref(null)
let logAbortController = null
function openLogStream(row) {
logTunnel.value = row
logContent.value = ''
logVisible.value = true
resumeLogStream()
}
async function resumeLogStream() {
if (!logTunnel.value) return
stopLogStream()
const token = localStorage.getItem('vpn_token')
logAbortController = new AbortController()
logStreaming.value = true
try {
const resp = await fetch(`/api/tunnels/${logTunnel.value.id}/logs`, {
headers: { 'Authorization': `Bearer ${token}` },
signal: logAbortController.signal
})
if (!resp.ok) { logStreaming.value = false; return }
const reader = resp.body.getReader()
const decoder = new TextDecoder()
let buffer = ''
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split('\n')
buffer = lines.pop()
for (const line of lines) {
if (line.startsWith('data: ')) {
logContent.value += line.slice(6) + '\n'
}
}
nextTick(() => {
if (logView.value) logView.value.scrollTop = logView.value.scrollHeight
})
if (logContent.value.length > 100000) {
logContent.value = logContent.value.slice(-50000)
}
}
} catch (e) {
if (e.name !== 'AbortError') logContent.value += `\n${t('common.loadFailed')}\n`
}
logStreaming.value = false
}
function stopLogStream() {
logStreaming.value = false
logAbortController?.abort()
logAbortController = null
}
function closeLogStream() {
stopLogStream()
logContent.value = ''
}
// ---- 流量策略 ----
async function loadPolicy(tunnelId) {
policyLoading.value = true
try {
const { data } = await api.get(`/tunnels/${tunnelId}/policy`)
policy.value = {
quota_gb: data.quota_bytes > 0 ? +(data.quota_bytes / (1024 * 1024 * 1024)).toFixed(2) : 0,
rate_limit_mbps: data.rate_limit_mbps || 0,
action_on_exceed: data.action_on_exceed || 'block',
reset_cycle: data.reset_cycle || 'none',
used_bytes: data.used_bytes || 0,
}
} catch { policy.value = { quota_gb: 0, rate_limit_mbps: 0, action_on_exceed: 'block', reset_cycle: 'none', used_bytes: 0 } }
finally { policyLoading.value = false }
}
async function savePolicy(tunnelId) {
policySaving.value = true
try {
const quotaBytes = Math.round((policy.value.quota_gb || 0) * 1024 * 1024 * 1024)
await api.put(`/tunnels/${tunnelId}/policy`, {
quota_bytes: quotaBytes,
rate_limit_mbps: policy.value.rate_limit_mbps,
action_on_exceed: policy.value.action_on_exceed,
reset_cycle: policy.value.reset_cycle,
})
ElMessage.success(t('tunnels.policySaved'))
} catch (e) {
ElMessage.error(e.response?.data?.error || t('common.saveFailed'))
} finally { policySaving.value = false }
}
async function changeState(row, action) {
if (changingIds.value.has(row.id)) return
changingIds.value = new Set([...changingIds.value, row.id])
++loadRevision // discard a poll started before this operation
try {
const { data } = await api.post(`/tunnels/${row.id}/${action}`)
row.state = data.state
row.runtime = null // discard the monitor's older snapshot immediately
if (stateFilter.value && stateFilter.value !== data.state) {
list.value = list.value.filter(item => item.id !== row.id)
}
if (data.warning) ElMessage.warning(data.warning)
else ElMessage.success(t(action === 'start' ? 'tunnels.started' : 'tunnels.stopped'))
} catch (e) {
ElMessage.error(e.response?.data?.error || t(action === 'start' ? 'tunnels.startFailed' : 'tunnels.stopFailed'))
} finally {
try { await load() } finally {
const next = new Set(changingIds.value)
next.delete(row.id)
changingIds.value = next
}
}
}
async function remove(row) {
try {
await ElMessageBox.confirm(t('tunnels.deleteTunnelConfirm', { name: row.name }), t('common.deleteConfirm'), { type: 'warning' })
await api.delete(`/tunnels/${row.id}`)
ElMessage.success(t('common.deleteSuccess'))
load()
} catch (e) {
if (e !== 'cancel') ElMessage.error(e.response?.data?.error || t('common.deleteFailed'))
}
}
async function showDetail(row) {
try {
const { data } = await api.get(`/tunnels/${row.id}`)
detail.value = data
detailVisible.value = true
if (row.protocol === 'wireguard') {
await loadPeers(row.id)
} else {
wgPeers.value = []
runtimePeers.value = []
}
loadPolicy(row.id)
} catch (e) {
ElMessage.error(e.response?.data?.error || t('common.loadFailed'))
}
}
async function loadPeers(tunnelId) {
peersLoading.value = true
try {
const { data } = await api.get(`/tunnels/${tunnelId}/wireguard/peers`)
wgPeers.value = data.peers || []
runtimePeers.value = data.runtime_peers || []
} catch {
wgPeers.value = []
} finally {
peersLoading.value = false
}
}
function openAddPeer() {
peerForm.value = { id: '', name: '', client_address: '', allowed_ips: '', public_key: '' }
peerDialog.value = true
}
async function addPeer() {
if (!peerForm.value.client_address) return ElMessage.warning(t('tunnels.fillClientAddress'))
peerSaving.value = true
try {
const body = { ...peerForm.value }
if (!body.id) delete body.id
if (!body.public_key) delete body.public_key
if (!body.allowed_ips) delete body.allowed_ips
await api.post(`/tunnels/${detail.value.tunnel.id}/wireguard/peers`, body)
ElMessage.success(t('tunnels.peerAdded'))
peerDialog.value = false
await loadPeers(detail.value.tunnel.id)
const { data } = await api.get(`/tunnels/${detail.value.tunnel.id}`)
detail.value = data
load()
} catch (e) {
ElMessage.error(e.response?.data?.error || t('tunnels.addFailed'))
} finally {
peerSaving.value = false
}
}
async function removePeer(row) {
await ElMessageBox.confirm(t('tunnels.deletePeerConfirm', { id: row.id }), t('common.confirm'), { type: 'warning' })
try {
await api.delete(`/tunnels/${detail.value.tunnel.id}/wireguard/peers/${row.id}`)
ElMessage.success(t('common.deleteSuccess'))
await loadPeers(detail.value.tunnel.id)
load()
} catch (e) {
ElMessage.error(e.response?.data?.error || t('common.deleteFailed'))
}
}
function downloadPeer(row) {
fetch(`/api/tunnels/${detail.value.tunnel.id}/wireguard/peers/${row.id}/config`, {
headers: { Authorization: `Bearer ${localStorage.getItem('vpn_token')}` }
}).then(async r => {
if (!r.ok) {
const d = await r.json()
throw new Error(d.error)
}
return r.blob()
}).then(b => {
const a = document.createElement('a')
a.href = URL.createObjectURL(b)
a.download = `wireguard-${row.id}.conf`
a.click()
}).catch(e => ElMessage.error(e.message || t('tunnels.downloadFailed')))
}
async function qrPeer(row) {
try {
const res = await fetch(`/api/tunnels/${detail.value.tunnel.id}/wireguard/peers/${row.id}/config`, {
headers: { Authorization: `Bearer ${localStorage.getItem('vpn_token')}` }
})
if (!res.ok) {
const d = await res.json()
throw new Error(d.error)
}
const conf = await res.text()
qrVisible.value = true
await nextTick()
QRCode.toCanvas(qrCanvas.value, conf, { width: 280, margin: 1 })
} catch (e) {
ElMessage.error(e.message || t('tunnels.qrFailed'))
}
}
function download(row) {
fetch(`/api/tunnels/${row.id}/config`, { headers: { Authorization: `Bearer ${localStorage.getItem('vpn_token')}` } })
.then(r => { if (!r.ok) throw new Error(r.statusText); return r.blob() })
.then(b => {
const a = document.createElement('a')
const url = URL.createObjectURL(b)
a.href = url
const ext = row.protocol === 'openvpn' ? 'ovpn' : 'conf'
a.download = `${row.protocol}-${row.name}.${ext}`
a.click()
URL.revokeObjectURL(url)
})
.catch(e => ElMessage.error(e.message || t('tunnels.downloadFailed')))
}
async function showQr(row) {
try {
const { data } = await api.get(`/tunnels/${row.id}`)
if (!data.client_config) return ElMessage.warning(t('tunnels.noClientConfigWarn'))
qrVisible.value = true
await nextTick()
QRCode.toCanvas(qrCanvas.value, data.client_config, { width: 280, margin: 1 })
} catch (e) {
ElMessage.error(e.response?.data?.error || t('tunnels.qrFailed'))
}
}
let timer
onMounted(async () => {
load()
timer = setInterval(load, 15000)
try {
const { data } = await api.get('/protocols')
protocolOptions.value = data.map(p => p.name)
} catch { /* protocols load optional */ }
})
onUnmounted(() => clearInterval(timer))
</script>
<style scoped>
.tunnel-toolbar, .tunnel-filters, .tunnel-actions { display: flex; align-items: center; gap: var(--space-2); }
.tunnel-toolbar { justify-content: space-between; flex-wrap: wrap; }
.tunnel-filters { flex: 1; min-width: 0; flex-wrap: wrap; }
.tunnel-search { width: 200px; }
.tunnel-filter { width: 150px; }
.tunnel-error { margin-bottom: var(--space-4); }
@media (max-width: 640px) { .tunnel-search { flex: 1 1 100%; width: 100%; } .tunnel-filter { flex: 1 1 135px; width: auto; } .tunnel-actions { width: 100%; justify-content: flex-end; } }
.remark { font-size: 12px; color: var(--text-secondary); }
.proto-cell { display: inline-flex; align-items: center; gap: 6px; font-weight: 600; }
.conf-box { background: var(--bg-inset); border: 1px solid var(--border-base); border-radius: var(--radius-sm); padding: 12px; max-height: 260px; overflow: auto; }
.log-toolbar { display: flex; align-items: center; gap: 8px; margin-bottom: 12px; }
.log-view {
background: #1e1e1e; color: #d4d4d4; border-radius: var(--radius-sm); padding: 16px;
height: 400px; overflow-y: auto; font-size: 13px; line-height: 1.6; white-space: pre-wrap; word-break: break-all;
}
.batch-bar {
display: flex; align-items: center; gap: 10px;
padding: 10px 16px; margin-bottom: 12px;
background: var(--el-color-primary-light-9);
border-radius: var(--radius-sm);
border: 1px solid var(--el-color-primary-light-5);
}
.batch-info { font-size: 13px; font-weight: 600; color: var(--el-color-primary); margin-right: auto; }
</style>