feat: 日志中心 + GRE协议支持 + 流量监控 + 部署脚本
- 新增日志中心功能 (api/logapi.go + Logs.vue) - 新增GRE协议服务支持 (service/gre/) - 新增流量监控组件 (TrafficChart.vue) - 新增L2TP/IPSec测试用例 - 新增日志轮转服务 (logrotate.go) - 新增部署脚本 (scripts/install.sh, uninstall.sh, service文件) - 更新i18n国际化文件 - 优化后端API和前端路由
This commit is contained in:
1 parent
7de1f70905
commit
a14d9e62e6
25 files changed
+2285
-25
No files matched your search
@@ -0,0 +1,97 @@
|
||||
<template>
|
||||
<div>
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px">
|
||||
<h4 style="margin:0">{{ $t('tunnels.trafficTrend') }}</h4>
|
||||
<el-radio-group v-model="hours" size="small" @change="load">
|
||||
<el-radio-button :value="1">1h</el-radio-button>
|
||||
<el-radio-button :value="6">6h</el-radio-button>
|
||||
<el-radio-button :value="24">24h</el-radio-button>
|
||||
<el-radio-button :value="168">7d</el-radio-button>
|
||||
</el-radio-group>
|
||||
</div>
|
||||
<div ref="chartRef" style="height:220px" v-loading="loading"></div>
|
||||
<el-empty v-if="!loading && samples.length === 0" :description="$t('tunnels.noTrafficData')" :image-size="60" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch, onMounted, onBeforeUnmount, nextTick } from 'vue'
|
||||
import * as echarts from 'echarts'
|
||||
import api from '../api'
|
||||
import { fmtBytes } from '../utils/format'
|
||||
|
||||
const props = defineProps({ tunnelId: { type: Number, required: true } })
|
||||
|
||||
const chartRef = ref(null)
|
||||
const loading = ref(false)
|
||||
const hours = ref(24)
|
||||
const samples = ref([])
|
||||
let chart = null
|
||||
|
||||
function granularity(h) {
|
||||
if (h <= 1) return 'minute'
|
||||
if (h <= 168) return 'hour'
|
||||
return 'day'
|
||||
}
|
||||
|
||||
async function load() {
|
||||
if (!props.tunnelId) return
|
||||
loading.value = true
|
||||
try {
|
||||
const { data } = await api.get(`/tunnels/${props.tunnelId}/traffic/history`, {
|
||||
params: { hours: hours.value, granularity: granularity(hours.value) }
|
||||
})
|
||||
samples.value = data.samples || []
|
||||
renderChart()
|
||||
} catch (e) {
|
||||
samples.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function renderChart() {
|
||||
if (!chartRef.value || samples.value.length === 0) return
|
||||
if (!chart) chart = echarts.init(chartRef.value)
|
||||
const times = samples.value.map(s => {
|
||||
const d = new Date(s.time)
|
||||
return hours.value <= 24 ? d.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })
|
||||
: d.toLocaleDateString('zh-CN', { month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit' })
|
||||
})
|
||||
chart.setOption({
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
formatter(params) {
|
||||
const t = params[0].axisValue
|
||||
const rx = params[0] ? fmtBytes(params[0].value) : '0'
|
||||
const tx = params[1] ? fmtBytes(params[1].value) : '0'
|
||||
return `${t}<br/>↓ RX: ${rx}<br/>↑ TX: ${tx}`
|
||||
}
|
||||
},
|
||||
legend: { data: ['RX', 'TX'], right: 0, top: 0, textStyle: { fontSize: 11 } },
|
||||
grid: { left: 50, right: 16, top: 30, bottom: 24 },
|
||||
xAxis: { type: 'category', data: times, axisLabel: { fontSize: 10 } },
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
axisLabel: { fontSize: 10, formatter: v => fmtBytes(v) }
|
||||
},
|
||||
series: [
|
||||
{ name: 'RX', type: 'line', smooth: true, areaStyle: { opacity: 0.15 }, data: samples.value.map(s => s.delta_rx), lineStyle: { width: 2 } },
|
||||
{ name: 'TX', type: 'line', smooth: true, areaStyle: { opacity: 0.15 }, data: samples.value.map(s => s.delta_tx), lineStyle: { width: 2 } }
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
let resizeHandler = null
|
||||
onMounted(() => {
|
||||
load()
|
||||
resizeHandler = () => chart?.resize()
|
||||
window.addEventListener('resize', resizeHandler)
|
||||
})
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('resize', resizeHandler)
|
||||
chart?.dispose()
|
||||
})
|
||||
|
||||
watch(() => props.tunnelId, () => { hours.value = 24; load() })
|
||||
</script>
|
||||
@@ -207,7 +207,9 @@
|
||||
"taskComplete": "All done",
|
||||
"taskPartialFail": "Partial failure",
|
||||
"viewTasks": "View Tasks",
|
||||
"noTasks": "No task history"
|
||||
"noTasks": "No task history",
|
||||
"trafficTrend": "Traffic Trend",
|
||||
"noTrafficData": "No traffic data yet (waiting for samples…)"
|
||||
},
|
||||
"tunnelEditor": {
|
||||
"title": "Edit Tunnel",
|
||||
@@ -423,6 +425,29 @@
|
||||
"kickConfirm": "Kick user \"{username}\" offline?",
|
||||
"kicked": "User kicked offline"
|
||||
},
|
||||
"logs": {
|
||||
"auditTitle": "Audit Logs",
|
||||
"connTitle": "Connection Logs",
|
||||
"alertTitle": "Traffic Alerts",
|
||||
"statsTitle": "Log Statistics",
|
||||
"keywordPlaceholder": "Search target/detail",
|
||||
"startDate": "Start Date",
|
||||
"endDate": "End Date",
|
||||
"exportCsv": "Export CSV",
|
||||
"eventType": "Event Type",
|
||||
"tunnelId": "Tunnel ID",
|
||||
"auditCount": "Audit Logs",
|
||||
"connCount": "Connection Logs",
|
||||
"sessionCount": "Sessions",
|
||||
"from": "From",
|
||||
"cleanup": "Cleanup Logs",
|
||||
"cleanupHint": "Delete log data older than specified days",
|
||||
"cleanupPrompt": "Keep logs from the last how many days?",
|
||||
"cleanupInputError": "Please enter a positive integer",
|
||||
"cleanupDone": "Cleanup done: {audit} audit, {conn} connection, {session} session records deleted",
|
||||
"loadFailed": "Failed to load logs",
|
||||
"noAlerts": "No traffic alerts"
|
||||
},
|
||||
"settings": {
|
||||
"mustChangePassword": "First login or password has been reset by admin. You must change your password before using other features.",
|
||||
"changePassword": "Change Password",
|
||||
@@ -451,6 +476,7 @@
|
||||
"prodSuggestion": "Production Tip",
|
||||
"prodSuggestionDesc": "Expose via Nginx/Caddy reverse proxy with TLS termination",
|
||||
"auditTitle": "Audit Log (Latest 100)",
|
||||
"auditMigrated": "Audit logs have been moved to Log Center with pagination, filtering, and CSV export",
|
||||
"noAuditLogs": "No audit logs yet",
|
||||
"mfaEnabledTitle": "MFA Enabled — Please Save Backup Codes",
|
||||
"backupCodesHint": "These backup codes are shown only once. Use them to log in if you lose your phone. Copy and save them now!",
|
||||
@@ -599,6 +625,7 @@
|
||||
"vpnUsers": "VPN Dial-in Users",
|
||||
"plugins": "Protocol Plugins",
|
||||
"settings": "Settings",
|
||||
"logs": "Log Center",
|
||||
"notFound": "Page Not Found",
|
||||
"sidebarOps": "Tunnel Ops",
|
||||
"sidebarResource": "Resource Mgmt",
|
||||
|
||||
@@ -207,7 +207,9 @@
|
||||
"taskComplete": "全部完成",
|
||||
"taskPartialFail": "部分失败",
|
||||
"viewTasks": "查看任务",
|
||||
"noTasks": "暂无任务记录"
|
||||
"noTasks": "暂无任务记录",
|
||||
"trafficTrend": "流量趋势",
|
||||
"noTrafficData": "暂无流量数据(等待采样中…)"
|
||||
},
|
||||
"tunnelEditor": {
|
||||
"title": "编辑专线",
|
||||
@@ -423,6 +425,29 @@
|
||||
"kickConfirm": "确定踢用户「{username}」下线?",
|
||||
"kicked": "已踢下线"
|
||||
},
|
||||
"logs": {
|
||||
"auditTitle": "操作审计日志",
|
||||
"connTitle": "连接审计日志",
|
||||
"alertTitle": "流量告警",
|
||||
"statsTitle": "日志统计",
|
||||
"keywordPlaceholder": "搜索目标/详情",
|
||||
"startDate": "开始日期",
|
||||
"endDate": "结束日期",
|
||||
"exportCsv": "导出 CSV",
|
||||
"eventType": "事件类型",
|
||||
"tunnelId": "隧道ID",
|
||||
"auditCount": "审计日志",
|
||||
"connCount": "连接日志",
|
||||
"sessionCount": "会话记录",
|
||||
"from": "从",
|
||||
"cleanup": "清理日志",
|
||||
"cleanupHint": "删除指定天数之前的日志数据",
|
||||
"cleanupPrompt": "保留最近多少天的日志?",
|
||||
"cleanupInputError": "请输入正整数",
|
||||
"cleanupDone": "清理完成:审计 {audit} 条、连接 {conn} 条、会话 {session} 条",
|
||||
"loadFailed": "加载日志失败",
|
||||
"noAlerts": "暂无流量告警"
|
||||
},
|
||||
"settings": {
|
||||
"mustChangePassword": "首次登录或密码已被管理员重置,必须修改密码后才能使用其他功能",
|
||||
"changePassword": "修改密码",
|
||||
@@ -451,6 +476,7 @@
|
||||
"prodSuggestion": "生产建议",
|
||||
"prodSuggestionDesc": "经 Nginx/Caddy 反向代理终止 TLS 后暴露",
|
||||
"auditTitle": "操作审计(最近 100 条)",
|
||||
"auditMigrated": "审计日志已迁移至「日志中心」,支持分页、筛选和导出",
|
||||
"noAuditLogs": "暂无审计日志",
|
||||
"mfaEnabledTitle": "MFA 已启用 — 请保存备用码",
|
||||
"backupCodesHint": "这些备用码仅显示一次,丢失手机时用于登录。请立即复制保存!",
|
||||
@@ -599,6 +625,7 @@
|
||||
"vpnUsers": "VPN 拨号用户",
|
||||
"plugins": "协议插件",
|
||||
"settings": "系统设置",
|
||||
"logs": "日志中心",
|
||||
"notFound": "页面不存在",
|
||||
"sidebarOps": "专线运维",
|
||||
"sidebarResource": "资源管理",
|
||||
|
||||
@@ -12,6 +12,7 @@ const routes = [
|
||||
{ path: 'dashboard', component: () => import('./views/Dashboard.vue'), meta: { titleKey: 'router.dashboard' } },
|
||||
{ path: 'tunnels', component: () => import('./views/Tunnels.vue'), meta: { titleKey: 'router.tunnels' } },
|
||||
{ path: 'sessions', component: () => import('./views/Sessions.vue'), meta: { titleKey: 'router.sessions' } },
|
||||
{ path: 'logs', component: () => import('./views/Logs.vue'), meta: { titleKey: 'router.logs' } },
|
||||
{ path: 'tunnels/create', component: () => import('./views/TunnelWizard.vue'), meta: { titleKey: 'router.createTunnel' } },
|
||||
{ path: 'tunnels/:id/edit', component: () => import('./views/TunnelEditor.vue'), meta: { titleKey: 'router.editTunnel' } },
|
||||
{ path: 'ipsec-peer', component: () => import('./views/IpsecPeer.vue'), meta: { titleKey: 'router.ipsecPeer' } },
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
<el-menu-item index="/dashboard"><el-icon><DataAnalysis /></el-icon>{{ $t('router.dashboard') }}</el-menu-item>
|
||||
<el-menu-item index="/tunnels"><el-icon><Connection /></el-icon>{{ $t('router.tunnels') }}</el-menu-item>
|
||||
<el-menu-item index="/sessions"><el-icon><Monitor /></el-icon>{{ $t('router.sessions') }}</el-menu-item>
|
||||
<el-menu-item index="/logs"><el-icon><Document /></el-icon>{{ $t('router.logs') }}</el-menu-item>
|
||||
</el-menu-item-group>
|
||||
<el-menu-item-group v-if="auth.isAdmin()" :title="$t('router.sidebarResource')">
|
||||
<el-menu-item index="/nodes"><el-icon><Coin /></el-icon>{{ $t('router.nodes') }}</el-menu-item>
|
||||
@@ -93,7 +94,7 @@ import { ref, computed } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { DataAnalysis, Connection, Coin, Grid, Setting, Avatar, ArrowDown, UserFilled, Iphone, Lock, Monitor, Promotion } from '@element-plus/icons-vue'
|
||||
import { DataAnalysis, Connection, Coin, Grid, Setting, Avatar, ArrowDown, UserFilled, Iphone, Lock, Monitor, Promotion, Document } from '@element-plus/icons-vue'
|
||||
import api, { auth } from '../api'
|
||||
|
||||
const sideBg = 'var(--bg-sidebar)'
|
||||
|
||||
@@ -0,0 +1,344 @@
|
||||
<template>
|
||||
<div>
|
||||
<el-tabs v-model="activeTab" @tab-change="onTabChange">
|
||||
<!-- 审计日志 Tab -->
|
||||
<el-tab-pane :label="$t('logs.auditTitle')" name="audit">
|
||||
<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="loadAudit" @keyup.enter="loadAudit" />
|
||||
<el-input v-model="auditFilters.action" :placeholder="$t('common.action')" clearable size="small" style="width:140px" @clear="loadAudit" @keyup.enter="loadAudit" />
|
||||
<el-input v-model="auditFilters.keyword" :placeholder="$t('logs.keywordPlaceholder')" clearable size="small" style="width:160px" @clear="loadAudit" @keyup.enter="loadAudit" />
|
||||
<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="loadAudit" clearable />
|
||||
<el-button type="primary" size="small" @click="loadAudit">{{ $t('common.search') }}</el-button>
|
||||
</div>
|
||||
<div style="display:flex;gap:8px">
|
||||
<el-button size="small" @click="exportAudit">{{ $t('logs.exportCsv') }}</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<el-table :data="auditLogs" size="small" v-loading="auditLoading" max-height="560">
|
||||
<el-table-column prop="id" label="ID" width="70" />
|
||||
<el-table-column prop="created_at" :label="$t('common.time')" width="170">
|
||||
<template #default="{ row }">{{ fmtTime(row.created_at) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="username" :label="$t('common.username')" width="100" />
|
||||
<el-table-column prop="action" :label="$t('common.action')" width="160">
|
||||
<template #default="{ row }">
|
||||
<el-tag size="small" :type="actionTagType(row.action)">{{ row.action }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="target" :label="$t('common.target')" min-width="160" show-overflow-tooltip />
|
||||
<el-table-column prop="detail" :label="$t('common.detail')" min-width="200" show-overflow-tooltip />
|
||||
</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="loadAudit"
|
||||
/>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- 连接日志 Tab -->
|
||||
<el-tab-pane :label="$t('logs.connTitle')" name="connection">
|
||||
<el-card class="page-card" shadow="never">
|
||||
<template #header>
|
||||
<div class="row-between">
|
||||
<div class="filter-bar">
|
||||
<el-input v-model="connFilters.username" :placeholder="$t('common.username')" clearable size="small" style="width:120px" @clear="loadConn" @keyup.enter="loadConn" />
|
||||
<el-select v-model="connFilters.event" :placeholder="$t('logs.eventType')" clearable multiple size="small" style="width:200px" @change="loadConn">
|
||||
<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="connDateRange" type="daterange" :start-placeholder="$t('logs.startDate')" :end-placeholder="$t('logs.endDate')" size="small" value-format="YYYY-MM-DD" style="width:260px" @change="loadConn" clearable />
|
||||
<el-button type="primary" size="small" @click="loadConn">{{ $t('common.search') }}</el-button>
|
||||
</div>
|
||||
<div style="display:flex;gap:8px">
|
||||
<el-button size="small" @click="exportConn">{{ $t('logs.exportCsv') }}</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<el-table :data="connLogs" size="small" v-loading="connLoading" max-height="560">
|
||||
<el-table-column prop="id" label="ID" width="70" />
|
||||
<el-table-column prop="created_at" :label="$t('common.time')" width="170">
|
||||
<template #default="{ row }">{{ fmtTime(row.created_at) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="tunnel_id" :label="$t('logs.tunnelId')" width="80" />
|
||||
<el-table-column prop="username" :label="$t('common.username')" width="100" />
|
||||
<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')" min-width="200" show-overflow-tooltip />
|
||||
</el-table>
|
||||
<div class="pagination-bar">
|
||||
<el-pagination
|
||||
v-model:current-page="connPage"
|
||||
v-model:page-size="connPageSize"
|
||||
:total="connTotal"
|
||||
:page-sizes="[20, 50, 100]"
|
||||
layout="total, sizes, prev, pager, next"
|
||||
@current-change="loadConn"
|
||||
@size-change="loadConn"
|
||||
/>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- 流量告警 Tab -->
|
||||
<el-tab-pane :label="$t('logs.alertTitle')" name="alerts">
|
||||
<el-card class="page-card" shadow="never">
|
||||
<template #header>
|
||||
<div class="row-between">
|
||||
<b style="color:var(--color-danger)">{{ $t('logs.alertTitle') }}</b>
|
||||
<el-button link @click="loadAlerts">{{ $t('common.refresh') }}</el-button>
|
||||
</div>
|
||||
</template>
|
||||
<el-table :data="alerts" size="small" v-loading="alertLoading">
|
||||
<el-table-column prop="created_at" :label="$t('common.time')" width="170">
|
||||
<template #default="{ row }">{{ fmtTime(row.created_at) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="tunnel_id" :label="$t('logs.tunnelId')" width="80" />
|
||||
<el-table-column prop="username" :label="$t('sessions.userTunnel')" width="100" />
|
||||
<el-table-column :label="$t('sessions.alertType')" width="110">
|
||||
<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="ip_address" :label="$t('common.ipAddress')" width="140" />
|
||||
<el-table-column prop="detail" :label="$t('common.detail')" min-width="240" show-overflow-tooltip />
|
||||
<template #empty><el-empty :description="$t('logs.noAlerts')" /></template>
|
||||
</el-table>
|
||||
</el-card>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
|
||||
<!-- 日志统计卡片 -->
|
||||
<el-card class="page-card" shadow="never" style="margin-top:16px" v-if="stats">
|
||||
<template #header><b>{{ $t('logs.statsTitle') }}</b></template>
|
||||
<el-descriptions :column="3" size="small" border>
|
||||
<el-descriptions-item :label="$t('logs.auditCount')">
|
||||
{{ stats.audit?.count || 0 }} 条
|
||||
<span v-if="stats.audit?.oldest" style="color:var(--text-secondary);font-size:12px;margin-left:8px">
|
||||
{{ $t('logs.from') }} {{ fmtTime(stats.audit.oldest) }}
|
||||
</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item :label="$t('logs.connCount')">
|
||||
{{ stats.connection?.count || 0 }} 条
|
||||
<span v-if="stats.connection?.oldest" style="color:var(--text-secondary);font-size:12px;margin-left:8px">
|
||||
{{ $t('logs.from') }} {{ fmtTime(stats.connection.oldest) }}
|
||||
</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item :label="$t('logs.sessionCount')">
|
||||
{{ stats.session?.count || 0 }} 条
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
<div v-if="auth.isAdmin()" style="margin-top:12px;display:flex;align-items:center;gap:12px">
|
||||
<el-button type="danger" size="small" @click="cleanupLogs">{{ $t('logs.cleanup') }}</el-button>
|
||||
<span style="font-size:12px;color:var(--text-secondary)">{{ $t('logs.cleanupHint') }}</span>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import api, { auth } from '../api'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const activeTab = ref('audit')
|
||||
|
||||
// ---- 审计日志 ----
|
||||
const auditLogs = ref([])
|
||||
const auditLoading = ref(false)
|
||||
const auditPage = ref(1)
|
||||
const auditPageSize = ref(20)
|
||||
const auditTotal = ref(0)
|
||||
const auditFilters = reactive({ username: '', action: '', keyword: '' })
|
||||
const auditDateRange = ref(null)
|
||||
|
||||
async function loadAudit() {
|
||||
auditLoading.value = true
|
||||
try {
|
||||
const params = {
|
||||
page: auditPage.value,
|
||||
page_size: auditPageSize.value,
|
||||
...auditFilters,
|
||||
}
|
||||
if (auditDateRange.value) {
|
||||
params.start = auditDateRange.value[0]
|
||||
params.end = auditDateRange.value[1]
|
||||
}
|
||||
const { data } = await api.get('/audit-logs', { params })
|
||||
auditLogs.value = data.items || []
|
||||
auditTotal.value = data.total || 0
|
||||
} catch (e) {
|
||||
ElMessage.error(t('logs.loadFailed'))
|
||||
} finally { auditLoading.value = false }
|
||||
}
|
||||
|
||||
function exportAudit() {
|
||||
const params = { ...auditFilters }
|
||||
if (auditDateRange.value) {
|
||||
params.start = auditDateRange.value[0]
|
||||
params.end = auditDateRange.value[1]
|
||||
}
|
||||
const qs = new URLSearchParams(params).toString()
|
||||
window.open(`/api/audit-logs/export?token=${auth.token}&${qs}`, '_blank')
|
||||
}
|
||||
|
||||
// ---- 连接日志 ----
|
||||
const connLogs = ref([])
|
||||
const connLoading = ref(false)
|
||||
const connPage = ref(1)
|
||||
const connPageSize = ref(20)
|
||||
const connTotal = ref(0)
|
||||
const connFilters = reactive({ username: '', event: [], keyword: '' })
|
||||
const connDateRange = ref(null)
|
||||
|
||||
async function loadConn() {
|
||||
connLoading.value = true
|
||||
try {
|
||||
const params = {
|
||||
page: connPage.value,
|
||||
page_size: connPageSize.value,
|
||||
username: connFilters.username,
|
||||
keyword: connFilters.keyword,
|
||||
}
|
||||
if (connFilters.event && connFilters.event.length > 0) {
|
||||
params.event = connFilters.event.join(',')
|
||||
}
|
||||
if (connDateRange.value) {
|
||||
params.start = connDateRange.value[0]
|
||||
params.end = connDateRange.value[1]
|
||||
}
|
||||
const { data } = await api.get('/connection-logs', { params })
|
||||
connLogs.value = data.items || []
|
||||
connTotal.value = data.total || 0
|
||||
} catch (e) {
|
||||
ElMessage.error(t('logs.loadFailed'))
|
||||
} finally { connLoading.value = false }
|
||||
}
|
||||
|
||||
function exportConn() {
|
||||
const params = { username: connFilters.username, keyword: connFilters.keyword }
|
||||
if (connFilters.event && connFilters.event.length > 0) {
|
||||
params.event = connFilters.event.join(',')
|
||||
}
|
||||
if (connDateRange.value) {
|
||||
params.start = connDateRange.value[0]
|
||||
params.end = connDateRange.value[1]
|
||||
}
|
||||
const qs = new URLSearchParams(params).toString()
|
||||
window.open(`/api/connection-logs/export?token=${auth.token}&${qs}`, '_blank')
|
||||
}
|
||||
|
||||
// ---- 流量告警 ----
|
||||
const alerts = ref([])
|
||||
const alertLoading = ref(false)
|
||||
|
||||
async function loadAlerts() {
|
||||
alertLoading.value = true
|
||||
try {
|
||||
const { data } = await api.get('/traffic/alerts')
|
||||
alerts.value = data || []
|
||||
} catch { /* ignore */ } finally { alertLoading.value = false }
|
||||
}
|
||||
|
||||
// ---- 日志统计 ----
|
||||
const stats = ref(null)
|
||||
|
||||
async function loadStats() {
|
||||
try {
|
||||
const { data } = await api.get('/logs/stats')
|
||||
stats.value = data
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
// ---- 日志清理 ----
|
||||
async function cleanupLogs() {
|
||||
try {
|
||||
const { value } = await ElMessageBox.prompt(t('logs.cleanupPrompt'), t('logs.cleanup'), {
|
||||
inputValue: '90',
|
||||
inputPattern: /^\d+$/,
|
||||
inputErrorMessage: t('logs.cleanupInputError'),
|
||||
confirmButtonText: t('common.confirm'),
|
||||
cancelButtonText: t('common.cancel'),
|
||||
})
|
||||
const days = parseInt(value) || 90
|
||||
const { data } = await api.post('/logs/cleanup', { days })
|
||||
ElMessage.success(t('logs.cleanupDone', { audit: data.audit_deleted, conn: data.conn_deleted, session: data.session_deleted }))
|
||||
loadStats()
|
||||
if (activeTab.value === 'audit') loadAudit()
|
||||
if (activeTab.value === 'connection') loadConn()
|
||||
} catch { /* cancel */ }
|
||||
}
|
||||
|
||||
// ---- 通用 ----
|
||||
function onTabChange(tab) {
|
||||
if (tab === 'audit') loadAudit()
|
||||
if (tab === 'connection') loadConn()
|
||||
if (tab === 'alerts') loadAlerts()
|
||||
}
|
||||
|
||||
function fmtTime(d) {
|
||||
if (!d) return '-'
|
||||
return new Date(d).toLocaleString()
|
||||
}
|
||||
|
||||
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'
|
||||
}
|
||||
|
||||
function actionTagType(a) {
|
||||
if (a.includes('login')) return 'primary'
|
||||
if (a.includes('create') || a.includes('add')) return 'success'
|
||||
if (a.includes('delete') || a.includes('kick')) return 'danger'
|
||||
if (a.includes('update') || a.includes('change') || a.includes('set')) return 'warning'
|
||||
return 'info'
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadAudit()
|
||||
loadStats()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.filter-bar {
|
||||
display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
|
||||
}
|
||||
.pagination-bar {
|
||||
display: flex; justify-content: flex-end; margin-top: 12px;
|
||||
}
|
||||
.row-between {
|
||||
display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 8px;
|
||||
}
|
||||
</style>
|
||||
@@ -45,15 +45,9 @@
|
||||
<el-col :xs="24" :lg="14" v-if="auth.isAdmin()">
|
||||
<el-card class="page-card" shadow="never">
|
||||
<template #header><b>{{ $t('settings.auditTitle') }}</b></template>
|
||||
<el-table :data="logs" size="small" max-height="560" 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="username" :label="$t('common.username')" width="90" />
|
||||
<el-table-column prop="action" :label="$t('common.action')" width="140" />
|
||||
<el-table-column prop="target" :label="$t('common.target')" show-overflow-tooltip />
|
||||
<template #empty><el-empty :description="$t('settings.noAuditLogs')" /></template>
|
||||
</el-table>
|
||||
<el-empty :description="$t('settings.auditMigrated')">
|
||||
<el-button type="primary" @click="$router.push('/logs')">{{ $t('router.logs') }}</el-button>
|
||||
</el-empty>
|
||||
</el-card>
|
||||
</el-col>
|
||||
|
||||
@@ -83,8 +77,6 @@ import api, { auth } from '../api'
|
||||
const { t } = useI18n()
|
||||
const pwd = ref({ old_password: '', new_password: '', confirm: '' })
|
||||
const pwdLoading = ref(false)
|
||||
const logs = ref([])
|
||||
const auditLoading = ref(false)
|
||||
const mfa = ref({ enabled: false, uri: '', code: '' })
|
||||
const mfaCanvas = ref(null)
|
||||
const mfaLoading = ref(false)
|
||||
@@ -110,13 +102,6 @@ onMounted(async () => {
|
||||
const status = await api.get('/mfa/status')
|
||||
mfa.value.enabled = status.data.enabled
|
||||
} catch { /* ignore */ }
|
||||
if (auth.isAdmin()) {
|
||||
auditLoading.value = true
|
||||
try {
|
||||
const audit = await api.get('/audit')
|
||||
logs.value = audit.data || []
|
||||
} catch { /* viewer */ } finally { auditLoading.value = false }
|
||||
}
|
||||
})
|
||||
|
||||
async function setupMFA() {
|
||||
|
||||
@@ -162,6 +162,9 @@
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</template>
|
||||
|
||||
<!-- 流量趋势图 -->
|
||||
<TrafficChart v-if="detail.tunnel.id" :tunnel-id="detail.tunnel.id" />
|
||||
</template>
|
||||
</el-drawer>
|
||||
|
||||
@@ -229,6 +232,7 @@ 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()
|
||||
|
||||
Reference in new issue
Block a user