超级管理员现在可在“组织与权限 → 运维与审计 → 系统性能”中直接查看:

请求量、5xx 比例、HTTP/数据库 P95
请求速率与延迟趋势
最慢接口排行
慢查询与数据库查询排行
Grafana 原始调用链入口
This commit is contained in:
2026-07-31 09:34:31 +08:00 Unverified
parent ca7148ab61
commit 551f1143b7
12 changed files with 1655 additions and 3 deletions
+2
View File
@@ -42,6 +42,7 @@ declare module 'vue' {
ElResult: typeof import('element-plus/es')['ElResult']
ElSegmented: typeof import('element-plus/es')['ElSegmented']
ElSelect: typeof import('element-plus/es')['ElSelect']
ElSkeleton: typeof import('element-plus/es')['ElSkeleton']
ElSlider: typeof import('element-plus/es')['ElSlider']
ElSubMenu: typeof import('element-plus/es')['ElSubMenu']
ElSwitch: typeof import('element-plus/es')['ElSwitch']
@@ -52,6 +53,7 @@ declare module 'vue' {
ElTag: typeof import('element-plus/es')['ElTag']
ElTimeSelect: typeof import('element-plus/es')['ElTimeSelect']
ElUpload: typeof import('element-plus/es')['ElUpload']
PerformanceReportPanel: typeof import('./components/PerformanceReportPanel.vue')['default']
RichMessageContent: typeof import('./components/RichMessageContent.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
@@ -0,0 +1,762 @@
<script setup lang="ts">
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
import { Connection, RefreshRight, TopRight } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import * as echarts from 'echarts/core'
import { LineChart } from 'echarts/charts'
import {
GridComponent,
LegendComponent,
TooltipComponent,
} from 'echarts/components'
import { CanvasRenderer } from 'echarts/renderers'
import http, { apiErrorMessage } from '../api/http'
echarts.use([
LineChart,
GridComponent,
LegendComponent,
TooltipComponent,
CanvasRenderer,
])
type ReportStatus = 'ready' | 'not_configured' | 'unavailable'
type ReportRange = '15m' | '1h' | '24h' | '7d'
interface PerformanceHeadline {
requestCount?: number
requestP95Milliseconds?: number
serverErrorRatePercent?: number
databaseP95Milliseconds?: number
slowDatabaseCommandCount?: number
failedDatabaseCommandCount?: number
}
interface TimelinePoint {
timestamp: string
requestsPerSecond?: number
requestP95Milliseconds?: number
}
interface RankingItem {
name: string
p95Milliseconds?: number
requestCount?: number
exceptionalCount?: number
}
interface PerformanceReport {
status: ReportStatus
range: ReportRange
from?: string
to?: string
generatedAt: string
dataSource: string
dashboardUrl?: string
detail?: string
headline?: PerformanceHeadline
timeline: TimelinePoint[]
endpoints: RankingItem[]
databaseQueries: RankingItem[]
}
const rangeOptions: { value: ReportRange; label: string }[] = [
{ value: '15m', label: '15 分钟' },
{ value: '1h', label: '1 小时' },
{ value: '24h', label: '24 小时' },
{ value: '7d', label: '7 天' },
]
const range = ref<ReportRange>('1h')
const loading = ref(true)
const report = ref<PerformanceReport>()
const chartElement = ref<HTMLElement>()
let chart: echarts.ECharts | undefined
let resizeObserver: ResizeObserver | undefined
const hasSamples = computed(() =>
Boolean(report.value?.headline) &&
(report.value?.timeline.length ?? 0) > 0,
)
const operatingNote = computed(() => {
const headline = report.value?.headline
if (!headline) return '等待指标样本'
if ((headline.failedDatabaseCommandCount ?? 0) > 0)
return '存在失败的数据库命令'
if ((headline.serverErrorRatePercent ?? 0) >= 1)
return '服务端错误率需要关注'
if ((headline.slowDatabaseCommandCount ?? 0) > 0)
return '存在超过阈值的慢查询'
return '当前采样窗口内未见明显异常'
})
function formatNumber(value?: number, digits = 0) {
if (value == null || !Number.isFinite(value)) return '—'
return value.toLocaleString('zh-CN', {
maximumFractionDigits: digits,
minimumFractionDigits: digits,
})
}
function formatTime(value?: string) {
if (!value) return '—'
return new Date(value).toLocaleString('zh-CN', {
hour12: false,
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
})
}
function formatAxisTime(value: string) {
return new Date(value).toLocaleString('zh-CN', {
hour12: false,
month: range.value === '7d' ? '2-digit' : undefined,
day: range.value === '7d' ? '2-digit' : undefined,
hour: '2-digit',
minute: '2-digit',
})
}
function metricWidth(value?: number, rows: RankingItem[] = []) {
if (value == null || value <= 0) return '0%'
const maximum = Math.max(
...rows.map((row) => row.p95Milliseconds ?? 0),
value,
)
return `${Math.max(4, value / maximum * 100)}%`
}
async function loadReport() {
loading.value = true
try {
const { data } = await http.get<PerformanceReport>(
'/operations/performance',
{ params: { range: range.value } },
)
report.value = data
await nextTick()
renderChart()
} catch (error) {
report.value = {
status: 'unavailable',
range: range.value,
generatedAt: new Date().toISOString(),
dataSource: 'prometheus',
detail: apiErrorMessage(error),
timeline: [],
endpoints: [],
databaseQueries: [],
}
ElMessage.error(apiErrorMessage(error))
disposeChart()
} finally {
loading.value = false
}
}
function renderChart() {
disposeChart()
if (!chartElement.value || !report.value?.timeline.length) return
chart = echarts.init(chartElement.value)
const times = report.value.timeline.map((point) =>
formatAxisTime(point.timestamp),
)
chart.setOption({
animationDuration: 420,
color: ['#1f7468', '#b47722'],
grid: { left: 50, right: 54, top: 54, bottom: 38 },
legend: {
top: 4,
left: 0,
itemWidth: 18,
itemHeight: 3,
textStyle: {
color: '#53636d',
fontFamily: '"Cascadia Mono", Consolas, monospace',
fontSize: 10,
},
},
tooltip: {
trigger: 'axis',
backgroundColor: 'rgba(30, 41, 50, 0.94)',
borderWidth: 0,
textStyle: { color: '#fff', fontSize: 12 },
valueFormatter: (value: unknown) =>
typeof value === 'number' ? value.toFixed(2) : '—',
},
xAxis: {
type: 'category',
boundaryGap: false,
data: times,
axisLine: { lineStyle: { color: '#cbd4d7' } },
axisTick: { show: false },
axisLabel: { color: '#79878f', fontSize: 10, hideOverlap: true },
},
yAxis: [
{
type: 'value',
name: '请求 / 秒',
nameTextStyle: { color: '#63727b', fontSize: 10 },
splitLine: { lineStyle: { color: '#e8edef', type: 'dashed' } },
axisLabel: { color: '#79878f', fontSize: 10 },
},
{
type: 'value',
name: 'P95 / ms',
nameTextStyle: { color: '#8b6b36', fontSize: 10 },
splitLine: { show: false },
axisLabel: { color: '#8b6b36', fontSize: 10 },
},
],
series: [
{
name: '请求速率',
type: 'line',
smooth: 0.22,
symbol: 'none',
lineStyle: { width: 2 },
areaStyle: { color: 'rgba(31, 116, 104, 0.10)' },
data: report.value.timeline.map(
(point) => point.requestsPerSecond ?? null,
),
},
{
name: '接口 P95',
type: 'line',
yAxisIndex: 1,
smooth: 0.22,
symbol: 'none',
lineStyle: { width: 1.5 },
data: report.value.timeline.map(
(point) => point.requestP95Milliseconds ?? null,
),
},
],
})
resizeObserver = new ResizeObserver(() => chart?.resize())
resizeObserver.observe(chartElement.value)
}
function disposeChart() {
resizeObserver?.disconnect()
resizeObserver = undefined
chart?.dispose()
chart = undefined
}
watch(range, loadReport)
onMounted(loadReport)
onUnmounted(disposeChart)
</script>
<template>
<section class="performance-panel" aria-labelledby="performance-title">
<header class="performance-heading">
<div>
<span>PERFORMANCE RAIL / {{ report?.dataSource?.toUpperCase() || 'PROMETHEUS' }}</span>
<h3 id="performance-title">系统性能</h3>
<p>从接口进入数据库定位响应时间消耗在哪里</p>
</div>
<div class="performance-actions">
<div class="range-switch" aria-label="性能报表时间范围">
<button
v-for="item in rangeOptions"
:key="item.value"
type="button"
:class="{ active: range === item.value }"
:aria-pressed="range === item.value"
@click="range = item.value"
>
{{ item.label }}
</button>
</div>
<el-button
circle
:icon="RefreshRight"
:loading="loading"
aria-label="刷新性能报表"
@click="loadReport"
/>
</div>
</header>
<div v-if="loading && !report" class="performance-loading">
<el-skeleton :rows="5" animated />
</div>
<div
v-else-if="report?.status !== 'ready'"
:class="`is-${report?.status || 'unavailable'}`"
class="source-state"
>
<el-icon><Connection /></el-icon>
<div>
<strong>
{{ report?.status === 'not_configured' ? '等待连接性能数据源' : '性能数据暂不可用' }}
</strong>
<p>{{ report?.detail }}</p>
<small>
业务接口继续正常运行报表不会回退读取教务业务数据库
</small>
</div>
<a
v-if="report?.dashboardUrl"
:href="report.dashboardUrl"
target="_blank"
rel="noreferrer"
>
打开监控平台
<el-icon><TopRight /></el-icon>
</a>
</div>
<template v-else>
<div class="performance-strip">
<div class="strip-lead">
<span>采样结论</span>
<strong>{{ operatingNote }}</strong>
<small>
{{ formatTime(report.from) }} {{ formatTime(report.to) }}
</small>
</div>
<dl>
<div>
<dt>请求总量</dt>
<dd>{{ formatNumber(report.headline?.requestCount) }}</dd>
</div>
<div>
<dt>接口 P95</dt>
<dd>{{ formatNumber(report.headline?.requestP95Milliseconds, 1) }}<small>ms</small></dd>
</div>
<div>
<dt>5xx 比例</dt>
<dd>{{ formatNumber(report.headline?.serverErrorRatePercent, 2) }}<small>%</small></dd>
</div>
<div>
<dt>数据库 P95</dt>
<dd>{{ formatNumber(report.headline?.databaseP95Milliseconds, 1) }}<small>ms</small></dd>
</div>
<div>
<dt>慢查询</dt>
<dd>{{ formatNumber(report.headline?.slowDatabaseCommandCount) }}</dd>
</div>
<div>
<dt>查询失败</dt>
<dd>{{ formatNumber(report.headline?.failedDatabaseCommandCount) }}</dd>
</div>
</dl>
</div>
<div v-if="hasSamples" class="performance-rail">
<div ref="chartElement" class="rail-chart" aria-label="请求速率与接口 P95 趋势图" />
</div>
<div v-else class="samples-empty">
数据源已连接但该时间范围内尚未收到请求指标
</div>
<div class="rankings">
<article class="ranking-board">
<div class="ranking-heading">
<div>
<span>HTTP ROUTES</span>
<h4>接口耗时排行</h4>
</div>
<small>P95 / 请求量 / 5xx</small>
</div>
<div v-if="report.endpoints.length" class="ranking-list">
<div
v-for="(item, index) in report.endpoints"
:key="item.name"
class="ranking-row"
>
<b>{{ String(index + 1).padStart(2, '0') }}</b>
<div>
<strong :title="item.name">{{ item.name }}</strong>
<span>
<i
:style="{ width: metricWidth(item.p95Milliseconds, report.endpoints) }"
/>
</span>
</div>
<dl>
<dd>{{ formatNumber(item.p95Milliseconds, 1) }} ms</dd>
<dt>{{ formatNumber(item.requestCount) }} · {{ formatNumber(item.exceptionalCount) }} 错误</dt>
</dl>
</div>
</div>
<p v-else class="ranking-empty">该范围内没有接口指标</p>
</article>
<article class="ranking-board database-board">
<div class="ranking-heading">
<div>
<span>DATABASE QUERIES</span>
<h4>数据库查询排行</h4>
</div>
<small>P95 / 调用量 / 慢查询</small>
</div>
<div v-if="report.databaseQueries.length" class="ranking-list">
<div
v-for="(item, index) in report.databaseQueries"
:key="item.name"
class="ranking-row"
>
<b>{{ String(index + 1).padStart(2, '0') }}</b>
<div>
<strong :title="item.name">{{ item.name }}</strong>
<span>
<i
:style="{ width: metricWidth(item.p95Milliseconds, report.databaseQueries) }"
/>
</span>
</div>
<dl>
<dd>{{ formatNumber(item.p95Milliseconds, 1) }} ms</dd>
<dt>{{ formatNumber(item.requestCount) }} · {{ formatNumber(item.exceptionalCount) }} </dt>
</dl>
</div>
</div>
<p v-else class="ranking-empty">该范围内没有数据库指标</p>
</article>
</div>
<footer class="performance-foot">
<span>生成于 {{ formatTime(report.generatedAt) }} · 页面数据采用短时缓存</span>
<a
v-if="report.dashboardUrl"
:href="report.dashboardUrl"
target="_blank"
rel="noreferrer"
>
查看原始调用链
<el-icon><TopRight /></el-icon>
</a>
</footer>
</template>
</section>
</template>
<style scoped>
.performance-panel {
--ink: #1e2932;
--muted: #66747e;
--line: #d8dee1;
--paper: #f6f8f8;
--panel: #fff;
--signal: #1f7468;
--warning: #b47722;
--danger: #b1463e;
background: var(--panel);
border: 1px solid var(--line);
color: var(--ink);
min-width: 0;
overflow: hidden;
}
.performance-heading {
align-items: flex-end;
background:
linear-gradient(90deg, rgba(31, 116, 104, 0.06), transparent 38%),
var(--panel);
border-bottom: 1px solid var(--line);
display: flex;
justify-content: space-between;
padding: 20px 22px 18px;
}
.performance-heading span,
.ranking-heading span,
.strip-lead > span {
color: var(--signal);
font-family: "Cascadia Mono", Consolas, monospace;
font-size: 10px;
font-weight: 700;
letter-spacing: .12em;
}
.performance-heading h3 {
font-family: "Noto Serif SC", "Source Han Serif SC", serif;
font-size: 21px;
margin: 5px 0 3px;
}
.performance-heading p {
color: var(--muted);
font-size: 12px;
margin: 0;
}
.performance-actions {
align-items: center;
display: flex;
gap: 10px;
}
.range-switch {
background: #edf1f1;
display: flex;
padding: 3px;
}
.range-switch button {
background: transparent;
border: 0;
color: #68767e;
cursor: pointer;
font-size: 11px;
padding: 7px 11px;
transition: background .16s ease, color .16s ease;
}
.range-switch button.active {
background: var(--ink);
color: #fff;
}
.range-switch button:focus-visible {
outline: 2px solid var(--signal);
outline-offset: 2px;
}
.performance-loading {
padding: 32px;
}
.source-state {
align-items: flex-start;
background: #f8faf9;
display: grid;
gap: 16px;
grid-template-columns: auto minmax(0, 1fr) auto;
margin: 22px;
padding: 24px;
}
.source-state > .el-icon {
background: #e7efed;
color: var(--signal);
font-size: 22px;
padding: 12px;
}
.source-state strong {
display: block;
font-size: 15px;
margin: 2px 0 6px;
}
.source-state p,
.source-state small {
color: var(--muted);
line-height: 1.6;
margin: 0;
}
.source-state p { font-size: 12px; }
.source-state small { font-size: 10px; }
.source-state.is-unavailable > .el-icon { background: #f6e9e8; color: var(--danger); }
.source-state a,
.performance-foot a {
align-items: center;
color: var(--signal);
display: inline-flex;
font-size: 11px;
gap: 4px;
text-decoration: none;
}
.performance-strip {
border-bottom: 1px solid var(--line);
display: grid;
grid-template-columns: 230px minmax(0, 1fr);
}
.strip-lead {
background: var(--ink);
color: #fff;
padding: 18px 20px;
}
.strip-lead > span { color: #84c7bc; }
.strip-lead strong { display: block; font-size: 13px; margin: 9px 0 13px; }
.strip-lead small { color: #aebbc2; font-family: "Cascadia Mono", Consolas, monospace; font-size: 9px; }
.performance-strip > dl {
display: grid;
grid-template-columns: repeat(6, minmax(0, 1fr));
margin: 0;
}
.performance-strip dl > div {
border-right: 1px solid #e5eaec;
padding: 16px 14px;
}
.performance-strip dl > div:last-child { border-right: 0; }
.performance-strip dt { color: var(--muted); font-size: 10px; margin-bottom: 9px; }
.performance-strip dd { font-family: "Cascadia Mono", Consolas, monospace; font-size: 18px; margin: 0; }
.performance-strip dd small { color: var(--muted); font-size: 9px; margin-left: 3px; }
.performance-rail {
border-bottom: 1px solid var(--line);
padding: 16px 20px 8px;
}
.rail-chart { height: 260px; width: 100%; }
.samples-empty,
.ranking-empty {
color: var(--muted);
font-size: 12px;
margin: 0;
padding: 44px 22px;
text-align: center;
}
.samples-empty { border-bottom: 1px solid var(--line); }
.rankings {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.ranking-board {
border-right: 1px solid var(--line);
min-width: 0;
padding: 18px 20px 20px;
}
.ranking-board:last-child { border-right: 0; }
.ranking-heading {
align-items: flex-start;
display: flex;
justify-content: space-between;
margin-bottom: 14px;
}
.ranking-heading h4 {
font-family: "Noto Serif SC", "Source Han Serif SC", serif;
font-size: 15px;
margin: 4px 0 0;
}
.ranking-heading > small {
color: #89959b;
font-family: "Cascadia Mono", Consolas, monospace;
font-size: 9px;
margin-top: 5px;
}
.ranking-list { display: grid; gap: 1px; }
.ranking-row {
align-items: center;
background: #f8fafa;
display: grid;
gap: 11px;
grid-template-columns: 26px minmax(0, 1fr) 116px;
min-height: 50px;
padding: 7px 10px;
}
.ranking-row > b {
color: #9aa6ab;
font-family: "Cascadia Mono", Consolas, monospace;
font-size: 10px;
}
.ranking-row > div { min-width: 0; }
.ranking-row strong {
display: block;
font-family: "Cascadia Mono", Consolas, monospace;
font-size: 10px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ranking-row > div > span {
background: #dfe7e7;
display: block;
height: 3px;
margin-top: 8px;
overflow: hidden;
}
.ranking-row i {
background: var(--signal);
display: block;
height: 100%;
}
.database-board .ranking-row i { background: var(--warning); }
.ranking-row dl {
margin: 0;
text-align: right;
}
.ranking-row dd {
font-family: "Cascadia Mono", Consolas, monospace;
font-size: 10px;
margin: 0 0 3px;
}
.ranking-row dt { color: var(--muted); font-size: 9px; }
.performance-foot {
align-items: center;
background: var(--paper);
border-top: 1px solid var(--line);
color: var(--muted);
display: flex;
font-size: 10px;
justify-content: space-between;
padding: 11px 20px;
}
@media (prefers-reduced-motion: reduce) {
.range-switch button { transition: none; }
}
@media (max-width: 1120px) {
.performance-strip { grid-template-columns: 190px minmax(0, 1fr); }
.performance-strip > dl { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.performance-strip dl > div:nth-child(3) { border-right: 0; }
.performance-strip dl > div:nth-child(-n+3) { border-bottom: 1px solid #e5eaec; }
}
@media (max-width: 820px) {
.performance-heading { align-items: flex-start; gap: 16px; }
.performance-actions { align-items: flex-end; flex-direction: column-reverse; }
.performance-strip { display: block; }
.rankings { grid-template-columns: 1fr; }
.ranking-board { border-bottom: 1px solid var(--line); border-right: 0; }
.ranking-board:last-child { border-bottom: 0; }
}
@media (max-width: 560px) {
.performance-heading { display: block; padding: 17px 14px; }
.performance-actions { align-items: stretch; flex-direction: row; margin-top: 14px; }
.range-switch { flex: 1; overflow-x: auto; }
.range-switch button { flex: 1 0 auto; padding-inline: 9px; }
.source-state { grid-template-columns: auto 1fr; margin: 12px; padding: 16px; }
.source-state a { grid-column: 2; }
.performance-strip > dl { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.performance-strip dl > div,
.performance-strip dl > div:nth-child(3) { border-bottom: 1px solid #e5eaec; border-right: 1px solid #e5eaec; }
.performance-strip dl > div:nth-child(even) { border-right: 0; }
.performance-strip dl > div:nth-last-child(-n+2) { border-bottom: 0; }
.performance-rail { padding-inline: 8px; }
.rail-chart { height: 230px; }
.ranking-board { padding-inline: 12px; }
.ranking-heading > small { display: none; }
.ranking-row { grid-template-columns: 22px minmax(0, 1fr) 96px; padding-inline: 7px; }
.performance-foot { align-items: flex-start; gap: 8px; }
}
</style>
+3
View File
@@ -8,6 +8,7 @@ import {
import { ElMessage, ElMessageBox } from 'element-plus'
import http, { apiErrorMessage } from '../api/http'
import AppUpdateManagementPanel from '../components/AppUpdateManagementPanel.vue'
import PerformanceReportPanel from '../components/PerformanceReportPanel.vue'
type HealthStatus = 'healthy' | 'warning' | 'unhealthy'
@@ -406,6 +407,8 @@ onMounted(refreshAll)
</div>
</section>
<PerformanceReportPanel />
<section class="console-split">
<article class="alerts-panel">
<div class="panel-heading">