feat(frontend): finish backlog UX for audit, dashboard, wizard
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.
This commit is contained in:
1 parent
f01d3dc5ea
commit
9984e21930
7 files changed
+183
-38
No files matched your search
@@ -114,7 +114,13 @@
|
|||||||
"fixSuccess": "Fixed: {items}",
|
"fixSuccess": "Fixed: {items}",
|
||||||
"fixFailed": "Fix failed: {items}",
|
"fixFailed": "Fix failed: {items}",
|
||||||
"fixNeedRoot": "Root privileges required to modify system config",
|
"fixNeedRoot": "Root privileges required to modify system config",
|
||||||
"fixNothing": "Nothing to auto-fix"
|
"fixNothing": "Nothing to auto-fix",
|
||||||
|
"quickErrorTunnels": "Unhealthy tunnels",
|
||||||
|
"quickErrorHint": "Tunnels in error state",
|
||||||
|
"quickOnline": "Online sessions",
|
||||||
|
"quickOnlineHint": "Currently connected users",
|
||||||
|
"quickAlerts": "Open alerts",
|
||||||
|
"quickAlertsHint": "Traffic and quota alerts"
|
||||||
},
|
},
|
||||||
"tunnels": {
|
"tunnels": {
|
||||||
"searchPlaceholder": "Search by name",
|
"searchPlaceholder": "Search by name",
|
||||||
@@ -356,7 +362,10 @@
|
|||||||
"installSuccess": "{protocol} {version} installed successfully {method}",
|
"installSuccess": "{protocol} {version} installed successfully {method}",
|
||||||
"methodPackageManager": "(Package Manager)",
|
"methodPackageManager": "(Package Manager)",
|
||||||
"methodDownload": "(One-Click Install)",
|
"methodDownload": "(One-Click Install)",
|
||||||
"installFailed": "Failed to install {protocol}"
|
"installFailed": "Failed to install {protocol}",
|
||||||
|
"groupEmployee": "Employee access",
|
||||||
|
"groupSite": "Site interconnect",
|
||||||
|
"groupPenetrate": "Intranet penetration"
|
||||||
},
|
},
|
||||||
"users": {
|
"users": {
|
||||||
"title": "Admins",
|
"title": "Admins",
|
||||||
@@ -581,7 +590,7 @@
|
|||||||
"cleanupHint": "Delete log data older than specified days",
|
"cleanupHint": "Delete log data older than specified days",
|
||||||
"cleanupPrompt": "Keep logs from the last how many days?",
|
"cleanupPrompt": "Keep logs from the last how many days?",
|
||||||
"cleanupInputError": "Please enter a positive integer",
|
"cleanupInputError": "Please enter a positive integer",
|
||||||
"cleanupDone": "Cleanup done: {audit} audit, {conn} connection, {session} session records deleted",
|
"cleanupDone": "Cleanup done: {audit} audit logs, {session} session records",
|
||||||
"loadFailed": "Failed to load logs",
|
"loadFailed": "Failed to load logs",
|
||||||
"noAlerts": "No traffic alerts"
|
"noAlerts": "No traffic alerts"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -114,7 +114,13 @@
|
|||||||
"fixSuccess": "已修复:{items}",
|
"fixSuccess": "已修复:{items}",
|
||||||
"fixFailed": "修复失败:{items}",
|
"fixFailed": "修复失败:{items}",
|
||||||
"fixNeedRoot": "需要以 root 运行才能修改系统配置",
|
"fixNeedRoot": "需要以 root 运行才能修改系统配置",
|
||||||
"fixNothing": "当前没有可自动修复的项"
|
"fixNothing": "当前没有可自动修复的项",
|
||||||
|
"quickErrorTunnels": "异常专线",
|
||||||
|
"quickErrorHint": "状态为错误的专线",
|
||||||
|
"quickOnline": "在线会话",
|
||||||
|
"quickOnlineHint": "当前在线拨号用户",
|
||||||
|
"quickAlerts": "待处理告警",
|
||||||
|
"quickAlertsHint": "流量异常与配额告警"
|
||||||
},
|
},
|
||||||
"tunnels": {
|
"tunnels": {
|
||||||
"searchPlaceholder": "搜索名称",
|
"searchPlaceholder": "搜索名称",
|
||||||
@@ -356,7 +362,10 @@
|
|||||||
"installSuccess": "{protocol} {version} 安装成功 {method}",
|
"installSuccess": "{protocol} {version} 安装成功 {method}",
|
||||||
"methodPackageManager": "(包管理器安装)",
|
"methodPackageManager": "(包管理器安装)",
|
||||||
"methodDownload": "(一键安装)",
|
"methodDownload": "(一键安装)",
|
||||||
"installFailed": "{protocol} 安装失败"
|
"installFailed": "{protocol} 安装失败",
|
||||||
|
"groupEmployee": "员工接入",
|
||||||
|
"groupSite": "站点对接",
|
||||||
|
"groupPenetrate": "内网穿透"
|
||||||
},
|
},
|
||||||
"users": {
|
"users": {
|
||||||
"title": "管理员",
|
"title": "管理员",
|
||||||
@@ -581,7 +590,7 @@
|
|||||||
"cleanupHint": "删除指定天数之前的日志数据",
|
"cleanupHint": "删除指定天数之前的日志数据",
|
||||||
"cleanupPrompt": "保留最近多少天的日志?",
|
"cleanupPrompt": "保留最近多少天的日志?",
|
||||||
"cleanupInputError": "请输入正整数",
|
"cleanupInputError": "请输入正整数",
|
||||||
"cleanupDone": "清理完成:审计 {audit} 条、连接 {conn} 条、会话 {session} 条",
|
"cleanupDone": "清理完成:操作审计 {audit} 条、会话记录 {session} 条",
|
||||||
"loadFailed": "加载日志失败",
|
"loadFailed": "加载日志失败",
|
||||||
"noAlerts": "暂无流量告警"
|
"noAlerts": "暂无流量告警"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -31,6 +31,16 @@
|
|||||||
</el-col>
|
</el-col>
|
||||||
</el-row>
|
</el-row>
|
||||||
|
|
||||||
|
<el-row :gutter="12" class="quick-actions" v-if="readinessLoaded">
|
||||||
|
<el-col :xs="24" :sm="8" v-for="item in quickLinks" :key="item.to">
|
||||||
|
<RouterLink :to="item.to" class="quick-card" :class="item.tone">
|
||||||
|
<div class="quick-title">{{ item.title }}</div>
|
||||||
|
<div class="quick-value">{{ item.value }}</div>
|
||||||
|
<div class="quick-hint">{{ item.hint }}</div>
|
||||||
|
</RouterLink>
|
||||||
|
</el-col>
|
||||||
|
</el-row>
|
||||||
|
|
||||||
<el-alert v-if="readinessLoaded && !hostReady" class="host-readiness" type="warning" :closable="false" show-icon
|
<el-alert v-if="readinessLoaded && !hostReady" class="host-readiness" type="warning" :closable="false" show-icon
|
||||||
:title="$t('dashboard.hostReadinessWarning')" :description="readinessHint">
|
:title="$t('dashboard.hostReadinessWarning')" :description="readinessHint">
|
||||||
<template #default>
|
<template #default>
|
||||||
@@ -214,6 +224,13 @@ function copyText(text) { navigator.clipboard.writeText(text).then(() => ElMessa
|
|||||||
|
|
||||||
function fmtTime(t) { return t ? new Date(t).toLocaleString() : '-' }
|
function fmtTime(t) { return t ? new Date(t).toLocaleString() : '-' }
|
||||||
|
|
||||||
|
const alertCount = ref(0)
|
||||||
|
const errorTunnelCount = ref(0)
|
||||||
|
const quickLinks = computed(() => [
|
||||||
|
{ title: t('dashboard.quickErrorTunnels'), value: errorTunnelCount.value, hint: t('dashboard.quickErrorHint'), to: '/tunnels?filter=unhealthy', tone: 'danger' },
|
||||||
|
{ title: t('dashboard.quickOnline'), value: ov.value.online_sessions ?? '—', hint: t('dashboard.quickOnlineHint'), to: '/sessions?tab=online', tone: 'ok' },
|
||||||
|
{ title: t('dashboard.quickAlerts'), value: alertCount.value ?? '—', hint: t('dashboard.quickAlertsHint'), to: '/logs?tab=alert', tone: 'warn' },
|
||||||
|
])
|
||||||
const statCards = computed(() => [
|
const statCards = computed(() => [
|
||||||
{ label: t('dashboard.totalTunnels'), value: ov.value.total ?? '—', icon: Connection, tone: 'tunnels', to: '/tunnels' },
|
{ label: t('dashboard.totalTunnels'), value: ov.value.total ?? '—', icon: Connection, tone: 'tunnels', to: '/tunnels' },
|
||||||
{ label: t('dashboard.running'), value: ov.value.running ?? '—', icon: Monitor, tone: 'running', to: '/tunnels?state=running' },
|
{ label: t('dashboard.running'), value: ov.value.running ?? '—', icon: Monitor, tone: 'running', to: '/tunnels?state=running' },
|
||||||
@@ -233,11 +250,21 @@ async function load() {
|
|||||||
// 轮询可见性守卫:标签页隐藏时跳过(P1 修复)
|
// 轮询可见性守卫:标签页隐藏时跳过(P1 修复)
|
||||||
if (document.visibilityState !== 'visible') return
|
if (document.visibilityState !== 'visible') return
|
||||||
try {
|
try {
|
||||||
const [{ data: o }, { data: p }, { data: r }] = await Promise.all([api.get('/overview'), api.get('/protocols'), api.get('/readiness')])
|
const [{ data: o }, { data: p }, { data: r }, alertsRes, tunnelsRes] = await Promise.all([
|
||||||
|
api.get('/overview'), api.get('/protocols'), api.get('/readiness'),
|
||||||
|
api.get('/traffic/alerts').catch(() => ({ data: [] })),
|
||||||
|
api.get('/tunnels').catch(() => ({ data: [] })),
|
||||||
|
])
|
||||||
ov.value = o
|
ov.value = o
|
||||||
protocols.value = p
|
protocols.value = p
|
||||||
readiness.value = r || {}
|
readiness.value = r || {}
|
||||||
readinessLoaded.value = true
|
readinessLoaded.value = true
|
||||||
|
alertCount.value = Array.isArray(alertsRes.data) ? alertsRes.data.length : 0
|
||||||
|
const tunnelList = Array.isArray(tunnelsRes.data) ? tunnelsRes.data : (tunnelsRes.data?.items || [])
|
||||||
|
errorTunnelCount.value = tunnelList.filter(x => {
|
||||||
|
const st = x.runtime?.state || x.Runtime?.State || x.state || x.State || ''
|
||||||
|
return String(st).toLowerCase() === 'error'
|
||||||
|
}).length
|
||||||
loadError.value = false
|
loadError.value = false
|
||||||
await nextTick()
|
await nextTick()
|
||||||
renderPie()
|
renderPie()
|
||||||
@@ -331,4 +358,18 @@ onUnmounted(() => {
|
|||||||
.command-block { margin:8px 0; padding:12px; background:var(--bg-inset); border:1px solid var(--border-base); white-space:pre-wrap; overflow:auto; }
|
.command-block { margin:8px 0; padding:12px; background:var(--bg-inset); border:1px solid var(--border-base); white-space:pre-wrap; overflow:auto; }
|
||||||
.other-command { margin-bottom:8px; }
|
.other-command { margin-bottom:8px; }
|
||||||
.other-command code { flex:1; min-width:0; overflow-wrap:anywhere; font-size:12px; }
|
.other-command code { flex:1; min-width:0; overflow-wrap:anywhere; font-size:12px; }
|
||||||
|
|
||||||
|
.quick-actions { margin: 0 0 16px; }
|
||||||
|
.quick-card {
|
||||||
|
display: block; padding: 14px 16px; border-radius: 12px; text-decoration: none;
|
||||||
|
background: var(--bg-card); border: 1px solid var(--border-base); color: inherit;
|
||||||
|
transition: box-shadow .15s, border-color .15s;
|
||||||
|
}
|
||||||
|
.quick-card:hover { box-shadow: var(--shadow-sm); border-color: var(--brand-primary); }
|
||||||
|
.quick-title { font-size: 13px; color: var(--text-secondary); }
|
||||||
|
.quick-value { font-size: 22px; font-weight: 700; margin: 4px 0; }
|
||||||
|
.quick-hint { font-size: 12px; color: var(--text-secondary); }
|
||||||
|
.quick-card.danger .quick-value { color: var(--color-danger); }
|
||||||
|
.quick-card.warn .quick-value { color: var(--color-warning); }
|
||||||
|
.quick-card.ok .quick-value { color: var(--color-success, #67c23a); }
|
||||||
</style>
|
</style>
|
||||||
@@ -81,19 +81,13 @@
|
|||||||
<!-- 日志统计卡片 -->
|
<!-- 日志统计卡片 -->
|
||||||
<el-card class="page-card" shadow="never" style="margin-top:16px" v-if="stats">
|
<el-card class="page-card" shadow="never" style="margin-top:16px" v-if="stats">
|
||||||
<template #header><b>{{ $t('logs.statsTitle') }}</b></template>
|
<template #header><b>{{ $t('logs.statsTitle') }}</b></template>
|
||||||
<el-descriptions :column="3" size="small" border>
|
<el-descriptions :column="2" size="small" border>
|
||||||
<el-descriptions-item :label="$t('logs.auditCount')">
|
<el-descriptions-item :label="$t('logs.auditCount')">
|
||||||
{{ stats.audit?.count || 0 }} 条
|
{{ stats.audit?.count || 0 }} 条
|
||||||
<span v-if="stats.audit?.oldest" style="color:var(--text-secondary);font-size:12px;margin-left:8px">
|
<span v-if="stats.audit?.oldest" style="color:var(--text-secondary);font-size:12px;margin-left:8px">
|
||||||
{{ $t('logs.from') }} {{ fmtTime(stats.audit.oldest) }}
|
{{ $t('logs.from') }} {{ fmtTime(stats.audit.oldest) }}
|
||||||
</span>
|
</span>
|
||||||
</el-descriptions-item>
|
</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')">
|
<el-descriptions-item :label="$t('logs.sessionCount')">
|
||||||
{{ stats.session?.count || 0 }} 条
|
{{ stats.session?.count || 0 }} 条
|
||||||
</el-descriptions-item>
|
</el-descriptions-item>
|
||||||
@@ -116,8 +110,12 @@ import api, { auth } from '../api'
|
|||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const TAB_NAMES = ['audit', 'alerts']
|
function normalizeLogTab(name) {
|
||||||
const activeTab = ref(TAB_NAMES.includes(route.query.tab) ? route.query.tab : 'audit')
|
if (name === 'alert' || name === 'alerts') return 'alerts'
|
||||||
|
if (name === 'audit') return 'audit'
|
||||||
|
return 'audit'
|
||||||
|
}
|
||||||
|
const activeTab = ref(normalizeLogTab(route.query.tab))
|
||||||
|
|
||||||
watch(() => route.query.tab, (name) => {
|
watch(() => route.query.tab, (name) => {
|
||||||
// 连接审计已迁到 Sessions;旧链接 ?tab=connection 跳过去
|
// 连接审计已迁到 Sessions;旧链接 ?tab=connection 跳过去
|
||||||
@@ -228,7 +226,7 @@ async function cleanupLogs() {
|
|||||||
})
|
})
|
||||||
const days = parseInt(value) || 90
|
const days = parseInt(value) || 90
|
||||||
const { data } = await api.post('/logs/cleanup', { days })
|
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 }))
|
ElMessage.success(t('logs.cleanupDone', { audit: data.audit_deleted, session: data.session_deleted }))
|
||||||
loadStats()
|
loadStats()
|
||||||
if (activeTab.value === 'audit') loadAudit()
|
if (activeTab.value === 'audit') loadAudit()
|
||||||
if (activeTab.value === 'alerts') loadAlerts()
|
if (activeTab.value === 'alerts') loadAlerts()
|
||||||
|
|||||||
@@ -3,7 +3,6 @@
|
|||||||
<el-tabs v-model="activeTab" @tab-change="onTabChange">
|
<el-tabs v-model="activeTab" @tab-change="onTabChange">
|
||||||
<el-tab-pane :label="$t('sessions.title')" name="online">
|
<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-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">
|
<el-card class="page-card" shadow="never" style="margin-bottom:16px">
|
||||||
<template #header>
|
<template #header>
|
||||||
<div class="row-between">
|
<div class="row-between">
|
||||||
@@ -42,7 +41,6 @@
|
|||||||
</el-table>
|
</el-table>
|
||||||
</el-card>
|
</el-card>
|
||||||
|
|
||||||
<!-- 异常流量告警 -->
|
|
||||||
<el-card class="page-card" shadow="never" style="margin-bottom:16px" v-if="alerts.length">
|
<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>
|
<template #header><b style="color:var(--color-danger)">{{ $t('sessions.trafficAlert') }}</b></template>
|
||||||
<el-table :data="alerts" size="small">
|
<el-table :data="alerts" size="small">
|
||||||
@@ -60,33 +58,59 @@
|
|||||||
<el-table-column prop="detail" :label="$t('common.detail')" show-overflow-tooltip />
|
<el-table-column prop="detail" :label="$t('common.detail')" show-overflow-tooltip />
|
||||||
</el-table>
|
</el-table>
|
||||||
</el-card>
|
</el-card>
|
||||||
|
|
||||||
</el-tab-pane>
|
</el-tab-pane>
|
||||||
|
|
||||||
<el-tab-pane :label="$t('sessions.auditLog')" name="audit">
|
<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-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">
|
<el-card class="page-card" shadow="never">
|
||||||
<template #header>
|
<template #header>
|
||||||
<div class="row-between">
|
<div class="row-between">
|
||||||
<b>{{ $t('sessions.auditLog') }}</b>
|
<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>
|
<el-button link @click="loadAudit">{{ $t('common.refresh') }}</el-button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<el-table :data="logs" size="small" max-height="500" v-loading="auditLoading">
|
<el-table :data="logs" size="small" max-height="500" v-loading="auditLoading">
|
||||||
<el-table-column prop="created_at" :label="$t('common.time')" width="170">
|
<el-table-column prop="created_at" :label="$t('common.time')" width="170">
|
||||||
<template #default="{ row }">{{ new Date(row.created_at).toLocaleString() }}</template>
|
<template #default="{ row }">{{ new Date(row.created_at).toLocaleString() }}</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column :label="$t('common.username')" min-width="180">
|
<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>
|
<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>
|
||||||
<el-table-column :label="$t('sessions.event')" width="100">
|
<el-table-column :label="$t('sessions.event')" width="110">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-tag :type="eventTagType(row.event)" size="small">{{ eventLabel(row.event) }}</el-tag>
|
<el-tag :type="eventTagType(row.event)" size="small">{{ eventLabel(row.event) }}</el-tag>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="ip_address" :label="$t('common.ipAddress')" width="140" />
|
<el-table-column prop="ip_address" :label="$t('common.ipAddress')" width="140" />
|
||||||
<el-table-column prop="detail" :label="$t('common.detail')" show-overflow-tooltip />
|
<el-table-column prop="detail" :label="$t('common.detail')" show-overflow-tooltip />
|
||||||
|
<template #empty><el-empty :description="$t('common.noData')" /></template>
|
||||||
</el-table>
|
</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-card>
|
||||||
</el-tab-pane>
|
</el-tab-pane>
|
||||||
</el-tabs>
|
</el-tabs>
|
||||||
@@ -94,7 +118,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, watch, onMounted, onUnmounted } from 'vue'
|
import { ref, reactive, watch, onMounted, onUnmounted } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
@@ -109,7 +133,6 @@ const sessions = ref([])
|
|||||||
const logs = ref([])
|
const logs = ref([])
|
||||||
const alerts = ref([])
|
const alerts = ref([])
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
/** 规范名 online / audit;兼容 ?tab=sessions|connection */
|
|
||||||
function normalizeTab(name) {
|
function normalizeTab(name) {
|
||||||
if (name === 'sessions' || name === 'online') return 'online'
|
if (name === 'sessions' || name === 'online') return 'online'
|
||||||
if (name === 'audit' || name === 'connection') return 'audit'
|
if (name === 'audit' || name === 'connection') return 'audit'
|
||||||
@@ -119,6 +142,12 @@ const activeTab = ref(normalizeTab(route.query.tab))
|
|||||||
const sessionError = ref('')
|
const sessionError = ref('')
|
||||||
const auditError = ref('')
|
const auditError = ref('')
|
||||||
const auditLoading = ref(false)
|
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) => {
|
watch(() => route.query.tab, (name) => {
|
||||||
const next = normalizeTab(name)
|
const next = normalizeTab(name)
|
||||||
@@ -126,7 +155,6 @@ watch(() => route.query.tab, (name) => {
|
|||||||
}, { immediate: true })
|
}, { immediate: true })
|
||||||
|
|
||||||
watch(activeTab, (tab) => {
|
watch(activeTab, (tab) => {
|
||||||
// 写入规范 query:在线清空;审计固定 tab=audit(别名落地后会改写)
|
|
||||||
const query = tab === 'online' ? {} : { tab: 'audit' }
|
const query = tab === 'online' ? {} : { tab: 'audit' }
|
||||||
const cur = route.query.tab
|
const cur = route.query.tab
|
||||||
const needsReplace = normalizeTab(cur) !== tab
|
const needsReplace = normalizeTab(cur) !== tab
|
||||||
@@ -170,17 +198,56 @@ async function loadOnline() {
|
|||||||
} finally { loading.value = false }
|
} finally { loading.value = false }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function searchAudit() {
|
||||||
|
auditPage.value = 1
|
||||||
|
loadAudit()
|
||||||
|
}
|
||||||
|
|
||||||
async function loadAudit() {
|
async function loadAudit() {
|
||||||
auditLoading.value = true
|
auditLoading.value = true
|
||||||
try {
|
try {
|
||||||
const response = await api.get('/sessions/log')
|
const params = {
|
||||||
logs.value = response.data || []
|
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 = ''
|
auditError.value = ''
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
auditError.value = e.response?.data?.error || t('sessions.loadDataFailed')
|
auditError.value = e.response?.data?.error || t('sessions.loadDataFailed')
|
||||||
} finally { auditLoading.value = false }
|
} 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) {
|
function onTabChange(tab) {
|
||||||
activeTab.value = tab
|
activeTab.value = tab
|
||||||
if (tab === 'audit') loadAudit()
|
if (tab === 'audit') loadAudit()
|
||||||
@@ -202,10 +269,15 @@ onMounted(() => {
|
|||||||
else loadOnline()
|
else loadOnline()
|
||||||
timer = setInterval(() => {
|
timer = setInterval(() => {
|
||||||
if (activeTab.value === 'online') loadOnline()
|
if (activeTab.value === 'online') loadOnline()
|
||||||
else loadAudit()
|
|
||||||
}, 15000)
|
}, 15000)
|
||||||
})
|
})
|
||||||
onUnmounted(() => clearInterval(timer))
|
onUnmounted(() => clearInterval(timer))
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>.identity-subject { font-size: 11px; color: var(--el-text-color-secondary); overflow-wrap: anywhere; } .session-error { margin-bottom: var(--space-4); }</style>
|
<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>
|
||||||
@@ -8,16 +8,19 @@
|
|||||||
|
|
||||||
<!-- 第一步:协议选择 -->
|
<!-- 第一步:协议选择 -->
|
||||||
<el-alert v-if="step === 0" type="info" :closable="false" :title="$t('tunnelWizard.onDemandHint')" style="margin-bottom:16px" />
|
<el-alert v-if="step === 0" type="info" :closable="false" :title="$t('tunnelWizard.onDemandHint')" style="margin-bottom:16px" />
|
||||||
<div v-if="step === 0" class="proto-grid">
|
<div v-if="step === 0" class="proto-groups">
|
||||||
<div v-for="(meta, key) in builtin" :key="key"
|
<div v-for="group in protocolGroups" :key="group.id" class="proto-group">
|
||||||
|
<div class="proto-group-title">{{ $t(group.titleKey) }}</div>
|
||||||
|
<div class="proto-grid">
|
||||||
|
<div v-for="key in group.keys.filter(k => builtin[k])" :key="key"
|
||||||
class="proto-card"
|
class="proto-card"
|
||||||
:class="{ active: form.protocol === key, disabled: depStatus[key] && !depStatus[key].available }"
|
:class="{ active: form.protocol === key, disabled: depStatus[key] && !depStatus[key].available }"
|
||||||
@click="selectProtocol(key)">
|
@click="selectProtocol(key)">
|
||||||
<el-tag v-if="key === 'wireguard'" type="danger" size="small" effect="dark" class="recommend-badge">{{ $t('tunnelWizard.recommend') }}</el-tag>
|
<el-tag v-if="key === 'wireguard'" type="danger" size="small" effect="dark" class="recommend-badge">{{ $t('tunnelWizard.recommend') }}</el-tag>
|
||||||
<div class="proto-icon"><ProtocolIcon :protocol="key" :size="36" /></div>
|
<div class="proto-icon"><ProtocolIcon :protocol="key" :size="36" /></div>
|
||||||
<div class="proto-name" :style="{ color: meta.color }">{{ $t(meta.name) }}</div>
|
<div class="proto-name" :style="{ color: builtin[key].color }">{{ $t(builtin[key].name) }}</div>
|
||||||
<div class="proto-desc">{{ $t(meta.desc) }}</div>
|
<div class="proto-desc">{{ $t(builtin[key].desc) }}</div>
|
||||||
<div class="proto-scene">{{ $t(meta.scenes) }}</div>
|
<div class="proto-scene">{{ $t(builtin[key].scenes) }}</div>
|
||||||
<div class="dep-badge" v-if="depStatus[key]">
|
<div class="dep-badge" v-if="depStatus[key]">
|
||||||
<el-tag :type="depStatus[key].available ? 'success' : 'warning'" size="small">
|
<el-tag :type="depStatus[key].available ? 'success' : 'warning'" size="small">
|
||||||
{{ depStatus[key].available ? $t('tunnelWizard.depReady') : $t('tunnelWizard.notDeployed') }}
|
{{ depStatus[key].available ? $t('tunnelWizard.depReady') : $t('tunnelWizard.notDeployed') }}
|
||||||
@@ -29,6 +32,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<template v-if="group.id === 'penetrate'">
|
||||||
<div v-for="p in customProtocols" :key="p.name" class="proto-card" :class="{ active: form.protocol === p.name }"
|
<div v-for="p in customProtocols" :key="p.name" class="proto-card" :class="{ active: form.protocol === p.name }"
|
||||||
@click="selectProtocol(p.name)">
|
@click="selectProtocol(p.name)">
|
||||||
<div class="proto-icon"><ProtocolIcon protocol="custom" :size="36" /></div>
|
<div class="proto-icon"><ProtocolIcon protocol="custom" :size="36" /></div>
|
||||||
@@ -36,6 +40,9 @@
|
|||||||
<div class="proto-desc">{{ $t('tunnelWizard.customPlugin') }}</div>
|
<div class="proto-desc">{{ $t('tunnelWizard.customPlugin') }}</div>
|
||||||
<div class="proto-scene">{{ $t('tunnelWizard.customScene') }}</div>
|
<div class="proto-scene">{{ $t('tunnelWizard.customScene') }}</div>
|
||||||
</div>
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 第二步:参数表单 -->
|
<!-- 第二步:参数表单 -->
|
||||||
@@ -166,6 +173,12 @@ import RouteListInput from '../components/RouteListInput.vue'
|
|||||||
import OpenVPNOIDCGuide from '../components/OpenVPNOIDCGuide.vue'
|
import OpenVPNOIDCGuide from '../components/OpenVPNOIDCGuide.vue'
|
||||||
import { PROTOCOL_META, CUSTOM_FIELDS, protocolColor, optionValue, optionLabel } from '../protocols'
|
import { PROTOCOL_META, CUSTOM_FIELDS, protocolColor, optionValue, optionLabel } from '../protocols'
|
||||||
|
|
||||||
|
const protocolGroups = [
|
||||||
|
{ id: 'employee', titleKey: 'tunnelWizard.groupEmployee', keys: ['wireguard', 'ikev2', 'l2tp', 'openvpn'] },
|
||||||
|
{ id: 'site', titleKey: 'tunnelWizard.groupSite', keys: ['ipsec', 'gre'] },
|
||||||
|
{ id: 'penetrate', titleKey: 'tunnelWizard.groupPenetrate', keys: ['frp'] },
|
||||||
|
]
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const step = ref(0)
|
const step = ref(0)
|
||||||
const form = ref({ name: '', protocol: '', config: {}, remark: '', auto_start: true, expire_at: null })
|
const form = ref({ name: '', protocol: '', config: {}, remark: '', auto_start: true, expire_at: null })
|
||||||
@@ -357,6 +370,8 @@ onMounted(async () => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
.proto-groups { display: flex; flex-direction: column; gap: 22px; }
|
||||||
|
.proto-group-title { font-size: 13px; font-weight: 600; color: var(--text-secondary); margin-bottom: 10px; letter-spacing: .4px; }
|
||||||
.proto-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 16px; max-width: 1000px; margin: 0 auto; }
|
.proto-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 16px; max-width: 1000px; margin: 0 auto; }
|
||||||
.proto-card {
|
.proto-card {
|
||||||
border: 2px solid #e8eaee; border-radius: 12px; padding: 22px 16px; text-align: center;
|
border: 2px solid #e8eaee; border-radius: 12px; padding: 22px 16px; text-align: center;
|
||||||
|
|||||||
@@ -122,7 +122,7 @@ import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue'
|
|||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { Plus, InfoFilled, ArrowDown, Refresh } from '@element-plus/icons-vue'
|
import { Plus, InfoFilled, ArrowDown, Refresh } from '@element-plus/icons-vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import api from '../api'
|
import api from '../api'
|
||||||
import { fmtBytes } from '../utils/format'
|
import { fmtBytes } from '../utils/format'
|
||||||
import ProtocolIcon from '../components/ProtocolIcon.vue'
|
import ProtocolIcon from '../components/ProtocolIcon.vue'
|
||||||
@@ -132,6 +132,7 @@ import TunnelLogDialog from './tunnels/components/TunnelLogDialog.vue'
|
|||||||
import { protocolDisplay, protocolColor } from '../protocols'
|
import { protocolDisplay, protocolColor } from '../protocols'
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const list = ref([])
|
const list = ref([])
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
@@ -140,7 +141,7 @@ const changingIds = ref(new Set())
|
|||||||
let loadRevision = 0
|
let loadRevision = 0
|
||||||
const keyword = ref('')
|
const keyword = ref('')
|
||||||
const protocolFilter = ref('')
|
const protocolFilter = ref('')
|
||||||
const stateFilter = ref('')
|
const stateFilter = ref(typeof route.query.state === 'string' ? route.query.state : '')
|
||||||
const protocolOptions = ref([])
|
const protocolOptions = ref([])
|
||||||
// 批量操作
|
// 批量操作
|
||||||
const selectedRows = ref([])
|
const selectedRows = ref([])
|
||||||
|
|||||||
Reference in new issue
Block a user