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 6ffdb0a8..f9c2a98b 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 @@ -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 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 88be6945..c071703b 100644 --- a/config/hwlab-web-probe-sentinel/report-views.multi-sentinel.yaml +++ b/config/hwlab-web-probe-sentinel/report-views.multi-sentinel.yaml @@ -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 diff --git a/config/hwlab-web-probe-sentinel/report-views.yaml b/config/hwlab-web-probe-sentinel/report-views.yaml index 923fce86..4c076051 100644 --- a/config/hwlab-web-probe-sentinel/report-views.yaml +++ b/config/hwlab-web-probe-sentinel/report-views.yaml @@ -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 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 b35b5c9d..c21d9160 100644 --- a/scripts/assets/web-probe-sentinel-monitor-web/monitor-web.css +++ b/scripts/assets/web-probe-sentinel-monitor-web/monitor-web.css @@ -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)); 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 2db380d6..49a3f017 100644 --- a/scripts/assets/web-probe-sentinel-monitor-web/monitor-web.js +++ b/scripts/assets/web-probe-sentinel-monitor-web/monitor-web.js @@ -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({
-

运行耗时曲线

-

按运行更新时间展示最近 {{ trendRows.length }} 次巡检耗时,单位为分钟

+

运行趋势曲线

+

按运行更新时间展示最近 {{ trendRows.length }} 次巡检的耗时、独立错误类型和独立告警类型

{{ cadence.stale ? "非阻塞报警" : "新鲜" }}
- + + + + + {{ dot.title }} @@ -502,8 +561,9 @@ createApp({
最新运行耗时 {{ runDurationText(latestTrendRun) }} + 独立错误 {{ trendErrorCount(latestTrendRun) }} / 最高 {{ trendErrorMax }} + 独立告警 {{ trendWarningCount(latestTrendRun) }} / 最高 {{ trendWarningMax }} 最近最高耗时 {{ formatMinutes(trendDurationMax) }} - 最新错误 {{ trendErrorCount(latestTrendRun) }} / 告警 {{ trendWarningCount(latestTrendRun) }} 历史样本累计 错误 {{ redCount({ severityCounts: severityTotals }) }} / 告警 {{ warningCount({ severityCounts: severityTotals }) }} {{ cadence.alert }}
@@ -651,6 +711,48 @@ createApp({
+
+
+

页面内存曲线

+ 每个页面一条线 · 峰值 {{ formatMb(detailMemory.maxMemoryMb || detailMemoryMax) }} +
+
+ + + + + + +
+ 0m + {{ formatMinutes(detailMemoryDurationMax) }} + 峰值 {{ formatMb(detailMemory.maxMemoryMb || detailMemoryMax) }} +
+
+ + {{ memorySeriesLabel(series) }} + +
+
+
暂无页面级内存样本
+

复现命令

{{ commandSummary(selectedDetail) }}
@@ -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)); diff --git a/scripts/src/hwlab-node-web-sentinel-cicd.ts b/scripts/src/hwlab-node-web-sentinel-cicd.ts index adb74ecc..b780f33f 100644 --- a/scripts/src/hwlab-node-web-sentinel-cicd.ts +++ b/scripts/src/hwlab-node-web-sentinel-cicd.ts @@ -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))), diff --git a/scripts/src/hwlab-node-web-sentinel-config.ts b/scripts/src/hwlab-node-web-sentinel-config.ts index a10bd640..23280877 100644 --- a/scripts/src/hwlab-node-web-sentinel-config.ts +++ b/scripts/src/hwlab-node-web-sentinel-config.ts @@ -102,7 +102,7 @@ const REQUIRED_TARGET_SHAPES: Record): Record | 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 & { points: Record[] } { + 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 }); diff --git a/scripts/src/hwlab-node-web-sentinel-p5.ts b/scripts/src/hwlab-node-web-sentinel-p5.ts index a47781a8..abfb913b 100644 --- a/scripts/src/hwlab-node-web-sentinel-p5.ts +++ b/scripts/src/hwlab-node-web-sentinel-p5.ts @@ -408,7 +408,7 @@ function reportText(value: unknown, maxChars: number): string | null { } export function runSentinelDashboard(state: SentinelCicdState, options: Extract): 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 { 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 { 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 ?? "-"}`, diff --git a/scripts/src/hwlab-node-web-sentinel-service.ts b/scripts/src/hwlab-node-web-sentinel-service.ts index 6e496581..db2afa24 100644 --- a/scripts/src/hwlab-node-web-sentinel-service.ts +++ b/scripts/src/hwlab-node-web-sentinel-service.ts @@ -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, stored: Record): Record { + 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 { + 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, options: Record): Record { + 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, options: Record, source: string): Record { + 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[], options: Record, source: string): Record { + const seriesByKey = new Map>(); + 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[], options: Record, source: string): Record { + 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 | 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): Record | 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[], maxSamples: number): readonly Record[] { + if (points.length <= maxSamples) return points; + const selected: Record[] = []; + 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): Record { const id = stringOrNull(row.id); const severityCounts = id === null ? {} : severityCountsForRun(config, db, id); diff --git a/scripts/src/hwlab-node/web-probe-observe.ts b/scripts/src/hwlab-node/web-probe-observe.ts index 290e0ebb..348d67c8 100644 --- a/scripts/src/hwlab-node/web-probe-observe.ts +++ b/scripts/src/hwlab-node/web-probe-observe.ts @@ -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,