feat(core): 初始化 Key-IP Sentinel 服务与部署骨架
- 搭建 FastAPI、Redis、PostgreSQL、Nginx 与 Docker Compose 基础结构 - 实现反向代理、首用绑定、拦截告警、归档任务和管理接口 - 提供 Vue3 管理后台初版,以及 uv/requirements 双依赖配置
This commit is contained in:
235
frontend/src/views/Dashboard.vue
Normal file
235
frontend/src/views/Dashboard.vue
Normal file
@@ -0,0 +1,235 @@
|
||||
<script setup>
|
||||
import * as echarts from 'echarts'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
|
||||
import MetricTile from '../components/MetricTile.vue'
|
||||
import PageHero from '../components/PageHero.vue'
|
||||
import { fetchDashboard, humanizeError } from '../api'
|
||||
import { usePolling } from '../composables/usePolling'
|
||||
import { formatCompactNumber, formatDateTime, formatPercent } from '../utils/formatters'
|
||||
|
||||
const loading = ref(false)
|
||||
const dashboard = ref({
|
||||
today: { total: 0, allowed: 0, intercepted: 0 },
|
||||
bindings: { active: 0, banned: 0 },
|
||||
trend: [],
|
||||
recent_intercepts: [],
|
||||
})
|
||||
const chartElement = ref(null)
|
||||
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: 500,
|
||||
color: ['#0b9e88', '#ef7f41'],
|
||||
grid: {
|
||||
left: 24,
|
||||
right: 24,
|
||||
top: 40,
|
||||
bottom: 28,
|
||||
containLabel: true,
|
||||
},
|
||||
legend: {
|
||||
top: 0,
|
||||
textStyle: {
|
||||
color: '#516a75',
|
||||
fontWeight: 600,
|
||||
},
|
||||
},
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
backgroundColor: 'rgba(8, 24, 34, 0.9)',
|
||||
borderWidth: 0,
|
||||
textStyle: {
|
||||
color: '#f7fffe',
|
||||
},
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
boundaryGap: false,
|
||||
data: dashboard.value.trend.map((item) => item.date.slice(5)),
|
||||
axisLine: { lineStyle: { color: 'rgba(9, 22, 30, 0.18)' } },
|
||||
axisLabel: { color: '#516a75', fontWeight: 600 },
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
splitLine: { lineStyle: { color: 'rgba(9, 22, 30, 0.08)' } },
|
||||
axisLabel: { color: '#516a75' },
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: 'Allowed',
|
||||
type: 'line',
|
||||
smooth: true,
|
||||
showSymbol: false,
|
||||
areaStyle: { color: 'rgba(11, 158, 136, 0.14)' },
|
||||
lineStyle: { width: 3 },
|
||||
data: dashboard.value.trend.map((item) => item.allowed),
|
||||
},
|
||||
{
|
||||
name: 'Intercepted',
|
||||
type: 'line',
|
||||
smooth: true,
|
||||
showSymbol: false,
|
||||
areaStyle: { color: 'rgba(239, 127, 65, 0.12)' },
|
||||
lineStyle: { width: 3 },
|
||||
data: dashboard.value.trend.map((item) => item.intercepted),
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
async function loadDashboard() {
|
||||
loading.value = true
|
||||
try {
|
||||
dashboard.value = await fetchDashboard()
|
||||
await renderChart()
|
||||
} catch (error) {
|
||||
ElMessage.error(humanizeError(error, 'Failed to load dashboard.'))
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function resizeChart() {
|
||||
chart?.resize()
|
||||
}
|
||||
|
||||
const { start: startPolling, stop: stopPolling } = usePolling(loadDashboard, 30000)
|
||||
|
||||
onMounted(async () => {
|
||||
await loadDashboard()
|
||||
startPolling()
|
||||
window.addEventListener('resize', resizeChart)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
stopPolling()
|
||||
window.removeEventListener('resize', resizeChart)
|
||||
chart?.dispose()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page-grid">
|
||||
<PageHero
|
||||
eyebrow="Traffic pulse"
|
||||
title="Edge decisions and security drift in one pass"
|
||||
description="The dashboard combines live proxy metrics with persisted intercept records so security events remain visible even if Redis rolls over."
|
||||
>
|
||||
<template #aside>
|
||||
<div class="hero-stat-pair">
|
||||
<div class="hero-stat">
|
||||
<span class="eyebrow">Intercept rate</span>
|
||||
<strong>{{ formatPercent(interceptRate) }}</strong>
|
||||
</div>
|
||||
<div class="hero-stat">
|
||||
<span class="eyebrow">Active share</span>
|
||||
<strong>{{ formatPercent(bindingCoverage) }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #actions>
|
||||
<el-button :loading="loading" type="primary" plain @click="loadDashboard">Refresh dashboard</el-button>
|
||||
</template>
|
||||
</PageHero>
|
||||
|
||||
<section class="metric-grid">
|
||||
<MetricTile
|
||||
eyebrow="Today"
|
||||
:value="formatCompactNumber(dashboard.today.total)"
|
||||
note="Total edge decisions recorded today."
|
||||
accent="slate"
|
||||
/>
|
||||
<MetricTile
|
||||
eyebrow="Allowed"
|
||||
:value="formatCompactNumber(dashboard.today.allowed)"
|
||||
note="Requests that passed binding enforcement."
|
||||
accent="mint"
|
||||
/>
|
||||
<MetricTile
|
||||
eyebrow="Intercepted"
|
||||
:value="formatCompactNumber(dashboard.today.intercepted)"
|
||||
note="Requests blocked for CIDR mismatch or banned keys."
|
||||
accent="amber"
|
||||
/>
|
||||
<MetricTile
|
||||
eyebrow="Bindings"
|
||||
:value="formatCompactNumber(dashboard.bindings.active)"
|
||||
:note="`Active bindings, with ${formatCompactNumber(dashboard.bindings.banned)} banned keys in reserve.`"
|
||||
accent="slate"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section class="content-grid">
|
||||
<article class="chart-card panel">
|
||||
<div class="toolbar">
|
||||
<div>
|
||||
<p class="eyebrow">7-day trend</p>
|
||||
<h3 class="section-title">Allowed vs intercepted flow</h3>
|
||||
</div>
|
||||
<div class="inline-meta">
|
||||
<el-tag round effect="plain" type="success">30s auto refresh</el-tag>
|
||||
<span class="muted">Redis metrics with PostgreSQL intercept backfill.</span>
|
||||
</div>
|
||||
</div>
|
||||
<div ref="chartElement" class="chart-surface" />
|
||||
</article>
|
||||
|
||||
<article class="table-card panel">
|
||||
<div class="table-toolbar-block">
|
||||
<p class="eyebrow">Recent blocks</p>
|
||||
<h3 class="section-title">Latest intercepted requests</h3>
|
||||
<p class="muted">Operators can triage repeated misuse and verify whether alert escalation has already fired.</p>
|
||||
</div>
|
||||
|
||||
<div v-if="!dashboard.recent_intercepts.length" class="empty-state">No intercepts recorded yet.</div>
|
||||
|
||||
<div v-else class="table-stack" style="margin-top: 18px;">
|
||||
<div
|
||||
v-for="item in dashboard.recent_intercepts"
|
||||
:key="item.id"
|
||||
class="insight-card"
|
||||
style="padding: 16px; border-radius: 20px;"
|
||||
>
|
||||
<div class="toolbar">
|
||||
<strong>{{ item.token_display }}</strong>
|
||||
<el-tag :type="item.alerted ? 'danger' : 'warning'" round>
|
||||
{{ item.alerted ? 'Alerted' : 'Pending' }}
|
||||
</el-tag>
|
||||
</div>
|
||||
<p class="insight-note">Bound CIDR: {{ item.bound_ip }}</p>
|
||||
<p class="insight-note">Attempt IP: {{ item.attempt_ip }}</p>
|
||||
<p class="insight-note">{{ formatDateTime(item.intercepted_at) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
Reference in New Issue
Block a user