763 lines
20 KiB
Vue
763 lines
20 KiB
Vue
<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>
|