Merge pull request #1351 from pikasTech/fix/1348-run-detail-memory
修复: Web哨兵运行详情增加按页面内存曲线
This commit is contained in:
@@ -16,6 +16,9 @@ sentinel:
|
||||
maxPageSize: 100
|
||||
rawAccess: explicit-only
|
||||
checkCatalogRef: config/hwlab-web-probe-sentinel/check-catalog.yaml#sentinel.checkCatalog
|
||||
detailMemory:
|
||||
maxPages: 8
|
||||
maxSamplesPerPage: 360
|
||||
redaction:
|
||||
prompt: hash-and-byte-count
|
||||
assistantFinal: summary-and-hash
|
||||
|
||||
@@ -17,6 +17,9 @@ sentinel:
|
||||
maxPageSize: 100
|
||||
rawAccess: explicit-only
|
||||
checkCatalogRef: config/hwlab-web-probe-sentinel/check-catalog.yaml#sentinel.checkCatalog
|
||||
detailMemory:
|
||||
maxPages: 8
|
||||
maxSamplesPerPage: 360
|
||||
redaction:
|
||||
prompt: hash-and-byte-count
|
||||
assistantFinal: summary-and-hash
|
||||
|
||||
@@ -16,6 +16,9 @@ sentinel:
|
||||
maxPageSize: 100
|
||||
rawAccess: explicit-only
|
||||
checkCatalogRef: config/hwlab-web-probe-sentinel/check-catalog.yaml#sentinel.checkCatalog
|
||||
detailMemory:
|
||||
maxPages: 8
|
||||
maxSamplesPerPage: 360
|
||||
redaction:
|
||||
prompt: hash-and-byte-count
|
||||
assistantFinal: summary-and-hash
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -121,6 +121,7 @@ export type WebProbeSentinelOptions =
|
||||
readonly viewport: string;
|
||||
readonly localDir: string;
|
||||
readonly name: string | null;
|
||||
readonly runId: string | null;
|
||||
readonly timeoutMs: number;
|
||||
readonly waitTimeoutMs: number;
|
||||
readonly timeoutSeconds: number;
|
||||
@@ -788,6 +789,7 @@ function publishCurrentDashboardOptions(state: SentinelCicdState, timeoutSeconds
|
||||
viewport: stringAt(dashboard, "viewport"),
|
||||
localDir: "/tmp",
|
||||
name: null,
|
||||
runId: null,
|
||||
timeoutMs,
|
||||
waitTimeoutMs,
|
||||
timeoutSeconds: Math.min(numberAt(dashboard, "commandTimeoutSeconds"), Math.max(1, Math.trunc(timeoutSeconds))),
|
||||
|
||||
@@ -102,7 +102,7 @@ const REQUIRED_TARGET_SHAPES: Record<HwlabRuntimeWebProbeSentinelConfigRefKey, R
|
||||
},
|
||||
reportViews: {
|
||||
kind: "object",
|
||||
requiredPaths: ["defaultView", "views[0]", "pageSize", "maxPageSize", "rawAccess", "checkCatalogRef", "redaction.prompt", "redaction.secrets"],
|
||||
requiredPaths: ["defaultView", "views[0]", "pageSize", "maxPageSize", "rawAccess", "checkCatalogRef", "detailMemory.maxPages", "detailMemory.maxSamplesPerPage", "redaction.prompt", "redaction.secrets"],
|
||||
},
|
||||
publicExposure: {
|
||||
kind: "object",
|
||||
|
||||
@@ -669,6 +669,48 @@ function compactQuickVerifyRecordAnalysis(value: unknown): Record<string, unknow
|
||||
screenshot: compactQuickVerifyRecordScreenshot(record(item.screenshot)),
|
||||
findings: Array.isArray(item.findings) ? item.findings.slice(0, 16).map(compactQuickVerifyRecordFinding) : [],
|
||||
pagePerformanceSlowApi: Array.isArray(item.pagePerformanceSlowApi) ? item.pagePerformanceSlowApi.slice(0, 6).map(record) : [],
|
||||
browserProcess: compactQuickVerifyRecordBrowserProcess(record(item.browserProcess)),
|
||||
valuesRedacted: true,
|
||||
};
|
||||
}
|
||||
|
||||
function compactQuickVerifyRecordBrowserProcess(value: Record<string, unknown>): Record<string, unknown> | null {
|
||||
const pageSeries = Array.isArray(value.pageSeries) ? value.pageSeries.map(compactQuickVerifyRecordMemorySeries).filter((item) => item.points.length > 0) : [];
|
||||
if (pageSeries.length === 0) return null;
|
||||
return {
|
||||
source: stringAtNullable(value, "source") ?? "analysis-browser-process",
|
||||
unit: stringAtNullable(value, "unit") ?? "MB",
|
||||
metric: stringAtNullable(value, "metric") ?? "page-heap-used",
|
||||
pageCount: pageSeries.length,
|
||||
sampleCount: pageSeries.reduce((sum, item) => sum + item.points.length, 0),
|
||||
pageSeries,
|
||||
valuesRedacted: true,
|
||||
};
|
||||
}
|
||||
|
||||
function compactQuickVerifyRecordMemorySeries(value: unknown): Record<string, unknown> & { points: Record<string, unknown>[] } {
|
||||
const item = record(value);
|
||||
const points = Array.isArray(item.points)
|
||||
? item.points.map(record).map((point) => ({
|
||||
ts: stringAtNullable(point, "ts"),
|
||||
elapsedSeconds: numberAtNullable(point, "elapsedSeconds"),
|
||||
elapsedMinutes: numberAtNullable(point, "elapsedMinutes"),
|
||||
memoryMb: numberAtNullable(point, "memoryMb"),
|
||||
heapUsedMb: numberAtNullable(point, "heapUsedMb"),
|
||||
jsHeapUsedMb: numberAtNullable(point, "jsHeapUsedMb"),
|
||||
effectiveHeapUsedMb: numberAtNullable(point, "effectiveHeapUsedMb"),
|
||||
effectiveJsHeapUsedMb: numberAtNullable(point, "effectiveJsHeapUsedMb"),
|
||||
domNodes: numberAtNullable(point, "domNodes"),
|
||||
valuesRedacted: true,
|
||||
})).filter((point) => point.ts !== null && point.memoryMb !== null)
|
||||
: [];
|
||||
return {
|
||||
key: stringAtNullable(item, "key"),
|
||||
pageRole: stringAtNullable(item, "pageRole"),
|
||||
pageId: stringAtNullable(item, "pageId"),
|
||||
label: stringAtNullable(item, "label"),
|
||||
url: stringAtNullable(item, "url"),
|
||||
points,
|
||||
valuesRedacted: true,
|
||||
};
|
||||
}
|
||||
@@ -820,7 +862,11 @@ function readAnalysisSummaryFromWorkspace(state: SentinelCicdState, stateDir: st
|
||||
"walk(stateDir);",
|
||||
"const findings=arr(report?.findings ?? report?.archiveSummary?.redFindings).slice(0,20).map((item)=>{const v=rec(item); return {id:clip(v.id??v.kind??v.code,80),kind:clip(v.kind??v.id??v.code,80),code:clip(v.code??v.kind??v.id,80),severity:clip(v.severity??v.level,32),level:clip(v.level??v.severity,32),count:Number(v.count??v.sampleCount??1),summary:clip(v.summary??v.message,220),message:clip(v.message??v.summary,220),rootCause:clip(v.rootCause,140),rootCauseStatus:clip(v.rootCauseStatus,90),rootCauseConfidence:clip(v.rootCauseConfidence,40),nextAction:clip(v.nextAction,240),evidenceSummary:v.evidence?clip(JSON.stringify(rec(v.evidence)),220):clip(v.evidenceSummary,220),timingSourceOfTruth:clip(v.timingSourceOfTruth??v.expectedElapsedSource??v.evidenceKind,100),timingStatus:clip(v.timingStatus,60),timingAlert:v.timingAlert===true,rootCauseSignals:compactRootCauseSignals(v.rootCauseSignals),blocking:v.blocking===true,afterRound:v.afterRound??null,canarySessionId:clip(v.canarySessionId,80),routeSessionId:clip(v.routeSessionId,80),activeSessionId:clip(v.activeSessionId,80),consecutiveUserMessageCount:v.consecutiveUserMessageCount??null,sentinelRange:clip(v.sentinelRange,80),sampleSeq:v.sampleSeq??null,traceIds:arr(v.traceIds).slice(0,8).map((x)=>clip(x,80)),pageRole:clip(v.pageRole,32),pageId:clip(v.pageId,80),observerId:clip(v.observerId,80),stateDir:clip(v.stateDir,160),commandId:clip(v.commandId,80),valuesRedacted:true};});",
|
||||
"const slow=arr(report?.pagePerformanceSlowApi ?? report?.archivePagePerformanceSlowApi).slice(0,8).map((item)=>{const v=rec(item); return {path:clip(v.path??v.route,120),sampleCount:v.sampleCount??null,p95Ms:v.p95Ms??null,maxMs:v.maxMs??null,overFiveSecondCount:v.overFiveSecondCount??null};});",
|
||||
"console.log(JSON.stringify({ok:!!report,reportOk:!!report&&report.ok!==false,stateDir,reportJsonPath:reportPath,reportJsonSha256:sha(jsonBuf),reportMdPath,reportMdSha256:sha(read(reportMdPath)),findingCount:Number(report?.findingCount??findings.length),artifactCount,screenshot,findings,counts:rec(report?.counts),analysisWindow:rec(report?.analysisWindow??report?.windows?.recent?.summary),pagePerformanceSlowApi:slow,valuesRedacted:true}));",
|
||||
"const num=(v)=>Number.isFinite(Number(v))?Number(v):null; const mb=(v)=>{const n=num(v); return n==null?null:Math.round((n/1024/1024)*100)/100};",
|
||||
"function pageMemory(page){const eff=rec(page.effectiveMemory); const heap=rec(page.heapUsage); const perf=rec(page.performance); const metrics=rec(perf.metrics); const heapUsed=num(eff.heapUsedMb)??mb(heap.usedSize); const jsHeap=num(eff.jsHeapUsedMb)??mb(metrics.JSHeapUsedSize); const memory=heapUsed??jsHeap; if(memory==null)return null; return {memoryMb:memory,heapUsedMb:heapUsed,jsHeapUsedMb:jsHeap,effectiveHeapUsedMb:num(eff.effectiveHeapUsedMb),effectiveJsHeapUsedMb:num(eff.effectiveJsHeapUsedMb),domNodes:num(eff.domNodes)??num(rec(page.domCounters).nodes)??num(metrics.Nodes)}; }",
|
||||
"function browserProcessPageSeries(){const p=path.join(stateDir,'browser-process.jsonl'); let lines=[]; try{lines=String(read(p)||'').split(/\\r?\\n/).filter(Boolean)}catch{} const map=new Map(); let firstTs=null; for(const line of lines){let row=null; try{row=JSON.parse(line)}catch{} if(!row||row.type!=='browser-process-sample')continue; const ts=row.ts||null; const tsMs=Date.parse(String(ts||'')); if(!ts||!Number.isFinite(tsMs))continue; for(const page of arr(row.pages)){const memory=pageMemory(rec(page)); if(!memory)continue; if(firstTs==null)firstTs=tsMs; const role=clip(page.pageRole||'page',32); const id=clip(page.pageId||`${role}-${page.pageEpoch??map.size}`,80); const key=`${role}:${id}`; const current=map.get(key)||{key,pageRole:role,pageId:id,label:`${role} ${String(id).slice(0,10)}`,url:clip(page.url,180),points:[],valuesRedacted:true}; current.points.push({ts,elapsedSeconds:Math.max(0,Math.round((tsMs-firstTs)/1000)),elapsedMinutes:Math.round(Math.max(0,(tsMs-firstTs)/60000)*100)/100,...memory,valuesRedacted:true}); map.set(key,current);} } return Array.from(map.values()).filter((item)=>item.points.length>0); }",
|
||||
"const browserPageSeries=browserProcessPageSeries();",
|
||||
"console.log(JSON.stringify({ok:!!report,reportOk:!!report&&report.ok!==false,stateDir,reportJsonPath:reportPath,reportJsonSha256:sha(jsonBuf),reportMdPath,reportMdSha256:sha(read(reportMdPath)),findingCount:Number(report?.findingCount??findings.length),artifactCount,screenshot,findings,counts:rec(report?.counts),analysisWindow:rec(report?.analysisWindow??report?.windows?.recent?.summary),pagePerformanceSlowApi:slow,browserProcess:{source:'analysis-browser-process-jsonl',unit:'MB',metric:'page-heap-used',pageCount:browserPageSeries.length,sampleCount:browserPageSeries.reduce((sum,item)=>sum+item.points.length,0),pageSeries:browserPageSeries,valuesRedacted:true},valuesRedacted:true}));",
|
||||
"NODE",
|
||||
].join("\n");
|
||||
const result = runCommand(["trans", `${state.spec.nodeId}:${state.spec.workspace}`, "sh"], repoRoot, { input: script, timeoutMs: Math.min(timeoutSeconds, 60) * 1000 });
|
||||
|
||||
@@ -408,7 +408,7 @@ function reportText(value: unknown, maxChars: number): string | null {
|
||||
}
|
||||
|
||||
export function runSentinelDashboard(state: SentinelCicdState, options: Extract<WebProbeSentinelOptions, { kind: "dashboard" }>): RenderedCliResult {
|
||||
const command = `web-probe sentinel dashboard ${options.action}`;
|
||||
const command = `web-probe sentinel dashboard ${options.action}${options.runId === null ? "" : ` --run ${options.runId}`}`;
|
||||
const result = probeSentinelDashboardBrowser(state, options);
|
||||
return rendered(result.ok === true, command, options.raw ? JSON.stringify(result, null, 2) : renderDashboardResult(result));
|
||||
}
|
||||
@@ -428,6 +428,7 @@ export function probeSentinelDashboardBrowser(state: SentinelCicdState, options:
|
||||
`export UNIDESK_SENTINEL_DASHBOARD_FULL_PAGE=${shellQuote(options.fullPage ? "1" : "0")}`,
|
||||
`export UNIDESK_SENTINEL_DASHBOARD_EXPECTED_SENTINEL_ID=${shellQuote(state.sentinelId)}`,
|
||||
`export UNIDESK_SENTINEL_DASHBOARD_EXPECTED_ROUTE_PREFIX=${shellQuote(stringAtNullable(state.publicExposure, "routePrefix") ?? "/")}`,
|
||||
`export UNIDESK_SENTINEL_DASHBOARD_RUN_ID=${shellQuote(options.runId ?? "")}`,
|
||||
`export UNIDESK_SENTINEL_DASHBOARD_PLAYWRIGHT_MODULE=${shellQuote(`${state.spec.workspace}/node_modules/playwright/index.mjs`)}`,
|
||||
"export PLAYWRIGHT_BROWSERS_PATH=0",
|
||||
"if command -v chromium >/dev/null 2>&1; then",
|
||||
@@ -470,6 +471,7 @@ export function probeSentinelDashboardBrowser(state: SentinelCicdState, options:
|
||||
node: state.spec.nodeId,
|
||||
lane: state.spec.lane,
|
||||
sentinelId: state.sentinelId,
|
||||
runId: options.runId,
|
||||
publicUrl: `${publicBaseUrl}/`,
|
||||
route,
|
||||
viewport: options.viewport,
|
||||
@@ -514,6 +516,7 @@ const fullPage = process.env.UNIDESK_SENTINEL_DASHBOARD_FULL_PAGE !== "0";
|
||||
const executablePath = process.env.UNIDESK_SENTINEL_DASHBOARD_EXECUTABLE_PATH || "";
|
||||
const expectedSentinelId = process.env.UNIDESK_SENTINEL_DASHBOARD_EXPECTED_SENTINEL_ID || "";
|
||||
const expectedRoutePrefix = process.env.UNIDESK_SENTINEL_DASHBOARD_EXPECTED_ROUTE_PREFIX || "/";
|
||||
const requestedRunId = process.env.UNIDESK_SENTINEL_DASHBOARD_RUN_ID || "";
|
||||
|
||||
if (!url) throw new Error("missing dashboard URL");
|
||||
|
||||
@@ -565,18 +568,72 @@ for (let attempt = 1; attempt <= maxNavigationAttempts; attempt += 1) {
|
||||
await page.waitForTimeout(750 * attempt);
|
||||
}
|
||||
|
||||
let requestedRunSelection = { requestedRunId, ok: requestedRunId.length === 0, reason: requestedRunId.length === 0 ? "not-requested" : "not-attempted" };
|
||||
if (requestedRunId) {
|
||||
requestedRunSelection = await page.evaluate(async (runId) => {
|
||||
const wait = (ms) => new Promise((resolve) => window.setTimeout(resolve, ms));
|
||||
const rowForRun = () => document.querySelector('.run-list .run-row[data-run-id="' + CSS.escape(runId) + '"]');
|
||||
const row = rowForRun();
|
||||
if (!(row instanceof HTMLElement)) return { requestedRunId: runId, ok: false, reason: "run-row-missing" };
|
||||
row.click();
|
||||
for (let index = 0; index < 40; index += 1) {
|
||||
const memoryChart = document.querySelector("[data-run-memory-chart='true']");
|
||||
const selected = rowForRun();
|
||||
if (memoryChart?.getAttribute("data-memory-run-id") === runId
|
||||
&& selected instanceof HTMLElement
|
||||
&& selected.classList.contains("selected")) {
|
||||
return { requestedRunId: runId, ok: true, reason: "selected" };
|
||||
}
|
||||
await wait(100);
|
||||
}
|
||||
return { requestedRunId: runId, ok: false, reason: "detail-switch-timeout" };
|
||||
}, requestedRunId).catch((error) => ({ requestedRunId, ok: false, reason: "selection-error", error: String(error?.message || error).slice(0, 300) }));
|
||||
await page.waitForTimeout(150);
|
||||
}
|
||||
|
||||
if (captureScreenshot && screenshotPath) {
|
||||
await page.screenshot({ path: screenshotPath, fullPage, animations: "disabled" }).catch((error) => {
|
||||
pageErrors.push({ message: "screenshot failed: " + String(error?.message || error).slice(0, 400) });
|
||||
});
|
||||
}
|
||||
|
||||
const dom = await page.evaluate(async ({ expectedRoutePrefix, expectedSentinelId }) => {
|
||||
const dom = await page.evaluate(async ({ expectedRoutePrefix, expectedSentinelId, requestedRunId, requestedRunSelection }) => {
|
||||
const numberValue = (value) => Number.isFinite(Number(value)) ? Number(value) : 0;
|
||||
const objectOrNull = (value) => value && typeof value === "object" && !Array.isArray(value) ? value : null;
|
||||
const text = (selector) => String(document.querySelector(selector)?.textContent || "").replace(/\s+/g, " ").trim();
|
||||
const root = document.querySelector("#monitor-web-root");
|
||||
const shell = document.querySelector("[data-monitor-shell='true']");
|
||||
const error = document.querySelector("#monitor-web-error");
|
||||
const trend = document.querySelector("[data-monitor-trend-curve]");
|
||||
const trendErrorCurve = document.querySelector("[data-monitor-trend-error-curve='true']");
|
||||
const trendWarningCurve = document.querySelector("[data-monitor-trend-warning-curve='true']");
|
||||
const memoryChart = document.querySelector("[data-run-memory-chart='true']");
|
||||
const legendTexts = Array.from(document.querySelectorAll(".trend-legend .legend-item")).map((item) => String(item.textContent || "").replace(/\s+/g, " ").trim());
|
||||
const legendNumber = (label) => {
|
||||
const row = legendTexts.find((item) => item.includes(label)) || "";
|
||||
const match = /(\d+(?:\.\d+)?)/u.exec(row);
|
||||
return match ? Number(match[1]) : null;
|
||||
};
|
||||
const chartTiming = {
|
||||
latestMinutes: legendNumber("最新运行耗时"),
|
||||
maxMinutes: legendNumber("最近最高耗时"),
|
||||
title: text("#trend-heading"),
|
||||
legendHasDuration: legendTexts.some((item) => item.includes("最新运行耗时")),
|
||||
legendHasError: legendTexts.some((item) => item.includes("独立错误")),
|
||||
legendHasWarning: legendTexts.some((item) => item.includes("独立告警")),
|
||||
hasDurationCurve: Boolean(trend),
|
||||
hasErrorCurve: Boolean(trendErrorCurve),
|
||||
hasWarningCurve: Boolean(trendWarningCurve),
|
||||
ok: false,
|
||||
};
|
||||
chartTiming.ok = chartTiming.title.includes("运行趋势")
|
||||
&& chartTiming.legendHasDuration === true
|
||||
&& chartTiming.legendHasError === true
|
||||
&& chartTiming.legendHasWarning === true
|
||||
&& chartTiming.hasDurationCurve === true
|
||||
&& chartTiming.hasErrorCurve === true
|
||||
&& chartTiming.hasWarningCurve === true
|
||||
&& Number.isFinite(chartTiming.latestMinutes);
|
||||
const dataset = root ? {
|
||||
node: root.getAttribute("data-node"),
|
||||
lane: root.getAttribute("data-lane"),
|
||||
@@ -624,6 +681,36 @@ const dom = await page.evaluate(async ({ expectedRoutePrefix, expectedSentinelId
|
||||
durationMinutes: numberValue(latestRun?.runDurationMinutes ?? latestRun?.durationMinutes ?? latestRun?.timing?.durationMinutes),
|
||||
severityKeys: Object.keys(latestCounts).sort(),
|
||||
};
|
||||
const targetRunId = requestedRunId || latestRunCounts.runId;
|
||||
const targetRun = targetRunId ? runs.find((run) => (run?.id || run?.runId) === targetRunId) || latestRun : latestRun;
|
||||
const targetDetailResult = targetRunId === null ? { ok: false, httpStatus: null, body: null } : await fetchJson("/api/runs/" + encodeURIComponent(targetRunId));
|
||||
const targetDetailPayload = objectOrNull(targetDetailResult.body) || {};
|
||||
const targetMemory = objectOrNull(targetDetailPayload.memory) || {};
|
||||
const targetPageSeries = Array.isArray(targetMemory.pageSeries) ? targetMemory.pageSeries : [];
|
||||
const memorySummary = {
|
||||
present: Boolean(memoryChart),
|
||||
runId: memoryChart?.getAttribute("data-memory-run-id") || null,
|
||||
targetRunId,
|
||||
targetScenarioId: targetRun?.scenarioId || targetRun?.scenario_id || null,
|
||||
matchesTargetRun: memoryChart?.getAttribute("data-memory-run-id") === targetRunId,
|
||||
pageCount: numberValue(memoryChart?.getAttribute("data-memory-page-count")),
|
||||
sampleCount: numberValue(memoryChart?.getAttribute("data-memory-sample-count")),
|
||||
source: memoryChart?.getAttribute("data-memory-source") || null,
|
||||
curveCount: document.querySelectorAll(".memory-chart .memory-line").length,
|
||||
legendCount: document.querySelectorAll(".memory-legend .legend-item").length,
|
||||
apiOk: targetDetailResult.ok === true && targetDetailPayload.ok !== false,
|
||||
apiPageCount: targetPageSeries.length,
|
||||
apiSampleCount: numberValue(targetMemory.sampleCount),
|
||||
apiSource: targetMemory.source || null,
|
||||
perPageLines: false,
|
||||
};
|
||||
memorySummary.perPageLines = memorySummary.present === true
|
||||
&& memorySummary.matchesTargetRun === true
|
||||
&& memorySummary.pageCount > 0
|
||||
&& memorySummary.curveCount === memorySummary.pageCount
|
||||
&& memorySummary.legendCount === memorySummary.pageCount
|
||||
&& memorySummary.apiOk === true
|
||||
&& memorySummary.apiPageCount === memorySummary.pageCount;
|
||||
const datasetSentinelId = String(dataset.sentinelId || "");
|
||||
const finalPath = new URL(window.location.href).pathname.replace(/\/+$/u, "") || "/";
|
||||
const expectedPath = expectedRoutePrefix.replace(/\/+$/u, "") || "/";
|
||||
@@ -681,6 +768,8 @@ const dom = await page.evaluate(async ({ expectedRoutePrefix, expectedSentinelId
|
||||
runCount: runs.length,
|
||||
latestRunId: latestRunCounts.runId,
|
||||
latestFindingTypeCount: latestRunCounts.typeCount,
|
||||
trendCurves: chartTiming.ok,
|
||||
memoryPerPageLines: memorySummary.perPageLines,
|
||||
},
|
||||
sentinelBoundary,
|
||||
title: document.title,
|
||||
@@ -688,9 +777,20 @@ const dom = await page.evaluate(async ({ expectedRoutePrefix, expectedSentinelId
|
||||
runRows: runs.length,
|
||||
checkRows: document.querySelectorAll("[data-check-row='true']").length,
|
||||
latestRunCounts,
|
||||
targetRunCounts: {
|
||||
runId: targetRunId,
|
||||
scenarioId: targetRun?.scenarioId || targetRun?.scenario_id || null,
|
||||
durationMinutes: numberValue(targetRun?.runDurationMinutes ?? targetRun?.durationMinutes ?? targetRun?.timing?.durationMinutes),
|
||||
requested: Boolean(requestedRunId),
|
||||
requestMatched: !requestedRunId || targetRunId === requestedRunId,
|
||||
},
|
||||
requestedRunSelection,
|
||||
chartTiming,
|
||||
memorySummary,
|
||||
api: {
|
||||
overview: { ok: overviewResult.ok, httpStatus: overviewResult.httpStatus },
|
||||
runs: { ok: runsResult.ok, httpStatus: runsResult.httpStatus },
|
||||
targetDetail: { ok: targetDetailResult.ok, httpStatus: targetDetailResult.httpStatus },
|
||||
},
|
||||
errorVisible: Boolean(error && !error.hidden),
|
||||
errorText: error && !error.hidden ? String(error.textContent || "").replace(/\s+/g, " ").trim().slice(0, 500) : "",
|
||||
@@ -702,7 +802,7 @@ const dom = await page.evaluate(async ({ expectedRoutePrefix, expectedSentinelId
|
||||
overflow: overflow.slice(0, 2),
|
||||
},
|
||||
};
|
||||
}, { expectedRoutePrefix, expectedSentinelId });
|
||||
}, { expectedRoutePrefix, expectedSentinelId, requestedRunId, requestedRunSelection });
|
||||
|
||||
const consoleErrors = consoleMessages.filter((item) => item.type === "error");
|
||||
const ok = !navigationError
|
||||
@@ -719,6 +819,9 @@ const ok = !navigationError
|
||||
&& dom.sentinelBoundary?.runRowsMatch === true
|
||||
&& dom.sentinelBoundary?.routePrefixMatches === true
|
||||
&& dom.errorVisible !== true
|
||||
&& dom.requestedRunSelection?.ok === true
|
||||
&& dom.chartTiming?.ok === true
|
||||
&& dom.memorySummary?.perPageLines === true
|
||||
&& dom.layout?.horizontalOverflow !== true
|
||||
&& pageErrors.length === 0;
|
||||
|
||||
@@ -1170,6 +1273,10 @@ function renderDashboardResult(result: Record<string, unknown>): string {
|
||||
const apiRuns = record(api.runs);
|
||||
const layout = record(dom.layout);
|
||||
const latestRunCounts = record(dom.latestRunCounts);
|
||||
const targetRunCounts = record(dom.targetRunCounts);
|
||||
const chartTiming = record(dom.chartTiming);
|
||||
const memorySummary = record(dom.memorySummary);
|
||||
const requestedRunSelection = record(dom.requestedRunSelection);
|
||||
const screenshot = record(result.screenshot);
|
||||
const remote = record(result.remote);
|
||||
const transport = record(result.transport);
|
||||
@@ -1210,6 +1317,27 @@ function renderDashboardResult(result: Record<string, unknown>): string {
|
||||
latestRunCounts.typeCount ?? "-",
|
||||
]]),
|
||||
"",
|
||||
table(["TARGET_RUN", "REQUESTED", "SELECTED", "TREND_OK", "ERR_CURVE", "WARN_CURVE"], [[
|
||||
targetRunCounts.runId ?? "-",
|
||||
targetRunCounts.requested ?? "-",
|
||||
requestedRunSelection.ok ?? "-",
|
||||
chartTiming.ok ?? "-",
|
||||
chartTiming.hasErrorCurve ?? "-",
|
||||
chartTiming.hasWarningCurve ?? "-",
|
||||
]]),
|
||||
"",
|
||||
table(["MEMORY_CHART", "MEMORY_RUN", "MEMORY_MATCH", "MEMORY_PAGES", "MEMORY_CURVES", "MEMORY_SAMPLES", "API_PAGES", "MEMORY_PAGE_LINES", "MEMORY_SOURCE"], [[
|
||||
memorySummary.present ?? "-",
|
||||
memorySummary.runId ?? "-",
|
||||
memorySummary.matchesTargetRun ?? "-",
|
||||
memorySummary.pageCount ?? "-",
|
||||
memorySummary.curveCount ?? "-",
|
||||
memorySummary.sampleCount ?? "-",
|
||||
memorySummary.apiPageCount ?? "-",
|
||||
memorySummary.perPageLines ?? "-",
|
||||
memorySummary.source ?? memorySummary.apiSource ?? "-",
|
||||
]]),
|
||||
"",
|
||||
table(["VIEWPORT", "DOC", "H_OVERFLOW", "OVERFLOW_COUNT"], [[
|
||||
result.viewport,
|
||||
`${record(layout.documentSize).width ?? "-"}x${record(layout.documentSize).height ?? "-"}`,
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
// Responsibility: Persistent HTTP wrapper service for web-probe observe scheduling, index, health, metrics, maintenance, and dashboard.
|
||||
import { Buffer } from "node:buffer";
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { Database } from "bun:sqlite";
|
||||
import { renderWebProbeSentinelDashboardHtml, webProbeSentinelDashboardAssetResponse } from "./hwlab-node-web-sentinel-dashboard-assets";
|
||||
@@ -814,6 +814,7 @@ function dashboardRunDetail(config: WebProbeSentinelServiceConfig, db: Database,
|
||||
sentinelId: config.sentinelId,
|
||||
run: dashboardRunSummary(config, db, row),
|
||||
summary: record(stored.summary),
|
||||
memory: dashboardRunMemoryDetail(config, row, stored),
|
||||
findings,
|
||||
viewsAvailable: Object.keys(views),
|
||||
artifacts: {
|
||||
@@ -940,6 +941,217 @@ function dashboardReportView(config: WebProbeSentinelServiceConfig, db: Database
|
||||
};
|
||||
}
|
||||
|
||||
function dashboardRunMemoryDetail(config: WebProbeSentinelServiceConfig, row: Record<string, unknown>, stored: Record<string, unknown>): Record<string, unknown> {
|
||||
const options = dashboardDetailMemoryOptions(config);
|
||||
const storedMemory = compactDashboardMemory(record(record(record(stored.summary).analysis).browserProcess), options, "recorded-analysis-summary");
|
||||
if (storedMemory.pageSeries.length > 0) return storedMemory;
|
||||
const fromArtifacts = readDashboardMemoryFromStateDir(config, row, options);
|
||||
if (fromArtifacts.pageSeries.length > 0) return fromArtifacts;
|
||||
return {
|
||||
ok: false,
|
||||
source: "unavailable",
|
||||
unit: "MB",
|
||||
metric: "page-heap-used",
|
||||
pageCount: 0,
|
||||
sampleCount: 0,
|
||||
maxMemoryMb: null,
|
||||
maxPageLabel: null,
|
||||
pageSeries: [],
|
||||
reason: fromArtifacts.reason ?? storedMemory.reason ?? "page-memory-samples-missing",
|
||||
valuesRedacted: true,
|
||||
};
|
||||
}
|
||||
|
||||
function dashboardDetailMemoryOptions(config: WebProbeSentinelServiceConfig): Record<string, number> {
|
||||
const detailMemory = recordTarget(valueAtPath(config.reportViews, "detailMemory"));
|
||||
return {
|
||||
maxPages: Math.max(1, Math.floor(numberAt(detailMemory, "maxPages"))),
|
||||
maxSamplesPerPage: Math.max(1, Math.floor(numberAt(detailMemory, "maxSamplesPerPage"))),
|
||||
};
|
||||
}
|
||||
|
||||
function readDashboardMemoryFromStateDir(config: WebProbeSentinelServiceConfig, row: Record<string, unknown>, options: Record<string, number>): Record<string, unknown> {
|
||||
const stateDir = stringOrNull(row.state_dir) ?? stringOrNull(row.stateDir);
|
||||
if (stateDir === null) return { ok: false, source: "artifact-browser-process-jsonl", reason: "state-dir-missing", pageSeries: [], valuesRedacted: true };
|
||||
if (!isSafeDashboardStateDir(stateDir)) return { ok: false, source: "artifact-browser-process-jsonl", reason: "unsafe-state-dir", pageSeries: [], valuesRedacted: true };
|
||||
const browserProcessPath = join(config.stateRoot, stateDir, "browser-process.jsonl");
|
||||
if (!existsSync(browserProcessPath)) return { ok: false, source: "artifact-browser-process-jsonl", reason: "browser-process-jsonl-missing", pageSeries: [], valuesRedacted: true };
|
||||
try {
|
||||
const rows = readFileSync(browserProcessPath, "utf8")
|
||||
.split(/\r?\n/u)
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
.flatMap((line) => {
|
||||
const parsed = parseJsonObject(line);
|
||||
return parsed === null ? [] : [parsed];
|
||||
});
|
||||
return buildDashboardMemoryDetail(rows, options, "artifact-browser-process-jsonl");
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
source: "artifact-browser-process-jsonl",
|
||||
reason: "browser-process-jsonl-read-failed",
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
pageSeries: [],
|
||||
valuesRedacted: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function compactDashboardMemory(value: Record<string, unknown>, options: Record<string, number>, source: string): Record<string, unknown> {
|
||||
const pageSeries = arrayRecords(value.pageSeries);
|
||||
if (pageSeries.length > 0) return finalizeDashboardMemorySeries(pageSeries, options, source);
|
||||
return { ok: false, source, reason: "recorded-page-series-missing", pageSeries: [], valuesRedacted: true };
|
||||
}
|
||||
|
||||
function buildDashboardMemoryDetail(rows: readonly Record<string, unknown>[], options: Record<string, number>, source: string): Record<string, unknown> {
|
||||
const seriesByKey = new Map<string, Record<string, unknown>>();
|
||||
let firstTsMs: number | null = null;
|
||||
for (const row of rows) {
|
||||
if (stringOrNull(row.type) !== "browser-process-sample") continue;
|
||||
const ts = stringOrNull(row.ts);
|
||||
const tsMs = ts === null ? null : Date.parse(ts);
|
||||
if (ts === null || tsMs === null || !Number.isFinite(tsMs)) continue;
|
||||
for (const page of arrayRecords(row.pages)) {
|
||||
const memory = dashboardPageMemoryPoint(page);
|
||||
if (memory === null) continue;
|
||||
if (firstTsMs === null) firstTsMs = tsMs;
|
||||
const pageRole = stringOrNull(page.pageRole) ?? "page";
|
||||
const pageId = stringOrNull(page.pageId) ?? `${pageRole}-${stringOrNull(page.pageEpoch) ?? seriesByKey.size}`;
|
||||
const key = `${pageRole}:${pageId}`;
|
||||
const existing = seriesByKey.get(key);
|
||||
const label = `${pageRole} ${shortDashboardText(pageId, 10)}`;
|
||||
const points = arrayRecords(existing?.points);
|
||||
points.push({
|
||||
ts,
|
||||
elapsedSeconds: Math.max(0, Math.round((tsMs - firstTsMs) / 1000)),
|
||||
elapsedMinutes: Math.round(Math.max(0, (tsMs - firstTsMs) / 60_000) * 100) / 100,
|
||||
...memory,
|
||||
valuesRedacted: true,
|
||||
});
|
||||
seriesByKey.set(key, {
|
||||
key,
|
||||
pageRole,
|
||||
pageId,
|
||||
label,
|
||||
url: safeDashboardUrl(stringOrNull(page.url)),
|
||||
points,
|
||||
valuesRedacted: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
return finalizeDashboardMemorySeries([...seriesByKey.values()], options, source);
|
||||
}
|
||||
|
||||
function finalizeDashboardMemorySeries(rawSeries: readonly Record<string, unknown>[], options: Record<string, number>, source: string): Record<string, unknown> {
|
||||
const maxPages = numberOr(options.maxPages, 1);
|
||||
const maxSamplesPerPage = numberOr(options.maxSamplesPerPage, 1);
|
||||
const series = rawSeries
|
||||
.map((item) => {
|
||||
const points = arrayRecords(item.points)
|
||||
.map((point) => ({
|
||||
ts: stringOrNull(point.ts),
|
||||
elapsedSeconds: numberOrNull(point.elapsedSeconds),
|
||||
elapsedMinutes: numberOrNull(point.elapsedMinutes),
|
||||
memoryMb: numberOrNull(point.memoryMb),
|
||||
heapUsedMb: numberOrNull(point.heapUsedMb),
|
||||
jsHeapUsedMb: numberOrNull(point.jsHeapUsedMb),
|
||||
effectiveHeapUsedMb: numberOrNull(point.effectiveHeapUsedMb),
|
||||
effectiveJsHeapUsedMb: numberOrNull(point.effectiveJsHeapUsedMb),
|
||||
domNodes: numberOrNull(point.domNodes),
|
||||
valuesRedacted: true,
|
||||
}))
|
||||
.filter((point) => point.ts !== null && point.memoryMb !== null);
|
||||
const thinned = thinDashboardPoints(points, maxSamplesPerPage);
|
||||
const maxMemoryMb = maxNumber(thinned.map((point) => numberOrNull(point.memoryMb)));
|
||||
return {
|
||||
key: stringOrNull(item.key) ?? stringOrNull(item.pageId) ?? "page",
|
||||
pageRole: stringOrNull(item.pageRole),
|
||||
pageId: stringOrNull(item.pageId),
|
||||
label: stringOrNull(item.label) ?? stringOrNull(item.pageRole) ?? stringOrNull(item.pageId) ?? "page",
|
||||
url: safeDashboardUrl(stringOrNull(item.url)),
|
||||
sampleCount: thinned.length,
|
||||
maxMemoryMb,
|
||||
latestMemoryMb: thinned.length > 0 ? numberOrNull(thinned[thinned.length - 1].memoryMb) : null,
|
||||
points: thinned,
|
||||
valuesRedacted: true,
|
||||
};
|
||||
})
|
||||
.filter((item) => item.sampleCount > 0)
|
||||
.sort((a, b) => numberOr(b.maxMemoryMb, 0) - numberOr(a.maxMemoryMb, 0))
|
||||
.slice(0, maxPages);
|
||||
const sampleCount = series.reduce((sum, item) => sum + numberOr(item.sampleCount, 0), 0);
|
||||
const maxSeries = series.reduce<Record<string, unknown> | null>((best, item) => best === null || numberOr(item.maxMemoryMb, 0) > numberOr(best.maxMemoryMb, 0) ? item : best, null);
|
||||
return {
|
||||
ok: series.length > 0,
|
||||
source,
|
||||
unit: "MB",
|
||||
metric: "page-heap-used",
|
||||
pageCount: series.length,
|
||||
sampleCount,
|
||||
maxMemoryMb: maxSeries === null ? null : numberOrNull(maxSeries.maxMemoryMb),
|
||||
maxPageLabel: maxSeries === null ? null : stringOrNull(maxSeries.label),
|
||||
pageSeries: series,
|
||||
valuesRedacted: true,
|
||||
};
|
||||
}
|
||||
|
||||
function dashboardPageMemoryPoint(page: Record<string, unknown>): Record<string, unknown> | null {
|
||||
const effectiveMemory = record(page.effectiveMemory);
|
||||
const heapUsage = record(page.heapUsage);
|
||||
const performance = record(page.performance);
|
||||
const metrics = record(performance.metrics);
|
||||
const heapUsedMb = numberOrNull(effectiveMemory.heapUsedMb) ?? bytesToMbNullable(heapUsage.usedSize);
|
||||
const jsHeapUsedMb = numberOrNull(effectiveMemory.jsHeapUsedMb) ?? bytesToMbNullable(metrics.JSHeapUsedSize);
|
||||
const memoryMb = heapUsedMb ?? jsHeapUsedMb;
|
||||
if (memoryMb === null) return null;
|
||||
return {
|
||||
memoryMb,
|
||||
heapUsedMb,
|
||||
jsHeapUsedMb,
|
||||
effectiveHeapUsedMb: numberOrNull(effectiveMemory.effectiveHeapUsedMb),
|
||||
effectiveJsHeapUsedMb: numberOrNull(effectiveMemory.effectiveJsHeapUsedMb),
|
||||
domNodes: numberOrNull(effectiveMemory.domNodes) ?? numberOrNull(record(page.domCounters).nodes) ?? numberOrNull(metrics.Nodes),
|
||||
};
|
||||
}
|
||||
|
||||
function thinDashboardPoints(points: readonly Record<string, unknown>[], maxSamples: number): readonly Record<string, unknown>[] {
|
||||
if (points.length <= maxSamples) return points;
|
||||
const selected: Record<string, unknown>[] = [];
|
||||
const last = points.length - 1;
|
||||
const targetLast = Math.max(1, maxSamples - 1);
|
||||
for (let index = 0; index < maxSamples; index += 1) {
|
||||
selected.push(points[Math.round((index / targetLast) * last)]);
|
||||
}
|
||||
return selected;
|
||||
}
|
||||
|
||||
function isSafeDashboardStateDir(value: string): boolean {
|
||||
if (value.startsWith("/") || value.includes("\0")) return false;
|
||||
const normalized = value.replace(/\\/gu, "/");
|
||||
if (!normalized.startsWith(".state/")) return false;
|
||||
return normalized.split("/").every((part) => part !== "..");
|
||||
}
|
||||
|
||||
function bytesToMbNullable(value: unknown): number | null {
|
||||
const bytes = numberOrNull(value);
|
||||
return bytes === null ? null : Math.round((bytes / 1024 / 1024) * 100) / 100;
|
||||
}
|
||||
|
||||
function maxNumber(values: readonly (number | null)[]): number | null {
|
||||
const finite = values.filter((value): value is number => value !== null && Number.isFinite(value));
|
||||
return finite.length === 0 ? null : Math.max(...finite);
|
||||
}
|
||||
|
||||
function safeDashboardUrl(value: string | null): string | null {
|
||||
if (value === null) return null;
|
||||
return value.slice(0, 180);
|
||||
}
|
||||
|
||||
function shortDashboardText(value: string, max: number): string {
|
||||
return value.length <= max ? value : `${value.slice(0, max)}`;
|
||||
}
|
||||
|
||||
function dashboardRunSummary(config: WebProbeSentinelServiceConfig, db: Database, row: Record<string, unknown>): Record<string, unknown> {
|
||||
const id = stringOrNull(row.id);
|
||||
const severityCounts = id === null ? {} : severityCountsForRun(config, db, id);
|
||||
|
||||
@@ -125,6 +125,7 @@ export function parseNodeWebProbeSentinelOptions(args: string[]): NodeWebProbeSe
|
||||
const timeoutMs = positiveIntegerOption(args, "--timeout-ms", 30000, 120000);
|
||||
const waitTimeoutMs = positiveIntegerOption(args, "--wait-timeout-ms", Math.max(60000, timeoutMs + 15000), 600000);
|
||||
const commandTimeoutSeconds = positiveIntegerOption(args, "--command-timeout-seconds", Math.min(900, Math.max(120, Math.ceil(waitTimeoutMs / 1000) + 30)), 900);
|
||||
const runId = optionValue(args, "--run") ?? optionValue(args, "--run-id") ?? null;
|
||||
sentinel = {
|
||||
kind: "dashboard",
|
||||
action: dashboardAction,
|
||||
@@ -134,6 +135,7 @@ export function parseNodeWebProbeSentinelOptions(args: string[]): NodeWebProbeSe
|
||||
viewport: parseWebProbeSentinelDashboardViewport(optionValue(args, "--viewport") ?? "1440x900"),
|
||||
localDir: optionValue(args, "--local-dir") ?? "/tmp",
|
||||
name: optionValue(args, "--name") ?? null,
|
||||
runId,
|
||||
timeoutMs,
|
||||
waitTimeoutMs,
|
||||
timeoutSeconds: commandTimeoutSeconds,
|
||||
|
||||
Reference in New Issue
Block a user