fix: add sentinel run memory detail curves

This commit is contained in:
Codex
2026-07-01 02:07:23 +00:00
parent 1f5fddd8b1
commit ebcbdc766e
11 changed files with 626 additions and 12 deletions
@@ -351,6 +351,23 @@ select {
stroke-linejoin: round;
}
.trend-red,
.trend-warning,
.memory-line {
fill: none;
stroke-width: 2.4;
stroke-linecap: round;
stroke-linejoin: round;
}
.trend-red {
stroke: var(--red);
}
.trend-warning {
stroke: var(--amber);
}
.trend-grid-line {
stroke: #e5ecea;
stroke-width: 1;
@@ -362,6 +379,18 @@ select {
stroke-width: 1.5;
}
.trend-dot-red {
fill: var(--red);
stroke: #ffffff;
stroke-width: 1.2;
}
.trend-dot-warning {
fill: var(--amber);
stroke: #ffffff;
stroke-width: 1.2;
}
.trend-dot-hit {
cursor: default;
outline: none;
@@ -442,6 +471,42 @@ select {
background: var(--blue);
}
.legend-swatch.memory.memory-line-1,
.memory-line-1 {
stroke: #2a6fbb;
background: #2a6fbb;
}
.legend-swatch.memory.memory-line-2,
.memory-line-2 {
stroke: #1f8a5b;
background: #1f8a5b;
}
.legend-swatch.memory.memory-line-3,
.memory-line-3 {
stroke: #b7791f;
background: #b7791f;
}
.legend-swatch.memory.memory-line-4,
.memory-line-4 {
stroke: #7a5dc7;
background: #7a5dc7;
}
.legend-swatch.memory.memory-line-5,
.memory-line-5 {
stroke: #c04f7a;
background: #c04f7a;
}
.legend-swatch.memory.memory-line-6,
.memory-line-6 {
stroke: #327b89;
background: #327b89;
}
.timeline-panel {
display: flex;
min-height: 0;
@@ -888,6 +953,46 @@ select {
font-size: 14px;
}
.detail-card-heading {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
margin-bottom: 8px;
}
.detail-card-heading h3 {
margin-bottom: 0;
}
.memory-card {
overflow: hidden;
}
.memory-chart-wrap {
display: grid;
gap: 8px;
}
.memory-chart {
display: block;
width: 100%;
height: 154px;
border: 1px solid var(--line);
border-radius: 8px;
background: #ffffff;
}
.memory-axis,
.memory-legend {
display: flex;
min-width: 0;
flex-wrap: wrap;
gap: 8px;
color: var(--muted);
font-size: 12px;
}
.detail-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
@@ -49,9 +49,13 @@ createApp({
const trendRows = computed(() => runs.value.slice().sort((a, b) => Date.parse(a.updatedAt || a.createdAt || "") - Date.parse(b.updatedAt || b.createdAt || "")).slice(-48));
const latestTrendRun = computed(() => trendRows.value.length > 0 ? trendRows.value[trendRows.value.length - 1] : latestRun.value);
const trendDurationMax = computed(() => Math.max(0, ...trendRows.value.map((run) => trendDurationMinutes(run))));
const trendMax = computed(() => Math.max(1, trendDurationMax.value));
const trendErrorMax = computed(() => Math.max(0, ...trendRows.value.map((run) => trendErrorCount(run))));
const trendWarningMax = computed(() => Math.max(0, ...trendRows.value.map((run) => trendWarningCount(run))));
const trendMax = computed(() => Math.max(1, trendDurationMax.value, trendErrorMax.value, trendWarningMax.value));
const trendPolylines = computed(() => ({
duration: trendPolyline((run) => trendDurationMinutes(run)),
red: trendPolyline((run) => trendErrorCount(run)),
warning: trendPolyline((run) => trendWarningCount(run)),
}));
const trendDots = computed(() => trendRows.value.map((run, index) => {
const red = trendErrorCount(run);
@@ -60,17 +64,22 @@ createApp({
const duration = trendDurationMinutes(run);
const x = trendX(index, trendRows.value.length);
const durationY = trendY(duration);
const redY = trendY(red);
const warningY = trendY(warning);
const rawTime = run.updatedAt || run.createdAt || "";
const durationText = runDurationText(run);
const configTimingText = runTimingConfigText(run);
const tooltipY = Math.min(durationY, redY, warningY);
return {
id: run.id || String(index),
runId: run.id || "",
x,
durationY,
redY,
warningY,
duration,
tooltipLeft: `${clamp((x / 720) * 100, 16, 84)}%`,
tooltipTop: `${clamp(((durationY + 18) / 142) * 100, 24, 76)}%`,
tooltipTop: `${clamp(((tooltipY + 18) / 142) * 100, 24, 76)}%`,
red,
warning,
total,
@@ -94,6 +103,16 @@ createApp({
const rows = selectedDetail.value?.findings;
return Array.isArray(rows) ? rows : [];
});
const detailMemory = computed(() => selectedDetail.value?.memory && typeof selectedDetail.value.memory === "object" ? selectedDetail.value.memory : {});
const detailMemorySeries = computed(() => {
const rows = Array.isArray(detailMemory.value.pageSeries) ? detailMemory.value.pageSeries : [];
return rows.map((series) => ({
...series,
points: Array.isArray(series.points) ? series.points.filter((point) => Number.isFinite(Number(point?.memoryMb))) : [],
})).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 scopedCheckFindings = computed(() => {
if (checkScope.value === "history") return historicalCheckFindings.value;
return runDetailCheckFindings.value;
@@ -291,6 +310,32 @@ createApp({
return Math.round(126 - (Number(value || 0) / trendMax.value) * 102);
}
function memoryPolyline(series) {
const points = Array.isArray(series?.points) ? series.points : [];
if (points.length < 2) return "";
return points.map((point) => `${memoryX(point)},${memoryY(point)}`).join(" ");
}
function memoryX(point) {
const elapsed = number(point?.elapsedMinutes);
return Math.round(24 + (elapsed / detailMemoryDurationMax.value) * 672);
}
function memoryY(point) {
const memory = number(point?.memoryMb);
return Math.round(126 - (memory / detailMemoryMax.value) * 102);
}
function memoryLineClass(index) {
return `memory-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 showTrendTooltip(dot) {
hoveredTrendDot.value = dot;
}
@@ -346,11 +391,17 @@ createApp({
trendRows,
latestTrendRun,
trendDurationMax,
trendErrorMax,
trendWarningMax,
trendPolylines,
trendDots,
timelineRuns,
historicalCheckFindings,
runDetailCheckFindings,
detailMemory,
detailMemorySeries,
detailMemoryMax,
detailMemoryDurationMax,
scopedCheckFindings,
scopedCheckSummary,
visibleCheckFindings,
@@ -380,6 +431,9 @@ createApp({
trendErrorCount,
trendWarningCount,
trendDurationMinutes,
memoryPolyline,
memoryLineClass,
memorySeriesLabel,
runDurationText,
runTimingConfigText,
severityClass,
@@ -387,6 +441,7 @@ createApp({
formatAbsoluteDate,
formatDuration,
formatMinutes,
formatMb,
shortId,
rootCauseText,
findingTitle,
@@ -455,17 +510,19 @@ createApp({
<section class="trend-panel" aria-labelledby="trend-heading">
<div class="panel-header">
<div>
<h2 id="trend-heading">运行耗时曲线</h2>
<p>按运行更新时间展示最近 {{ trendRows.length }} 次巡检耗时,单位为分钟</p>
<h2 id="trend-heading">运行趋势曲线</h2>
<p>按运行更新时间展示最近 {{ trendRows.length }} 次巡检耗时、独立错误类型和独立告警类型</p>
</div>
<span class="pill" :class="cadence.stale ? 'warning' : 'healthy'">{{ cadence.stale ? "非阻塞报警" : "新鲜" }}</span>
</div>
<div class="trend-chart-wrap">
<svg class="trend-chart" viewBox="0 0 720 142" role="img" aria-label="运行耗时分钟趋势" data-monitor-trend-curve="true">
<svg class="trend-chart" viewBox="0 0 720 142" role="img" aria-label="运行耗时、独立错误和独立告警趋势" data-monitor-trend-curve="true">
<line class="trend-grid-line" x1="24" x2="696" y1="24" y2="24"></line>
<line class="trend-grid-line" x1="24" x2="696" y1="75" y2="75"></line>
<line class="trend-grid-line" x1="24" x2="696" y1="126" y2="126"></line>
<polyline v-if="trendPolylines.duration" class="trend-duration" :points="trendPolylines.duration"></polyline>
<polyline v-if="trendPolylines.red" class="trend-red" :points="trendPolylines.red" data-monitor-trend-error-curve="true"></polyline>
<polyline v-if="trendPolylines.warning" class="trend-warning" :points="trendPolylines.warning" data-monitor-trend-warning-curve="true"></polyline>
<g
v-for="dot in trendDots"
:key="dot.id"
@@ -479,6 +536,8 @@ createApp({
@focusout="hideTrendTooltip"
>
<circle class="trend-hit-area" :cx="dot.x" :cy="dot.durationY" r="12"></circle>
<circle class="trend-dot-red" :cx="dot.x" :cy="dot.redY" r="3"></circle>
<circle class="trend-dot-warning" :cx="dot.x" :cy="dot.warningY" r="3"></circle>
<circle class="trend-dot-duration" :cx="dot.x" :cy="dot.durationY" r="4">
<title>{{ dot.title }}</title>
</circle>
@@ -502,8 +561,9 @@ createApp({
</div>
<div class="trend-legend">
<span class="legend-item"><span class="legend-swatch duration"></span>最新运行耗时 {{ runDurationText(latestTrendRun) }}</span>
<span class="legend-item"><span class="legend-swatch red"></span>独立错误 {{ trendErrorCount(latestTrendRun) }} / 最高 {{ trendErrorMax }}</span>
<span class="legend-item"><span class="legend-swatch warning"></span>独立告警 {{ trendWarningCount(latestTrendRun) }} / 最高 {{ trendWarningMax }}</span>
<span class="legend-item">最近最高耗时 {{ formatMinutes(trendDurationMax) }}</span>
<span class="legend-item">最新错误 {{ trendErrorCount(latestTrendRun) }} / 告警 {{ trendWarningCount(latestTrendRun) }}</span>
<span class="legend-item">历史样本累计 错误 {{ redCount({ severityCounts: severityTotals }) }} / 告警 {{ warningCount({ severityCounts: severityTotals }) }}</span>
<span class="legend-item">{{ cadence.alert }}</span>
</div>
@@ -651,6 +711,48 @@ createApp({
</div>
</div>
</section>
<section
class="detail-card detail-card-wide memory-card"
data-run-memory-chart="true"
:data-memory-run-id="selectedRunId"
:data-memory-page-count="detailMemorySeries.length"
:data-memory-sample-count="detailMemory.sampleCount || 0"
:data-memory-source="detailMemory.source || ''"
>
<div class="detail-card-heading">
<h3>页面内存曲线</h3>
<span class="tag">每个页面一条线 · 峰值 {{ formatMb(detailMemory.maxMemoryMb || detailMemoryMax) }}</span>
</div>
<div v-if="detailMemorySeries.length > 0" class="memory-chart-wrap">
<svg class="memory-chart" viewBox="0 0 720 142" role="img" aria-label="运行详情页面内存曲线">
<line class="trend-grid-line" x1="24" x2="696" y1="24" y2="24"></line>
<line class="trend-grid-line" x1="24" x2="696" y1="75" y2="75"></line>
<line class="trend-grid-line" x1="24" x2="696" y1="126" y2="126"></line>
<polyline
v-for="(series, index) in detailMemorySeries"
:key="series.key || series.label || index"
class="memory-line"
:class="memoryLineClass(index)"
:points="memoryPolyline(series)"
></polyline>
</svg>
<div class="memory-axis">
<span>0m</span>
<span>{{ formatMinutes(detailMemoryDurationMax) }}</span>
<span>峰值 {{ formatMb(detailMemory.maxMemoryMb || detailMemoryMax) }}</span>
</div>
<div class="memory-legend">
<span
v-for="(series, index) in detailMemorySeries"
:key="(series.key || series.label || index) + '-legend'"
class="legend-item"
>
<span class="legend-swatch memory" :class="memoryLineClass(index)"></span>{{ memorySeriesLabel(series) }}
</span>
</div>
</div>
<div v-else class="empty">暂无页面级内存样本</div>
</section>
<section class="detail-card">
<h3>复现命令</h3>
<pre>{{ commandSummary(selectedDetail) }}</pre>
@@ -963,6 +1065,14 @@ function formatMinutes(value) {
return `${Number.isInteger(rounded) ? String(rounded) : String(rounded).replace(/0+$/u, "").replace(/\.$/u, "")} 分钟`;
}
function formatMb(value) {
const numberValue = optionalNumber(value);
if (numberValue === null) return "-";
const rounded = Math.round(numberValue * 100) / 100;
const text = rounded >= 100 ? String(Math.round(rounded)) : rounded >= 10 ? rounded.toFixed(1).replace(/\.0$/u, "") : rounded.toFixed(2).replace(/0+$/u, "").replace(/\.$/u, "");
return `${text} MB`;
}
function runDurationText(run) {
const timing = run?.timing || {};
return formatMinutes(optionalNumber(run?.runDurationMinutes, run?.durationMinutes, timing.runDurationMinutes, timing.durationMinutes));