Files
sentinel/frontend/src/views/Dashboard.vue

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