feat: add web sentinel request-rate curves
This commit is contained in:
@@ -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));
|
||||
|
||||
Reference in New Issue
Block a user