diff --git a/config/hwlab-node-lanes.yaml b/config/hwlab-node-lanes.yaml
index 98571101..2943982d 100644
--- a/config/hwlab-node-lanes.yaml
+++ b/config/hwlab-node-lanes.yaml
@@ -221,6 +221,10 @@ lanes:
screenshotTimeoutRedCount: 2
pageErrorRedCount: 2
browserProcessSampleIntervalMs: 1000
+ requestRateBucketMs: 10000
+ requestRateTotalRedPerMinute: 300
+ requestRatePageRedPerMinute: 240
+ requestRateApiPathRedPerMinute: 120
browserTotalRssRedMb: 800
browserProcessRssRedMb: 600
browserRssGrowthRedMb: 300
@@ -646,6 +650,10 @@ lanes:
screenshotTimeoutRedCount: 2
pageErrorRedCount: 2
browserProcessSampleIntervalMs: 1000
+ requestRateBucketMs: 10000
+ requestRateTotalRedPerMinute: 300
+ requestRatePageRedPerMinute: 240
+ requestRateApiPathRedPerMinute: 120
browserTotalRssRedMb: 800
browserProcessRssRedMb: 600
browserRssGrowthRedMb: 300
@@ -1001,6 +1009,10 @@ lanes:
screenshotTimeoutRedCount: 2
pageErrorRedCount: 2
browserProcessSampleIntervalMs: 1000
+ requestRateBucketMs: 10000
+ requestRateTotalRedPerMinute: 300
+ requestRatePageRedPerMinute: 240
+ requestRateApiPathRedPerMinute: 120
browserTotalRssRedMb: 800
browserProcessRssRedMb: 600
browserRssGrowthRedMb: 300
diff --git a/config/hwlab-web-probe-sentinel/check-catalog.yaml b/config/hwlab-web-probe-sentinel/check-catalog.yaml
index a64f5888..29f21f16 100644
--- a/config/hwlab-web-probe-sentinel/check-catalog.yaml
+++ b/config/hwlab-web-probe-sentinel/check-catalog.yaml
@@ -803,3 +803,27 @@ sentinel:
actionZh: 检查 stateDir/analysis/report.json 读取路径和回填流程。
blocking: true
order: 980
+ - code: WBC-099
+ id: request-rate-total-peak
+ level: error
+ titleZh: API 请求总频率过高
+ summaryZh: 同源 API 请求总量在 YAML 配置窗口内超过峰值阈值,可能存在请求风暴。
+ actionZh: 对照请求频率曲线和页面内存曲线,查看峰值时间点和 top API path。
+ blocking: true
+ order: 990
+ - code: WBC-100
+ id: request-rate-page-peak
+ level: error
+ titleZh: 页面 API 请求频率过高
+ summaryZh: 单个页面在 YAML 配置窗口内的 API 请求频率超过阈值,可能存在页面级轮询风暴。
+ actionZh: 查看对应页面曲线、峰值时间点和同窗口内的内存变化。
+ blocking: true
+ order: 1000
+ - code: WBC-101
+ id: request-rate-api-path-peak
+ level: error
+ titleZh: API path 请求频率过高
+ summaryZh: 单个 API path 在 YAML 配置窗口内的请求频率超过阈值,可能存在接口级请求风暴。
+ actionZh: 查看对应 API path 曲线、调用页面和峰值时间点。
+ blocking: true
+ order: 1010
diff --git a/config/hwlab-web-probe-sentinel/report-views.auth-session-switch.yaml b/config/hwlab-web-probe-sentinel/report-views.auth-session-switch.yaml
index f9c2a98b..83b56c7b 100644
--- a/config/hwlab-web-probe-sentinel/report-views.auth-session-switch.yaml
+++ b/config/hwlab-web-probe-sentinel/report-views.auth-session-switch.yaml
@@ -19,6 +19,12 @@ sentinel:
detailMemory:
maxPages: 8
maxSamplesPerPage: 360
+ detailRequestRate:
+ maxTotalBuckets: 120
+ maxPageSeries: 6
+ maxApiPathSeries: 8
+ maxBucketsPerSeries: 120
+ maxPeaks: 12
redaction:
prompt: hash-and-byte-count
assistantFinal: summary-and-hash
diff --git a/config/hwlab-web-probe-sentinel/report-views.multi-sentinel.yaml b/config/hwlab-web-probe-sentinel/report-views.multi-sentinel.yaml
index c071703b..364bb769 100644
--- a/config/hwlab-web-probe-sentinel/report-views.multi-sentinel.yaml
+++ b/config/hwlab-web-probe-sentinel/report-views.multi-sentinel.yaml
@@ -20,6 +20,12 @@ sentinel:
detailMemory:
maxPages: 8
maxSamplesPerPage: 360
+ detailRequestRate:
+ maxTotalBuckets: 120
+ maxPageSeries: 6
+ maxApiPathSeries: 8
+ maxBucketsPerSeries: 120
+ maxPeaks: 12
redaction:
prompt: hash-and-byte-count
assistantFinal: summary-and-hash
diff --git a/config/hwlab-web-probe-sentinel/report-views.yaml b/config/hwlab-web-probe-sentinel/report-views.yaml
index 4c076051..70073c35 100644
--- a/config/hwlab-web-probe-sentinel/report-views.yaml
+++ b/config/hwlab-web-probe-sentinel/report-views.yaml
@@ -19,6 +19,12 @@ sentinel:
detailMemory:
maxPages: 8
maxSamplesPerPage: 360
+ detailRequestRate:
+ maxTotalBuckets: 120
+ maxPageSeries: 6
+ maxApiPathSeries: 8
+ maxBucketsPerSeries: 120
+ maxPeaks: 12
redaction:
prompt: hash-and-byte-count
assistantFinal: summary-and-hash
diff --git a/scripts/assets/web-probe-sentinel-monitor-web/monitor-web.css b/scripts/assets/web-probe-sentinel-monitor-web/monitor-web.css
index 9091b0ed..18d3daa2 100644
--- a/scripts/assets/web-probe-sentinel-monitor-web/monitor-web.css
+++ b/scripts/assets/web-probe-sentinel-monitor-web/monitor-web.css
@@ -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;
diff --git a/scripts/assets/web-probe-sentinel-monitor-web/monitor-web.js b/scripts/assets/web-probe-sentinel-monitor-web/monitor-web.js
index 14c66057..4190d31c 100644
--- a/scripts/assets/web-probe-sentinel-monitor-web/monitor-web.js
+++ b/scripts/assets/web-probe-sentinel-monitor-web/monitor-web.js
@@ -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 || ''"
>
-
页面内存曲线
- 每个页面一条线 · 峰值 {{ formatMb(detailMemory.maxMemoryMb || detailMemoryMax) }}
+ 请求频率 / 页面内存曲线
+ 请求峰值 {{ formatRpm(detailRequestRatePeak) }} · 内存峰值 {{ formatMb(detailMemory.maxMemoryMb || detailMemoryMax) }}
+
+
+
+
+ {{ requestRateSeriesLabel(series) }}
+
+
+
+ 暂无 API 请求频率样本