feat: add web sentinel request-rate curves

This commit is contained in:
Codex
2026-07-02 04:51:18 +00:00
parent 9d9f8e1dca
commit f7a0b0672c
13 changed files with 921 additions and 12 deletions
@@ -391,7 +391,8 @@ select {
.trend-red,
.trend-warning,
.memory-line {
.memory-line,
.request-rate-line {
fill: none;
stroke-width: 2.4;
stroke-linecap: round;
@@ -545,6 +546,42 @@ select {
background: #327b89;
}
.legend-swatch.request-rate.request-rate-line-1,
.request-rate-line-1 {
stroke: #c2410c;
background: #c2410c;
}
.legend-swatch.request-rate.request-rate-line-2,
.request-rate-line-2 {
stroke: #a16207;
background: #a16207;
}
.legend-swatch.request-rate.request-rate-line-3,
.request-rate-line-3 {
stroke: #7c3aed;
background: #7c3aed;
}
.legend-swatch.request-rate.request-rate-line-4,
.request-rate-line-4 {
stroke: #be185d;
background: #be185d;
}
.legend-swatch.request-rate.request-rate-line-5,
.request-rate-line-5 {
stroke: #0f766e;
background: #0f766e;
}
.legend-swatch.request-rate.request-rate-line-6,
.request-rate-line-6 {
stroke: #4338ca;
background: #4338ca;
}
.timeline-panel {
display: flex;
min-height: 0;
@@ -1012,7 +1049,14 @@ select {
gap: 8px;
}
.memory-chart {
.request-rate-chart-wrap {
display: grid;
gap: 8px;
margin-bottom: 12px;
}
.memory-chart,
.request-rate-chart {
display: block;
width: 100%;
height: 192px;
@@ -1021,6 +1065,10 @@ select {
background: #ffffff;
}
.request-rate-chart {
background: #fffdfb;
}
.memory-legend {
display: flex;
min-width: 0;
@@ -1030,6 +1078,10 @@ select {
font-size: 12px;
}
.request-rate-empty {
margin-bottom: 12px;
}
.memory-grid-line {
stroke: #e5ecea;
stroke-width: 1;
@@ -121,6 +121,19 @@ createApp({
return Array.isArray(rows) ? rows : [];
});
const detailMemory = computed(() => selectedDetail.value?.memory && typeof selectedDetail.value.memory === "object" ? selectedDetail.value.memory : {});
const detailRequestRate = computed(() => selectedDetail.value?.requestRate && typeof selectedDetail.value.requestRate === "object" ? selectedDetail.value.requestRate : {});
const detailRequestRateSeries = computed(() => {
const rate = detailRequestRate.value;
const rows = [
...tagRequestRateSeries(Array.isArray(rate.totalSeries) ? rate.totalSeries : [], "total"),
...tagRequestRateSeries(Array.isArray(rate.pageSeries) ? rate.pageSeries : [], "page"),
...tagRequestRateSeries(Array.isArray(rate.apiPathSeries) ? rate.apiPathSeries : [], "apiPath"),
];
return rows.map((series) => ({
...series,
points: Array.isArray(series.points) ? series.points.filter((point) => Number.isFinite(Number(point?.requestPerMinute))) : [],
})).filter((series) => series.points.length > 0);
});
const detailMemorySeries = computed(() => {
const rows = Array.isArray(detailMemory.value.pageSeries) ? detailMemory.value.pageSeries : [];
return rows.map((series) => ({
@@ -129,7 +142,40 @@ createApp({
})).filter((series) => series.points.length > 0);
});
const detailMemoryMax = computed(() => Math.max(1, ...detailMemorySeries.value.flatMap((series) => series.points.map((point) => number(point.memoryMb)))));
const detailMemoryDurationMax = computed(() => Math.max(1, ...detailMemorySeries.value.flatMap((series) => series.points.map((point) => number(point.elapsedMinutes)))));
const detailRequestRateMax = computed(() => Math.max(
1,
number(detailRequestRate.value.totalPeakPerMinute),
number(detailRequestRate.value.pagePeakPerMinute),
number(detailRequestRate.value.apiPathPeakPerMinute),
...detailRequestRateSeries.value.flatMap((series) => series.points.map((point) => number(point.requestPerMinute))),
));
const detailTimelineStartMs = computed(() => {
const values = [
...detailMemorySeries.value.flatMap((series) => series.points.map(pointTimeMs)),
...detailRequestRateSeries.value.flatMap((series) => series.points.map(pointTimeMs)),
].filter((value) => value !== null);
return values.length === 0 ? null : Math.min(...values);
});
const detailTimelineDurationMax = computed(() => {
const startMs = detailTimelineStartMs.value;
const values = [
...detailMemorySeries.value.flatMap((series) => series.points.map((point) => pointElapsedMinutes(point, startMs))),
...detailRequestRateSeries.value.flatMap((series) => series.points.map((point) => pointElapsedMinutes(point, startMs))),
].filter((value) => Number.isFinite(value));
return Math.max(1, ...values);
});
const detailMemoryDurationMax = computed(() => detailTimelineDurationMax.value);
const detailRequestRateYAxisTicks = computed(() => memoryTickValues(detailRequestRateMax.value).map((value) => ({
key: `request-rate-y-${value}`,
value,
y: requestRateYValue(value),
label: formatRpm(value),
})));
const detailRequestRatePeak = computed(() => {
const peak = optionalNumber(detailRequestRate.value.totalPeakPerMinute);
if (peak !== null) return peak;
return detailRequestRateSeries.value.length > 0 ? detailRequestRateMax.value : null;
});
const detailMemoryYAxisTicks = computed(() => memoryTickValues(detailMemoryMax.value).map((value) => ({
key: `memory-y-${value}`,
value,
@@ -409,9 +455,14 @@ createApp({
return points.map((point) => `${memoryX(point)},${memoryY(point)}`).join(" ");
}
function requestRatePolyline(series) {
const points = Array.isArray(series?.points) ? series.points : [];
if (points.length < 2) return "";
return points.map((point) => `${memoryX(point)},${requestRateY(point)}`).join(" ");
}
function memoryX(point) {
const elapsed = number(point?.elapsedMinutes);
return memoryXValue(elapsed);
return memoryXValue(pointElapsedMinutes(point, detailTimelineStartMs.value));
}
function memoryY(point) {
@@ -419,6 +470,10 @@ createApp({
return memoryYValue(memory);
}
function requestRateY(point) {
return requestRateYValue(number(point?.requestPerMinute));
}
function memoryXValue(elapsedMinutes) {
const ratio = clamp(number(elapsedMinutes) / detailMemoryDurationMax.value, 0, 1);
return Math.round(memoryChartFrame.left + ratio * (memoryChartFrame.right - memoryChartFrame.left));
@@ -429,16 +484,31 @@ createApp({
return Math.round(memoryChartFrame.bottom - ratio * (memoryChartFrame.bottom - memoryChartFrame.top));
}
function requestRateYValue(requestPerMinute) {
const ratio = clamp(number(requestPerMinute) / detailRequestRateMax.value, 0, 1);
return Math.round(memoryChartFrame.bottom - ratio * (memoryChartFrame.bottom - memoryChartFrame.top));
}
function memoryLineClass(index) {
return `memory-line-${(index % 6) + 1}`;
}
function requestRateLineClass(index) {
return `request-rate-line-${(index % 6) + 1}`;
}
function memorySeriesLabel(series) {
const sampleCount = Number.isFinite(Number(series?.sampleCount)) ? Number(series.sampleCount) : Array.isArray(series?.points) ? series.points.length : 0;
const latest = Array.isArray(series?.points) && series.points.length > 0 ? series.points[series.points.length - 1] : null;
return `${series?.label || series?.pageRole || series?.pageId || "page"} · 最新 ${formatMb(latest?.memoryMb)} · 样本 ${sampleCount}`;
}
function requestRateSeriesLabel(series) {
const sampleCount = Number.isFinite(Number(series?.bucketCount)) ? Number(series.bucketCount) : Array.isArray(series?.points) ? series.points.length : 0;
const latest = Array.isArray(series?.points) && series.points.length > 0 ? series.points[series.points.length - 1] : null;
return `${series?.label || series?.path || series?.key || "request"} · 最新 ${formatRpm(latest?.requestPerMinute)} · 峰值 ${formatRpm(series?.peakRequestPerMinute)} · 桶 ${sampleCount}`;
}
function showTrendTooltip(dot) {
hoveredTrendDot.value = dot;
}
@@ -506,6 +576,11 @@ createApp({
timelineRuns,
historicalCheckFindings,
runDetailCheckFindings,
detailRequestRate,
detailRequestRateSeries,
detailRequestRateMax,
detailRequestRatePeak,
detailRequestRateYAxisTicks,
detailMemory,
detailMemorySeries,
detailMemoryMax,
@@ -546,6 +621,9 @@ createApp({
trendErrorCount,
trendWarningCount,
trendDurationMinutes,
requestRatePolyline,
requestRateLineClass,
requestRateSeriesLabel,
memoryPolyline,
memoryLineClass,
memorySeriesLabel,
@@ -557,6 +635,7 @@ createApp({
formatDuration,
formatMinutes,
formatMb,
formatRpm,
shortId,
rootCauseText,
findingTitle,
@@ -867,11 +946,50 @@ createApp({
:data-memory-page-count="detailMemorySeries.length"
:data-memory-sample-count="detailMemory.sampleCount || 0"
:data-memory-source="detailMemory.source || ''"
:data-request-rate-series-count="detailRequestRateSeries.length"
:data-request-rate-source="detailRequestRate.source || ''"
>
<div class="detail-card-heading">
<h3>页面内存曲线</h3>
<span class="tag">每个页面一条线 · 峰值 {{ formatMb(detailMemory.maxMemoryMb || detailMemoryMax) }}</span>
<h3>请求频率 / 页面内存曲线</h3>
<span class="tag">请求峰值 {{ formatRpm(detailRequestRatePeak) }} · 内存峰值 {{ formatMb(detailMemory.maxMemoryMb || detailMemoryMax) }}</span>
</div>
<div v-if="detailRequestRateSeries.length > 0" class="request-rate-chart-wrap" data-run-request-rate-chart="true">
<svg class="request-rate-chart" viewBox="0 0 720 178" role="img" aria-label="运行详情 API 请求频率曲线,纵轴为每分钟请求数,横轴与内存曲线对齐">
<g data-request-rate-axis-y="true">
<g v-for="tick in detailRequestRateYAxisTicks" :key="tick.key" data-request-rate-axis-y-tick="true">
<line class="memory-grid-line" :x1="memoryFrame.left" :x2="memoryFrame.right" :y1="tick.y" :y2="tick.y"></line>
<text class="memory-axis-label memory-axis-label-y" :x="memoryFrame.yLabelX" :y="tick.y + 4">{{ tick.label }}</text>
</g>
<line class="memory-axis-line" :x1="memoryFrame.left" :x2="memoryFrame.left" :y1="memoryFrame.top" :y2="memoryFrame.bottom"></line>
<text class="memory-axis-title memory-axis-title-y" x="18" y="78" transform="rotate(-90 18 78)">请求 rpm</text>
</g>
<g data-request-rate-axis-x="true">
<line class="memory-axis-line" :x1="memoryFrame.left" :x2="memoryFrame.right" :y1="memoryFrame.bottom" :y2="memoryFrame.bottom"></line>
<g v-for="tick in detailMemoryXAxisTicks" :key="'request-' + tick.key" data-request-rate-axis-x-tick="true">
<line class="memory-tick-line" :x1="tick.x" :x2="tick.x" :y1="memoryFrame.top" :y2="memoryFrame.bottom + 6"></line>
<text class="memory-axis-label memory-axis-label-x" :x="tick.x" :y="memoryFrame.xLabelY">{{ tick.label }}</text>
</g>
<text class="memory-axis-title memory-axis-title-x" :x="(memoryFrame.left + memoryFrame.right) / 2" :y="memoryFrame.xTitleY">运行分钟</text>
</g>
<polyline
v-for="(series, index) in detailRequestRateSeries"
:key="series.key || series.label || index"
class="request-rate-line"
:class="requestRateLineClass(index)"
:points="requestRatePolyline(series)"
></polyline>
</svg>
<div class="memory-legend request-rate-legend">
<span
v-for="(series, index) in detailRequestRateSeries"
:key="(series.key || series.label || index) + '-request-rate-legend'"
class="legend-item"
>
<span class="legend-swatch request-rate" :class="requestRateLineClass(index)"></span>{{ requestRateSeriesLabel(series) }}
</span>
</div>
</div>
<div v-else class="empty request-rate-empty">暂无 API 请求频率样本</div>
<div v-if="detailMemorySeries.length > 0" class="memory-chart-wrap">
<svg class="memory-chart" viewBox="0 0 720 178" role="img" aria-label="运行详情页面内存曲线,纵轴为 MB,横轴为运行分钟">
<g data-memory-axis-y="true">
@@ -1280,11 +1398,39 @@ function formatMb(value) {
return `${text} MB`;
}
function formatRpm(value) {
const numberValue = optionalNumber(value);
if (numberValue === null) return "-";
const rounded = Math.round(numberValue * 10) / 10;
const text = Number.isInteger(rounded) ? String(rounded) : rounded.toFixed(1).replace(/\.0$/u, "");
return `${text} rpm`;
}
function memoryTickValues(maxValue) {
const max = Math.max(1, number(maxValue));
return [0, max / 2, max];
}
function tagRequestRateSeries(rows, scope) {
return rows.map((series) => ({
...series,
scope: series?.scope || scope,
}));
}
function pointTimeMs(point) {
const raw = point?.ts || point?.startAt;
if (typeof raw !== "string" || raw.length === 0) return null;
const parsed = Date.parse(raw);
return Number.isFinite(parsed) ? parsed : null;
}
function pointElapsedMinutes(point, timelineStartMs) {
const tsMs = pointTimeMs(point);
if (tsMs !== null && Number.isFinite(Number(timelineStartMs))) return Math.max(0, (tsMs - Number(timelineStartMs)) / 60000);
return number(point?.elapsedMinutes);
}
function runDurationText(run) {
const timing = run?.timing || {};
return formatMinutes(optionalNumber(run?.runDurationMinutes, run?.durationMinutes, timing.runDurationMinutes, timing.durationMinutes));