Wire sessions audit to paged connection-logs with filters/export, drop connection-count from log retention, add dashboard quick cards, and group tunnel wizard protocols by scene.
284 lines
13 KiB
Vue
284 lines
13 KiB
Vue
<template>
|
|
<div>
|
|
<el-tabs v-model="activeTab" @tab-change="onTabChange">
|
|
<el-tab-pane :label="$t('sessions.title')" name="online">
|
|
<el-alert v-if="sessionError" type="error" :closable="false" :title="sessionError" class="session-error"><el-button size="small" @click="loadOnline">{{ $t('common.refresh') }}</el-button></el-alert>
|
|
<el-card class="page-card" shadow="never" style="margin-bottom:16px">
|
|
<template #header>
|
|
<div class="row-between">
|
|
<div>
|
|
<b>{{ $t('sessions.title') }}</b>
|
|
<el-tag size="small" type="success" style="margin-left:8px">{{ $t('sessions.onlineCount', { count: sessions.length }) }}</el-tag>
|
|
</div>
|
|
<el-button link @click="loadOnline">{{ $t('common.refresh') }}</el-button>
|
|
</div>
|
|
</template>
|
|
<el-table :data="sessions" size="default" v-loading="loading">
|
|
<el-table-column :label="$t('common.username')" min-width="120">
|
|
<template #default="{ row }"><span :title="row.username">{{ row.display_name || displayUsername(row.username) }}</span><div v-if="row.display_name" class="identity-subject">{{ row.username }}</div></template>
|
|
</el-table-column>
|
|
<el-table-column :label="$t('common.protocol')" width="100">
|
|
<template #default="{ row }">
|
|
<span class="protocol-tag" :style="{ color: protocolColor(row.protocol) }">{{ $t(protocolDisplay(row.protocol)) }}</span>
|
|
</template>
|
|
</el-table-column>
|
|
<el-table-column prop="real_address" :label="$t('sessions.publicAddress')" min-width="140" />
|
|
<el-table-column prop="virtual_address" :label="$t('sessions.vpnIp')" min-width="120" />
|
|
<el-table-column :label="$t('sessions.connectedDuration')" width="120">
|
|
<template #default="{ row }">{{ fmtDuration(row.connected_at) }}</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('common.operation')" width="100">
|
|
<template #default="{ row }">
|
|
<el-button type="danger" size="small" link @click="disconnect(row)">{{ $t('sessions.kickOffline') }}</el-button>
|
|
</template>
|
|
</el-table-column>
|
|
<template #empty><el-empty :description="$t('sessions.noOnlineUsers')" /></template>
|
|
</el-table>
|
|
</el-card>
|
|
|
|
<el-card class="page-card" shadow="never" style="margin-bottom:16px" v-if="alerts.length">
|
|
<template #header><b style="color:var(--color-danger)">{{ $t('sessions.trafficAlert') }}</b></template>
|
|
<el-table :data="alerts" size="small">
|
|
<el-table-column prop="created_at" :label="$t('common.time')" width="170">
|
|
<template #default="{ row }">{{ new Date(row.created_at).toLocaleString() }}</template>
|
|
</el-table-column>
|
|
<el-table-column prop="username" :label="$t('sessions.userTunnel')" width="100" />
|
|
<el-table-column :label="$t('sessions.alertType')" width="100">
|
|
<template #default="{ row }">
|
|
<el-tag :type="row.event === 'rate_alert' ? 'warning' : 'danger'" size="small">
|
|
{{ { rate_alert: $t('sessions.rateAlert'), quota_exceed: $t('sessions.quotaExceed') }[row.event] || row.event }}
|
|
</el-tag>
|
|
</template>
|
|
</el-table-column>
|
|
<el-table-column prop="detail" :label="$t('common.detail')" show-overflow-tooltip />
|
|
</el-table>
|
|
</el-card>
|
|
</el-tab-pane>
|
|
|
|
<el-tab-pane :label="$t('sessions.auditLog')" name="audit">
|
|
<el-alert v-if="auditError" type="error" :closable="false" :title="auditError" class="session-error"><el-button size="small" @click="loadAudit">{{ $t('common.refresh') }}</el-button></el-alert>
|
|
<el-card class="page-card" shadow="never">
|
|
<template #header>
|
|
<div class="row-between">
|
|
<div class="filter-bar">
|
|
<el-input v-model="auditFilters.username" :placeholder="$t('common.username')" clearable size="small" style="width:120px" @clear="searchAudit" @keyup.enter="searchAudit" />
|
|
<el-select v-model="auditFilters.event" :placeholder="$t('logs.eventType')" clearable multiple size="small" style="width:220px" @change="searchAudit">
|
|
<el-option :label="$t('sessions.eventConnect')" value="connect" />
|
|
<el-option :label="$t('sessions.eventDisconnect')" value="disconnect" />
|
|
<el-option :label="$t('sessions.eventAuthFail')" value="auth_fail" />
|
|
<el-option :label="$t('sessions.eventQuotaExceed')" value="quota_exceed" />
|
|
<el-option :label="$t('sessions.eventRateAlert')" value="rate_alert" />
|
|
</el-select>
|
|
<el-date-picker v-model="auditDateRange" type="daterange" :start-placeholder="$t('logs.startDate')" :end-placeholder="$t('logs.endDate')" size="small" value-format="YYYY-MM-DD" style="width:260px" @change="searchAudit" clearable />
|
|
<el-button type="primary" size="small" @click="searchAudit">{{ $t('common.search') }}</el-button>
|
|
</div>
|
|
<div style="display:flex;gap:8px">
|
|
<el-button size="small" :loading="auditExporting" @click="exportAudit">{{ $t('logs.exportCsv') }}</el-button>
|
|
<el-button link @click="loadAudit">{{ $t('common.refresh') }}</el-button>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
<el-table :data="logs" size="small" max-height="500" v-loading="auditLoading">
|
|
<el-table-column prop="created_at" :label="$t('common.time')" width="170">
|
|
<template #default="{ row }">{{ new Date(row.created_at).toLocaleString() }}</template>
|
|
</el-table-column>
|
|
<el-table-column prop="tunnel_id" :label="$t('logs.tunnelId')" width="80" />
|
|
<el-table-column :label="$t('common.username')" min-width="160">
|
|
<template #default="{ row }"><span :title="row.username">{{ row.display_name || displayUsername(row.username) }}</span><div v-if="row.display_name" class="identity-subject">{{ row.username }}</div></template>
|
|
</el-table-column>
|
|
<el-table-column :label="$t('sessions.event')" width="110">
|
|
<template #default="{ row }">
|
|
<el-tag :type="eventTagType(row.event)" size="small">{{ eventLabel(row.event) }}</el-tag>
|
|
</template>
|
|
</el-table-column>
|
|
<el-table-column prop="ip_address" :label="$t('common.ipAddress')" width="140" />
|
|
<el-table-column prop="detail" :label="$t('common.detail')" show-overflow-tooltip />
|
|
<template #empty><el-empty :description="$t('common.noData')" /></template>
|
|
</el-table>
|
|
<div class="pagination-bar">
|
|
<el-pagination
|
|
v-model:current-page="auditPage"
|
|
v-model:page-size="auditPageSize"
|
|
:total="auditTotal"
|
|
:page-sizes="[20, 50, 100]"
|
|
layout="total, sizes, prev, pager, next"
|
|
@current-change="loadAudit"
|
|
@size-change="searchAudit"
|
|
/>
|
|
</div>
|
|
</el-card>
|
|
</el-tab-pane>
|
|
</el-tabs>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { ref, reactive, watch, onMounted, onUnmounted } from 'vue'
|
|
import { useRoute, useRouter } from 'vue-router'
|
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
|
import { useI18n } from 'vue-i18n'
|
|
import api from '../api'
|
|
import { fmtBytes } from '../utils/format'
|
|
import { protocolColor, protocolDisplay } from '../protocols'
|
|
|
|
const { t } = useI18n()
|
|
const route = useRoute()
|
|
const router = useRouter()
|
|
const sessions = ref([])
|
|
const logs = ref([])
|
|
const alerts = ref([])
|
|
const loading = ref(false)
|
|
function normalizeTab(name) {
|
|
if (name === 'sessions' || name === 'online') return 'online'
|
|
if (name === 'audit' || name === 'connection') return 'audit'
|
|
return 'online'
|
|
}
|
|
const activeTab = ref(normalizeTab(route.query.tab))
|
|
const sessionError = ref('')
|
|
const auditError = ref('')
|
|
const auditLoading = ref(false)
|
|
const auditExporting = ref(false)
|
|
const auditPage = ref(1)
|
|
const auditPageSize = ref(20)
|
|
const auditTotal = ref(0)
|
|
const auditFilters = reactive({ username: '', event: [] })
|
|
const auditDateRange = ref(null)
|
|
|
|
watch(() => route.query.tab, (name) => {
|
|
const next = normalizeTab(name)
|
|
if (activeTab.value !== next) activeTab.value = next
|
|
}, { immediate: true })
|
|
|
|
watch(activeTab, (tab) => {
|
|
const query = tab === 'online' ? {} : { tab: 'audit' }
|
|
const cur = route.query.tab
|
|
const needsReplace = normalizeTab(cur) !== tab
|
|
|| (tab === 'online' && cur != null && cur !== '')
|
|
|| (tab === 'audit' && cur !== 'audit')
|
|
if (needsReplace) router.replace({ path: '/sessions', query })
|
|
})
|
|
|
|
function displayUsername(value) {
|
|
return typeof value === 'string' && value.trim() && value.trim().toUpperCase() !== 'UNDEF'
|
|
? value
|
|
: t('sessions.identityUnknown')
|
|
}
|
|
|
|
function fmtDuration(dur) {
|
|
if (!dur) return '-'
|
|
const sec = Math.floor((Date.now() - new Date(dur).getTime()) / 1000)
|
|
if (sec < 60) return t('sessions.seconds', { n: sec })
|
|
if (sec < 3600) return t('sessions.minutes', { n: Math.floor(sec / 60) })
|
|
return t('sessions.hoursMinutes', { h: Math.floor(sec / 3600), m: Math.floor((sec % 3600) / 60) })
|
|
}
|
|
function eventLabel(e) {
|
|
return { connect: t('sessions.eventConnect'), disconnect: t('sessions.eventDisconnect'), auth_fail: t('sessions.eventAuthFail'), quota_exceed: t('sessions.eventQuotaExceed'), rate_alert: t('sessions.eventRateAlert') }[e] || e
|
|
}
|
|
function eventTagType(e) {
|
|
return { connect: 'success', disconnect: 'info', auth_fail: 'danger', quota_exceed: 'warning', rate_alert: 'warning' }[e] || 'info'
|
|
}
|
|
|
|
async function loadOnline() {
|
|
loading.value = true
|
|
try {
|
|
const [s, a] = await Promise.all([
|
|
api.get('/sessions'),
|
|
api.get('/traffic/alerts'),
|
|
])
|
|
sessions.value = s.data || []
|
|
alerts.value = a.data || []
|
|
sessionError.value = ''
|
|
} catch (e) {
|
|
sessionError.value = e.response?.data?.error || t('sessions.loadDataFailed')
|
|
} finally { loading.value = false }
|
|
}
|
|
|
|
function searchAudit() {
|
|
auditPage.value = 1
|
|
loadAudit()
|
|
}
|
|
|
|
async function loadAudit() {
|
|
auditLoading.value = true
|
|
try {
|
|
const params = {
|
|
page: auditPage.value,
|
|
page_size: auditPageSize.value,
|
|
username: auditFilters.username,
|
|
}
|
|
if (auditFilters.event?.length) params.event = auditFilters.event.join(',')
|
|
if (auditDateRange.value) {
|
|
params.start = auditDateRange.value[0]
|
|
params.end = auditDateRange.value[1]
|
|
}
|
|
const { data } = await api.get('/connection-logs', { params })
|
|
logs.value = data.items || []
|
|
auditTotal.value = data.total || 0
|
|
auditError.value = ''
|
|
} catch (e) {
|
|
auditError.value = e.response?.data?.error || t('sessions.loadDataFailed')
|
|
} finally { auditLoading.value = false }
|
|
}
|
|
|
|
async function exportAudit() {
|
|
auditExporting.value = true
|
|
const params = { username: auditFilters.username }
|
|
if (auditFilters.event?.length) params.event = auditFilters.event.join(',')
|
|
if (auditDateRange.value) {
|
|
params.start = auditDateRange.value[0]
|
|
params.end = auditDateRange.value[1]
|
|
}
|
|
try {
|
|
const { data } = await api.get('/connection-logs/export', { params, responseType: 'blob' })
|
|
const url = URL.createObjectURL(data)
|
|
try {
|
|
const link = document.createElement('a')
|
|
link.href = url
|
|
link.download = `connection-logs-${new Date().toISOString().slice(0, 10)}.csv`
|
|
document.body.appendChild(link)
|
|
link.click()
|
|
link.remove()
|
|
} finally { setTimeout(() => URL.revokeObjectURL(url), 1000) }
|
|
} catch { ElMessage.error(t('logs.exportFailed')) }
|
|
finally { auditExporting.value = false }
|
|
}
|
|
|
|
function onTabChange(tab) {
|
|
activeTab.value = tab
|
|
if (tab === 'audit') loadAudit()
|
|
else loadOnline()
|
|
}
|
|
|
|
async function disconnect(row) {
|
|
try {
|
|
await ElMessageBox.confirm(t('sessions.kickConfirm', { username: row.display_name || displayUsername(row.username) }), t('common.confirm'), { type: 'warning' })
|
|
await api.post(`/sessions/${row.id}/disconnect`)
|
|
ElMessage.success(t('sessions.kicked'))
|
|
loadOnline()
|
|
} catch (e) { if (e !== 'cancel') ElMessage.error(t('common.operationFailed')) }
|
|
}
|
|
|
|
let timer
|
|
onMounted(() => {
|
|
if (activeTab.value === 'audit') loadAudit()
|
|
else loadOnline()
|
|
timer = setInterval(() => {
|
|
if (activeTab.value === 'online') loadOnline()
|
|
}, 15000)
|
|
})
|
|
onUnmounted(() => clearInterval(timer))
|
|
</script>
|
|
|
|
<style scoped>
|
|
.identity-subject { font-size: 11px; color: var(--el-text-color-secondary); overflow-wrap: anywhere; }
|
|
.session-error { margin-bottom: var(--space-4); }
|
|
.row-between { display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 8px; }
|
|
.filter-bar { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
|
.pagination-bar { display: flex; justify-content: flex-end; margin-top: 12px; }
|
|
</style>
|