2026-03-04 00:18:33 +08:00
|
|
|
|
<script setup>
|
|
|
|
|
|
import * as echarts from 'echarts'
|
|
|
|
|
|
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
|
|
|
|
|
|
|
|
|
|
|
|
import MetricTile from '../components/MetricTile.vue'
|
|
|
|
|
|
import PageHero from '../components/PageHero.vue'
|
2026-03-04 00:18:47 +08:00
|
|
|
|
import { useAsyncAction } from '../composables/useAsyncAction'
|
|
|
|
|
|
import { fetchDashboard } from '../api'
|
2026-03-04 00:18:33 +08:00
|
|
|
|
import { usePolling } from '../composables/usePolling'
|
2026-03-04 00:18:59 +08:00
|
|
|
|
import { formatCompactNumber, formatDate, formatDateTime, formatPercent } from '../utils/formatters'
|
2026-03-04 00:18:33 +08:00
|
|
|
|
|
|
|
|
|
|
const dashboard = ref({
|
|
|
|
|
|
today: { total: 0, allowed: 0, intercepted: 0 },
|
|
|
|
|
|
bindings: { active: 0, banned: 0 },
|
|
|
|
|
|
trend: [],
|
|
|
|
|
|
recent_intercepts: [],
|
|
|
|
|
|
})
|
|
|
|
|
|
const chartElement = ref(null)
|
2026-03-04 00:18:47 +08:00
|
|
|
|
const { loading, run } = useAsyncAction()
|
2026-03-04 00:18:33 +08:00
|
|
|
|
let chart
|
|
|
|
|
|
|
|
|
|
|
|
const interceptRate = computed(() => {
|
|
|
|
|
|
const total = dashboard.value.today.total || 0
|
|
|
|
|
|
if (!total) {
|
|
|
|
|
|
return 0
|
|
|
|
|
|
}
|
|
|
|
|
|
return dashboard.value.today.intercepted / total
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
const bindingCoverage = computed(() => {
|
|
|
|
|
|
const active = dashboard.value.bindings.active || 0
|
|
|
|
|
|
const banned = dashboard.value.bindings.banned || 0
|
|
|
|
|
|
const total = active + banned
|
|
|
|
|
|
if (!total) {
|
|
|
|
|
|
return 0
|
|
|
|
|
|
}
|
|
|
|
|
|
return active / total
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
async function renderChart() {
|
|
|
|
|
|
await nextTick()
|
|
|
|
|
|
if (!chartElement.value) {
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
chart ||= echarts.init(chartElement.value)
|
|
|
|
|
|
chart.setOption({
|
2026-03-04 15:00:52 +08:00
|
|
|
|
animationDuration: 400,
|
|
|
|
|
|
color: ['#4d8ff7', '#f29a44'],
|
2026-03-04 00:18:33 +08:00
|
|
|
|
grid: {
|
|
|
|
|
|
left: 24,
|
|
|
|
|
|
right: 24,
|
|
|
|
|
|
top: 40,
|
|
|
|
|
|
bottom: 28,
|
|
|
|
|
|
containLabel: true,
|
|
|
|
|
|
},
|
|
|
|
|
|
legend: {
|
|
|
|
|
|
top: 0,
|
|
|
|
|
|
textStyle: {
|
2026-03-04 15:00:52 +08:00
|
|
|
|
color: '#5f7893',
|
2026-03-04 00:18:33 +08:00
|
|
|
|
fontWeight: 600,
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
tooltip: {
|
|
|
|
|
|
trigger: 'axis',
|
2026-03-04 15:00:52 +08:00
|
|
|
|
backgroundColor: 'rgba(34, 67, 108, 0.92)',
|
2026-03-04 00:18:33 +08:00
|
|
|
|
borderWidth: 0,
|
|
|
|
|
|
textStyle: {
|
|
|
|
|
|
color: '#f7fffe',
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
xAxis: {
|
|
|
|
|
|
type: 'category',
|
|
|
|
|
|
boundaryGap: false,
|
|
|
|
|
|
data: dashboard.value.trend.map((item) => item.date.slice(5)),
|
2026-03-04 15:00:52 +08:00
|
|
|
|
axisLine: { lineStyle: { color: 'rgba(39, 74, 110, 0.16)' } },
|
|
|
|
|
|
axisLabel: { color: '#5f7893', fontWeight: 600 },
|
2026-03-04 00:18:33 +08:00
|
|
|
|
},
|
|
|
|
|
|
yAxis: {
|
|
|
|
|
|
type: 'value',
|
2026-03-04 15:00:52 +08:00
|
|
|
|
splitLine: { lineStyle: { color: 'rgba(39, 74, 110, 0.08)' } },
|
|
|
|
|
|
axisLabel: { color: '#5f7893' },
|
2026-03-04 00:18:33 +08:00
|
|
|
|
},
|
|
|
|
|
|
series: [
|
|
|
|
|
|
{
|
2026-03-04 15:00:52 +08:00
|
|
|
|
name: '放行',
|
2026-03-04 00:18:33 +08:00
|
|
|
|
type: 'line',
|
|
|
|
|
|
smooth: true,
|
|
|
|
|
|
showSymbol: false,
|
2026-03-04 15:00:52 +08:00
|
|
|
|
areaStyle: { color: 'rgba(77, 143, 247, 0.14)' },
|
2026-03-04 00:18:33 +08:00
|
|
|
|
lineStyle: { width: 3 },
|
|
|
|
|
|
data: dashboard.value.trend.map((item) => item.allowed),
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
2026-03-04 15:00:52 +08:00
|
|
|
|
name: '拦截',
|
2026-03-04 00:18:33 +08:00
|
|
|
|
type: 'line',
|
|
|
|
|
|
smooth: true,
|
|
|
|
|
|
showSymbol: false,
|
2026-03-04 15:00:52 +08:00
|
|
|
|
areaStyle: { color: 'rgba(242, 154, 68, 0.12)' },
|
2026-03-04 00:18:33 +08:00
|
|
|
|
lineStyle: { width: 3 },
|
|
|
|
|
|
data: dashboard.value.trend.map((item) => item.intercepted),
|
|
|
|
|
|
},
|
|
|
|
|
|
],
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function loadDashboard() {
|
2026-03-04 00:18:47 +08:00
|
|
|
|
await run(async () => {
|
2026-03-04 00:18:33 +08:00
|
|
|
|
dashboard.value = await fetchDashboard()
|
|
|
|
|
|
await renderChart()
|
2026-03-04 15:00:52 +08:00
|
|
|
|
}, '加载看板失败。')
|
2026-03-04 00:18:33 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-04 00:18:59 +08:00
|
|
|
|
async function refreshDashboard() {
|
|
|
|
|
|
try {
|
|
|
|
|
|
await loadDashboard()
|
|
|
|
|
|
} catch {}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-04 00:18:33 +08:00
|
|
|
|
function resizeChart() {
|
|
|
|
|
|
chart?.resize()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-04 00:18:59 +08:00
|
|
|
|
const { start: startPolling, stop: stopPolling } = usePolling(refreshDashboard, 30000)
|
2026-03-04 00:18:33 +08:00
|
|
|
|
|
|
|
|
|
|
onMounted(async () => {
|
2026-03-04 00:18:59 +08:00
|
|
|
|
await refreshDashboard()
|
2026-03-04 00:18:33 +08:00
|
|
|
|
startPolling()
|
|
|
|
|
|
window.addEventListener('resize', resizeChart)
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
onBeforeUnmount(() => {
|
|
|
|
|
|
stopPolling()
|
|
|
|
|
|
window.removeEventListener('resize', resizeChart)
|
|
|
|
|
|
chart?.dispose()
|
|
|
|
|
|
})
|
|
|
|
|
|
</script>
|
|
|
|
|
|
|
|
|
|
|
|
<template>
|
|
|
|
|
|
<div class="page-grid">
|
|
|
|
|
|
<PageHero
|
2026-03-04 15:00:52 +08:00
|
|
|
|
eyebrow="运行概览"
|
|
|
|
|
|
title="在一个页面里查看放行、拦截与绑定状态"
|
|
|
|
|
|
description="看板汇总今日代理结果、绑定规模和最近拦截记录,便于快速判断系统是否稳定运行。"
|
2026-03-04 00:18:33 +08:00
|
|
|
|
>
|
|
|
|
|
|
<template #aside>
|
|
|
|
|
|
<div class="hero-stat-pair">
|
|
|
|
|
|
<div class="hero-stat">
|
2026-03-04 15:00:52 +08:00
|
|
|
|
<span class="eyebrow">拦截率</span>
|
2026-03-04 00:18:33 +08:00
|
|
|
|
<strong>{{ formatPercent(interceptRate) }}</strong>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="hero-stat">
|
2026-03-04 15:00:52 +08:00
|
|
|
|
<span class="eyebrow">活跃占比</span>
|
2026-03-04 00:18:33 +08:00
|
|
|
|
<strong>{{ formatPercent(bindingCoverage) }}</strong>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
|
|
<template #actions>
|
2026-03-04 15:00:52 +08:00
|
|
|
|
<el-button :loading="loading" type="primary" plain @click="refreshDashboard">刷新看板</el-button>
|
2026-03-04 00:18:33 +08:00
|
|
|
|
</template>
|
|
|
|
|
|
</PageHero>
|
|
|
|
|
|
|
|
|
|
|
|
<section class="metric-grid">
|
|
|
|
|
|
<MetricTile
|
2026-03-04 15:00:52 +08:00
|
|
|
|
eyebrow="今日总量"
|
2026-03-04 00:18:33 +08:00
|
|
|
|
:value="formatCompactNumber(dashboard.today.total)"
|
2026-03-04 15:00:52 +08:00
|
|
|
|
note="今天经过网关处理的请求总数。"
|
2026-03-04 00:18:33 +08:00
|
|
|
|
accent="slate"
|
|
|
|
|
|
/>
|
|
|
|
|
|
<MetricTile
|
2026-03-04 15:00:52 +08:00
|
|
|
|
eyebrow="放行请求"
|
2026-03-04 00:18:33 +08:00
|
|
|
|
:value="formatCompactNumber(dashboard.today.allowed)"
|
2026-03-04 15:00:52 +08:00
|
|
|
|
note="通过绑定校验并成功转发的请求。"
|
|
|
|
|
|
accent="slate"
|
2026-03-04 00:18:33 +08:00
|
|
|
|
/>
|
|
|
|
|
|
<MetricTile
|
2026-03-04 15:00:52 +08:00
|
|
|
|
eyebrow="拦截请求"
|
2026-03-04 00:18:33 +08:00
|
|
|
|
:value="formatCompactNumber(dashboard.today.intercepted)"
|
2026-03-04 15:00:52 +08:00
|
|
|
|
note="因 IP 不匹配或 Token 被封禁而拦截。"
|
2026-03-04 00:18:33 +08:00
|
|
|
|
accent="amber"
|
|
|
|
|
|
/>
|
|
|
|
|
|
<MetricTile
|
2026-03-04 15:00:52 +08:00
|
|
|
|
eyebrow="当前绑定"
|
2026-03-04 00:18:33 +08:00
|
|
|
|
:value="formatCompactNumber(dashboard.bindings.active)"
|
2026-03-04 15:00:52 +08:00
|
|
|
|
:note="`活跃绑定 ${formatCompactNumber(dashboard.bindings.active)} 条,封禁 ${formatCompactNumber(dashboard.bindings.banned)} 条。`"
|
2026-03-04 00:18:33 +08:00
|
|
|
|
accent="slate"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</section>
|
|
|
|
|
|
|
|
|
|
|
|
<section class="content-grid">
|
|
|
|
|
|
<article class="chart-card panel">
|
|
|
|
|
|
<div class="toolbar">
|
|
|
|
|
|
<div>
|
2026-03-04 15:00:52 +08:00
|
|
|
|
<p class="eyebrow">7 日趋势</p>
|
|
|
|
|
|
<h3 class="section-title">近 7 天放行与拦截趋势</h3>
|
2026-03-04 00:18:33 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
<div class="inline-meta">
|
2026-03-04 15:00:52 +08:00
|
|
|
|
<el-tag round effect="plain" type="primary">30 秒自动刷新</el-tag>
|
|
|
|
|
|
<span class="muted">结合 Redis 指标与 PostgreSQL 日志统计。</span>
|
2026-03-04 00:18:33 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div ref="chartElement" class="chart-surface" />
|
2026-03-04 00:18:59 +08:00
|
|
|
|
<div class="trend-summary">
|
2026-03-04 15:00:52 +08:00
|
|
|
|
<p class="eyebrow">趋势明细</p>
|
2026-03-04 00:18:59 +08:00
|
|
|
|
<div class="trend-table-wrap">
|
|
|
|
|
|
<table class="trend-table">
|
|
|
|
|
|
<thead>
|
|
|
|
|
|
<tr>
|
2026-03-04 15:00:52 +08:00
|
|
|
|
<th scope="col">日期</th>
|
|
|
|
|
|
<th scope="col">放行</th>
|
|
|
|
|
|
<th scope="col">拦截</th>
|
2026-03-04 00:18:59 +08:00
|
|
|
|
</tr>
|
|
|
|
|
|
</thead>
|
|
|
|
|
|
<tbody>
|
|
|
|
|
|
<tr v-for="item in dashboard.trend" :key="item.date">
|
|
|
|
|
|
<td>{{ formatDate(item.date) }}</td>
|
|
|
|
|
|
<td>{{ formatCompactNumber(item.allowed) }}</td>
|
|
|
|
|
|
<td>{{ formatCompactNumber(item.intercepted) }}</td>
|
|
|
|
|
|
</tr>
|
|
|
|
|
|
</tbody>
|
|
|
|
|
|
</table>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2026-03-04 00:18:33 +08:00
|
|
|
|
</article>
|
|
|
|
|
|
|
|
|
|
|
|
<article class="table-card panel">
|
|
|
|
|
|
<div class="table-toolbar-block">
|
2026-03-04 15:00:52 +08:00
|
|
|
|
<p class="eyebrow">最新事件</p>
|
|
|
|
|
|
<h3 class="section-title">最近拦截记录</h3>
|
|
|
|
|
|
<p class="muted">用于快速确认异常来源、告警状态和是否需要进一步处置。</p>
|
2026-03-04 00:18:33 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-03-04 15:00:52 +08:00
|
|
|
|
<div v-if="!dashboard.recent_intercepts.length" class="empty-state">当前还没有拦截记录。</div>
|
2026-03-04 00:18:33 +08:00
|
|
|
|
|
2026-03-04 00:18:59 +08:00
|
|
|
|
<div v-else class="table-stack table-stack--spaced">
|
|
|
|
|
|
<article
|
2026-03-04 00:18:33 +08:00
|
|
|
|
v-for="item in dashboard.recent_intercepts"
|
|
|
|
|
|
:key="item.id"
|
2026-03-04 00:18:59 +08:00
|
|
|
|
class="insight-card insight-card--compact"
|
2026-03-04 00:18:33 +08:00
|
|
|
|
>
|
|
|
|
|
|
<div class="toolbar">
|
|
|
|
|
|
<strong>{{ item.token_display }}</strong>
|
|
|
|
|
|
<el-tag :type="item.alerted ? 'danger' : 'warning'" round>
|
2026-03-04 15:00:52 +08:00
|
|
|
|
{{ item.alerted ? '已告警' : '待观察' }}
|
2026-03-04 00:18:33 +08:00
|
|
|
|
</el-tag>
|
|
|
|
|
|
</div>
|
2026-03-04 15:00:52 +08:00
|
|
|
|
<p class="insight-note">绑定地址:{{ item.bound_ip }}</p>
|
|
|
|
|
|
<p class="insight-note">尝试地址:{{ item.attempt_ip }}</p>
|
2026-03-04 00:18:33 +08:00
|
|
|
|
<p class="insight-note">{{ formatDateTime(item.intercepted_at) }}</p>
|
2026-03-04 00:18:59 +08:00
|
|
|
|
</article>
|
2026-03-04 00:18:33 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</article>
|
|
|
|
|
|
</section>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</template>
|