超级管理员现在可在“组织与权限 → 运维与审计 → 系统性能”中直接查看:
请求量、5xx 比例、HTTP/数据库 P95 请求速率与延迟趋势 最慢接口排行 慢查询与数据库查询排行 Grafana 原始调用链入口
This commit is contained in:
Vendored
+2
@@ -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>
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user